diff --git a/.reuse/dep5 b/.reuse/dep5 index 110535151..89fc2f5a4 100644 --- a/.reuse/dep5 +++ b/.reuse/dep5 @@ -3,11 +3,7 @@ Upstream-Name: react-client Upstream-Contact: The HedgeDoc developers Source: https://github.com/hedgedoc/react-client -Files: public/mock-backend/public/* -Copyright: 2021 The HedgeDoc developers (see AUTHORS file) -License: CC0-1.0 - -Files: public/mock-backend/api/* +Files: public/mock-public/* Copyright: 2021 The HedgeDoc developers (see AUTHORS file) License: CC0-1.0 @@ -19,14 +15,10 @@ Files: locales/* Copyright: 2021 The HedgeDoc developers (see AUTHORS file) 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 License: CC0-1.0 -Files: public/index.html -Copyright: 2021 The HedgeDoc developers (see AUTHORS file) -License: CC0-1.0 - Files: public/robots.txt Copyright: 2021 The HedgeDoc developers (see AUTHORS file) License: CC0-1.0 diff --git a/cypress/integration/deleteNote.spec.ts b/cypress/integration/deleteNote.spec.ts index 48e4cad6b..12953a4ea 100644 --- a/cypress/integration/deleteNote.spec.ts +++ b/cypress/integration/deleteNote.spec.ts @@ -4,14 +4,16 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { testNoteId } from '../support/visit-test-editor' + describe('Delete note', () => { beforeEach(() => { cy.visitTestNote() }) it('correctly deletes a note', () => { - cy.intercept('DELETE', '/mock-backend/api/private/notes/mock_note_id', { - statusCode: 200 + cy.intercept('DELETE', `/api/mock-backend/private/notes/${testNoteId}`, { + statusCode: 204 }) cy.getByCypressId('sidebar.deleteNote.button').click() cy.getByCypressId('sidebar.deleteNote.modal').should('be.visible') diff --git a/cypress/integration/fileUpload.spec.ts b/cypress/integration/fileUpload.spec.ts index 9570a75b6..191133a04 100644 --- a/cypress/integration/fileUpload.spec.ts +++ b/cypress/integration/fileUpload.spec.ts @@ -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 */ @@ -16,13 +16,13 @@ describe('File upload', () => { beforeEach(() => { cy.intercept( { - method: 'GET', - url: '/mock-backend/api/private/media/upload-post' + method: 'POST', + url: '/api/mock-backend/private/media' }, { - statusCode: 200, + statusCode: 201, body: { - link: imageUrl + url: imageUrl } } ) @@ -69,8 +69,8 @@ describe('File upload', () => { it('fails', () => { cy.intercept( { - method: 'GET', - url: '/mock-backend/api/private/media/upload-post' + method: 'POST', + url: '/api/mock-backend/private/media' }, { statusCode: 400 diff --git a/cypress/integration/history.spec.ts b/cypress/integration/history.spec.ts index d3e72aa7c..c1eb428ec 100644 --- a/cypress/integration/history.spec.ts +++ b/cypress/integration/history.spec.ts @@ -24,7 +24,7 @@ describe('History', () => { describe('is as given when not empty', () => { beforeEach(() => { cy.clearLocalStorage('history') - cy.intercept('GET', '/mock-backend/api/private/me/history', { + cy.intercept('GET', '/api/mock-backend/private/me/history', { body: [ { identifier: 'cypress', @@ -51,7 +51,7 @@ describe('History', () => { describe('is untitled when not empty', () => { beforeEach(() => { cy.clearLocalStorage('history') - cy.intercept('GET', '/mock-backend/api/private/me/history', { + cy.intercept('GET', '/api/mock-backend/private/me/history', { body: [ { identifier: 'cypress-no-title', @@ -84,7 +84,7 @@ describe('History', () => { describe('working', () => { 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) }) }) @@ -106,7 +106,7 @@ describe('History', () => { describe('failing', () => { beforeEach(() => { - cy.intercept('PUT', '/mock-backend/api/private/me/history/features', { + cy.intercept('PUT', '/api/mock-backend/private/me/history/features', { statusCode: 401 }) }) @@ -128,7 +128,7 @@ describe('History', () => { describe('Import', () => { beforeEach(() => { cy.clearLocalStorage('history') - cy.intercept('GET', '/mock-backend/api/private/me/history', { + cy.intercept('GET', '/api/mock-backend/private/me/history', { body: [] }) cy.visitHistory() diff --git a/cypress/integration/intro.spec.ts b/cypress/integration/intro.spec.ts index e5bb144b8..a6729b51f 100644 --- a/cypress/integration/intro.spec.ts +++ b/cypress/integration/intro.spec.ts @@ -7,7 +7,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-call */ describe('Intro page', () => { beforeEach(() => { - cy.intercept('/mock-backend/public/intro.md', 'test content') + cy.intercept('/mock-public/intro.md', 'test content') cy.visitHome() }) @@ -17,7 +17,7 @@ describe('Intro page', () => { }) 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 }) cy.visitHome() diff --git a/cypress/integration/motd.spec.ts b/cypress/integration/motd.spec.ts index f61e336b2..06b9a187a 100644 --- a/cypress/integration/motd.spec.ts +++ b/cypress/integration/motd.spec.ts @@ -11,13 +11,13 @@ const motdMockHtml = 'This is the mock Motd call' describe('Motd', () => { const mockExistingMotd = (useEtag?: boolean, content = motdMockContent) => { - cy.intercept('GET', '/mock-backend/public/motd.md', { + cy.intercept('GET', '/mock-public/motd.md', { statusCode: 200, headers: { [useEtag ? 'etag' : 'Last-Modified']: MOCK_LAST_MODIFIED }, body: content }) - cy.intercept('HEAD', '/mock-backend/public/motd.md', { + cy.intercept('HEAD', '/mock-public/motd.md', { statusCode: 200, headers: { [useEtag ? 'etag' : 'Last-Modified']: MOCK_LAST_MODIFIED } }) diff --git a/cypress/integration/profile.spec.ts b/cypress/integration/profile.spec.ts index bf82a6512..af7d4de5a 100644 --- a/cypress/integration/profile.spec.ts +++ b/cypress/integration/profile.spec.ts @@ -8,7 +8,7 @@ describe('profile page', () => { beforeEach(() => { cy.intercept( { - url: '/mock-backend/api/private/tokens', + url: '/api/mock-backend/private/tokens', method: 'GET' }, { @@ -25,7 +25,7 @@ describe('profile page', () => { ) cy.intercept( { - url: '/mock-backend/api/private/tokens', + url: '/api/mock-backend/private/tokens', method: 'POST' }, { @@ -36,16 +36,18 @@ describe('profile page', () => { createdAt: '2021-11-21T01:11:12+01:00', lastUsed: '2021-11-21T01:11:12+01:00', validUntil: '2023-11-21' - } + }, + statusCode: 201 } ) cy.intercept( { - url: '/mock-backend/api/private/tokens/cypress', + url: '/api/mock-backend/private/tokens/cypress', method: 'DELETE' }, { - body: [] + body: [], + statusCode: 204 } ) cy.visit('/profile', { retryOnNetworkFailure: true }) diff --git a/cypress/integration/signInButton.spec.ts b/cypress/integration/signInButton.spec.ts index c82961be8..3faf7abb1 100644 --- a/cypress/integration/signInButton.spec.ts +++ b/cypress/integration/signInButton.spec.ts @@ -4,28 +4,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -const authProvidersDisabled = { - facebook: false, - github: false, - twitter: false, - gitlab: false, - dropbox: false, - ldap: false, - google: false, - saml: false, - oauth2: false, - local: false -} +import type { AuthProvider } from '../../src/api/config/types' +import { AuthProviderType } from '../../src/api/config/types' -const initLoggedOutTestWithCustomAuthProviders = ( - cy: Cypress.cy, - enabledProviders: Partial -) => { +const initLoggedOutTestWithCustomAuthProviders = (cy: Cypress.cy, enabledProviders: AuthProvider[]) => { cy.loadConfig({ - authProviders: { - ...authProvidersDisabled, - ...enabledProviders - } + authProviders: enabledProviders }) cy.visitHome() cy.logout() @@ -41,55 +25,71 @@ describe('When logged-in, ', () => { describe('When logged-out ', () => { describe('and no auth-provider is enabled, ', () => { it('sign-in button is hidden', () => { - initLoggedOutTestWithCustomAuthProviders(cy, {}) + initLoggedOutTestWithCustomAuthProviders(cy, []) cy.getByCypressId('sign-in-button').should('not.exist') }) }) describe('and an interactive auth-provider is enabled, ', () => { it('sign-in button points to login route: internal', () => { - initLoggedOutTestWithCustomAuthProviders(cy, { - local: true - }) + initLoggedOutTestWithCustomAuthProviders(cy, [ + { + type: AuthProviderType.LOCAL + } + ]) cy.getByCypressId('sign-in-button').should('be.visible').should('have.attr', 'href', '/login') }) it('sign-in button points to login route: ldap', () => { - initLoggedOutTestWithCustomAuthProviders(cy, { - ldap: true - }) + initLoggedOutTestWithCustomAuthProviders(cy, [ + { + type: AuthProviderType.LDAP, + identifier: 'cy-ldap', + providerName: 'cy LDAP' + } + ]) cy.getByCypressId('sign-in-button').should('be.visible').should('have.attr', 'href', '/login') }) }) describe('and only one one-click auth-provider is enabled, ', () => { it('sign-in button points to auth-provider', () => { - initLoggedOutTestWithCustomAuthProviders(cy, { - saml: true - }) + initLoggedOutTestWithCustomAuthProviders(cy, [ + { + type: AuthProviderType.GITHUB + } + ]) cy.getByCypressId('sign-in-button') .should('be.visible') // 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, ', () => { it('sign-in button points to login route', () => { - initLoggedOutTestWithCustomAuthProviders(cy, { - saml: true, - github: true - }) + initLoggedOutTestWithCustomAuthProviders(cy, [ + { + type: AuthProviderType.GITHUB + }, + { + type: AuthProviderType.GOOGLE + } + ]) 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, ', () => { it('sign-in button points to login route', () => { - initLoggedOutTestWithCustomAuthProviders(cy, { - saml: true, - local: true - }) + initLoggedOutTestWithCustomAuthProviders(cy, [ + { + type: AuthProviderType.GITHUB + }, + { + type: AuthProviderType.LOCAL + } + ]) cy.getByCypressId('sign-in-button').should('be.visible').should('have.attr', 'href', '/login') }) }) diff --git a/cypress/support/config.ts b/cypress/support/config.ts index 99aa0f2bb..6e9c7e7d2 100644 --- a/cypress/support/config.ts +++ b/cypress/support/config.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { AuthProviderType } from '../../src/api/config/types' + declare namespace Cypress { interface Chainable { loadConfig(): Chainable @@ -12,43 +14,70 @@ declare namespace Cypress { export const branding = { name: 'DEMO Corp', - logo: '/mock-backend/public/img/demo.png' + logo: '/mock-public/img/demo.png' } -export const authProviders = { - facebook: true, - github: true, - twitter: true, - gitlab: true, - dropbox: true, - ldap: true, - google: true, - saml: true, - oauth2: true, - local: true -} +export const authProviders = [ + { + type: AuthProviderType.FACEBOOK + }, + { + type: AuthProviderType.GITHUB + }, + { + type: AuthProviderType.TWITTER + }, + { + 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 = { allowAnonymous: true, + allowRegister: true, authProviders: authProviders, branding: branding, - customAuthNames: { - ldap: 'FooBar', - oauth2: 'Olaf2', - saml: 'aufSAMLn.de' - }, - maxDocumentLength: 200, + useImageProxy: false, specialUrls: { privacy: 'https://example.com/privacy', termsOfUse: 'https://example.com/termsOfUse', imprint: 'https://example.com/imprint' }, - plantumlServer: 'http://mock-plantuml.local', version: { - version: 'mock', - sourceCodeUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', - issueTrackerUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' + major: 0, + minor: 0, + patch: 0, + preRelease: '', + commit: 'MOCK' }, + plantumlServer: 'http://mock-plantuml.local', + maxDocumentLength: 200, iframeCommunication: { editorOrigin: '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) => { - return cy.intercept('/mock-backend/api/private/config', { + return cy.intercept('/api/mock-backend/private/config', { statusCode: 200, body: { ...config, @@ -68,11 +97,11 @@ Cypress.Commands.add('loadConfig', (additionalConfig?: Partial) = beforeEach(() => { cy.loadConfig() - cy.intercept('GET', '/mock-backend/public/motd.md', { + cy.intercept('GET', '/mock-public/motd.md', { body: '404 Not Found!', statusCode: 404 }) - cy.intercept('HEAD', '/mock-backend/public/motd.md', { + cy.intercept('HEAD', '/mock-public/motd.md', { statusCode: 404 }) }) diff --git a/cypress/support/index.ts b/cypress/support/index.ts index 037bdb760..cd5c942ad 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -25,6 +25,6 @@ import './config' import './fill' import './get-by-id' import './get-iframe-content' -import './login' +import './logout' import './visit-test-editor' import './visit' diff --git a/cypress/support/login.ts b/cypress/support/logout.ts similarity index 100% rename from cypress/support/login.ts rename to cypress/support/logout.ts diff --git a/cypress/support/visit-test-editor.ts b/cypress/support/visit-test-editor.ts index fc21cb13c..4f12ed555 100644 --- a/cypress/support/visit-test-editor.ts +++ b/cypress/support/visit-test-editor.ts @@ -3,26 +3,30 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ - export const testNoteId = 'test' beforeEach(() => { - cy.intercept(`/mock-backend/api/private/notes/${testNoteId}-get`, { + cy.intercept(`/api/mock-backend/private/notes/${testNoteId}`, { content: '', metadata: { - id: 'mock_note_id', - alias: 'mockNote', - version: 2, - viewCount: 0, + id: testNoteId, + alias: ['mock-note'], + primaryAlias: 'mock-note', + title: 'Mock Note', + description: 'Mocked note for testing', + tags: ['test', 'mock', 'cypress'], updateTime: '2021-04-24T09:27:51.000Z', - updateUser: { - userName: 'test', - displayName: 'Testy', - photo: '', - email: '' - }, + updateUser: null, + viewCount: 0, + version: 2, createTime: '2021-04-24T09:27:51.000Z', - editedBy: [] - } + editedBy: [], + permissions: { + owner: null, + sharedToUsers: [], + sharedToGroups: [] + } + }, + editedByAtPosition: [] }) }) diff --git a/locales/en.json b/locales/en.json index 76c28bb08..9e7f3f1d5 100644 --- a/locales/en.json +++ b/locales/en.json @@ -163,7 +163,9 @@ "new": "New password", "newAgain": "New password again", "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.", "accountManagement": "Account management", @@ -178,6 +180,7 @@ "label": "Token label", "created": "created {{time}}", "lastUsed": "last used {{time}}", + "neverUsed": "never used", "loadingFailed": "Fetching your access tokens has failed. Try reloading this page.", "creationFailed": "Creating the access token failed.", "expiry": "Expiry date" @@ -375,7 +378,7 @@ }, "documentInfo": { "title": "Document info", - "created": "<0> created this note <1>", + "created": "This note was created <0>", "edited": "<0> was the last editor <1>", "usersContributed": "<0> users contributed to this document", "revisions": "<0> revisions are saved", @@ -393,6 +396,7 @@ "title": "Revisions", "revertButton": "Revert", "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", "download": "Download selected revision" }, @@ -410,18 +414,25 @@ "title": "Permissions", "owner": "Owner", "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", "viewOnlyUser": "Change {{name}}'s permissions to view only", "removeUser": "Remove {{name}}'s permissions", "addUser": "Add user", "editGroup": "Change permissions of group \"{{name}}\" to view & edit", "viewOnlyGroup": "Change permissions of group \"{{name}}\" to view only", + "removeGroup": "Remove permissions of group \"{{name}}\"", "denyGroup": "Deny access to group \"{{name}}\"", "addGroup": "Add group", "allUser": "Everyone", "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": { "title": "Share link", @@ -499,9 +510,10 @@ "avatarOf": "avatar of '{{name}}'", "why": "Why?", "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.", - "readForMoreInfo": "Read here for more information" + "errorOccurred": "An error occurred", + "readForMoreInfo": "Read here for more information", + "guestUser": "Guest user" }, "copyOverlay": { "error": "Error while copying!", diff --git a/netlify/intro.md b/netlify/intro.md index 8437de0e4..353b2b1cb 100644 --- a/netlify/intro.md +++ b/netlify/intro.md @@ -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. ::: -![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) diff --git a/netlify/patch-files.sh b/netlify/patch-files.sh index 96b497c86..283f6aefd 100644 --- a/netlify/patch-files.sh +++ b/netlify/patch-files.sh @@ -1,14 +1,16 @@ #!/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 +# set -e 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.' -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' jq ".version = \"0.0.0+${GITHUB_SHA:0:8}\"" src/version.json > src/_version.json mv src/_version.json src/version.json diff --git a/package.json b/package.json index 6a10b5094..7d09e6d3f 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "lint": "eslint --max-warnings=0 --ext .ts,.tsx src", "lint:fix": "eslint --fix --ext .ts,.tsx src", "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", "cy:open": "cypress open", "cy:run:chrome": "cypress run --browser chrome", @@ -53,6 +54,7 @@ "copy-webpack-plugin": "10.2.4", "cross-env": "7.0.3", "d3-graphviz": "4.1.1", + "deepmerge": "4.2.2", "diff": "5.0.0", "dompurify": "2.3.6", "emoji-picker-element": "1.11.2", diff --git a/public/mock-backend/api/private/config b/public/mock-backend/api/private/config deleted file mode 100644 index 062652494..000000000 --- a/public/mock-backend/api/private/config +++ /dev/null @@ -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/" - } -} diff --git a/public/mock-backend/api/private/me-get b/public/mock-backend/api/private/me-get deleted file mode 100644 index c21c60a1a..000000000 --- a/public/mock-backend/api/private/me-get +++ /dev/null @@ -1,6 +0,0 @@ -{ - "username": "mockUser", - "photo": "/mock-backend/public/img/avatar.png", - "displayName": "Test", - "email": "mock@hedgedoc.dev" -} diff --git a/public/mock-backend/api/private/me/history b/public/mock-backend/api/private/me/history deleted file mode 100644 index f5d49d61a..000000000 --- a/public/mock-backend/api/private/me/history +++ /dev/null @@ -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 - Let’s 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" - ] - } -] diff --git a/public/mock-backend/api/private/media/upload-post b/public/mock-backend/api/private/media/upload-post deleted file mode 100644 index 3aaa05f41..000000000 --- a/public/mock-backend/api/private/media/upload-post +++ /dev/null @@ -1,3 +0,0 @@ -{ - "link": "/mock-backend/public/img/avatar.png" -} diff --git a/public/mock-backend/api/private/notes/ABC2/revisions-list b/public/mock-backend/api/private/notes/ABC2/revisions-list deleted file mode 100644 index e89b4e765..000000000 --- a/public/mock-backend/api/private/notes/ABC2/revisions-list +++ /dev/null @@ -1,12 +0,0 @@ -[ - { - "timestamp": 1598390307, - "length": 2788, - "authors": ["dermolly", "mrdrogdrog"] - }, - { - "timestamp": 1598389571, - "length": 2782, - "authors": ["dermolly", "mrdrogdrog", "emcrx"] - } -] diff --git a/public/mock-backend/api/private/notes/ABC2/revisions/1598389571 b/public/mock-backend/api/private/notes/ABC2/revisions/1598389571 deleted file mode 100644 index 94ca9c3a0..000000000 --- a/public/mock-backend/api/private/notes/ABC2/revisions/1598389571 +++ /dev/null @@ -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 displayed<\/size><\/back>\n __left of__ Alice.\nend note\nnote left of Bob\n This<\/u> is displayed<\/color>\n **left of<\/color> Alice<\/strike> Bob**.\nend note\nnote over Alice, Bob\n This is hosted<\/w> by \nend note\n@enduml\n```\n\n", - "timestamp": 1598389571, - "authors": ["mrdrogdrog", "dermolly", "emcrx"] -} diff --git a/public/mock-backend/api/private/notes/ABC2/revisions/1598390307 b/public/mock-backend/api/private/notes/ABC2/revisions/1598390307 deleted file mode 100644 index b053369d1..000000000 --- a/public/mock-backend/api/private/notes/ABC2/revisions/1598390307 +++ /dev/null @@ -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 displayed<\/size><\/back>\n __left of__ Alice.\nend note\nnote left of Bob\n This<\/u> is displayed<\/color>\n **left of<\/color> Alice<\/strike> Bob**.\nend note\nnote over Alice, Bob\n This is hosted<\/w> by \nend note\n@enduml\n```\n\n", - "timestamp": 1598390307, - "authors": ["mrdrogdrog", "dermolly"] -} diff --git a/public/mock-backend/api/private/notes/features-get b/public/mock-backend/api/private/notes/features-get deleted file mode 100644 index 3cbe1cad2..000000000 --- a/public/mock-backend/api/private/notes/features-get +++ /dev/null @@ -1,18 +0,0 @@ -{ - "content": "---\ntitle: Features\ndescription: Many features, such wow!\nrobots: noindex\ntags:\n - hedgedoc\n - demo\n - react\nopengraph:\n title: Features\n---\n# Embedding demo\n[TOC]\n\n## markmap\n\n\n```markmap\n# MarkMap\n\n## Pro\n\n### written in typescript\n\n## Cons\n\n### must redeclare types\n```\n\n## Vega-Lite\n\n```vega-lite\n\n{\n \"$schema\": \"https://vega.github.io/schema/vega-lite/v5.json\",\n \"description\": \"Reproducing http://robslink.com/SAS/democd91/pyramid_pie.htm\",\n \"data\": {\n \"values\": [\n {\"category\": \"Sky\", \"value\": 75, \"order\": 3},\n {\"category\": \"Shady side of a pyramid\", \"value\": 10, \"order\": 1},\n {\"category\": \"Sunny side of a pyramid\", \"value\": 15, \"order\": 2}\n ]\n },\n \"mark\": {\"type\": \"arc\", \"outerRadius\": 80},\n \"encoding\": {\n \"theta\": {\n \"field\": \"value\", \"type\": \"quantitative\",\n \"scale\": {\"range\": [2.35619449, 8.639379797]},\n \"stack\": true\n },\n \"color\": {\n \"field\": \"category\", \"type\": \"nominal\",\n \"scale\": {\n \"domain\": [\"Sky\", \"Shady side of a pyramid\", \"Sunny side of a pyramid\"],\n \"range\": [\"#416D9D\", \"#674028\", \"#DEAC58\"]\n },\n \"legend\": {\n \"orient\": \"none\",\n \"title\": null,\n \"columns\": 1,\n \"legendX\": 200,\n \"legendY\": 80\n }\n },\n \"order\": {\n \"field\": \"order\"\n }\n },\n \"view\": {\"stroke\": null}\n}\n\n\n```\n\n## GraphViz\n\n```graphviz\ngraph {\n a -- b\n a -- b\n b -- a [color=blue]\n}\n```\n\n```graphviz\ndigraph structs {\n node [shape=record];\n struct1 [label=\" left| mid\ dle| right\"];\n struct2 [label=\" one| two\"];\n struct3 [label=\"hello\nworld |{ b |{c| d|e}| f}| g | h\"];\n struct1:f1 -> struct2:f0;\n struct1:f2 -> struct3:here;\n}\n```\n\n```graphviz\ndigraph G {\n main -> parse -> execute;\n main -> init;\n main -> cleanup;\n execute -> make_string;\n execute -> printf\n init -> make_string;\n main -> printf;\n execute -> compare;\n}\n```\n\n```graphviz\ndigraph D {\n node [fontname=\"Arial\"];\n node_A [shape=record label=\"shape=record|{above|middle|below}|right\"];\n node_B [shape=plaintext label=\"shape=plaintext|{curly|braces and|bars without}|effect\"];\n}\n```\n\n```graphviz\ndigraph D {\n A -> {B, C, D} -> {F}\n}\n```\n\n## High Res Image\n\n![Wheat Field with Cypresses](/mock-backend/public/img/highres.jpg)\n\n## Sequence Diagram (deprecated)\n\n```sequence\nTitle: Here is a title\nnote over A: asdd\nA->B: Normal line\nB-->C: Dashed line\nC->>D: Open arrow\nD-->>A: Dashed open arrow\nparticipant IOOO\n```\n\n## Mermaid\n\n```mermaid\ngantt\n title A Gantt Diagram\n\n section Section\n A task: a1, 2014-01-01, 30d\n Another task: after a1, 20d\n\n section Another\n Task in sec: 2014-01-12, 12d\n Another task: 24d\n```\n\n## Flowchart\n\n```flow\nst=>start: Start\ne=>end: End\nop=>operation: My Operation\nop2=>operation: lalala\ncond=>condition: Yes or No?\n\nst->op->op2->cond\ncond(yes)->e\ncond(no)->op2\n```\n\n## ABC\n\n```abc\nX:1\nT:Speed the Plough\nM:4/4\nC:Trad.\nK:G\n|:GABc dedB|dedB dedB|c2ec B2dB|c2A2 A2BA|\nGABc dedB|dedB dedB|c2ec B2dB|A2F2 G4:|\n|:g2gf gdBd|g2f2 e2d2|c2ec B2dB|c2A2 A2df|\ng2gf g2Bd|g2f2 e2d2|c2ec B2dB|A2F2 G4:|\n```\n\n## CSV\n\n```csv delimiter=; header\nUsername; Identifier;First name;Last name\n\"booker12; rbooker\";9012;Rachel;Booker\ngrey07;2070;Laura;Grey\njohnson81;4081;Craig;Johnson\njenkins46;9346;Mary;Jenkins\nsmith79;5079;Jamie;Smith\n```\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## KaTeX\nYou can render *LaTeX* mathematical expressions using **KaTeX**, 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=YE7VzlLtp-4\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```js=\nvar s = \"JavaScript syntax highlighting\";\nalert(s);\nfunction $initHighlight(block, cls) {\n try {\n if (cls.search(/\\bno\\-highlight\\b/) != -1)\n return process(block, true, 0x0F) +\n ' class=\"\"';\n } catch (e) {\n /* handle exception */\n }\n for (var i = 0 / 2; i < classes.length; i++) {\n if (checkCondition(classes[i]) === undefined)\n return /\\d+[\\s/]/g;\n }\n}\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 displayed\n __left of__ Alice.\nend note\nnote left of Bob\n This is displayed\n **left of Alice Bob**.\nend note\nnote over Alice, Bob\n This is hosted by \nend note\n@enduml\n```\n\n## ToDo List\n\n- [ ] ToDos\n - [X] Buy some salad\n - [ ] Brush teeth\n - [x] Drink some water\n - [ ] **Click my box** and see the source code, if you're allowed to edit!\n\n", - "metadata": { - "id": "ABC2", - "alias": "features", - "version": 2, - "viewCount": 0, - "updateTime": "2021-04-24T09:27:51.000Z", - "updateUser": { - "userName": "test", - "displayName": "Testy", - "photo": "", - "email": "" - }, - "createTime": "2021-04-24T09:27:51.000Z", - "editedBy": [] - } -} diff --git a/public/mock-backend/api/private/notes/old-get b/public/mock-backend/api/private/notes/old-get deleted file mode 100644 index 028529b58..000000000 --- a/public/mock-backend/api/private/notes/old-get +++ /dev/null @@ -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": [] - } -} diff --git a/public/mock-backend/api/private/notes/slide-example-get b/public/mock-backend/api/private/notes/slide-example-get deleted file mode 100644 index c25346665..000000000 --- a/public/mock-backend/api/private/notes/slide-example-get +++ /dev/null @@ -1,18 +0,0 @@ -{ - "content": "---\ntype: slide\nslideOptions:\n transition: slide\n---\n\n# Slide example\n\nThis feature still in beta, may have some issues.\n\nFor details please visit:\n\n\nYou can use `URL query` or `slideOptions` of the YAML metadata to customize your slides.\n\n---\n\n## First slide\n\n`---`\n\nIs the divider of slides\n\n----\n\n### First branch of first the slide\n\n`----`\n\nIs the divider of branches\n\nUse the *Space* key to navigate through all slides.\n\n----\n\n### Second branch of first the slide\n\nNested slides are useful for adding additional detail underneath a high-level horizontal slide.\n\n---\n\n## Point of View\n\nPress **ESC** to enter the slide overview.\n\n---\n\n## Touch Optimized\n\nPresentations look great on touch devices, like mobile phones and tablets. Simply swipe through your slides.\n\n---\n\n## Fragments\n\n``\n\nIs the fragment syntax\n\nHit the next arrow...\n\n... to step through ...\n\n... a fragmented slide.\n\nNote:\n This slide has fragments which are also stepped through in the notes window.\n\n---\n\n## Fragment Styles\n\nThere are different types of fragments, like:\n\ngrow\n\nshrink\n\nfade-out\n\nfade-up (also down, left and right!)\n\ncurrent-visible\n\nHighlight red blue green\n\n---\n\n\n\n## Transition Styles\nDifferent background transitions are available via the transition option. This one's called \"zoom\".\n\n``\n\nIs the transition syntax\n\nYou can use:\n\nnone/fade/slide/convex/concave/zoom\n\n---\n\n\n\n``\n\nAlso, you can set different in/out transition\n\nYou can use:\n\nnone/fade/slide/convex/concave/zoom\n\npostfix with `-in` or `-out`\n\n---\n\n\n\n``\n\nCustom the transition speed!\n\nYou can use:\n\ndefault/fast/slow\n\n---\n\n## Themes\n\nreveal.js comes with a few themes built in:\n\nBlack (default) - White - League - Sky - Beige - Simple\n\nSerif - Blood - Night - Moon - Solarized\n\nIt can be set in YAML slideOptions\n\n---\n\n\n\n``\n\nIs the background syntax\n\n---\n\n\n\n
\n\n## Image Backgrounds\n\n``\n\n
\n\n----\n\n\n\n
\n\n## Tiled Backgrounds\n\n``\n\n
\n\n----\n\n\n\n
\n\n## Video Backgrounds\n\n``\n\n
\n\n----\n\n\n\n## ... and GIFs!\n\n---\n\n## Pretty Code\n\n``` javascript\nfunction linkify( selector ) {\n if( supports3DTransforms ) {\n\n const nodes = document.querySelectorAll( selector );\n\n for( const i = 0, len = nodes.length; i < len; i++ ) {\n var node = nodes[i];\n\n if( !node.className ) {\n node.className += ' roll';\n }\n }\n }\n}\n```\nCode syntax highlighting courtesy of [highlight.js](http://softwaremaniacs.org/soft/highlight/en/description/).\n\n---\n\n## Marvelous List\n\n- No order here\n- Or here\n- Or here\n- Or here\n\n---\n\n## Fantastic Ordered List\n\n1. One is smaller than...\n2. Two is smaller than...\n3. Three!\n\n---\n\n## Tabular Tables\n\n| Item | Value | Quantity |\n| ---- | ----- | -------- |\n| Apples | $1 | 7 |\n| Lemonade | $2 | 18 |\n| Bread | $3 | 2 |\n\n---\n\n## Clever Quotes\n\n> “For years there has been a theory that millions of monkeys typing at random on millions of typewriters would reproduce the entire works of Shakespeare. The Internet has proven this theory to be untrue.”\n\n---\n\n## Intergalactic Interconnections\n\nYou can link between slides internally, [like this](#/1/3).\n\n---\n\n## Speaker\n\nThere's a [speaker view](https://github.com/hakimel/reveal.js#speaker-notes). It includes a timer, preview of the upcoming slide as well as your speaker notes.\n\nPress the *S* key to try it out.\n\nNote:\n Oh hey, these are some notes. They'll be hidden in your presentation, but you can see them if you open the speaker notes window (hit `s` on your keyboard).\n\n---\n\n## Take a Moment\n\nPress `B` or `.` on your keyboard to pause the presentation. This is helpful when you're on stage and want to take distracting slides off the screen.\n\n---\n\n## Print your Slides\n\nDown below you can find a print icon.\n\nAfter you click on it, use the print function of your browser (either CTRL+P or cmd+P) to print the slides as PDF. [See official reveal.js instructions for details](https://github.com/hakimel/reveal.js#instructions-1)\n\n---\n\n# The End\n\n", - "metadata": { - "id": "SLIDE2", - "alias": "slide-example", - "version": 2, - "viewCount": 0, - "updateTime": "2021-04-30T18:38:23.000Z", - "updateUser": { - "userName": "test", - "displayName": "Testy", - "photo": "", - "email": "" - }, - "createTime": "2021-04-30T18:38:14.000Z", - "editedBy": [] - } -} diff --git a/public/mock-backend/api/private/tokens b/public/mock-backend/api/private/tokens deleted file mode 100644 index 0183bc195..000000000 --- a/public/mock-backend/api/private/tokens +++ /dev/null @@ -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" - } -] diff --git a/public/mock-backend/api/private/users/dermolly b/public/mock-backend/api/private/users/dermolly deleted file mode 100644 index c213e0a41..000000000 --- a/public/mock-backend/api/private/users/dermolly +++ /dev/null @@ -1,7 +0,0 @@ -{ - "id": "dermolly", - "photo": "/mock-backend/public/img/avatar.png", - "name": "Philip", - "status": "ok", - "provider": "internal" -} diff --git a/public/mock-backend/api/private/users/emcrx b/public/mock-backend/api/private/users/emcrx deleted file mode 100644 index fb1a71149..000000000 --- a/public/mock-backend/api/private/users/emcrx +++ /dev/null @@ -1,7 +0,0 @@ -{ - "id": "emcrx", - "photo": "/mock-backend/public/img/avatar.png", - "name": "Erik", - "status": "ok", - "provider": "internal" -} diff --git a/public/mock-backend/api/private/users/mrdrogdrog b/public/mock-backend/api/private/users/mrdrogdrog deleted file mode 100644 index cff873548..000000000 --- a/public/mock-backend/api/private/users/mrdrogdrog +++ /dev/null @@ -1,7 +0,0 @@ -{ - "id": "mrdrogdrog", - "photo": "/mock-backend/public/img/avatar.png", - "name": "Tilman", - "status": "ok", - "provider": "internal" -} diff --git a/public/mock-backend/public/img/avatar.png b/public/mock-public/img/avatar.png similarity index 100% rename from public/mock-backend/public/img/avatar.png rename to public/mock-public/img/avatar.png diff --git a/public/mock-backend/public/img/demo.png b/public/mock-public/img/demo.png similarity index 100% rename from public/mock-backend/public/img/demo.png rename to public/mock-public/img/demo.png diff --git a/public/mock-backend/public/img/highres.jpg b/public/mock-public/img/highres.jpg similarity index 100% rename from public/mock-backend/public/img/highres.jpg rename to public/mock-public/img/highres.jpg diff --git a/public/mock-backend/public/intro.md b/public/mock-public/intro.md similarity index 67% rename from public/mock-backend/public/intro.md rename to public/mock-public/intro.md index 885360038..6752d891a 100644 --- a/public/mock-backend/public/intro.md +++ b/public/mock-public/intro.md @@ -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. ::: -![HedgeDoc Screenshot](/mock-backend/public/screenshot.png) +![HedgeDoc Screenshot](/mock-public/screenshot.png) diff --git a/public/mock-backend/public/motd.md b/public/mock-public/motd.md similarity index 100% rename from public/mock-backend/public/motd.md rename to public/mock-public/motd.md diff --git a/public/mock-backend/public/screenshot.png b/public/mock-public/screenshot.png similarity index 100% rename from public/mock-backend/public/screenshot.png rename to public/mock-public/screenshot.png diff --git a/src/api/alias/index.ts b/src/api/alias/index.ts new file mode 100644 index 000000000..d7346f335 --- /dev/null +++ b/src/api/alias/index.ts @@ -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 => { + const response = await new PostApiRequestBuilder('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 => { + const response = await new PutApiRequestBuilder('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 => { + await new DeleteApiRequestBuilder('alias/' + alias).sendRequest() +} diff --git a/src/api/alias/types.ts b/src/api/alias/types.ts new file mode 100644 index 000000000..193145e9c --- /dev/null +++ b/src/api/alias/types.ts @@ -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 +} diff --git a/src/api/auth/index.ts b/src/api/auth/index.ts index e3348cbaa..737a369b1 100644 --- a/src/api/auth/index.ts +++ b/src/api/auth/index.ts @@ -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 */ -import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils' - -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' -} +import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder' /** - * Requests to logout the current user. + * Requests to log out the current user. * @throws Error if logout is not possible. */ export const doLogout = async (): Promise => { - const response = await fetch(getApiUrl() + 'auth/logout', { - ...defaultFetchConfig, - method: 'DELETE' - }) - - expectResponseCode(response) + await new DeleteApiRequestBuilder('auth/logout').sendRequest() } diff --git a/src/api/auth/ldap.ts b/src/api/auth/ldap.ts index 41ed218b9..43d5bd28a 100644 --- a/src/api/auth/ldap.ts +++ b/src/api/auth/ldap.ts @@ -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 */ -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. + * @param provider The identifier of the LDAP provider with which to login. * @param username The username with which to try the login. * @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 => { - const response = await fetch(getApiUrl() + 'auth/ldap', { - ...defaultFetchConfig, - method: 'POST', - body: JSON.stringify({ +export const doLdapLogin = async (provider: string, username: string, password: string): Promise => { + await new PostApiRequestBuilder('auth/ldap/' + provider) + .withJsonBody({ username: username, password: password }) - }) - - expectResponseCode(response) + .withStatusCodeErrorMapping({ + 401: AuthError.INVALID_CREDENTIALS + }) + .sendRequest() } diff --git a/src/api/auth/local.ts b/src/api/auth/local.ts index 578238619..78afb28ea 100644 --- a/src/api/auth/local.ts +++ b/src/api/auth/local.ts @@ -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 */ - -import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils' -import { AuthError, RegisterError } from './index' +import type { ChangePasswordDto, LoginDto, RegisterDto } from './types' +import { AuthError, RegisterError } from './types' +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. * @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.LOGIN_DISABLED} when the local login is disabled on the backend. */ export const doLocalLogin = async (username: string, password: string): Promise => { - const response = await fetch(getApiUrl() + 'auth/local/login', { - ...defaultFetchConfig, - method: 'POST', - body: JSON.stringify({ + await new PostApiRequestBuilder('auth/local/login') + .withJsonBody({ username, password }) - }) - - if (response.status === 400) { - throw new Error(AuthError.LOGIN_DISABLED) - } - - if (response.status === 401) { - throw new Error(AuthError.INVALID_CREDENTIALS) - } - - expectResponseCode(response, 201) + .withStatusCodeErrorMapping({ + 400: AuthError.LOGIN_DISABLED, + 401: AuthError.INVALID_CREDENTIALS + }) + .sendRequest() } /** @@ -40,29 +33,21 @@ export const doLocalLogin = async (username: string, password: string): Promise< * @param username The username of the new user. * @param displayName The display name 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. */ export const doLocalRegister = async (username: string, displayName: string, password: string): Promise => { - const response = await fetch(getApiUrl() + 'auth/local', { - ...defaultFetchConfig, - method: 'POST', - body: JSON.stringify({ + await new PostApiRequestBuilder('auth/local') + .withJsonBody({ username, displayName, password }) - }) - - if (response.status === 409) { - throw new Error(RegisterError.USERNAME_EXISTING) - } - - if (response.status === 400) { - throw new Error(RegisterError.REGISTRATION_DISABLED) - } - - expectResponseCode(response) + .withStatusCodeErrorMapping({ + 400: RegisterError.REGISTRATION_DISABLED, + 409: RegisterError.USERNAME_EXISTING + }) + .sendRequest() } /** @@ -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. */ export const doLocalPasswordChange = async (currentPassword: string, newPassword: string): Promise => { - const response = await fetch(getApiUrl() + 'auth/local', { - ...defaultFetchConfig, - method: 'PUT', - body: JSON.stringify({ + await new PutApiRequestBuilder('auth/local') + .withJsonBody({ currentPassword, newPassword }) - }) - - if (response.status === 401) { - throw new Error(AuthError.INVALID_CREDENTIALS) - } - - if (response.status === 400) { - throw new Error(AuthError.LOGIN_DISABLED) - } - - expectResponseCode(response) + .withStatusCodeErrorMapping({ + 400: AuthError.LOGIN_DISABLED, + 401: AuthError.INVALID_CREDENTIALS + }) + .sendRequest() } diff --git a/src/api/auth/types.ts b/src/api/auth/types.ts new file mode 100644 index 000000000..b34629a34 --- /dev/null +++ b/src/api/auth/types.ts @@ -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 +} diff --git a/src/api/common/api-request-builder/api-request-builder-with-body.ts b/src/api/common/api-request-builder/api-request-builder-with-body.ts new file mode 100644 index 000000000..f26df7a4b --- /dev/null +++ b/src/api/common/api-request-builder/api-request-builder-with-body.ts @@ -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 extends ApiRequestBuilder { + /** + * 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)) + } +} diff --git a/src/api/common/api-request-builder/api-request-builder.ts b/src/api/common/api-request-builder/api-request-builder.ts new file mode 100644 index 000000000..0fbb29583 --- /dev/null +++ b/src/api/common/api-request-builder/api-request-builder.ts @@ -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 { + private readonly targetUrl: string + private overrideExpectedResponseStatus: number | undefined + private customRequestOptions = defaultConfig + private customRequestHeaders = new Headers(defaultHeaders) + private customStatusCodeErrorMapping: Record | 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> { + 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>): 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): 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> +} diff --git a/src/api/common/api-request-builder/delete-api-request-builder.test.ts b/src/api/common/api-request-builder/delete-api-request-builder.test.ts new file mode 100644 index 000000000..f94d419c1 --- /dev/null +++ b/src/api/common/api-request-builder/delete-api-request-builder.test.ts @@ -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('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('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('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('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('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('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('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('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('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('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('test') + .withStatusCodeErrorMapping({ + 400: 'noooooo', + 401: 'not you!' + }) + .sendRequest() + await expect(request).rejects.toThrow('not you!') + }) + }) +}) diff --git a/src/api/common/api-request-builder/delete-api-request-builder.ts b/src/api/common/api-request-builder/delete-api-request-builder.ts new file mode 100644 index 000000000..8191edde3 --- /dev/null +++ b/src/api/common/api-request-builder/delete-api-request-builder.ts @@ -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 extends ApiRequestBuilderWithBody< + ResponseType, + RequestBodyType +> { + /** + * @see {ApiRequestBuilder#sendRequest} + */ + sendRequest(): Promise> { + return this.sendRequestAndVerifyResponse('DELETE', 204) + } +} diff --git a/src/api/common/api-request-builder/get-api-request-builder.test.ts b/src/api/common/api-request-builder/get-api-request-builder.test.ts new file mode 100644 index 000000000..def80d38c --- /dev/null +++ b/src/api/common/api-request-builder/get-api-request-builder.test.ts @@ -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('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('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('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('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('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('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('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('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('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('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('test') + .withStatusCodeErrorMapping({ + 400: 'noooooo', + 401: 'not you!' + }) + .sendRequest() + await expect(request).rejects.toThrow('not you!') + }) + }) +}) diff --git a/src/api/common/api-request-builder/get-api-request-builder.ts b/src/api/common/api-request-builder/get-api-request-builder.ts new file mode 100644 index 000000000..ffe2bcf12 --- /dev/null +++ b/src/api/common/api-request-builder/get-api-request-builder.ts @@ -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 extends ApiRequestBuilder { + /** + * @see {ApiRequestBuilder#sendRequest} + */ + sendRequest(): Promise> { + return this.sendRequestAndVerifyResponse('GET', 200) + } +} diff --git a/src/api/common/api-request-builder/post-api-request-builder.test.ts b/src/api/common/api-request-builder/post-api-request-builder.test.ts new file mode 100644 index 000000000..ab5623a68 --- /dev/null +++ b/src/api/common/api-request-builder/post-api-request-builder.test.ts @@ -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('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('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('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('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('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('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('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('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('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('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('test') + .withStatusCodeErrorMapping({ + 400: 'noooooo', + 401: 'not you!' + }) + .sendRequest() + await expect(request).rejects.toThrow('not you!') + }) + }) +}) diff --git a/src/api/common/api-request-builder/post-api-request-builder.ts b/src/api/common/api-request-builder/post-api-request-builder.ts new file mode 100644 index 000000000..e2168d689 --- /dev/null +++ b/src/api/common/api-request-builder/post-api-request-builder.ts @@ -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 extends ApiRequestBuilderWithBody< + ResponseType, + RequestBodyType +> { + /** + * @see {ApiRequestBuilder#sendRequest} + */ + sendRequest(): Promise> { + return this.sendRequestAndVerifyResponse('POST', 201) + } +} diff --git a/src/api/common/api-request-builder/put-api-request-builder.test.ts b/src/api/common/api-request-builder/put-api-request-builder.test.ts new file mode 100644 index 000000000..81325bdc3 --- /dev/null +++ b/src/api/common/api-request-builder/put-api-request-builder.test.ts @@ -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('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('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('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('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('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('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('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('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('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('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('test') + .withStatusCodeErrorMapping({ + 400: 'noooooo', + 401: 'not you!' + }) + .sendRequest() + await expect(request).rejects.toThrow('not you!') + }) + }) +}) diff --git a/src/api/common/api-request-builder/put-api-request-builder.ts b/src/api/common/api-request-builder/put-api-request-builder.ts new file mode 100644 index 000000000..7f0381766 --- /dev/null +++ b/src/api/common/api-request-builder/put-api-request-builder.ts @@ -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 extends ApiRequestBuilderWithBody< + ResponseType, + RequestBodyType +> { + /** + * @see {ApiRequestBuilder#sendRequest} + */ + sendRequest(): Promise> { + return this.sendRequestAndVerifyResponse('PUT', 200) + } +} diff --git a/src/api/common/api-request-builder/test-utils/expect-fetch.ts b/src/api/common/api-request-builder/test-utils/expect-fetch.ts new file mode 100644 index 000000000..fb50d5a3c --- /dev/null +++ b/src/api/common/api-request-builder/test-utils/expect-fetch.ts @@ -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 => { + expect(fetchUrl).toEqual(expectedUrl) + expect(fetchOptions).toStrictEqual({ + ...defaultConfig, + body: undefined, + headers: new Headers(), + ...expectedOptions + }) + return Promise.resolve( + Mock.of({ + status: expectedStatusCode + }) + ) + }) +} diff --git a/src/api/common/api-response.test.ts b/src/api/common/api-response.test.ts new file mode 100644 index 000000000..2f037d0d7 --- /dev/null +++ b/src/api/common/api-response.test.ts @@ -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() + const responseObj = new ApiResponse(mockResponse) + expect(responseObj.getResponse()).toEqual(mockResponse) + }) + + it('asBlob', async () => { + const mockBlob = Mock.of() + const mockResponse = Mock.of({ + blob(): Promise { + 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({ + 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({ + headers: mockHeaders, + json(): Promise { + return Promise.resolve(mockBody) + } + }) + const responseObj = new ApiResponse(mockResponse) + await expect(responseObj.asParsedJsonObject()).resolves.toEqual(mockBody) + }) + }) +}) diff --git a/src/api/common/api-response.ts b/src/api/common/api-response.ts new file mode 100644 index 000000000..58f891b71 --- /dev/null +++ b/src/api/common/api-response.ts @@ -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 { + 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 { + 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 { + return await this.response.blob() + } +} diff --git a/src/api/common/default-config.ts b/src/api/common/default-config.ts new file mode 100644 index 000000000..ea2f846b5 --- /dev/null +++ b/src/api/common/default-config.ts @@ -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 = { + mode: 'cors', + cache: 'no-cache', + credentials: 'same-origin', + redirect: 'follow', + referrerPolicy: 'no-referrer' +} diff --git a/src/api/config/index.ts b/src/api/config/index.ts index e21e56084..5c13f5600 100644 --- a/src/api/config/index.ts +++ b/src/api/config/index.ts @@ -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 */ -import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils' 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 => { - const response = await fetch(getApiUrl() + 'config', { - ...defaultFetchConfig - }) - expectResponseCode(response) - return (await response.json()) as Promise + const response = await new GetApiRequestBuilder('config').sendRequest() + return response.asParsedJsonObject() } diff --git a/src/api/config/types.d.ts b/src/api/config/types.d.ts deleted file mode 100644 index d8407b6f9..000000000 --- a/src/api/config/types.d.ts +++ /dev/null @@ -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 -} diff --git a/src/api/config/types.ts b/src/api/config/types.ts new file mode 100644 index 000000000..ce49ab9be --- /dev/null +++ b/src/api/config/types.ts @@ -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 +} diff --git a/src/api/group/index.ts b/src/api/group/index.ts new file mode 100644 index 000000000..7bef46f3a --- /dev/null +++ b/src/api/group/index.ts @@ -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 => { + const response = await new GetApiRequestBuilder('groups/' + groupName).sendRequest() + return response.asParsedJsonObject() +} diff --git a/src/api/group/types.d.ts b/src/api/group/types.ts similarity index 85% rename from src/api/group/types.d.ts rename to src/api/group/types.ts index f6e2c8c1f..505921031 100644 --- a/src/api/group/types.d.ts +++ b/src/api/group/types.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -export interface GroupInfoDto { +export interface GroupInfo { name: string displayName: string special: boolean diff --git a/src/api/history/dto-methods.ts b/src/api/history/dto-methods.ts index cc39de039..3028b79a9 100644 --- a/src/api/history/dto-methods.ts +++ b/src/api/history/dto-methods.ts @@ -3,32 +3,20 @@ * * 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' -import { HistoryEntryOrigin } from '../../redux/history/types' -import type { HistoryEntryDto, HistoryEntryPutDto, HistoryEntryUpdateDto } from './types' - -export const historyEntryDtoToHistoryEntry = (entryDto: HistoryEntryDto): HistoryEntry => { +export const addRemoteOriginToHistoryEntry = (entryDto: HistoryEntry): HistoryEntryWithOrigin => { return { - origin: HistoryEntryOrigin.REMOTE, - title: entryDto.title, - pinStatus: entryDto.pinStatus, - identifier: entryDto.identifier, - tags: entryDto.tags, - lastVisited: entryDto.lastVisited + ...entryDto, + origin: HistoryEntryOrigin.REMOTE } } export const historyEntryToHistoryEntryPutDto = (entry: HistoryEntry): HistoryEntryPutDto => { return { pinStatus: entry.pinStatus, - lastVisited: entry.lastVisited, + lastVisitedAt: entry.lastVisitedAt, note: entry.identifier } } - -export const historyEntryToHistoryEntryUpdateDto = (entry: HistoryEntry): HistoryEntryUpdateDto => { - return { - pinStatus: entry.pinStatus - } -} diff --git a/src/api/history/index.ts b/src/api/history/index.ts index 126c7bfec..906409497 100644 --- a/src/api/history/index.ts +++ b/src/api/history/index.ts @@ -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 */ +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' - -export const getHistory = async (): Promise => { - const response = await fetch(getApiUrl() + 'me/history', { - ...defaultFetchConfig - }) - expectResponseCode(response) - return (await response.json()) as Promise +/** + * Fetches the remote history for the user from the server. + * @return The remote history entries of the user. + */ +export const getRemoteHistory = async (): Promise => { + const response = await new GetApiRequestBuilder('me/history').sendRequest() + return response.asParsedJsonObject() } -export const postHistory = async (entries: HistoryEntryPutDto[]): Promise => { - const response = await fetch(getApiUrl() + 'me/history', { - ...defaultFetchConfig, - method: 'POST', - body: JSON.stringify(entries) - }) - expectResponseCode(response) +/** + * Replaces the remote history of the user with the given history entries. + * @param entries The history entries to store remotely. + */ +export const setRemoteHistoryEntries = async (entries: HistoryEntryPutDto[]): Promise => { + await new PostApiRequestBuilder('me/history').withJsonBody(entries).sendRequest() } -export const updateHistoryEntryPinStatus = async (noteId: string, entry: HistoryEntryUpdateDto): Promise => { - const response = await fetch(getApiUrl() + 'me/history/' + noteId, { - ...defaultFetchConfig, - method: 'PUT', - body: JSON.stringify(entry) - }) - expectResponseCode(response) +/** + * Updates a remote history entry's pin state. + * @param noteIdOrAlias The note id for which to update the pinning state. + * @param pinStatus True when the note should be pinned, false otherwise. + */ +export const updateRemoteHistoryEntryPinStatus = async ( + noteIdOrAlias: string, + pinStatus: boolean +): Promise => { + const response = await new PutApiRequestBuilder('me/history/' + noteIdOrAlias) + .withJsonBody({ + pinStatus + }) + .sendRequest() + return response.asParsedJsonObject() } -export const deleteHistoryEntry = async (noteId: string): Promise => { - const response = await fetch(getApiUrl() + 'me/history/' + noteId, { - ...defaultFetchConfig, - method: 'DELETE' - }) - expectResponseCode(response) +/** + * Deletes a remote history entry. + * @param noteIdOrAlias The note id or alias of the history entry to remove. + */ +export const deleteRemoteHistoryEntry = async (noteIdOrAlias: string): Promise => { + await new DeleteApiRequestBuilder('me/history/' + noteIdOrAlias).sendRequest() } -export const deleteHistory = async (): Promise => { - const response = await fetch(getApiUrl() + 'me/history', { - ...defaultFetchConfig, - method: 'DELETE' - }) - expectResponseCode(response) +/** + * Deletes the complete remote history. + */ +export const deleteRemoteHistory = async (): Promise => { + await new DeleteApiRequestBuilder('me/history').sendRequest() } diff --git a/src/api/history/types.d.ts b/src/api/history/types.d.ts deleted file mode 100644 index 5e42df888..000000000 --- a/src/api/history/types.d.ts +++ /dev/null @@ -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 -} diff --git a/src/api/history/types.ts b/src/api/history/types.ts new file mode 100644 index 000000000..9b8f0e2f1 --- /dev/null +++ b/src/api/history/types.ts @@ -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 +} diff --git a/src/api/me/index.ts b/src/api/me/index.ts index 2727eeebb..9d065e894 100644 --- a/src/api/me/index.ts +++ b/src/api/me/index.ts @@ -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 */ - -import type { UserInfoDto } from '../users/types' -import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils' -import { isMockMode } from '../../utils/test-modes' +import type { MediaUpload } from '../media/types' +import type { ChangeDisplayNameDto, LoginUserInfo } 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 { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder' /** * Returns metadata about the currently signed-in user from the API. * @throws Error when the user is not signed-in. * @return The user metadata. */ -export const getMe = async (): Promise => { - const response = await fetch(getApiUrl() + `me${isMockMode() ? '-get' : ''}`, { - ...defaultFetchConfig - }) - expectResponseCode(response) - return (await response.json()) as UserInfoDto -} - -export const updateDisplayName = async (displayName: string): Promise => { - const response = await fetch(getApiUrl() + 'me', { - ...defaultFetchConfig, - method: 'POST', - body: JSON.stringify({ - name: displayName - }) - }) - - expectResponseCode(response) +export const getMe = async (): Promise => { + const response = await new GetApiRequestBuilder('me').sendRequest() + return response.asParsedJsonObject() } +/** + * Deletes the current user from the server. + */ export const deleteUser = async (): Promise => { - const response = await fetch(getApiUrl() + 'me', { - ...defaultFetchConfig, - method: 'DELETE' - }) - - expectResponseCode(response) + await new DeleteApiRequestBuilder('me').sendRequest() +} + +/** + * Changes the display name of the current user. + * @param displayName The new display name to set. + */ +export const updateDisplayName = async (displayName: string): Promise => { + await new PostApiRequestBuilder('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 => { + const response = await new GetApiRequestBuilder('me/media').sendRequest() + return response.asParsedJsonObject() } diff --git a/src/api/me/types.ts b/src/api/me/types.ts new file mode 100644 index 000000000..d7727f0b2 --- /dev/null +++ b/src/api/me/types.ts @@ -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 +} diff --git a/src/api/media/index.ts b/src/api/media/index.ts index 624c20272..44b869dc8 100644 --- a/src/api/media/index.ts +++ b/src/api/media/index.ts @@ -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 */ +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' - -export interface ImageProxyResponse { - src: string -} - +/** + * Requests an image-proxy URL from the backend for a given image URL. + * @param imageUrl The image URL which should be proxied. + * @return The proxy URL for the image. + */ export const getProxiedUrl = async (imageUrl: string): Promise => { - const response = await fetch(getApiUrl() + 'media/proxy', { - ...defaultFetchConfig, - method: 'POST', - body: JSON.stringify({ - src: imageUrl + const response = await new PostApiRequestBuilder('media/proxy') + .withJsonBody({ + url: imageUrl }) - }) - expectResponseCode(response) - return (await response.json()) as Promise + .sendRequest() + return response.asParsedJsonObject() } -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 => { + const postData = new FormData() + postData.append('file', media) + const response = await new PostApiRequestBuilder('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 => { - const response = await fetch(`${getApiUrl()}media/upload${isMockMode() ? '-post' : ''}`, { - ...defaultFetchConfig, - headers: { - 'Content-Type': media.type, - 'HedgeDoc-Note': noteId - }, - 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 +/** + * Deletes some uploaded media object. + * @param mediaId The identifier of the media object to delete. + */ +export const deleteUploadedMedia = async (mediaId: string): Promise => { + await new DeleteApiRequestBuilder('media/' + mediaId).sendRequest() } diff --git a/src/api/media/types.ts b/src/api/media/types.ts new file mode 100644 index 000000000..ff59fd275 --- /dev/null +++ b/src/api/media/types.ts @@ -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 +} diff --git a/src/api/notes/index.ts b/src/api/notes/index.ts index 89454d706..7791a8bd3 100644 --- a/src/api/notes/index.ts +++ b/src/api/notes/index.ts @@ -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 */ +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' -import { isMockMode } from '../../utils/test-modes' - -export const getNote = async (noteId: string): Promise => { - // The "-get" suffix is necessary, because in our mock api (filesystem) the note id might already be a folder. - // TODO: [mrdrogdrog] replace -get with actual api route as soon as api backend is ready. - const response = await fetch(getApiUrl() + `notes/${noteId}${isMockMode() ? '-get' : ''}`, { - ...defaultFetchConfig - }) - expectResponseCode(response) - return (await response.json()) as Promise +/** + * Retrieves the content and metadata about the specified note. + * @param noteIdOrAlias The id or alias of the note. + * @return Content and metadata of the specified note. + */ +export const getNote = async (noteIdOrAlias: string): Promise => { + const response = await new GetApiRequestBuilder('notes/' + noteIdOrAlias).sendRequest() + return response.asParsedJsonObject() } -export const deleteNote = async (noteId: string): Promise => { - const response = await fetch(getApiUrl() + `notes/${noteId}`, { - ...defaultFetchConfig, - method: 'DELETE' - }) - expectResponseCode(response) +/** + * Returns a list of media objects associated with the specified note. + * @param noteIdOrAlias The id or alias of the note. + * @return List of media object metadata associated with specified note. + */ +export const getMediaForNote = async (noteIdOrAlias: string): Promise => { + const response = await new GetApiRequestBuilder(`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 => { + const response = await new PostApiRequestBuilder('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 => { + const response = await new PostApiRequestBuilder('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 => { + await new DeleteApiRequestBuilder('notes/' + noteIdOrAlias).sendRequest() } diff --git a/src/api/notes/types.d.ts b/src/api/notes/types.d.ts deleted file mode 100644 index e5ae5f8b9..000000000 --- a/src/api/notes/types.d.ts +++ /dev/null @@ -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 -} diff --git a/src/api/notes/types.ts b/src/api/notes/types.ts new file mode 100644 index 000000000..f0c9e9ef5 --- /dev/null +++ b/src/api/notes/types.ts @@ -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 +} diff --git a/src/api/permissions/index.ts b/src/api/permissions/index.ts new file mode 100644 index 000000000..3f152c487 --- /dev/null +++ b/src/api/permissions/index.ts @@ -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 => { + const response = await new PutApiRequestBuilder( + `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 => { + const response = await new PutApiRequestBuilder( + `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 => { + const response = await new PutApiRequestBuilder( + `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 => { + const response = await new DeleteApiRequestBuilder( + `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 => { + const response = await new DeleteApiRequestBuilder( + `notes/${noteId}/metadata/permissions/groups/${groupName}` + ) + .withExpectedStatusCode(200) + .sendRequest() + return response.asParsedJsonObject() +} diff --git a/src/api/permissions/types.ts b/src/api/permissions/types.ts new file mode 100644 index 000000000..da505c956 --- /dev/null +++ b/src/api/permissions/types.ts @@ -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 +} diff --git a/src/api/revisions/index.ts b/src/api/revisions/index.ts index 425bd83a8..299b8cbaf 100644 --- a/src/api/revisions/index.ts +++ b/src/api/revisions/index.ts @@ -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 */ +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' -import type { Revision, RevisionListEntry } from './types' - -const revisionCache = new Cache(3600) - -export const getRevision = async (noteId: string, timestamp: number): Promise => { - const cacheKey = `${noteId}:${timestamp}` - if (revisionCache.has(cacheKey)) { - return revisionCache.get(cacheKey) - } - 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 +/** + * Retrieves a note revision while using a cache for often retrieved revisions. + * @param noteId The id of the note for which to fetch the revision. + * @param revisionId The id of the revision to fetch. + * @return The revision. + */ +export const getRevision = async (noteId: string, revisionId: number): Promise => { + const response = await new GetApiRequestBuilder( + `notes/${noteId}/revisions/${revisionId}` + ).sendRequest() + return response.asParsedJsonObject() } -export const getAllRevisions = async (noteId: string): Promise => { - // TODO Change 'revisions-list' to 'revisions' as soon as the backend is ready to serve some data! - const response = await fetch(getApiUrl() + `notes/${noteId}/revisions-list`, { - ...defaultFetchConfig - }) - expectResponseCode(response) - return (await response.json()) as Promise +/** + * Retrieves a list of all revisions stored for a given note. + * @param noteId The id of the note for which to look up the stored revisions. + * @return A list of revision ids. + */ +export const getAllRevisions = async (noteId: string): Promise => { + const response = await new GetApiRequestBuilder(`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 => { + await new DeleteApiRequestBuilder(`notes/${noteIdOrAlias}/revisions`).sendRequest() } diff --git a/src/api/revisions/types.d.ts b/src/api/revisions/types.d.ts deleted file mode 100644 index a1e3ba545..000000000 --- a/src/api/revisions/types.d.ts +++ /dev/null @@ -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[] -} diff --git a/src/api/revisions/types.ts b/src/api/revisions/types.ts new file mode 100644 index 000000000..cb227e97e --- /dev/null +++ b/src/api/revisions/types.ts @@ -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 +} diff --git a/src/api/tokens/index.ts b/src/api/tokens/index.ts index 59772bd1e..e4b557fd2 100644 --- a/src/api/tokens/index.ts +++ b/src/api/tokens/index.ts @@ -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 */ +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 => { - const response = await fetch(`${getApiUrl()}tokens`, { - ...defaultFetchConfig - }) - expectResponseCode(response) - return (await response.json()) as AccessToken[] + const response = await new GetApiRequestBuilder('tokens').sendRequest() + return response.asParsedJsonObject() } -export const postNewAccessToken = async (label: string, expiryDate: string): Promise => { - const response = await fetch(`${getApiUrl()}tokens`, { - ...defaultFetchConfig, - method: 'POST', - body: JSON.stringify({ - label: label, - validUntil: expiryDate +/** + * Creates a new access token for the current user. + * @param label The user-defined label for the new access token. + * @param validUntil The user-defined expiry date of the new access token in milliseconds of unix time. + * @return The new access token metadata along with its secret. + */ +export const postNewAccessToken = async (label: string, validUntil: number): Promise => { + const response = await new PostApiRequestBuilder('tokens') + .withJsonBody({ + label, + validUntil }) - }) - expectResponseCode(response) - return (await response.json()) as AccessTokenWithSecret + .sendRequest() + return response.asParsedJsonObject() } +/** + * 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 => { - const response = await fetch(`${getApiUrl()}tokens/${keyId}`, { - ...defaultFetchConfig, - method: 'DELETE' - }) - expectResponseCode(response) + await new DeleteApiRequestBuilder('tokens/' + keyId).sendRequest() } diff --git a/src/api/tokens/types.d.ts b/src/api/tokens/types.ts similarity index 59% rename from src/api/tokens/types.d.ts rename to src/api/tokens/types.ts index 6944eac7b..a34f57d4d 100644 --- a/src/api/tokens/types.d.ts +++ b/src/api/tokens/types.ts @@ -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 */ @@ -9,9 +9,14 @@ export interface AccessToken { validUntil: string keyId: string createdAt: string - lastUsed: string + lastUsedAt: string | null } export interface AccessTokenWithSecret extends AccessToken { secret: string } + +export interface CreateAccessTokenDto { + label: string + validUntil: number +} diff --git a/src/api/users/index.ts b/src/api/users/index.ts index be61825e7..242ae9d31 100644 --- a/src/api/users/index.ts +++ b/src/api/users/index.ts @@ -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 */ -import { Cache } from '../../components/common/cache/cache' -import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils' -import type { UserResponse } from './types' +import type { UserInfo } from './types' +import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder' -const cache = new Cache(600) - -export const getUserById = async (userid: string): Promise => { - if (cache.has(userid)) { - return cache.get(userid) - } - const response = await fetch(`${getApiUrl()}/users/${userid}`, { - ...defaultFetchConfig - }) - expectResponseCode(response) - const userData = (await response.json()) as UserResponse - cache.put(userid, userData) - return userData +/** + * Retrieves information about a specific user while using a cache to avoid many requests for the same username. + * @param username The username of interest. + * @return Metadata about the requested user. + */ +export const getUser = async (username: string): Promise => { + const response = await new GetApiRequestBuilder('users/' + username).sendRequest() + return response.asParsedJsonObject() } diff --git a/src/api/users/types.d.ts b/src/api/users/types.d.ts deleted file mode 100644 index 4fa22f6d6..000000000 --- a/src/api/users/types.d.ts +++ /dev/null @@ -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 -} diff --git a/src/api/users/types.ts b/src/api/users/types.ts new file mode 100644 index 000000000..96e6f0d92 --- /dev/null +++ b/src/api/users/types.ts @@ -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 +} diff --git a/src/api/utils.ts b/src/api/utils.ts deleted file mode 100644 index ea1010e1f..000000000 --- a/src/api/utils.ts +++ /dev/null @@ -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 = { - 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}`) - } -} diff --git a/src/components/application-loader/application-loader.tsx b/src/components/application-loader/application-loader.tsx index 9f0e21641..0075a688e 100644 --- a/src/components/application-loader/application-loader.tsx +++ b/src/components/application-loader/application-loader.tsx @@ -6,10 +6,8 @@ import type { PropsWithChildren } from 'react' import React, { Suspense } from 'react' -import { useBackendBaseUrl } from '../../hooks/common/use-backend-base-url' import { createSetUpTaskList } from './initializers' import { LoadingScreen } from './loading-screen/loading-screen' -import { useCustomizeAssetsUrl } from '../../hooks/common/use-customize-assets-url' import { Logger } from '../../utils/logger' import { useAsync } from 'react-use' import { ApplicationLoaderError } from './application-loader-error' @@ -17,11 +15,8 @@ import { ApplicationLoaderError } from './application-loader-error' const log = new Logger('ApplicationLoader') export const ApplicationLoader: React.FC> = ({ children }) => { - const backendBaseUrl = useBackendBaseUrl() - const customizeAssetsUrl = useCustomizeAssetsUrl() - const { error, loading } = useAsync(async () => { - const initTasks = createSetUpTaskList(customizeAssetsUrl, backendBaseUrl) + const initTasks = createSetUpTaskList() for (const task of initTasks) { try { await task.task diff --git a/src/components/application-loader/initializers/fetch-motd.ts b/src/components/application-loader/initializers/fetch-motd.ts index 0d7e1c3bb..75a49141f 100644 --- a/src/components/application-loader/initializers/fetch-motd.ts +++ b/src/components/application-loader/initializers/fetch-motd.ts @@ -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 */ import { setMotd } from '../../../redux/motd/methods' -import { defaultFetchConfig } from '../../../api/utils' 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' 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. * 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. - * - * @param customizeAssetsUrl the URL where the motd.md can be found. * @return A promise that gets resolved if the motd was fetched successfully. */ -export const fetchMotd = async (customizeAssetsUrl: string): Promise => { +export const fetchMotd = async (): Promise => { const cachedLastModified = window.localStorage.getItem(MOTD_LOCAL_STORAGE_KEY) const motdUrl = `${customizeAssetsUrl}motd.md` if (cachedLastModified) { const response = await fetch(motdUrl, { - ...defaultFetchConfig, + ...defaultConfig, method: 'HEAD' }) if (response.status !== 200) { @@ -39,7 +38,7 @@ export const fetchMotd = async (customizeAssetsUrl: string): Promise => { } const response = await fetch(motdUrl, { - ...defaultFetchConfig + ...defaultConfig }) if (response.status !== 200) { diff --git a/src/components/application-loader/initializers/index.ts b/src/components/application-loader/initializers/index.ts index 622a71710..e4ca979ee 100644 --- a/src/components/application-loader/initializers/index.ts +++ b/src/components/application-loader/initializers/index.ts @@ -7,7 +7,6 @@ import { setUpI18n } from './setupI18n' import { refreshHistoryState } from '../../../redux/history/methods' import { fetchMotd } from './fetch-motd' -import { setApiUrl } from '../../../redux/api-url/methods' import { fetchAndSetUser } from '../../login-page/auth/utils' import { fetchFrontendConfig } from './fetch-frontend-config' import { loadDarkMode } from './load-dark-mode' @@ -25,11 +24,7 @@ export interface InitTask { task: Promise } -export const createSetUpTaskList = (customizeAssetsUrl: string, backendBaseUrl: string): InitTask[] => { - setApiUrl({ - apiUrl: `${backendBaseUrl}api/private/` - }) - +export const createSetUpTaskList = (): InitTask[] => { return [ { name: 'Load dark mode', @@ -49,7 +44,7 @@ export const createSetUpTaskList = (customizeAssetsUrl: string, backendBaseUrl: }, { name: 'Motd', - task: fetchMotd(customizeAssetsUrl) + task: fetchMotd() }, { name: 'Load history state', diff --git a/src/components/common/async-loading-boundary.tsx b/src/components/common/async-loading-boundary.tsx index e12782a47..364658a8b 100644 --- a/src/components/common/async-loading-boundary.tsx +++ b/src/components/common/async-loading-boundary.tsx @@ -12,7 +12,7 @@ import { Alert } from 'react-bootstrap' export interface AsyncLoadingBoundaryProps { loading: boolean - error?: boolean + error?: Error | boolean componentName: string } @@ -32,7 +32,7 @@ export const AsyncLoadingBoundary: React.FC { useTranslation() - if (error === true) { + if (error !== undefined && error !== false) { return ( diff --git a/src/components/common/pagination/pager-pagination.tsx b/src/components/common/pagination/pager-pagination.tsx index ceb36dd4e..c203c4d7b 100644 --- a/src/components/common/pagination/pager-pagination.tsx +++ b/src/components/common/pagination/pager-pagination.tsx @@ -4,7 +4,7 @@ * 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 { ShowIf } from '../show-if/show-if' import { PagerItem } from './pager-item' @@ -33,25 +33,34 @@ export const PagerPagination: React.FC = ({ onPageChange(pageIndex) }, [onPageChange, pageIndex]) - const correctedLowerPageIndex = Math.min( - Math.max(Math.min(wantedLowerPageIndex, wantedLowerPageIndex + lastPageIndex - wantedUpperPageIndex), 0), - lastPageIndex + const correctedLowerPageIndex = useMemo( + () => + Math.min( + Math.max(Math.min(wantedLowerPageIndex, wantedLowerPageIndex + lastPageIndex - wantedUpperPageIndex), 0), + lastPageIndex + ), + [wantedLowerPageIndex, lastPageIndex, wantedUpperPageIndex] ) - const correctedUpperPageIndex = Math.max( - Math.min(Math.max(wantedUpperPageIndex, wantedUpperPageIndex - wantedLowerPageIndex), lastPageIndex), - 0 + const correctedUpperPageIndex = useMemo( + () => + 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 itemIndex = correctedLowerPageIndex + index - return - }) + const paginationItemsBefore = useMemo(() => { + return new Array(correctedPageIndex - correctedLowerPageIndex).map((k, index) => { + const itemIndex = correctedLowerPageIndex + index + return + }) + }, [correctedPageIndex, correctedLowerPageIndex, setPageIndex]) - const paginationItemsAfter = Array.from(new Array(correctedUpperPageIndex - correctedPageIndex)).map((k, index) => { - const itemIndex = correctedPageIndex + index + 1 - return - }) + const paginationItemsAfter = useMemo(() => { + return new Array(correctedUpperPageIndex - correctedPageIndex).map((k, index) => { + const itemIndex = correctedPageIndex + index + 1 + return + }) + }, [correctedUpperPageIndex, correctedPageIndex, setPageIndex]) return ( diff --git a/src/components/common/pagination/pager.tsx b/src/components/common/pagination/pager.tsx index 6e1d00db7..533383e1c 100644 --- a/src/components/common/pagination/pager.tsx +++ b/src/components/common/pagination/pager.tsx @@ -5,7 +5,7 @@ */ import type { PropsWithChildren } from 'react' -import React, { Fragment, useEffect } from 'react' +import React, { Fragment, useEffect, useMemo } from 'react' export interface PagerPageProps { pageIndex: number @@ -26,12 +26,12 @@ export const Pager: React.FC> = ({ onLastPageIndexChange(maxPageIndex) }, [children, maxPageIndex, numberOfElementsPerPage, onLastPageIndexChange]) - return ( - - {React.Children.toArray(children).filter((value, index) => { - const pageOfElement = Math.floor(index / numberOfElementsPerPage) - return pageOfElement === correctedPageIndex - })} - - ) + const filteredChildren = useMemo(() => { + return React.Children.toArray(children).filter((value, index) => { + const pageOfElement = Math.floor(index / numberOfElementsPerPage) + return pageOfElement === correctedPageIndex + }) + }, [children, numberOfElementsPerPage, correctedPageIndex]) + + return {filteredChildren} } diff --git a/src/components/common/user-avatar/user-avatar-for-username.tsx b/src/components/common/user-avatar/user-avatar-for-username.tsx new file mode 100644 index 000000000..fc1f75541 --- /dev/null +++ b/src/components/common/user-avatar/user-avatar-for-username.tsx @@ -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 { + 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 = ({ username, ...props }) => { + const { t } = useTranslation() + const { error, value, loading } = useAsync(async (): Promise => { + if (username) { + return await getUser(username) + } + return { + displayName: t('common.guestUser'), + photo: `${customizeAssetsUrl}img/avatar.png`, + username: '' + } + }, [username, t]) + + if (!value) { + return null + } + + return ( + + + + ) +} diff --git a/src/components/common/user-avatar/user-avatar.module.scss b/src/components/common/user-avatar/user-avatar.module.scss index c64be990f..57e716641 100644 --- a/src/components/common/user-avatar/user-avatar.module.scss +++ b/src/components/common/user-avatar/user-avatar.module.scss @@ -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 */ @@ -9,3 +9,7 @@ flex: 1 1; overflow: hidden; } + +.user-image { + color: transparent; +} diff --git a/src/components/common/user-avatar/user-avatar.tsx b/src/components/common/user-avatar/user-avatar.tsx index 418d61b84..b0be421a1 100644 --- a/src/components/common/user-avatar/user-avatar.tsx +++ b/src/components/common/user-avatar/user-avatar.tsx @@ -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 */ @@ -7,18 +7,25 @@ import React, { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { ShowIf } from '../show-if/show-if' -import Image from 'next/image' import styles from './user-avatar.module.scss' +import type { UserInfo } from '../../../api/users/types' export interface UserAvatarProps { size?: 'sm' | 'lg' - name: string - photo: string additionalClasses?: string showName?: boolean + user: UserInfo } -const UserAvatar: React.FC = ({ 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 = ({ user, size, additionalClasses = '', showName = true }) => { const { t } = useTranslation() const imageSize = useMemo(() => { @@ -34,20 +41,18 @@ const UserAvatar: React.FC = ({ name, photo, size, additionalCl return ( - {t('common.avatarOf', - {name} + {user.displayName} ) } - -export { UserAvatar } diff --git a/src/components/document-read-only-page/document-infobar.tsx b/src/components/document-read-only-page/document-infobar.tsx index bee918cc3..0935fb2d2 100644 --- a/src/components/document-read-only-page/document-infobar.tsx +++ b/src/components/document-read-only-page/document-infobar.tsx @@ -3,66 +3,38 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ - -import type { DateTime } from 'luxon' import React from 'react' import { Trans, useTranslation } from 'react-i18next' import { InternalLink } from '../common/links/internal-link' 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 { 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 - changedTime: DateTime - createdAuthor: string - createdTime: DateTime - editable: boolean - noteId: string - viewCount: number -} - -export const DocumentInfobar: React.FC = ({ - changedAuthor, - changedTime, - createdAuthor, - createdTime, - editable, - noteId, - viewCount -}) => { +/** + * Renders an infobar with metadata about the current note. + */ +export const DocumentInfobar: React.FC = () => { 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 (
 
- - + +
- {viewCount} - + {noteDetails.viewCount} + { useTranslation() const markdownContentLines = useTrimmedNoteMarkdownContentWithoutFrontmatter() - const noteDetails = useApplicationState((state) => state.noteDetails) useSendFrontmatterInfoFromReduxToRenderer() + // TODO Change todo values with real ones as soon as the backend is ready. return ( - + { const { t } = useTranslation() - const id = useApplicationState((state) => state.noteDetails.id) + const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress) return ( - + + + + ) +} diff --git a/src/components/editor-page/document-bar/permissions/permission-entry-buttons.tsx b/src/components/editor-page/document-bar/permissions/permission-entry-buttons.tsx new file mode 100644 index 000000000..a80baf809 --- /dev/null +++ b/src/components/editor-page/document-bar/permissions/permission-entry-buttons.tsx @@ -0,0 +1,93 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import React, { useMemo } from 'react' +import { Button, ToggleButton, ToggleButtonGroup } from 'react-bootstrap' +import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon' +import { AccessLevel } from './types' +import { useTranslation } from 'react-i18next' + +interface PermissionEntryButtonI18nKeys { + remove: string + setReadOnly: string + setWriteable: string +} + +export enum PermissionType { + USER, + GROUP +} + +export interface PermissionEntryButtonsProps { + type: PermissionType + currentSetting: AccessLevel + name: string + onSetReadOnly: () => void + onSetWriteable: () => void + onRemove: () => void +} + +/** + * Buttons next to a user or group permission entry to change the permissions or remove the entry. + * @param name The name of the user or group. + * @param type The type of the entry. Either {@link PermissionType.USER} or {@link PermissionType.GROUP}. + * @param currentSetting How the permission is currently set. + * @param onSetReadOnly Callback that is fired when the entry is changed to read-only permission. + * @param onSetWriteable Callback that is fired when the entry is changed to writeable permission. + * @param onRemove Callback that is fired when the entry is removed. + */ +export const PermissionEntryButtons: React.FC = ({ + name, + type, + currentSetting, + onSetReadOnly, + onSetWriteable, + onRemove +}) => { + const { t } = useTranslation() + + const i18nKeys: PermissionEntryButtonI18nKeys = useMemo(() => { + switch (type) { + case PermissionType.USER: + return { + remove: 'editor.modal.permissions.removeUser', + setReadOnly: 'editor.modal.permissions.viewOnlyUser', + setWriteable: 'editor.modal.permissions.editUser' + } + case PermissionType.GROUP: + return { + remove: 'editor.modal.permissions.removeGroup', + setReadOnly: 'editor.modal.permissions.viewOnlyGroup', + setWriteable: 'editor.modal.permissions.editGroup' + } + } + }, [type]) + + return ( +
+ + + + + + + + + +
+ ) +} diff --git a/src/components/editor-page/document-bar/permissions/permission-entry-special-group.tsx b/src/components/editor-page/document-bar/permissions/permission-entry-special-group.tsx new file mode 100644 index 000000000..1d7c0997e --- /dev/null +++ b/src/components/editor-page/document-bar/permissions/permission-entry-special-group.tsx @@ -0,0 +1,96 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import React, { useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { AccessLevel, SpecialGroup } from './types' +import { ToggleButton, ToggleButtonGroup } from 'react-bootstrap' +import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon' +import { removeGroupPermission, setGroupPermission } from '../../../../api/permissions' +import { useApplicationState } from '../../../../hooks/common/use-application-state' +import { setNotePermissionsFromServer } from '../../../../redux/note-details/methods' +import { showErrorNotification } from '../../../../redux/ui-notifications/methods' + +export interface PermissionEntrySpecialGroupProps { + level: AccessLevel + type: SpecialGroup +} + +/** + * Permission entry that represents one of the built-in special groups. + * @param level The access level that is currently set for the group. + * @param type The type of the special group. Must be either {@link SpecialGroup.EVERYONE} or {@link SpecialGroup.LOGGED_IN}. + */ +export const PermissionEntrySpecialGroup: React.FC = ({ level, type }) => { + const noteId = useApplicationState((state) => state.noteDetails.primaryAddress) + const { t } = useTranslation() + + const onSetEntryReadOnly = useCallback(() => { + setGroupPermission(noteId, type, false) + .then((updatedPermissions) => { + setNotePermissionsFromServer(updatedPermissions) + }) + .catch(showErrorNotification('editor.modal.permissions.error')) + }, [noteId, type]) + + const onSetEntryWriteable = useCallback(() => { + setGroupPermission(noteId, type, true) + .then((updatedPermissions) => { + setNotePermissionsFromServer(updatedPermissions) + }) + .catch(showErrorNotification('editor.modal.permissions.error')) + }, [noteId, type]) + + const onSetEntryDenied = useCallback(() => { + removeGroupPermission(noteId, type) + .then((updatedPermissions) => { + setNotePermissionsFromServer(updatedPermissions) + }) + .catch(showErrorNotification('editor.modal.permissions.error')) + }, [noteId, type]) + + const name = useMemo(() => { + switch (type) { + case SpecialGroup.LOGGED_IN: + return t('editor.modal.permissions.allLoggedInUser') + case SpecialGroup.EVERYONE: + return t('editor.modal.permissions.allUser') + } + }, [type, t]) + + return ( +
  • + {name} +
    + + + + + + + + + + + +
    +
  • + ) +} diff --git a/src/components/editor-page/document-bar/permissions/permission-entry-user.tsx b/src/components/editor-page/document-bar/permissions/permission-entry-user.tsx new file mode 100644 index 000000000..d0d1bc69b --- /dev/null +++ b/src/components/editor-page/document-bar/permissions/permission-entry-user.tsx @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import React, { useCallback } from 'react' +import { UserAvatar } from '../../../common/user-avatar/user-avatar' +import type { NoteUserPermissionEntry } from '../../../../api/notes/types' +import { PermissionEntryButtons, PermissionType } from './permission-entry-buttons' +import { AccessLevel } from './types' +import { useAsync } from 'react-use' +import { getUser } from '../../../../api/users' +import { ShowIf } from '../../../common/show-if/show-if' +import { removeUserPermission, setUserPermission } from '../../../../api/permissions' +import { useApplicationState } from '../../../../hooks/common/use-application-state' +import { setNotePermissionsFromServer } from '../../../../redux/note-details/methods' +import { showErrorNotification } from '../../../../redux/ui-notifications/methods' + +export interface PermissionEntryUserProps { + entry: NoteUserPermissionEntry +} + +/** + * Permission entry for a user that can be set to read-only or writeable and can be removed. + * @param entry The permission entry. + */ +export const PermissionEntryUser: React.FC = ({ entry }) => { + const noteId = useApplicationState((state) => state.noteDetails.primaryAddress) + + const onRemoveEntry = useCallback(() => { + removeUserPermission(noteId, entry.username) + .then((updatedPermissions) => { + setNotePermissionsFromServer(updatedPermissions) + }) + .catch(showErrorNotification('editor.modal.permissions.error')) + }, [noteId, entry.username]) + + const onSetEntryReadOnly = useCallback(() => { + setUserPermission(noteId, entry.username, false) + .then((updatedPermissions) => { + setNotePermissionsFromServer(updatedPermissions) + }) + .catch(showErrorNotification('editor.modal.permissions.error')) + }, [noteId, entry.username]) + + const onSetEntryWriteable = useCallback(() => { + setUserPermission(noteId, entry.username, true) + .then((updatedPermissions) => { + setNotePermissionsFromServer(updatedPermissions) + }) + .catch(showErrorNotification('editor.modal.permissions.error')) + }, [noteId, entry.username]) + + const { value, loading, error } = useAsync(async () => { + return await getUser(entry.username) + }, [entry.username]) + + if (!value) { + return null + } + + return ( + +
  • + + +
  • +
    + ) +} diff --git a/src/components/editor-page/document-bar/permissions/permission-group-entry.tsx b/src/components/editor-page/document-bar/permissions/permission-group-entry.tsx deleted file mode 100644 index ea587bba8..000000000 --- a/src/components/editor-page/document-bar/permissions/permission-group-entry.tsx +++ /dev/null @@ -1,55 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import React from 'react' -import { ToggleButton, ToggleButtonGroup } from 'react-bootstrap' -import { Trans, useTranslation } from 'react-i18next' -import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon' - -export interface PermissionGroupEntryProps { - title: string - editMode: GroupMode - onChangeEditMode: (newMode: GroupMode) => void -} - -export enum GroupMode { - NONE, - VIEW, - EDIT -} - -export const PermissionGroupEntry: React.FC = ({ title, editMode, onChangeEditMode }) => { - const { t } = useTranslation() - - return ( -
  • - - - - - - - - - - - - -
  • - ) -} diff --git a/src/components/editor-page/document-bar/permissions/permission-list.tsx b/src/components/editor-page/document-bar/permissions/permission-list.tsx deleted file mode 100644 index a60c39041..000000000 --- a/src/components/editor-page/document-bar/permissions/permission-list.tsx +++ /dev/null @@ -1,107 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { ReactElement } from 'react' -import React, { useState } from 'react' -import { Button, FormControl, InputGroup, ToggleButton, ToggleButtonGroup } from 'react-bootstrap' -import { useTranslation } from 'react-i18next' -import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon' -import type { Principal } from './permission-modal' - -export interface PermissionListProps { - list: Principal[] - identifier: (entry: Principal) => ReactElement - changeEditMode: (id: Principal['id'], canEdit: Principal['canEdit']) => void - removeEntry: (id: Principal['id']) => void - createEntry: (name: Principal['name']) => void - editI18nKey: string - viewI18nKey: string - removeI18nKey: string - addI18nKey: string -} - -export enum EditMode { - VIEW, - EDIT -} - -export const PermissionList: React.FC = ({ - list, - identifier, - changeEditMode, - removeEntry, - createEntry, - editI18nKey, - viewI18nKey, - removeI18nKey, - addI18nKey -}) => { - const { t } = useTranslation() - const [newEntry, setNewEntry] = useState('') - - const addEntry = () => { - createEntry(newEntry) - setNewEntry('') - } - - return ( -
      - {list.map((entry) => ( -
    • - {identifier(entry)} -
      - - changeEditMode(entry.id, value === EditMode.EDIT)}> - - - - - - - -
      -
    • - ))} -
    • -
      { - event.preventDefault() - addEntry() - }}> - - setNewEntry(event.currentTarget.value)} - /> - - -
      -
    • -
    - ) -} diff --git a/src/components/editor-page/document-bar/permissions/permission-modal.tsx b/src/components/editor-page/document-bar/permissions/permission-modal.tsx index 4ce5bc7fc..f1ee8f483 100644 --- a/src/components/editor-page/document-bar/permissions/permission-modal.tsx +++ b/src/components/editor-page/document-bar/permissions/permission-modal.tsx @@ -4,180 +4,26 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import React, { useEffect, useState } from 'react' -import { Alert, Modal } from 'react-bootstrap' -import { Trans, useTranslation } from 'react-i18next' -import { getUserById } from '../../../../api/users' +import React from 'react' +import { Modal } from 'react-bootstrap' import type { ModalVisibilityProps } from '../../../common/modals/common-modal' import { CommonModal } from '../../../common/modals/common-modal' -import { ShowIf } from '../../../common/show-if/show-if' -import type { UserAvatarProps } from '../../../common/user-avatar/user-avatar' -import { UserAvatar } from '../../../common/user-avatar/user-avatar' -import { GroupMode, PermissionGroupEntry } from './permission-group-entry' -import { PermissionList } from './permission-list' - -export interface Principal { - id: string - name: string - photo: string - canEdit: boolean -} - -interface NotePermissions { - owner: string - sharedTo: { - username: string - canEdit: boolean - }[] - sharedToGroup: { - id: string - canEdit: boolean - }[] -} - -export const EVERYONE_GROUP_ID = '1' -export const EVERYONE_LOGGED_IN_GROUP_ID = '2' - -const permissionsApiResponse: NotePermissions = { - owner: 'dermolly', - sharedTo: [ - { - username: 'emcrx', - canEdit: true - }, - { - username: 'mrdrogdrog', - canEdit: false - } - ], - sharedToGroup: [ - { - id: EVERYONE_GROUP_ID, - canEdit: true - }, - { - id: EVERYONE_LOGGED_IN_GROUP_ID, - canEdit: false - } - ] -} +import { PermissionSectionOwner } from './permission-section-owner' +import { PermissionSectionUsers } from './permission-section-users' +import { PermissionSectionSpecialGroups } from './permission-section-special-groups' +/** + * Modal for viewing and managing the permissions of the note. + * @param show true to show the modal, false otherwise. + * @param onHide Callback that is fired when the modal is about to be closed. + */ export const PermissionModal: React.FC = ({ show, onHide }) => { - useTranslation() - const [error, setError] = useState(false) - const [userList, setUserList] = useState([]) - const [owner, setOwner] = useState() - const [allUserPermissions, setAllUserPermissions] = useState(GroupMode.NONE) - const [allLoggedInUserPermissions, setAllLoggedInUserPermissions] = useState(GroupMode.NONE) - - useEffect(() => { - // set owner - getUserById(permissionsApiResponse.owner) - .then((response) => { - setOwner({ - name: response.name, - photo: response.photo - }) - }) - .catch(() => setError(true)) - // set user List - permissionsApiResponse.sharedTo.forEach((shareUser) => { - getUserById(shareUser.username) - .then((response) => { - setUserList((list) => - list.concat([ - { - id: response.id, - name: response.name, - photo: response.photo, - canEdit: shareUser.canEdit - } - ]) - ) - }) - .catch(() => setError(true)) - }) - // set group List - permissionsApiResponse.sharedToGroup.forEach((sharedGroup) => { - if (sharedGroup.id === EVERYONE_GROUP_ID) { - setAllUserPermissions(sharedGroup.canEdit ? GroupMode.EDIT : GroupMode.VIEW) - } else if (sharedGroup.id === EVERYONE_LOGGED_IN_GROUP_ID) { - setAllLoggedInUserPermissions(sharedGroup.canEdit ? GroupMode.EDIT : GroupMode.VIEW) - } - }) - }, []) - - const changeUserMode = (userId: Principal['id'], canEdit: Principal['canEdit']) => { - setUserList((list) => - list.map((user) => { - if (user.id === userId) { - user.canEdit = canEdit - } - return user - }) - ) - } - - const removeUser = (userId: Principal['id']) => { - setUserList((list) => list.filter((user) => user.id !== userId)) - } - - const addUser = (name: Principal['name']) => { - setUserList((list) => - list.concat({ - id: name, - photo: '/img/avatar.png', - name: name, - canEdit: false - }) - ) - } - return ( -
    - -
    - - - - - -
      -
    • - -
    • -
    -
    - -
    - } - changeEditMode={changeUserMode} - removeEntry={removeUser} - createEntry={addUser} - editI18nKey={'editor.modal.permissions.editUser'} - viewI18nKey={'editor.modal.permissions.viewOnlyUser'} - removeI18nKey={'editor.modal.permissions.removeUser'} - addI18nKey={'editor.modal.permissions.addUser'} - /> -
    - -
    -
      - - -
    + + +
    ) diff --git a/src/components/editor-page/document-bar/permissions/permission-owner-change.tsx b/src/components/editor-page/document-bar/permissions/permission-owner-change.tsx new file mode 100644 index 000000000..00339f36f --- /dev/null +++ b/src/components/editor-page/document-bar/permissions/permission-owner-change.tsx @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useOnInputChange } from '../../../../hooks/common/use-on-input-change' +import { Button, FormControl, InputGroup } from 'react-bootstrap' +import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon' + +export interface PermissionOwnerChangeProps { + onConfirmOwnerChange: (newOwner: string) => void +} + +export const PermissionOwnerChange: React.FC = ({ onConfirmOwnerChange }) => { + const { t } = useTranslation() + const [ownerFieldValue, setOwnerFieldValue] = useState('') + + const onChangeField = useOnInputChange(setOwnerFieldValue) + const onClickConfirm = useCallback(() => { + onConfirmOwnerChange(ownerFieldValue) + }, [ownerFieldValue, onConfirmOwnerChange]) + + const confirmButtonDisabled = useMemo(() => { + return ownerFieldValue.trim() === '' + }, [ownerFieldValue]) + + return ( + + + + + ) +} diff --git a/src/components/editor-page/document-bar/permissions/permission-owner-info.tsx b/src/components/editor-page/document-bar/permissions/permission-owner-info.tsx new file mode 100644 index 000000000..c73276f40 --- /dev/null +++ b/src/components/editor-page/document-bar/permissions/permission-owner-info.tsx @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { Fragment } from 'react' +import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon' +import { Button } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import { useApplicationState } from '../../../../hooks/common/use-application-state' +import { UserAvatarForUsername } from '../../../common/user-avatar/user-avatar-for-username' + +export interface PermissionOwnerInfoProps { + onEditOwner: () => void +} + +/** + * Content for the owner section of the permission modal that shows the current note owner. + * @param onEditOwner Callback that is fired when the user chooses to change the note owner. + */ +export const PermissionOwnerInfo: React.FC = ({ onEditOwner }) => { + const { t } = useTranslation() + const noteOwner = useApplicationState((state) => state.noteDetails.permissions.owner) + + return ( + + + + + ) +} diff --git a/src/components/editor-page/document-bar/permissions/permission-section-owner.tsx b/src/components/editor-page/document-bar/permissions/permission-section-owner.tsx new file mode 100644 index 000000000..83fd33509 --- /dev/null +++ b/src/components/editor-page/document-bar/permissions/permission-section-owner.tsx @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import React, { Fragment, useCallback, useState } from 'react' +import { Trans } from 'react-i18next' +import { PermissionOwnerChange } from './permission-owner-change' +import { PermissionOwnerInfo } from './permission-owner-info' +import { setNoteOwner } from '../../../../api/permissions' +import { useApplicationState } from '../../../../hooks/common/use-application-state' +import { showErrorNotification } from '../../../../redux/ui-notifications/methods' +import { setNotePermissionsFromServer } from '../../../../redux/note-details/methods' + +/** + * Section in the permissions modal for managing the owner of a note. + */ +export const PermissionSectionOwner: React.FC = () => { + const noteId = useApplicationState((state) => state.noteDetails.primaryAddress) + const [changeOwner, setChangeOwner] = useState(false) + + const onSetChangeOwner = useCallback(() => { + setChangeOwner(true) + }, []) + + const onOwnerChange = useCallback( + (newOwner: string) => { + setNoteOwner(noteId, newOwner) + .then((updatedPermissions) => { + setNotePermissionsFromServer(updatedPermissions) + }) + .catch(showErrorNotification('editor.modal.permissions.ownerChange.error')) + .finally(() => { + setChangeOwner(false) + }) + }, + [noteId] + ) + + return ( + +
    + +
    +
      +
    • + {changeOwner ? ( + + ) : ( + + )} +
    • +
    +
    + ) +} diff --git a/src/components/editor-page/document-bar/permissions/permission-section-special-groups.tsx b/src/components/editor-page/document-bar/permissions/permission-section-special-groups.tsx new file mode 100644 index 000000000..16befff5c --- /dev/null +++ b/src/components/editor-page/document-bar/permissions/permission-section-special-groups.tsx @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import React, { Fragment, useMemo } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { useApplicationState } from '../../../../hooks/common/use-application-state' +import { PermissionEntrySpecialGroup } from './permission-entry-special-group' +import { AccessLevel, SpecialGroup } from './types' + +/** + * Section of the permission modal for managing special group access to the note. + */ +export const PermissionSectionSpecialGroups: React.FC = () => { + useTranslation() + const groupPermissions = useApplicationState((state) => state.noteDetails.permissions.sharedToGroups) + + const specialGroupEntries = useMemo(() => { + const groupEveryone = groupPermissions.find((entry) => entry.groupName === SpecialGroup.EVERYONE) + const groupLoggedIn = groupPermissions.find((entry) => entry.groupName === SpecialGroup.LOGGED_IN) + + return { + everyone: groupEveryone + ? groupEveryone.canEdit + ? AccessLevel.WRITEABLE + : AccessLevel.READ_ONLY + : AccessLevel.NONE, + loggedIn: groupLoggedIn + ? groupLoggedIn.canEdit + ? AccessLevel.WRITEABLE + : AccessLevel.READ_ONLY + : AccessLevel.NONE + } + }, [groupPermissions]) + + return ( + +
    + +
    +
      + + +
    +
    + ) +} diff --git a/src/components/editor-page/document-bar/permissions/permission-section-users.tsx b/src/components/editor-page/document-bar/permissions/permission-section-users.tsx new file mode 100644 index 000000000..acc9e0994 --- /dev/null +++ b/src/components/editor-page/document-bar/permissions/permission-section-users.tsx @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import React, { Fragment, useCallback, useMemo } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { useApplicationState } from '../../../../hooks/common/use-application-state' +import { PermissionEntryUser } from './permission-entry-user' +import { PermissionAddEntryField } from './permission-add-entry-field' +import { setUserPermission } from '../../../../api/permissions' +import { setNotePermissionsFromServer } from '../../../../redux/note-details/methods' +import { showErrorNotification } from '../../../../redux/ui-notifications/methods' + +/** + * Section of the permission modal for managing user access to the note. + */ +export const PermissionSectionUsers: React.FC = () => { + useTranslation() + const userPermissions = useApplicationState((state) => state.noteDetails.permissions.sharedToUsers) + const noteId = useApplicationState((state) => state.noteDetails.primaryAddress) + + const userEntries = useMemo(() => { + return userPermissions.map((entry) => ) + }, [userPermissions]) + + const onAddEntry = useCallback( + (username: string) => { + setUserPermission(noteId, username, false) + .then((updatedPermissions) => { + setNotePermissionsFromServer(updatedPermissions) + }) + .catch(showErrorNotification('editor.modal.permissions.error')) + }, + [noteId] + ) + + return ( + +
    + +
    +
      + {userEntries} + +
    +
    + ) +} diff --git a/src/components/editor-page/document-bar/permissions/types.ts b/src/components/editor-page/document-bar/permissions/types.ts new file mode 100644 index 000000000..35491b167 --- /dev/null +++ b/src/components/editor-page/document-bar/permissions/types.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +export enum AccessLevel { + NONE, + READ_ONLY, + WRITEABLE +} + +export enum SpecialGroup { + EVERYONE = '_EVERYONE', + LOGGED_IN = '_LOGGED_IN' +} diff --git a/src/components/editor-page/document-bar/revisions/revision-modal-list-entry.module.scss b/src/components/editor-page/document-bar/revisions/revision-list-entry.module.scss similarity index 100% rename from src/components/editor-page/document-bar/revisions/revision-modal-list-entry.module.scss rename to src/components/editor-page/document-bar/revisions/revision-list-entry.module.scss diff --git a/src/components/editor-page/document-bar/revisions/revision-list-entry.tsx b/src/components/editor-page/document-bar/revisions/revision-list-entry.tsx new file mode 100644 index 000000000..7b68a0316 --- /dev/null +++ b/src/components/editor-page/document-bar/revisions/revision-list-entry.tsx @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { DateTime } from 'luxon' +import React, { useCallback, useMemo } from 'react' +import { ListGroup } from 'react-bootstrap' +import { Trans, useTranslation } from 'react-i18next' +import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon' +import { UserAvatar } from '../../../common/user-avatar/user-avatar' +import styles from './revision-list-entry.module.scss' +import type { RevisionMetadata } from '../../../../api/revisions/types' +import { getUserDataForRevision } from './utils' +import { showErrorNotification } from '../../../../redux/ui-notifications/methods' +import { useAsync } from 'react-use' +import { ShowIf } from '../../../common/show-if/show-if' +import { WaitSpinner } from '../../../common/wait-spinner/wait-spinner' + +export interface RevisionListEntryProps { + active: boolean + onSelect: (selectedId: number) => void + revision: RevisionMetadata +} + +/** + * Renders an entry in the revision list. + * @param active true if this is the currently selected revision entry. + * @param onSelect Callback that is fired when this revision entry is selected. + * @param revision The metadata for this revision entry. + */ +export const RevisionListEntry: React.FC = ({ active, onSelect, revision }) => { + useTranslation() + + const onSelectRevision = useCallback(() => { + onSelect(revision.id) + }, [revision, onSelect]) + + const revisionCreationTime = useMemo(() => { + return DateTime.fromISO(revision.createdAt).toFormat('DDDD T') + }, [revision.createdAt]) + + const revisionAuthors = useAsync(async () => { + try { + const authorDetails = await getUserDataForRevision(revision.authorUsernames) + return authorDetails.map((author) => ( + + )) + } catch (error) { + showErrorNotification('editor.modal.revision.errorUser')(error as Error) + return null + } + }, []) + + return ( + + + + {revisionCreationTime} + + + + : {revision.length} + + + + + + + {revisionAuthors.value} + + + ) +} diff --git a/src/components/editor-page/document-bar/revisions/revision-modal-footer.tsx b/src/components/editor-page/document-bar/revisions/revision-modal-footer.tsx new file mode 100644 index 000000000..0f9e5b5eb --- /dev/null +++ b/src/components/editor-page/document-bar/revisions/revision-modal-footer.tsx @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import React, { useCallback } from 'react' +import { Button, Modal } from 'react-bootstrap' +import { Trans, useTranslation } from 'react-i18next' +import { downloadRevision } from './utils' +import type { ModalVisibilityProps } from '../../../common/modals/common-modal' +import { useApplicationState } from '../../../../hooks/common/use-application-state' +import { getRevision } from '../../../../api/revisions' +import { showErrorNotification } from '../../../../redux/ui-notifications/methods' + +export interface RevisionModalFooterProps { + selectedRevisionId?: number +} + +/** + * Renders the footer of the revision modal that includes buttons to download the currently selected revision or to + * revert the note content back to that revision. + * @param selectedRevisionId The currently selected revision id or undefined if no revision was selected. + * @param onHide Callback that is fired when the modal is about to be closed. + */ +export const RevisionModalFooter: React.FC> = ({ + selectedRevisionId, + onHide +}) => { + useTranslation() + const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress) + + const onRevertToRevision = useCallback(() => { + // TODO Websocket message handler missing + // see https://github.com/hedgedoc/hedgedoc/issues/1984 + window.alert('Not yet implemented. Requires websocket.') + }, []) + + const onDownloadRevision = useCallback(() => { + if (selectedRevisionId === undefined) { + return + } + getRevision(noteIdentifier, selectedRevisionId) + .then((revision) => { + downloadRevision(noteIdentifier, revision) + }) + .catch(showErrorNotification('')) + }, [noteIdentifier, selectedRevisionId]) + + return ( + + + + + + ) +} diff --git a/src/components/editor-page/document-bar/revisions/revision-modal-list-entry.tsx b/src/components/editor-page/document-bar/revisions/revision-modal-list-entry.tsx deleted file mode 100644 index 15a29f61d..000000000 --- a/src/components/editor-page/document-bar/revisions/revision-modal-list-entry.tsx +++ /dev/null @@ -1,52 +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 } from 'react-bootstrap' -import { Trans } from 'react-i18next' -import type { RevisionListEntry } from '../../../../api/revisions/types' -import type { UserResponse } from '../../../../api/users/types' -import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon' -import { UserAvatar } from '../../../common/user-avatar/user-avatar' -import styles from './revision-modal-list-entry.module.scss' - -export interface RevisionModalListEntryProps { - active: boolean - onClick: () => void - revision: RevisionListEntry - revisionAuthorListMap: Map -} - -export const RevisionModalListEntry: React.FC = ({ - active, - onClick, - revision, - revisionAuthorListMap -}) => ( - - - - {DateTime.fromMillis(revision.timestamp * 1000).toFormat('DDDD T')} - - - - : {revision.length} - - - - {revisionAuthorListMap.get(revision.timestamp)?.map((user, index) => { - return ( - - ) - })} - - -) diff --git a/src/components/editor-page/document-bar/revisions/revision-modal.tsx b/src/components/editor-page/document-bar/revisions/revision-modal.tsx index 34df81f58..156b7f8a6 100644 --- a/src/components/editor-page/document-bar/revisions/revision-modal.tsx +++ b/src/components/editor-page/document-bar/revisions/revision-modal.tsx @@ -1,66 +1,54 @@ /* - * 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 */ -import React, { useEffect, useRef, useState } from 'react' -import { Alert, Button, Col, ListGroup, Modal, Row } from 'react-bootstrap' -import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer' -import { Trans, useTranslation } from 'react-i18next' -import { getAllRevisions, getRevision } from '../../../../api/revisions' -import type { Revision, RevisionListEntry } from '../../../../api/revisions/types' -import type { UserResponse } from '../../../../api/users/types' -import { useIsDarkModeActivated } from '../../../../hooks/common/use-is-dark-mode-activated' -import { useNoteMarkdownContent } from '../../../../hooks/common/use-note-markdown-content' +import React, { useCallback, useMemo, useState } from 'react' +import { Col, ListGroup, Modal, Row } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import { getAllRevisions } from '../../../../api/revisions' import type { ModalVisibilityProps } from '../../../common/modals/common-modal' import { CommonModal } from '../../../common/modals/common-modal' -import { ShowIf } from '../../../common/show-if/show-if' -import { RevisionModalListEntry } from './revision-modal-list-entry' +import { RevisionListEntry } from './revision-list-entry' import styles from './revision-modal.module.scss' -import { downloadRevision, getUserDataForRevision } from './utils' import { useApplicationState } from '../../../../hooks/common/use-application-state' +import { useAsync } from 'react-use' +import { RevisionModalFooter } from './revision-modal-footer' +import { RevisionViewer } from './revision-viewer' +import { AsyncLoadingBoundary } from '../../../common/async-loading-boundary' +/** + * Modal that shows the available revisions and allows for comparison between them. + * @param show true to show the modal, false otherwise. + * @param onHide Callback that is fired when the modal is requested to close. + */ export const RevisionModal: React.FC = ({ show, onHide }) => { useTranslation() - const [revisions, setRevisions] = useState([]) - const [selectedRevisionTimestamp, setSelectedRevisionTimestamp] = useState(null) - const [selectedRevision, setSelectedRevision] = useState(null) - const [error, setError] = useState(false) - const revisionAuthorListMap = useRef(new Map()) - const darkModeEnabled = useIsDarkModeActivated() - const id = useApplicationState((state) => state.noteDetails.id) + const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress) + const [selectedRevisionId, setSelectedRevisionId] = useState() - useEffect(() => { - if (!show) { - return + const { value, error, loading } = useAsync(() => { + return getAllRevisions(noteIdentifier) + }, [noteIdentifier]) + + const selectRevision = useCallback((revisionId: number) => { + setSelectedRevisionId(revisionId) + }, []) + + const revisionList = useMemo(() => { + if (loading || !value) { + return null } - getAllRevisions(id) - .then((fetchedRevisions) => { - fetchedRevisions.forEach((revision) => { - const authorData = getUserDataForRevision(revision.authors) - revisionAuthorListMap.current.set(revision.timestamp, authorData) - }) - setRevisions(fetchedRevisions) - if (fetchedRevisions.length >= 1) { - setSelectedRevisionTimestamp(fetchedRevisions[0].timestamp) - } - }) - .catch(() => setError(true)) - }, [setRevisions, setError, id, show]) - - useEffect(() => { - if (selectedRevisionTimestamp === null) { - return - } - getRevision(id, selectedRevisionTimestamp) - .then((fetchedRevision) => { - setSelectedRevision(fetchedRevision) - }) - .catch(() => setError(true)) - }, [selectedRevisionTimestamp, id]) - - const markdownContent = useNoteMarkdownContent() + return value.map((revisionListEntry) => ( + + )) + }, [loading, value, selectedRevisionId, selectRevision]) return ( = ({ show, onHide }) - - {revisions.map((revision, revisionIndex) => ( - setSelectedRevisionTimestamp(revision.timestamp)} - /> - ))} - + {revisionList} - - - - - - - - + + + - - - - - + ) } diff --git a/src/components/editor-page/document-bar/revisions/revision-viewer.tsx b/src/components/editor-page/document-bar/revisions/revision-viewer.tsx new file mode 100644 index 000000000..2cd155557 --- /dev/null +++ b/src/components/editor-page/document-bar/revisions/revision-viewer.tsx @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import React from 'react' +import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer' +import { useAsync } from 'react-use' +import type { RevisionDetails, RevisionMetadata } from '../../../../api/revisions/types' +import { getRevision } from '../../../../api/revisions' +import { useApplicationState } from '../../../../hooks/common/use-application-state' +import { useNoteMarkdownContent } from '../../../../hooks/common/use-note-markdown-content' +import { useIsDarkModeActivated } from '../../../../hooks/common/use-is-dark-mode-activated' +import type { AsyncState } from 'react-use/lib/useAsyncFn' +import { AsyncLoadingBoundary } from '../../../common/async-loading-boundary' + +export interface RevisionViewerProps { + selectedRevisionId?: number + allRevisions?: RevisionMetadata[] +} + +/** + * Renders the diff viewer for a given revision and its previous one. + * @param selectedRevisionId The id of the currently selected revision. + * @param allRevisions List of metadata for all available revisions. + */ +export const RevisionViewer: React.FC = ({ selectedRevisionId, allRevisions }) => { + const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress) + const markdownContent = useNoteMarkdownContent() + const darkModeEnabled = useIsDarkModeActivated() + + const previousRevisionContent = useAsync(async () => { + if (!allRevisions || selectedRevisionId === undefined) { + return Promise.reject() + } + const revisionIds = allRevisions.map((revisionMetadata) => revisionMetadata.id) + const largestId = Math.max(...revisionIds) + if (selectedRevisionId === largestId) { + return Promise.resolve(markdownContent) + } + const nextSmallerId = revisionIds + .sort() + .reverse() + .find((id) => id < selectedRevisionId) + if (!nextSmallerId) { + return Promise.resolve('') + } + const revision = await getRevision(noteIdentifier, nextSmallerId) + return revision.content + }, [selectedRevisionId, allRevisions]) + + const selectedRevision = useAsync(() => { + if (!allRevisions || selectedRevisionId === undefined) { + return Promise.reject() + } + return getRevision(noteIdentifier, selectedRevisionId) + }, [selectedRevisionId, noteIdentifier]) + + if (selectedRevisionId === undefined || !allRevisions) { + return null + } + + // TODO Rework the revision viewer to use pre-calculated diffs + // see https://github.com/hedgedoc/react-client/issues/1989 + + return ( + + ).value?.content} + splitView={false} + compareMethod={DiffMethod.WORDS} + useDarkTheme={darkModeEnabled} + /> + + ) +} diff --git a/src/components/editor-page/document-bar/revisions/utils.ts b/src/components/editor-page/document-bar/revisions/utils.ts index 06ad4b8dc..f9f65c29a 100644 --- a/src/components/editor-page/document-bar/revisions/utils.ts +++ b/src/components/editor-page/document-bar/revisions/utils.ts @@ -4,32 +4,37 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import type { Revision } from '../../../../api/revisions/types' -import { getUserById } from '../../../../api/users' -import type { UserResponse } from '../../../../api/users/types' +import type { RevisionDetails } from '../../../../api/revisions/types' +import { getUser } from '../../../../api/users' +import type { UserInfo } from '../../../../api/users/types' import { download } from '../../../common/download/download' -import { Logger } from '../../../../utils/logger' -const log = new Logger('RevisionsUtils') +const DISPLAY_MAX_USERS_PER_REVISION = 9 -export const downloadRevision = (noteId: string, revision: Revision | null): void => { +/** + * Downloads a given revision's content as markdown document in the browser. + * @param noteId The id of the note from which to download the revision. + * @param revision The revision details object containing the content to download. + */ +export const downloadRevision = (noteId: string, revision: RevisionDetails | null): void => { if (!revision) { return } - download(revision.content, `${noteId}-${revision.timestamp}.md`, 'text/markdown') + download(revision.content, `${noteId}-${revision.createdAt}.md`, 'text/markdown') } -export const getUserDataForRevision = (authors: string[]): UserResponse[] => { - const users: UserResponse[] = [] - authors.forEach((author, index) => { - if (index > 9) { - return - } - getUserById(author) - .then((userData) => { - users.push(userData) - }) - .catch((error: Error) => log.error(error)) - }) +/** + * Fetches user details for the given usernames while returning a maximum of 9 users. + * @param usernames The list of usernames to fetch. + * @throws {Error} in case the user-data request failed. + * @return An array of user details. + */ +export const getUserDataForRevision = async (usernames: string[]): Promise => { + const users: UserInfo[] = [] + const usersToFetch = Math.min(usernames.length, DISPLAY_MAX_USERS_PER_REVISION) - 1 + for (let i = 0; i <= usersToFetch; i++) { + const user = await getUser(usernames[i]) + users.push(user) + } return users } diff --git a/src/components/editor-page/document-bar/share/share-modal.tsx b/src/components/editor-page/document-bar/share/share-modal.tsx index d40c93a20..18d018c9a 100644 --- a/src/components/editor-page/document-bar/share/share-modal.tsx +++ b/src/components/editor-page/document-bar/share/share-modal.tsx @@ -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 */ @@ -20,20 +20,23 @@ export const ShareModal: React.FC = ({ show, onHide }) => const noteFrontmatter = useApplicationState((state) => state.noteDetails.frontmatter) const editorMode = useApplicationState((state) => state.editorConfig.editorMode) const baseUrl = useFrontendBaseUrl() - const id = useApplicationState((state) => state.noteDetails.id) + const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress) return ( - + - + - + diff --git a/src/components/editor-page/editor-pane/upload-handler.ts b/src/components/editor-page/editor-pane/upload-handler.ts index 13018be81..a06a6b9e0 100644 --- a/src/components/editor-page/editor-pane/upload-handler.ts +++ b/src/components/editor-page/editor-pane/upload-handler.ts @@ -7,7 +7,7 @@ import { uploadFile } from '../../../api/media' import { getGlobalState } from '../../../redux' import { supportedMimeTypes } from '../../common/upload-image-mimetypes' -import { replaceSelection, replaceInMarkdownContent } from '../../../redux/note-details/methods' +import { replaceInMarkdownContent, replaceSelection } from '../../../redux/note-details/methods' import { t } from 'i18next' import { showErrorNotification } from '../../../redux/ui-notifications/methods' import type { CursorSelection } from '../../../redux/editor/types' @@ -42,8 +42,8 @@ export const handleUpload = ( replaceSelection(uploadPlaceholder, cursorSelection) uploadFile(noteId, file) - .then(({ link }) => { - replaceInMarkdownContent(uploadPlaceholder, `![${description ?? ''}](${link}${additionalUrlText ?? ''})`) + .then(({ url }) => { + replaceInMarkdownContent(uploadPlaceholder, `![${description ?? ''}](${url}${additionalUrlText ?? ''})`) }) .catch((error: Error) => { showErrorNotification('editor.upload.failed', { fileName: file.name })(error) diff --git a/src/components/editor-page/hooks/useUpdateLocalHistoryEntry.ts b/src/components/editor-page/hooks/useUpdateLocalHistoryEntry.ts index 983473eb3..35f16cb1d 100644 --- a/src/components/editor-page/hooks/useUpdateLocalHistoryEntry.ts +++ b/src/components/editor-page/hooks/useUpdateLocalHistoryEntry.ts @@ -7,15 +7,15 @@ import equal from 'fast-deep-equal' import { useEffect, useRef } from 'react' import { getGlobalState } from '../../../redux' -import type { HistoryEntry } from '../../../redux/history/types' -import { HistoryEntryOrigin } from '../../../redux/history/types' import { updateLocalHistoryEntry } from '../../../redux/history/methods' import { useApplicationState } from '../../../hooks/common/use-application-state' +import type { HistoryEntryWithOrigin } from '../../../api/history/types' +import { HistoryEntryOrigin } from '../../../api/history/types' export const useUpdateLocalHistoryEntry = (updateReady: boolean): void => { const id = useApplicationState((state) => state.noteDetails.id) const userExists = useApplicationState((state) => !!state.user) - const currentNoteTitle = useApplicationState((state) => state.noteDetails.noteTitle) + const currentNoteTitle = useApplicationState((state) => state.noteDetails.title) const currentNoteTags = useApplicationState((state) => state.noteDetails.frontmatter.tags) const lastNoteTitle = useRef('') @@ -29,11 +29,11 @@ export const useUpdateLocalHistoryEntry = (updateReady: boolean): void => { return } const history = getGlobalState().history - const entry: HistoryEntry = history.find((entry) => entry.identifier === id) ?? { + const entry: HistoryEntryWithOrigin = history.find((entry) => entry.identifier === id) ?? { identifier: id, title: '', pinStatus: false, - lastVisited: '', + lastVisitedAt: '', tags: [], origin: HistoryEntryOrigin.LOCAL } @@ -42,7 +42,7 @@ export const useUpdateLocalHistoryEntry = (updateReady: boolean): void => { } entry.title = currentNoteTitle entry.tags = currentNoteTags - entry.lastVisited = new Date().toISOString() + entry.lastVisitedAt = new Date().toISOString() updateLocalHistoryEntry(id, entry) lastNoteTitle.current = currentNoteTitle lastNoteTags.current = currentNoteTags diff --git a/src/components/editor-page/render-context/use-origin-from-config.ts b/src/components/editor-page/render-context/use-origin-from-config.ts index 36e29f630..82f77eb70 100644 --- a/src/components/editor-page/render-context/use-origin-from-config.ts +++ b/src/components/editor-page/render-context/use-origin-from-config.ts @@ -25,6 +25,6 @@ export const useOriginFromConfig = (originType: ORIGIN_TYPE): string => { return useMemo(() => { return process.env.NEXT_PUBLIC_IGNORE_IFRAME_ORIGIN_CONFIG !== undefined ? window.location.origin + '/' - : originFromConfig + : originFromConfig ?? '' }, [originFromConfig]) } diff --git a/src/components/editor-page/renderer-pane/render-iframe.tsx b/src/components/editor-page/renderer-pane/render-iframe.tsx index 1c012149b..558f1e8c5 100644 --- a/src/components/editor-page/renderer-pane/render-iframe.tsx +++ b/src/components/editor-page/renderer-pane/render-iframe.tsx @@ -110,7 +110,7 @@ export const RenderIframe: React.FC = ({ log.error('Load triggered without content window') return } - log.debug(`Set iframecommunicator window with origin ${rendererOrigin}`) + log.debug(`Set iframecommunicator window with origin ${rendererOrigin ?? 'undefined'}`) iframeCommunicator.setMessageTarget(otherWindow, rendererOrigin) iframeCommunicator.enableCommunication() iframeCommunicator.sendMessageToOtherSide({ @@ -137,7 +137,7 @@ export const RenderIframe: React.FC = ({ {...cypressId('documentIframe')} onLoad={onIframeLoad} title='render' - {...(isTestMode() ? {} : { sandbox: 'allow-downloads allow-same-origin allow-scripts allow-popups' })} + {...(isTestMode ? {} : { sandbox: 'allow-downloads allow-same-origin allow-scripts allow-popups' })} allowFullScreen={true} ref={frameReference} referrerPolicy={'no-referrer'} diff --git a/src/components/editor-page/sidebar/delete-note-sidebar-entry/delete-note-modal.tsx b/src/components/editor-page/sidebar/delete-note-sidebar-entry/delete-note-modal.tsx index bc44e80d9..2517371d5 100644 --- a/src/components/editor-page/sidebar/delete-note-sidebar-entry/delete-note-modal.tsx +++ b/src/components/editor-page/sidebar/delete-note-sidebar-entry/delete-note-modal.tsx @@ -45,7 +45,7 @@ export const DeleteNoteModal: React.FC { - const noteTitle = useApplicationState((state) => state.noteDetails.noteTitle) + const noteTitle = useApplicationState((state) => state.noteDetails.title) return ( { selectedMenuId={selectedMenu} onClick={toggleValue} /> - + { const { t } = useTranslation() const markdownContent = useNoteMarkdownContent() const onClick = useCallback(() => { - const sanitized = sanitize(getGlobalState().noteDetails.noteTitle) + const sanitized = sanitize(getGlobalState().noteDetails.title) download(markdownContent, `${sanitized !== '' ? sanitized : t('editor.untitledNote')}.md`, 'text/markdown') }, [markdownContent, t]) diff --git a/src/components/editor-page/sidebar/specific-sidebar-entries/document-info-sidebar-entry.tsx b/src/components/editor-page/sidebar/specific-sidebar-entries/note-info-sidebar-entry.tsx similarity index 75% rename from src/components/editor-page/sidebar/specific-sidebar-entries/document-info-sidebar-entry.tsx rename to src/components/editor-page/sidebar/specific-sidebar-entries/note-info-sidebar-entry.tsx index d7e7a61b3..c42005cf5 100644 --- a/src/components/editor-page/sidebar/specific-sidebar-entries/document-info-sidebar-entry.tsx +++ b/src/components/editor-page/sidebar/specific-sidebar-entries/note-info-sidebar-entry.tsx @@ -6,12 +6,12 @@ import React, { Fragment, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' -import { DocumentInfoModal } from '../../document-bar/document-info/document-info-modal' +import { NoteInfoModal } from '../../document-bar/note-info/note-info-modal' import { SidebarButton } from '../sidebar-button/sidebar-button' import type { SpecificSidebarEntryProps } from '../types' import { cypressId } from '../../../../utils/cypress-attribute' -export const DocumentInfoSidebarEntry: React.FC = ({ className, hide }) => { +export const NoteInfoSidebarEntry: React.FC = ({ className, hide }) => { const [showModal, setShowModal] = useState(false) useTranslation() @@ -25,7 +25,7 @@ export const DocumentInfoSidebarEntry: React.FC = ({ {...cypressId('sidebar-btn-document-info')}> - setShowModal(false)} /> + setShowModal(false)} />
    ) } diff --git a/src/components/editor-page/sidebar/user-line/user-line.tsx b/src/components/editor-page/sidebar/user-line/user-line.tsx index ca99fc87e..ce0f88e8a 100644 --- a/src/components/editor-page/sidebar/user-line/user-line.tsx +++ b/src/components/editor-page/sidebar/user-line/user-line.tsx @@ -1,30 +1,38 @@ /* - * 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 */ import React from 'react' -import { UserAvatar } from '../../../common/user-avatar/user-avatar' import type { ActiveIndicatorStatus } from '../users-online-sidebar-menu/active-indicator' import { ActiveIndicator } from '../users-online-sidebar-menu/active-indicator' import styles from './user-line.module.scss' +import { UserAvatarForUsername } from '../../../common/user-avatar/user-avatar-for-username' export interface UserLineProps { - name: string - photo: string + username: string | null color: string status: ActiveIndicatorStatus } -export const UserLine: React.FC = ({ name, photo, color, status }) => { +/** + * Represents a user in the realtime activity status. + * @param username The name of the user to show. + * @param color The color of the user's edits. + * @param status The user's current online status. + */ +export const UserLine: React.FC = ({ username, color, status }) => { return (
    - +
    diff --git a/src/components/editor-page/sidebar/users-online-sidebar-menu/users-online-sidebar-menu.tsx b/src/components/editor-page/sidebar/users-online-sidebar-menu/users-online-sidebar-menu.tsx index b3195b45c..7dae39104 100644 --- a/src/components/editor-page/sidebar/users-online-sidebar-menu/users-online-sidebar-menu.tsx +++ b/src/components/editor-page/sidebar/users-online-sidebar-menu/users-online-sidebar-menu.tsx @@ -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 */ @@ -13,7 +13,6 @@ import { DocumentSidebarMenuSelection } from '../types' import { ActiveIndicatorStatus } from './active-indicator' import styles from './online-counter.module.scss' import { UserLine } from '../user-line/user-line' -import { useCustomizeAssetsUrl } from '../../../../hooks/common/use-customize-assets-url' export const UsersOnlineSidebarMenu: React.FC = ({ className, @@ -36,8 +35,8 @@ export const UsersOnlineSidebarMenu: React.FC = ({ onClick(menuId) }, [menuId, onClick]) - const avatarUrl = useCustomizeAssetsUrl() + 'img/avatar.png' - + // TODO Use real users here + // see https://github.com/hedgedoc/react-client/issues/1988 return ( = ({ - + - + diff --git a/src/components/history-page/entry-menu/entry-menu.tsx b/src/components/history-page/entry-menu/entry-menu.tsx index d16af4776..de51855d7 100644 --- a/src/components/history-page/entry-menu/entry-menu.tsx +++ b/src/components/history-page/entry-menu/entry-menu.tsx @@ -12,29 +12,43 @@ import { ShowIf } from '../../common/show-if/show-if' import { DeleteNoteItem } from './delete-note-item' import styles from './entry-menu.module.scss' import { RemoveNoteEntryItem } from './remove-note-entry-item' -import { HistoryEntryOrigin } from '../../../redux/history/types' import { useApplicationState } from '../../../hooks/common/use-application-state' import { cypressId } from '../../../utils/cypress-attribute' +import { HistoryEntryOrigin } from '../../../api/history/types' export interface EntryMenuProps { id: string title: string origin: HistoryEntryOrigin - isDark: boolean - onRemove: () => void - onDelete: () => void + onRemoveFromHistory: () => void + onDeleteNote: () => void className?: string } -export const EntryMenu: React.FC = ({ id, title, origin, isDark, onRemove, onDelete, className }) => { +/** + * Renders the dropdown menu for a history entry containing options like removing the entry or deleting the note. + * @param id The unique identifier of the history entry. + * @param title The title of the note of the history entry. + * @param origin The origin of the entry. Must be either {@link HistoryEntryOrigin.LOCAL} or {@link HistoryEntryOrigin.REMOTE}. + * @param onRemoveFromHistory Callback that is fired when the entry should be removed from the history. + * @param onDeleteNote Callback that is fired when the note should be deleted. + * @param className Additional CSS classes to add to the dropdown. + */ +export const EntryMenu: React.FC = ({ + id, + title, + origin, + onRemoveFromHistory, + onDeleteNote, + className +}) => { useTranslation() - const userExists = useApplicationState((state) => !!state.user) return ( @@ -51,17 +65,20 @@ export const EntryMenu: React.FC = ({ id, title, origin, isDark, + - + + + {/* TODO Check permissions (ownership) before showing option for delete */} - + diff --git a/src/components/history-page/history-card/history-card-list.tsx b/src/components/history-page/history-card/history-card-list.tsx index 70b22695b..a85a25e88 100644 --- a/src/components/history-page/history-card/history-card-list.tsx +++ b/src/components/history-page/history-card/history-card-list.tsx @@ -4,32 +4,45 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import React from 'react' +import React, { useMemo } from 'react' import { Row } from 'react-bootstrap' import { Pager } from '../../common/pagination/pager' import type { HistoryEntriesProps, HistoryEventHandlers } from '../history-content/history-content' import { HistoryCard } from './history-card' +/** + * Renders a paginated list of history entry cards. + * @param entries The history entries to render. + * @param onPinClick Callback that is fired when the pinning button was clicked for an entry. + * @param onRemoveEntryClick Callback that is fired when the entry removal button was clicked for an entry. + * @param onDeleteNoteClick Callback that is fired when the note deletion button was clicked for an entry. + * @param pageIndex The currently selected page. + * @param onLastPageIndexChange Callback returning the last page index of the pager. + */ export const HistoryCardList: React.FC = ({ entries, onPinClick, - onRemoveClick, - onDeleteClick, + onRemoveEntryClick, + onDeleteNoteClick, pageIndex, onLastPageIndexChange }) => { + const entryCards = useMemo(() => { + return entries.map((entry) => ( + + )) + }, [entries, onPinClick, onRemoveEntryClick, onDeleteNoteClick]) + return ( - {entries.map((entry) => ( - - ))} + {entryCards} ) diff --git a/src/components/history-page/history-card/history-card.tsx b/src/components/history-page/history-card/history-card.tsx index 8ff1a9980..b165b72f4 100644 --- a/src/components/history-page/history-card/history-card.tsx +++ b/src/components/history-page/history-card/history-card.tsx @@ -17,19 +17,30 @@ import { useHistoryEntryTitle } from '../use-history-entry-title' import { cypressId } from '../../../utils/cypress-attribute' import Link from 'next/link' +/** + * Renders a history entry as a card. + * @param entry The history entry. + * @param onPinClick Callback that is fired when the pinning button was clicked. + * @param onRemoveEntryClick Callback that is fired when the entry removal button was clicked. + * @param onDeleteNoteClick Callback that is fired when the note deletion button was clicked. + */ export const HistoryCard: React.FC = ({ entry, onPinClick, - onRemoveClick, - onDeleteClick + onRemoveEntryClick, + onDeleteNoteClick }) => { - const onRemove = useCallback(() => { - onRemoveClick(entry.identifier) - }, [onRemoveClick, entry.identifier]) + const onRemoveEntry = useCallback(() => { + onRemoveEntryClick(entry.identifier) + }, [onRemoveEntryClick, entry.identifier]) - const onDelete = useCallback(() => { - onDeleteClick(entry.identifier) - }, [onDeleteClick, entry.identifier]) + const onDeleteNote = useCallback(() => { + onDeleteNoteClick(entry.identifier) + }, [onDeleteNoteClick, entry.identifier]) + + const onPinEntry = useCallback(() => { + onPinClick(entry.identifier) + }, [onPinClick, entry.identifier]) const entryTitle = useHistoryEntryTitle(entry) @@ -43,14 +54,14 @@ export const HistoryCard: React.FC = ( [entry.tags] ) - const lastVisited = useMemo(() => formatHistoryDate(entry.lastVisited), [entry.lastVisited]) + const lastVisited = useMemo(() => formatHistoryDate(entry.lastVisitedAt), [entry.lastVisitedAt]) return (
    - onPinClick(entry.identifier)} /> +
    @@ -60,7 +71,7 @@ export const HistoryCard: React.FC = (
    - {DateTime.fromISO(entry.lastVisited).toRelative()} + {DateTime.fromISO(entry.lastVisitedAt).toRelative()}
    {lastVisited}
    @@ -74,9 +85,8 @@ export const HistoryCard: React.FC = ( id={entry.identifier} title={entryTitle} origin={entry.origin} - isDark={false} - onRemove={onRemove} - onDelete={onDelete} + onRemoveFromHistory={onRemoveEntry} + onDeleteNote={onDeleteNote} />
    diff --git a/src/components/history-page/history-content/history-content.tsx b/src/components/history-page/history-content/history-content.tsx index bbb4d606c..9518991d3 100644 --- a/src/components/history-page/history-content/history-content.tsx +++ b/src/components/history-page/history-content/history-content.tsx @@ -11,32 +11,35 @@ import { PagerPagination } from '../../common/pagination/pager-pagination' import { HistoryCardList } from '../history-card/history-card-list' import { HistoryTable } from '../history-table/history-table' import { ViewStateEnum } from '../history-toolbar/history-toolbar' -import type { HistoryEntry } from '../../../redux/history/types' import { removeHistoryEntry, toggleHistoryEntryPinning } from '../../../redux/history/methods' import { deleteNote } from '../../../api/notes' import { showErrorNotification } from '../../../redux/ui-notifications/methods' import { useApplicationState } from '../../../hooks/common/use-application-state' import { sortAndFilterEntries } from '../utils' import { useHistoryToolbarState } from '../history-toolbar/toolbar-context/use-history-toolbar-state' +import type { HistoryEntryWithOrigin } from '../../../api/history/types' type OnEntryClick = (entryId: string) => void export interface HistoryEventHandlers { onPinClick: OnEntryClick - onRemoveClick: OnEntryClick - onDeleteClick: OnEntryClick + onRemoveEntryClick: OnEntryClick + onDeleteNoteClick: OnEntryClick } export interface HistoryEntryProps { - entry: HistoryEntry + entry: HistoryEntryWithOrigin } export interface HistoryEntriesProps { - entries: HistoryEntry[] + entries: HistoryEntryWithOrigin[] pageIndex: number onLastPageIndexChange: (lastPageIndex: number) => void } +/** + * Renders the content of the history based on the current history toolbar state. + */ export const HistoryContent: React.FC = () => { useTranslation() const [pageIndex, setPageIndex] = useState(0) @@ -46,7 +49,7 @@ export const HistoryContent: React.FC = () => { const [historyToolbarState] = useHistoryToolbarState() - const entriesToShow = useMemo( + const entriesToShow = useMemo( () => sortAndFilterEntries(allEntries, historyToolbarState), [allEntries, historyToolbarState] ) @@ -72,8 +75,8 @@ export const HistoryContent: React.FC = () => { @@ -83,8 +86,8 @@ export const HistoryContent: React.FC = () => { diff --git a/src/components/history-page/history-table/history-table-row.tsx b/src/components/history-page/history-table/history-table-row.tsx index daccf46cb..5693cecb8 100644 --- a/src/components/history-page/history-table/history-table-row.tsx +++ b/src/components/history-page/history-table/history-table-row.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import React from 'react' +import React, { useCallback } from 'react' import { Badge } from 'react-bootstrap' import { EntryMenu } from '../entry-menu/entry-menu' import type { HistoryEntryProps, HistoryEventHandlers } from '../history-content/history-content' @@ -14,13 +14,33 @@ import { useHistoryEntryTitle } from '../use-history-entry-title' import { cypressId } from '../../../utils/cypress-attribute' import Link from 'next/link' +/** + * Renders a history entry as a table row. + * @param entry The history entry. + * @param onPinClick Callback that is fired when the pinning button was clicked. + * @param onRemoveEntryClick Callback that is fired when the entry removal button was clicked. + * @param onDeleteNoteClick Callback that is fired when the note deletion button was clicked. + */ export const HistoryTableRow: React.FC = ({ entry, onPinClick, - onRemoveClick, - onDeleteClick + onRemoveEntryClick, + onDeleteNoteClick }) => { const entryTitle = useHistoryEntryTitle(entry) + + const onPinEntry = useCallback(() => { + onPinClick(entry.identifier) + }, [onPinClick, entry.identifier]) + + const onEntryRemove = useCallback(() => { + onRemoveEntryClick(entry.identifier) + }, [onRemoveEntryClick, entry.identifier]) + + const onDeleteNote = useCallback(() => { + onDeleteNoteClick(entry.identifier) + }, [onDeleteNoteClick, entry.identifier]) + return ( @@ -30,7 +50,7 @@ export const HistoryTableRow: React.FC
    - {formatHistoryDate(entry.lastVisited)} + {formatHistoryDate(entry.lastVisitedAt)} {entry.tags.map((tag) => ( @@ -39,19 +59,13 @@ export const HistoryTableRow: React.FC ))} - onPinClick(entry.identifier)} - className={'mb-1 mr-1'} - /> + onRemoveClick(entry.identifier)} - onDelete={() => onDeleteClick(entry.identifier)} + onRemoveFromHistory={onEntryRemove} + onDeleteNote={onDeleteNote} /> diff --git a/src/components/history-page/history-table/history-table.tsx b/src/components/history-page/history-table/history-table.tsx index 2459731f4..0d54d3541 100644 --- a/src/components/history-page/history-table/history-table.tsx +++ b/src/components/history-page/history-table/history-table.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import React from 'react' +import React, { useMemo } from 'react' import { Table } from 'react-bootstrap' import { Trans, useTranslation } from 'react-i18next' import { Pager } from '../../common/pagination/pager' @@ -13,15 +13,37 @@ import { HistoryTableRow } from './history-table-row' import styles from './history-table.module.scss' import { cypressId } from '../../../utils/cypress-attribute' +/** + * Renders a paginated table of history entries. + * @param entries The history entries to render. + * @param onPinClick Callback that is fired when the pinning button was clicked for an entry. + * @param onRemoveEntryClick Callback that is fired when the entry removal button was clicked for an entry. + * @param onDeleteNoteClick Callback that is fired when the note deletion button was clicked for an entry. + * @param pageIndex The currently selected page. + * @param onLastPageIndexChange Callback returning the last page index of the pager. + */ export const HistoryTable: React.FC = ({ entries, onPinClick, - onRemoveClick, - onDeleteClick, + onRemoveEntryClick, + onDeleteNoteClick, pageIndex, onLastPageIndexChange }) => { useTranslation() + + const tableRows = useMemo(() => { + return entries.map((entry) => ( + + )) + }, [entries, onPinClick, onRemoveEntryClick, onDeleteNoteClick]) + return ( - {entries.map((entry) => ( - - ))} + {tableRows}
    diff --git a/src/components/history-page/history-toolbar/history-toolbar.tsx b/src/components/history-page/history-toolbar/history-toolbar.tsx index 6995fd645..989f6de55 100644 --- a/src/components/history-page/history-toolbar/history-toolbar.tsx +++ b/src/components/history-page/history-toolbar/history-toolbar.tsx @@ -12,7 +12,6 @@ import { ShowIf } from '../../common/show-if/show-if' import { ClearHistoryButton } from './clear-history-button' import { ExportHistoryButton } from './export-history-button' import { ImportHistoryButton } from './import-history-button' -import { HistoryEntryOrigin } from '../../../redux/history/types' import { importHistoryEntries, safeRefreshHistoryState, setHistoryEntries } from '../../../redux/history/methods' import { showErrorNotification } from '../../../redux/ui-notifications/methods' import { useApplicationState } from '../../../hooks/common/use-application-state' @@ -23,6 +22,7 @@ import { SortByTitleButton } from './sort-by-title-button' import { SortByLastVisitedButton } from './sort-by-last-visited-button' import { HistoryViewModeToggleButton } from './history-view-mode-toggle-button' import { useSyncToolbarStateToUrlEffect } from './toolbar-context/use-sync-toolbar-state-to-url-effect' +import { HistoryEntryOrigin } from '../../../api/history/types' export enum ViewStateEnum { CARD, diff --git a/src/components/history-page/history-toolbar/import-history-button.tsx b/src/components/history-page/history-toolbar/import-history-button.tsx index 984571fc1..a7c2f300f 100644 --- a/src/components/history-page/history-toolbar/import-history-button.tsx +++ b/src/components/history-page/history-toolbar/import-history-button.tsx @@ -8,8 +8,7 @@ import React, { useCallback, useRef, useState } from 'react' import { Button } from 'react-bootstrap' import { useTranslation } from 'react-i18next' import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon' -import type { HistoryEntry, HistoryExportJson, V1HistoryEntry } from '../../../redux/history/types' -import { HistoryEntryOrigin } from '../../../redux/history/types' +import type { HistoryExportJson, V1HistoryEntry } from '../../../redux/history/types' import { convertV1History, importHistoryEntries, @@ -19,7 +18,12 @@ import { import { dispatchUiNotification, showErrorNotification } from '../../../redux/ui-notifications/methods' import { useApplicationState } from '../../../hooks/common/use-application-state' import { cypressId } from '../../../utils/cypress-attribute' +import type { HistoryEntryWithOrigin } from '../../../api/history/types' +import { HistoryEntryOrigin } from '../../../api/history/types' +/** + * Button that lets the user select a history JSON file and uploads imports that into the history. + */ export const ImportHistoryButton: React.FC = () => { const { t } = useTranslation() const userExists = useApplicationState((state) => !!state.user) @@ -28,7 +32,7 @@ export const ImportHistoryButton: React.FC = () => { const [fileName, setFilename] = useState('') const onImportHistory = useCallback( - (entries: HistoryEntry[]): void => { + (entries: HistoryEntryWithOrigin[]): void => { entries.forEach((entry) => (entry.origin = userExists ? HistoryEntryOrigin.REMOTE : HistoryEntryOrigin.LOCAL)) importHistoryEntries(mergeHistoryEntries(historyState, entries)).catch((error: Error) => { showErrorNotification('landing.history.error.setHistory.text')(error) @@ -45,6 +49,8 @@ export const ImportHistoryButton: React.FC = () => { uploadInput.current.value = '' }, [uploadInput]) + const onUploadButtonClick = useCallback(() => uploadInput.current?.click(), [uploadInput]) + const handleUpload = (event: React.ChangeEvent): void => { const { validity, files } = event.target if (files && files[0] && validity.valid) { @@ -124,7 +130,7 @@ export const ImportHistoryButton: React.FC = () => { diff --git a/src/components/history-page/use-history-entry-title.ts b/src/components/history-page/use-history-entry-title.ts index f7918b9dc..9317a4a78 100644 --- a/src/components/history-page/use-history-entry-title.ts +++ b/src/components/history-page/use-history-entry-title.ts @@ -3,17 +3,16 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ - -import type { HistoryEntry } from '../../redux/history/types' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' +import type { HistoryEntryWithOrigin } from '../../api/history/types' /** * Hook that returns the title of a note in the history if present or the translation for "untitled" otherwise. * @param entry The history entry containing a title property, that might be an empty string. * @return A memoized string containing either the title of the entry or the translated version of "untitled". */ -export const useHistoryEntryTitle = (entry: HistoryEntry): string => { +export const useHistoryEntryTitle = (entry: HistoryEntryWithOrigin): string => { const { t } = useTranslation() return useMemo(() => { return entry.title !== '' ? entry.title : t('editor.untitledNote') diff --git a/src/components/history-page/utils.ts b/src/components/history-page/utils.ts index c3a6098a4..1a506a112 100644 --- a/src/components/history-page/utils.ts +++ b/src/components/history-page/utils.ts @@ -6,34 +6,72 @@ import { DateTime } from 'luxon' import { SortModeEnum } from './sort-button/sort-button' -import type { HistoryEntry } from '../../redux/history/types' import type { HistoryToolbarState } from './history-toolbar/history-toolbar-state' +import type { HistoryEntryWithOrigin } from '../../api/history/types' +/** + * Parses a given ISO formatted date string and outputs it as a date and time string. + * @param date The date in ISO format. + * @return The date formatted as date and time string. + */ export const formatHistoryDate = (date: string): string => DateTime.fromISO(date).toFormat('DDDD T') -export const sortAndFilterEntries = (entries: HistoryEntry[], toolbarState: HistoryToolbarState): HistoryEntry[] => { +/** + * Applies sorting and filter rules that match a given toolbar state to a list of history entries. + * @param entries The history entries to sort and filter. + * @param toolbarState The state of the history toolbar (sorting rules, keyword and tag input). + * @return The list of filtered and sorted history entries. + */ +export const sortAndFilterEntries = ( + entries: HistoryEntryWithOrigin[], + toolbarState: HistoryToolbarState +): HistoryEntryWithOrigin[] => { const filteredBySelectedTagsEntries = filterBySelectedTags(entries, toolbarState.selectedTags) const filteredByKeywordSearchEntries = filterByKeywordSearch(filteredBySelectedTagsEntries, toolbarState.search) return sortEntries(filteredByKeywordSearchEntries, toolbarState) } -const filterBySelectedTags = (entries: HistoryEntry[], selectedTags: string[]): HistoryEntry[] => { +/** + * Filters the given history entries by the given tags. + * @param entries The history entries to filter. + * @param selectedTags The tags that were selected as filter criteria. + * @return The list of filtered history entries. + */ +const filterBySelectedTags = (entries: HistoryEntryWithOrigin[], selectedTags: string[]): HistoryEntryWithOrigin[] => { return entries.filter((entry) => { return selectedTags.length === 0 || arrayCommonCheck(entry.tags, selectedTags) }) } +/** + * Checks whether the entries of array 1 are contained in array 2. + * @param array1 The first input array. + * @param array2 The second input array. + * @return true if all entries from array 1 are contained in array 2, false otherwise. + */ const arrayCommonCheck = (array1: T[], array2: T[]): boolean => { const foundElement = array1.find((element1) => array2.find((element2) => element2 === element1)) return !!foundElement } -const filterByKeywordSearch = (entries: HistoryEntry[], keywords: string): HistoryEntry[] => { +/** + * Filters the given history entries by the given search term. Works case-insensitive. + * @param entries The history entries to filter. + * @param keywords The search term. + * @return The history entries that contain the search term in their title. + */ +const filterByKeywordSearch = (entries: HistoryEntryWithOrigin[], keywords: string): HistoryEntryWithOrigin[] => { const searchTerm = keywords.toLowerCase() return entries.filter((entry) => entry.title.toLowerCase().includes(searchTerm)) } -const sortEntries = (entries: HistoryEntry[], viewState: HistoryToolbarState): HistoryEntry[] => { +/** + * Sorts the given history entries by the sorting rules of the provided toolbar state. + * @param entries The history entries to sort. + * @param viewState The toolbar state containing the sorting options. + * @return The sorted history entries. + */ +const sortEntries = (entries: HistoryEntryWithOrigin[], viewState: HistoryToolbarState): HistoryEntryWithOrigin[] => { return entries.sort((firstEntry, secondEntry) => { if (firstEntry.pinStatus && !secondEntry.pinStatus) { return -1 @@ -47,10 +85,10 @@ const sortEntries = (entries: HistoryEntry[], viewState: HistoryToolbarState): H } if (viewState.lastVisitedSortDirection !== SortModeEnum.no) { - if (firstEntry.lastVisited > secondEntry.lastVisited) { + if (firstEntry.lastVisitedAt > secondEntry.lastVisitedAt) { return 1 * viewState.lastVisitedSortDirection } - if (firstEntry.lastVisited < secondEntry.lastVisited) { + if (firstEntry.lastVisitedAt < secondEntry.lastVisitedAt) { return -1 * viewState.lastVisitedSortDirection } } diff --git a/src/components/intro-page/cover-buttons/cover-buttons.tsx b/src/components/intro-page/cover-buttons/cover-buttons.tsx index 737f4c1de..3ee722e54 100644 --- a/src/components/intro-page/cover-buttons/cover-buttons.tsx +++ b/src/components/intro-page/cover-buttons/cover-buttons.tsx @@ -17,9 +17,7 @@ import Link from 'next/link' export const CoverButtons: React.FC = () => { useTranslation() const userExists = useApplicationState((state) => !!state.user) - const anyAuthProviderActivated = useApplicationState((state) => - Object.values(state.config.authProviders).includes(true) - ) + const anyAuthProviderActivated = useApplicationState((state) => state.config.authProviders.length > 0) if (userExists) { return null diff --git a/src/components/intro-page/hooks/use-intro-page-content.ts b/src/components/intro-page/hooks/use-intro-page-content.ts index 5be3af5a2..344dc9363 100644 --- a/src/components/intro-page/hooks/use-intro-page-content.ts +++ b/src/components/intro-page/hooks/use-intro-page-content.ts @@ -7,18 +7,16 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { fetchFrontPageContent } from '../requests' -import { useCustomizeAssetsUrl } from '../../../hooks/common/use-customize-assets-url' export const useIntroPageContent = (): string[] | undefined => { const { t } = useTranslation() const [content, setContent] = useState(undefined) - const customizeAssetsUrl = useCustomizeAssetsUrl() useEffect(() => { - fetchFrontPageContent(customizeAssetsUrl) + fetchFrontPageContent() .then((content) => setContent(content.split('\n'))) .catch(() => setContent(undefined)) - }, [customizeAssetsUrl, t]) + }, [t]) return content } diff --git a/src/components/intro-page/requests.ts b/src/components/intro-page/requests.ts index fa3f7dc89..5837253fa 100644 --- a/src/components/intro-page/requests.ts +++ b/src/components/intro-page/requests.ts @@ -1,17 +1,20 @@ /* - * 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 */ -import { defaultFetchConfig, expectResponseCode } from '../../api/utils' +import { customizeAssetsUrl } from '../../utils/customize-assets-url' +import { defaultConfig } from '../../api/common/default-config' -export const fetchFrontPageContent = async (customizeAssetsUrl: string): Promise => { +export const fetchFrontPageContent = async (): Promise => { const response = await fetch(customizeAssetsUrl + 'intro.md', { - ...defaultFetchConfig, + ...defaultConfig, method: 'GET' }) - expectResponseCode(response) + if (response.status !== 200) { + throw new Error('Error fetching intro content') + } return await response.text() } diff --git a/src/components/landing-layout/navigation/sign-in-button.tsx b/src/components/landing-layout/navigation/sign-in-button.tsx index b1bf6920f..2b65d9a9a 100644 --- a/src/components/landing-layout/navigation/sign-in-button.tsx +++ b/src/components/landing-layout/navigation/sign-in-button.tsx @@ -9,34 +9,34 @@ import { Button } from 'react-bootstrap' import type { ButtonProps } from 'react-bootstrap/Button' import { Trans, useTranslation } from 'react-i18next' import { ShowIf } from '../../common/show-if/show-if' -import { INTERACTIVE_LOGIN_METHODS } from '../../../api/auth' import { useApplicationState } from '../../../hooks/common/use-application-state' import { cypressId } from '../../../utils/cypress-attribute' -import { useBackendBaseUrl } from '../../../hooks/common/use-backend-base-url' import Link from 'next/link' +import { filterOneClickProviders } from '../../login-page/auth/utils' +import { getOneClickProviderMetadata } from '../../login-page/auth/utils/get-one-click-provider-metadata' export type SignInButtonProps = Omit +/** + * Renders a sign-in button if auth providers are defined. It links to either the login page or if only a single one-click provider is configured, to this one. + * @param variant The style variant as inferred from the common button component. + * @param props Further props inferred from the common button component. + */ export const SignInButton: React.FC = ({ variant, ...props }) => { const { t } = useTranslation() - const backendBaseUrl = useBackendBaseUrl() const authProviders = useApplicationState((state) => state.config.authProviders) - const authEnabled = useMemo(() => Object.values(authProviders).includes(true), [authProviders]) const loginLink = useMemo(() => { - const activeProviders = Object.entries(authProviders) - .filter((entry: [string, boolean]) => entry[1]) - .map((entry) => entry[0]) - const activeOneClickProviders = activeProviders.filter((entry) => !INTERACTIVE_LOGIN_METHODS.includes(entry)) - - if (activeProviders.length === 1 && activeOneClickProviders.length === 1) { - return `${backendBaseUrl}auth/${activeOneClickProviders[0]}` + const oneClickProviders = authProviders.filter(filterOneClickProviders) + if (authProviders.length === 1 && oneClickProviders.length === 1) { + const metadata = getOneClickProviderMetadata(oneClickProviders[0]) + return metadata.url } return '/login' - }, [authProviders, backendBaseUrl]) + }, [authProviders]) return ( - + 0}> diff --git a/src/components/profile-page/settings/profile-change-password.tsx b/src/components/profile-page/settings/profile-change-password.tsx index 25ff2a47a..93ffcbfc6 100644 --- a/src/components/profile-page/settings/profile-change-password.tsx +++ b/src/components/profile-page/settings/profile-change-password.tsx @@ -1,15 +1,15 @@ /* - * 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 */ import type { FormEvent } from 'react' -import React, { useCallback, useMemo, useState } from 'react' +import React, { useCallback, useMemo, useRef, useState } from 'react' import { Button, Card, Form } from 'react-bootstrap' import { Trans, useTranslation } from 'react-i18next' import { doLocalPasswordChange } from '../../../api/auth/local' -import { showErrorNotification } from '../../../redux/ui-notifications/methods' +import { dispatchUiNotification, showErrorNotification } from '../../../redux/ui-notifications/methods' import { useOnInputChange } from '../../../hooks/common/use-on-input-change' import { NewPasswordField } from '../../common/fields/new-password-field' import { PasswordAgainField } from '../../common/fields/password-again-field' @@ -24,6 +24,8 @@ export const ProfileChangePassword: React.FC = () => { const [newPassword, setNewPassword] = useState('') const [newPasswordAgain, setNewPasswordAgain] = useState('') + const formRef = useRef(null) + const onChangeOldPassword = useOnInputChange(setOldPassword) const onChangeNewPassword = useOnInputChange(setNewPassword) const onChangeNewPasswordAgain = useOnInputChange(setNewPasswordAgain) @@ -31,7 +33,21 @@ export const ProfileChangePassword: React.FC = () => { const onSubmitPasswordChange = useCallback( (event: FormEvent) => { event.preventDefault() - doLocalPasswordChange(oldPassword, newPassword).catch(showErrorNotification('profile.changePassword.failed')) + doLocalPasswordChange(oldPassword, newPassword) + .then(() => { + return dispatchUiNotification('profile.changePassword.successTitle', 'profile.changePassword.successText', { + icon: 'check' + }) + }) + .catch(showErrorNotification('profile.changePassword.failed')) + .finally(() => { + if (formRef.current) { + formRef.current.reset() + } + setOldPassword('') + setNewPassword('') + setNewPasswordAgain('') + }) }, [oldPassword, newPassword] ) @@ -51,7 +67,7 @@ export const ProfileChangePassword: React.FC = () => { - + diff --git a/src/components/register-page/register-error/register-error.tsx b/src/components/register-page/register-error/register-error.tsx index 962d02453..39520abc4 100644 --- a/src/components/register-page/register-error/register-error.tsx +++ b/src/components/register-page/register-error/register-error.tsx @@ -5,7 +5,7 @@ */ import React, { useMemo } from 'react' -import { RegisterError as RegisterErrorType } from '../../../api/auth' +import { RegisterError as RegisterErrorType } from '../../../api/auth/types' import { Trans, useTranslation } from 'react-i18next' import { Alert } from 'react-bootstrap' diff --git a/src/handler-utils/respond-to-matching-request.ts b/src/handler-utils/respond-to-matching-request.ts new file mode 100644 index 000000000..0b4ccc26c --- /dev/null +++ b/src/handler-utils/respond-to-matching-request.ts @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { NextApiRequest, NextApiResponse } from 'next' + +export enum HttpMethod { + GET = 'GET', + POST = 'POST', + PUT = 'PUT', + DELETE = 'DELETE' +} + +/** + * Intercepts a mock HTTP request, checks the used request method and responds with given response content or an error + * that the request method is not allowed. + * + * @param method The expected HTTP method. + * @param req The request object. + * @param res The response object. + * @param response The response data that will be returned when the HTTP method was the expected one. + * @param statusCode The status code with which the response will be sent. + * @return true if the HTTP method of the request is the expected one, false otherwise. + */ +export const respondToMatchingRequest = ( + method: HttpMethod, + req: NextApiRequest, + res: NextApiResponse, + response: T, + statusCode = 200 +): boolean => { + if (method !== req.method) { + res.status(405).send('Method not allowed') + return false + } else { + res.status(statusCode).json(response) + return true + } +} diff --git a/src/hooks/common/use-backend-base-url.ts b/src/hooks/common/use-backend-base-url.ts deleted file mode 100644 index dc626f5d5..000000000 --- a/src/hooks/common/use-backend-base-url.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { isMockMode } from '../../utils/test-modes' -import { useMemo } from 'react' - -export const useBackendBaseUrl = (): string => { - return useMemo(() => { - const mockMode = isMockMode() - if (!mockMode && process.env.NEXT_PUBLIC_BACKEND_BASE_URL === undefined) { - throw new Error('NEXT_PUBLIC_BACKEND_BASE_URL is unset and mock mode is disabled') - } - - return mockMode ? '/mock-backend/' : (process.env.NEXT_PUBLIC_BACKEND_BASE_URL as string) - }, []) -} diff --git a/src/hooks/common/use-customize-assets-url.ts b/src/hooks/common/use-customize-assets-url.ts deleted file mode 100644 index c5ba3e165..000000000 --- a/src/hooks/common/use-customize-assets-url.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { useBackendBaseUrl } from './use-backend-base-url' - -export const useCustomizeAssetsUrl = (): string => { - const backendBaseUrl = useBackendBaseUrl() - return process.env.NEXT_PUBLIC_CUSTOMIZE_ASSETS_URL || `${backendBaseUrl}public/` -} diff --git a/src/hooks/common/use-note-title.ts b/src/hooks/common/use-note-title.ts index 252156e7c..fabf16f8f 100644 --- a/src/hooks/common/use-note-title.ts +++ b/src/hooks/common/use-note-title.ts @@ -14,7 +14,7 @@ import { useMemo } from 'react' export const useNoteTitle = (): string => { const { t } = useTranslation() const untitledNote = useMemo(() => t('editor.untitledNote'), [t]) - const noteTitle = useApplicationState((state) => state.noteDetails.noteTitle) + const noteTitle = useApplicationState((state) => state.noteDetails.title) return useMemo(() => (noteTitle === '' ? untitledNote : noteTitle), [noteTitle, untitledNote]) } diff --git a/src/pages/api/mock-backend/private/config.ts b/src/pages/api/mock-backend/private/config.ts new file mode 100644 index 000000000..014fa2d4d --- /dev/null +++ b/src/pages/api/mock-backend/private/config.ts @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import type { NextApiRequest, NextApiResponse } from 'next' +import type { Config } from '../../../../api/config/types' +import { AuthProviderType } from '../../../../api/config/types' +import { HttpMethod, respondToMatchingRequest } from '../../../../handler-utils/respond-to-matching-request' + +const handler = (req: NextApiRequest, res: NextApiResponse) => { + respondToMatchingRequest(HttpMethod.GET, req, res, { + allowAnonymous: true, + allowRegister: true, + authProviders: [ + { + type: AuthProviderType.LOCAL + }, + { + type: AuthProviderType.LDAP, + identifier: 'test-ldap', + providerName: 'Test LDAP' + }, + { + type: AuthProviderType.DROPBOX + }, + { + type: AuthProviderType.FACEBOOK + }, + { + type: AuthProviderType.GITHUB + }, + { + type: AuthProviderType.GITLAB, + identifier: 'test-gitlab', + providerName: 'Test GitLab' + }, + { + type: AuthProviderType.GOOGLE + }, + { + type: AuthProviderType.OAUTH2, + identifier: 'test-oauth2', + providerName: 'Test OAuth2' + }, + { + type: AuthProviderType.SAML, + identifier: 'test-saml', + providerName: 'Test SAML' + }, + { + type: AuthProviderType.TWITTER + } + ], + branding: { + name: 'DEMO Corp', + logo: '/mock-public/img/demo.png' + }, + useImageProxy: false, + specialUrls: { + privacy: 'https://example.com/privacy', + termsOfUse: 'https://example.com/termsOfUse', + imprint: 'https://example.com/imprint' + }, + version: { + major: 2, + minor: 0, + patch: 0, + commit: 'mock' + }, + plantumlServer: 'https://www.plantuml.com/plantuml', + maxDocumentLength: 1000000, + iframeCommunication: { + editorOrigin: process.env.NEXT_PUBLIC_EDITOR_ORIGIN ?? 'http://localhost:3001/', + rendererOrigin: process.env.NEXT_PUBLIC_RENDERER_ORIGIN ?? 'http://127.0.0.1:3001/' + } + }) +} + +export default handler diff --git a/src/pages/api/mock-backend/private/groups/_EVERYONE.ts b/src/pages/api/mock-backend/private/groups/_EVERYONE.ts new file mode 100644 index 000000000..7e9dcbbe4 --- /dev/null +++ b/src/pages/api/mock-backend/private/groups/_EVERYONE.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import type { NextApiRequest, NextApiResponse } from 'next' +import { HttpMethod, respondToMatchingRequest } from '../../../../../handler-utils/respond-to-matching-request' +import type { GroupInfo } from '../../../../../api/group/types' + +const handler = (req: NextApiRequest, res: NextApiResponse) => { + respondToMatchingRequest(HttpMethod.GET, req, res, { + name: '_EVERYONE', + displayName: 'Everyone', + special: true + }) +} + +export default handler diff --git a/src/pages/api/mock-backend/private/groups/_LOGGED_IN.ts b/src/pages/api/mock-backend/private/groups/_LOGGED_IN.ts new file mode 100644 index 000000000..3b4d30890 --- /dev/null +++ b/src/pages/api/mock-backend/private/groups/_LOGGED_IN.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import type { NextApiRequest, NextApiResponse } from 'next' +import { HttpMethod, respondToMatchingRequest } from '../../../../../handler-utils/respond-to-matching-request' +import type { GroupInfo } from '../../../../../api/group/types' + +const handler = (req: NextApiRequest, res: NextApiResponse) => { + respondToMatchingRequest(HttpMethod.GET, req, res, { + name: '_LOGGED_IN', + displayName: 'All registered users', + special: true + }) +} + +export default handler diff --git a/src/pages/api/mock-backend/private/groups/hedgedoc-devs.ts b/src/pages/api/mock-backend/private/groups/hedgedoc-devs.ts new file mode 100644 index 000000000..cde391019 --- /dev/null +++ b/src/pages/api/mock-backend/private/groups/hedgedoc-devs.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import type { NextApiRequest, NextApiResponse } from 'next' +import { HttpMethod, respondToMatchingRequest } from '../../../../../handler-utils/respond-to-matching-request' +import type { GroupInfo } from '../../../../../api/group/types' + +const handler = (req: NextApiRequest, res: NextApiResponse) => { + respondToMatchingRequest(HttpMethod.GET, req, res, { + name: 'hedgedoc-devs', + displayName: 'HedgeDoc devs', + special: true + }) +} + +export default handler diff --git a/src/pages/api/mock-backend/private/me/history.ts b/src/pages/api/mock-backend/private/me/history.ts new file mode 100644 index 000000000..51e29fd7b --- /dev/null +++ b/src/pages/api/mock-backend/private/me/history.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { NextApiRequest, NextApiResponse } from 'next' +import { HttpMethod, respondToMatchingRequest } from '../../../../../handler-utils/respond-to-matching-request' +import type { HistoryEntry } from '../../../../../api/history/types' + +const handler = (req: NextApiRequest, res: NextApiResponse) => { + respondToMatchingRequest(HttpMethod.GET, req, res, [ + { + identifier: 'slide-example', + title: 'Slide example', + lastVisitedAt: '2020-05-30T15:20:36.088Z', + pinStatus: true, + tags: ['features', 'cool', 'updated'] + }, + { + identifier: 'features', + title: 'Features', + lastVisitedAt: '2020-05-31T15:20:36.088Z', + pinStatus: true, + tags: ['features', 'cool', 'updated'] + }, + { + identifier: 'ODakLc2MQkyyFc_Xmb53sg', + title: 'Non existent', + lastVisitedAt: '2020-05-25T19:48:14.025Z', + pinStatus: false, + tags: [] + }, + { + identifier: 'l8JuWxApTR6Fqa0LCrpnLg', + title: 'Non existent', + lastVisitedAt: '2020-05-24T16:04:36.433Z', + pinStatus: false, + tags: ['agenda', 'HedgeDoc community', 'community call'] + } + ]) +} + +export default handler diff --git a/src/pages/api/mock-backend/private/me/index.ts b/src/pages/api/mock-backend/private/me/index.ts new file mode 100644 index 000000000..e0892e06e --- /dev/null +++ b/src/pages/api/mock-backend/private/me/index.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { NextApiRequest, NextApiResponse } from 'next' +import { HttpMethod, respondToMatchingRequest } from '../../../../../handler-utils/respond-to-matching-request' +import type { LoginUserInfo } from '../../../../../api/me/types' + +const handler = (req: NextApiRequest, res: NextApiResponse): void => { + respondToMatchingRequest(HttpMethod.GET, req, res, { + username: 'mock', + photo: '/mock-public/img/avatar.png', + displayName: 'Mock User', + authProvider: 'local', + email: 'mock@hedgedoc.test' + }) +} + +export default handler diff --git a/src/pages/api/mock-backend/private/me/media.ts b/src/pages/api/mock-backend/private/me/media.ts new file mode 100644 index 000000000..8b5c078c6 --- /dev/null +++ b/src/pages/api/mock-backend/private/me/media.ts @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { NextApiRequest, NextApiResponse } from 'next' +import { HttpMethod, respondToMatchingRequest } from '../../../../../handler-utils/respond-to-matching-request' +import type { MediaUpload } from '../../../../../api/media/types' + +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +const handler = (req: NextApiRequest, res: NextApiResponse) => { + respondToMatchingRequest(HttpMethod.GET, req, res, [ + { + username: 'tilman', + createdAt: '2022-03-20T20:36:32Z', + url: 'https://dummyimage.com/256/f00', + noteId: 'features' + }, + { + username: 'tilman', + createdAt: '2022-03-20T20:36:57+0000', + url: 'https://dummyimage.com/256/00f', + noteId: null + } + ]) +} + +export default handler diff --git a/src/pages/api/mock-backend/private/media.ts b/src/pages/api/mock-backend/private/media.ts new file mode 100644 index 000000000..1c2910c91 --- /dev/null +++ b/src/pages/api/mock-backend/private/media.ts @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { NextApiRequest, NextApiResponse } from 'next' +import type { MediaUpload } from '../../../../api/media/types' +import { HttpMethod, respondToMatchingRequest } from '../../../../handler-utils/respond-to-matching-request' +import { isMockMode, isTestMode } from '../../../../utils/test-modes' + +const handler = async (req: NextApiRequest, res: NextApiResponse): Promise => { + if (isMockMode && !isTestMode) { + await new Promise((resolve) => { + setTimeout(resolve, 3000) + }) + } + + respondToMatchingRequest( + HttpMethod.POST, + req, + res, + { + url: '/mock-public/img/avatar.png', + noteId: null, + username: 'test', + createdAt: '2022-02-27T21:54:23.856Z' + }, + 201 + ) +} + +export default handler diff --git a/src/pages/api/mock-backend/private/notes/features/index.ts b/src/pages/api/mock-backend/private/notes/features/index.ts new file mode 100644 index 000000000..cb296bea7 --- /dev/null +++ b/src/pages/api/mock-backend/private/notes/features/index.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { NextApiRequest, NextApiResponse } from 'next' +import { HttpMethod, respondToMatchingRequest } from '../../../../../../handler-utils/respond-to-matching-request' +import type { Note } from '../../../../../../api/notes/types' + +const handler = (req: NextApiRequest, res: NextApiResponse): void => { + respondToMatchingRequest(HttpMethod.GET, req, res, { + content: + '---\ntitle: Features\ndescription: Many features, such wow!\nrobots: noindex\ntags:\n - hedgedoc\n - demo\n - react\nopengraph:\n title: Features\n---\n# Embedding demo\n[TOC]\n\n## markmap\n\n\n```markmap\n# MarkMap\n\n## Pro\n\n### written in typescript\n\n## Cons\n\n### must redeclare types\n```\n\n## Vega-Lite\n\n```vega-lite\n\n{\n "$schema": "https://vega.github.io/schema/vega-lite/v5.json",\n "description": "Reproducing http://robslink.com/SAS/democd91/pyramid_pie.htm",\n "data": {\n "values": [\n {"category": "Sky", "value": 75, "order": 3},\n {"category": "Shady side of a pyramid", "value": 10, "order": 1},\n {"category": "Sunny side of a pyramid", "value": 15, "order": 2}\n ]\n },\n "mark": {"type": "arc", "outerRadius": 80},\n "encoding": {\n "theta": {\n "field": "value", "type": "quantitative",\n "scale": {"range": [2.35619449, 8.639379797]},\n "stack": true\n },\n "color": {\n "field": "category", "type": "nominal",\n "scale": {\n "domain": ["Sky", "Shady side of a pyramid", "Sunny side of a pyramid"],\n "range": ["#416D9D", "#674028", "#DEAC58"]\n },\n "legend": {\n "orient": "none",\n "title": null,\n "columns": 1,\n "legendX": 200,\n "legendY": 80\n }\n },\n "order": {\n "field": "order"\n }\n },\n "view": {"stroke": null}\n}\n\n\n```\n\n## GraphViz\n\n```graphviz\ngraph {\n a -- b\n a -- b\n b -- a [color=blue]\n}\n```\n\n```graphviz\ndigraph structs {\n node [shape=record];\n struct1 [label=" left| mid\ dle| right"];\n struct2 [label=" one| two"];\n struct3 [label="hello\nworld |{ b |{c| d|e}| f}| g | h"];\n struct1:f1 -> struct2:f0;\n struct1:f2 -> struct3:here;\n}\n```\n\n```graphviz\ndigraph G {\n main -> parse -> execute;\n main -> init;\n main -> cleanup;\n execute -> make_string;\n execute -> printf\n init -> make_string;\n main -> printf;\n execute -> compare;\n}\n```\n\n```graphviz\ndigraph D {\n node [fontname="Arial"];\n node_A [shape=record label="shape=record|{above|middle|below}|right"];\n node_B [shape=plaintext label="shape=plaintext|{curly|braces and|bars without}|effect"];\n}\n```\n\n```graphviz\ndigraph D {\n A -> {B, C, D} -> {F}\n}\n```\n\n## High Res Image\n\n![Wheat Field with Cypresses](/mock-public/img/highres.jpg)\n\n## Sequence Diagram (deprecated)\n\n```sequence\nTitle: Here is a title\nnote over A: asdd\nA->B: Normal line\nB-->C: Dashed line\nC->>D: Open arrow\nD-->>A: Dashed open arrow\nparticipant IOOO\n```\n\n## Mermaid\n\n```mermaid\ngantt\n title A Gantt Diagram\n\n section Section\n A task: a1, 2014-01-01, 30d\n Another task: after a1, 20d\n\n section Another\n Task in sec: 2014-01-12, 12d\n Another task: 24d\n```\n\n## Flowchart\n\n```flow\nst=>start: Start\ne=>end: End\nop=>operation: My Operation\nop2=>operation: lalala\ncond=>condition: Yes or No?\n\nst->op->op2->cond\ncond(yes)->e\ncond(no)->op2\n```\n\n## ABC\n\n```abc\nX:1\nT:Speed the Plough\nM:4/4\nC:Trad.\nK:G\n|:GABc dedB|dedB dedB|c2ec B2dB|c2A2 A2BA|\nGABc dedB|dedB dedB|c2ec B2dB|A2F2 G4:|\n|:g2gf gdBd|g2f2 e2d2|c2ec B2dB|c2A2 A2df|\ng2gf g2Bd|g2f2 e2d2|c2ec B2dB|A2F2 G4:|\n```\n\n## CSV\n\n```csv delimiter=; header\nUsername; Identifier;First name;Last name\n"booker12; rbooker";9012;Rachel;Booker\ngrey07;2070;Laura;Grey\njohnson81;4081;Craig;Johnson\njenkins46;9346;Mary;Jenkins\nsmith79;5079;Jamie;Smith\n```\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## KaTeX\nYou can render *LaTeX* mathematical expressions using **KaTeX**, 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=YE7VzlLtp-4\n\n## Vimeo\nhttps://vimeo.com/23237102\n\n## Asciinema\nhttps://asciinema.org/a/117928\n\n## PDF\n{%pdf https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf %}\n\n## Code highlighting\n```js=\nvar s = "JavaScript syntax highlighting";\nalert(s);\nfunction $initHighlight(block, cls) {\n try {\n if (cls.search(/\\bno\\-highlight\\b/) != -1)\n return process(block, true, 0x0F) +\n \' class=""\';\n } catch (e) {\n /* handle exception */\n }\n for (var i = 0 / 2; i < classes.length; i++) {\n if (checkCondition(classes[i]) === undefined)\n return /\\d+[\\s/]/g;\n }\n}\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 displayed\n __left of__ Alice.\nend note\nnote left of Bob\n This is displayed\n **left of Alice Bob**.\nend note\nnote over Alice, Bob\n This is hosted by \nend note\n@enduml\n```\n\n## ToDo List\n\n- [ ] ToDos\n - [X] Buy some salad\n - [ ] Brush teeth\n - [x] Drink some water\n - [ ] **Click my box** and see the source code, if you\'re allowed to edit!\n\n', + metadata: { + id: 'exampleId', + version: 2, + viewCount: 0, + updatedAt: '2021-04-24T09:27:51.000Z', + createdAt: '2021-04-24T09:27:51.000Z', + updateUsername: null, + primaryAddress: 'features', + editedBy: [], + title: 'Features', + tags: ['hedgedoc', 'demo', 'react'], + description: 'Many features, such wow!', + aliases: [ + { + name: 'features', + primaryAlias: true, + noteId: 'exampleId' + } + ], + permissions: { + owner: 'tilman', + sharedToUsers: [ + { + username: 'molly', + canEdit: true + } + ], + sharedToGroups: [ + { + groupName: '_LOGGED_IN', + canEdit: false + } + ] + } + }, + editedByAtPosition: [] + }) +} + +export default handler diff --git a/src/pages/api/mock-backend/private/notes/features/revisions/0.ts b/src/pages/api/mock-backend/private/notes/features/revisions/0.ts new file mode 100644 index 000000000..4f0ed0a32 --- /dev/null +++ b/src/pages/api/mock-backend/private/notes/features/revisions/0.ts @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { NextApiRequest, NextApiResponse } from 'next' +import { HttpMethod, respondToMatchingRequest } from '../../../../../../../handler-utils/respond-to-matching-request' +import type { RevisionDetails } from '../../../../../../../api/revisions/types' + +const handler = (req: NextApiRequest, res: NextApiResponse): void => { + respondToMatchingRequest(HttpMethod.GET, req, res, { + id: 0, + createdAt: '2021-12-21T16:59:42.000Z', + patch: '', + edits: [], + length: 2782, + authorUsernames: [], + anonymousAuthorCount: 2, + 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## Asciinema\nhttps://asciinema.org/a/117928\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 displayed\n __left of__ Alice.\nend note\nnote left of Bob\n This is displayed\n **left of Alice Bob**.\nend note\nnote over Alice, Bob\n This is hosted by \nend note\n@enduml\n```\n\n' + }) +} + +export default handler diff --git a/src/pages/api/mock-backend/private/notes/features/revisions/1.ts b/src/pages/api/mock-backend/private/notes/features/revisions/1.ts new file mode 100644 index 000000000..806374318 --- /dev/null +++ b/src/pages/api/mock-backend/private/notes/features/revisions/1.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import type { NextApiRequest, NextApiResponse } from 'next' +import { HttpMethod, respondToMatchingRequest } from '../../../../../../../handler-utils/respond-to-matching-request' +import type { RevisionDetails } from '../../../../../../../api/revisions/types' + +const handler = (req: NextApiRequest, res: NextApiResponse): void => { + respondToMatchingRequest(HttpMethod.GET, req, res, { + id: 1, + createdAt: '2021-12-29T17:54:11.000Z', + patch: '', + edits: [], + length: 2788, + authorUsernames: [], + anonymousAuthorCount: 4, + 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## Asciinema\nhttps://asciinema.org/a/117928\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 displayed\n __left of__ Alice.\nend note\nnote left of Bob\n This is displayed\n **left of Alice Bob**.\nend note\nnote over Alice, Bob\n This is hosted by \nend note\n@enduml\n```\n\n' + }) +} + +export default handler diff --git a/src/pages/api/mock-backend/private/notes/features/revisions/index.ts b/src/pages/api/mock-backend/private/notes/features/revisions/index.ts new file mode 100644 index 000000000..0fb084ad5 --- /dev/null +++ b/src/pages/api/mock-backend/private/notes/features/revisions/index.ts @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { NextApiRequest, NextApiResponse } from 'next' +import { HttpMethod, respondToMatchingRequest } from '../../../../../../../handler-utils/respond-to-matching-request' +import type { RevisionMetadata } from '../../../../../../../api/revisions/types' + +const handler = (req: NextApiRequest, res: NextApiResponse): void => { + respondToMatchingRequest(HttpMethod.GET, req, res, [ + { + id: 1, + createdAt: '2021-12-29T17:54:11.000Z', + length: 2788, + authorUsernames: [], + anonymousAuthorCount: 4 + }, + { + id: 0, + createdAt: '2021-12-21T16:59:42.000Z', + length: 2782, + authorUsernames: [], + anonymousAuthorCount: 2 + } + ]) +} + +export default handler diff --git a/src/pages/api/mock-backend/private/notes/slide-example/index.ts b/src/pages/api/mock-backend/private/notes/slide-example/index.ts new file mode 100644 index 000000000..ccc486df6 --- /dev/null +++ b/src/pages/api/mock-backend/private/notes/slide-example/index.ts @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { NextApiRequest, NextApiResponse } from 'next' +import { HttpMethod, respondToMatchingRequest } from '../../../../../../handler-utils/respond-to-matching-request' +import type { Note } from '../../../../../../api/notes/types' + +const handler = (req: NextApiRequest, res: NextApiResponse): void => { + respondToMatchingRequest(HttpMethod.GET, req, res, { + content: + '---\ntype: slide\nslideOptions:\n transition: slide\n---\n\n# Slide example\n\nThis feature still in beta, may have some issues.\n\nFor details please visit:\n\n\nYou can use `URL query` or `slideOptions` of the YAML metadata to customize your slides.\n\n---\n\n## First slide\n\n`---`\n\nIs the divider of slides\n\n----\n\n### First branch of first the slide\n\n`----`\n\nIs the divider of branches\n\nUse the *Space* key to navigate through all slides.\n\n----\n\n### Second branch of first the slide\n\nNested slides are useful for adding additional detail underneath a high-level horizontal slide.\n\n---\n\n## Point of View\n\nPress **ESC** to enter the slide overview.\n\n---\n\n## Touch Optimized\n\nPresentations look great on touch devices, like mobile phones and tablets. Simply swipe through your slides.\n\n---\n\n## Fragments\n\n``\n\nIs the fragment syntax\n\nHit the next arrow...\n\n... to step through ...\n\n... a fragmented slide.\n\nNote:\n This slide has fragments which are also stepped through in the notes window.\n\n---\n\n## Fragment Styles\n\nThere are different types of fragments, like:\n\ngrow\n\nshrink\n\nfade-out\n\nfade-up (also down, left and right!)\n\ncurrent-visible\n\nHighlight red blue green\n\n---\n\n\n\n## Transition Styles\nDifferent background transitions are available via the transition option. This one\'s called "zoom".\n\n``\n\nIs the transition syntax\n\nYou can use:\n\nnone/fade/slide/convex/concave/zoom\n\n---\n\n\n\n``\n\nAlso, you can set different in/out transition\n\nYou can use:\n\nnone/fade/slide/convex/concave/zoom\n\npostfix with `-in` or `-out`\n\n---\n\n\n\n``\n\nCustom the transition speed!\n\nYou can use:\n\ndefault/fast/slow\n\n---\n\n## Themes\n\nreveal.js comes with a few themes built in:\n\nBlack (default) - White - League - Sky - Beige - Simple\n\nSerif - Blood - Night - Moon - Solarized\n\nIt can be set in YAML slideOptions\n\n---\n\n\n\n``\n\nIs the background syntax\n\n---\n\n\n\n
    \n\n## Image Backgrounds\n\n``\n\n
    \n\n----\n\n\n\n
    \n\n## Tiled Backgrounds\n\n``\n\n
    \n\n----\n\n\n\n
    \n\n## Video Backgrounds\n\n``\n\n
    \n\n----\n\n\n\n## ... and GIFs!\n\n---\n\n## Pretty Code\n\n``` javascript\nfunction linkify( selector ) {\n if( supports3DTransforms ) {\n\n const nodes = document.querySelectorAll( selector );\n\n for( const i = 0, len = nodes.length; i < len; i++ ) {\n var node = nodes[i];\n\n if( !node.className ) {\n node.className += \' roll\';\n }\n }\n }\n}\n```\nCode syntax highlighting courtesy of [highlight.js](http://softwaremaniacs.org/soft/highlight/en/description/).\n\n---\n\n## Marvelous List\n\n- No order here\n- Or here\n- Or here\n- Or here\n\n---\n\n## Fantastic Ordered List\n\n1. One is smaller than...\n2. Two is smaller than...\n3. Three!\n\n---\n\n## Tabular Tables\n\n| Item | Value | Quantity |\n| ---- | ----- | -------- |\n| Apples | $1 | 7 |\n| Lemonade | $2 | 18 |\n| Bread | $3 | 2 |\n\n---\n\n## Clever Quotes\n\n> “For years there has been a theory that millions of monkeys typing at random on millions of typewriters would reproduce the entire works of Shakespeare. The Internet has proven this theory to be untrue.”\n\n---\n\n## Intergalactic Interconnections\n\nYou can link between slides internally, [like this](#/1/3).\n\n---\n\n## Speaker\n\nThere\'s a [speaker view](https://github.com/hakimel/reveal.js#speaker-notes). It includes a timer, preview of the upcoming slide as well as your speaker notes.\n\nPress the *S* key to try it out.\n\nNote:\n Oh hey, these are some notes. They\'ll be hidden in your presentation, but you can see them if you open the speaker notes window (hit `s` on your keyboard).\n\n---\n\n## Take a Moment\n\nPress `B` or `.` on your keyboard to pause the presentation. This is helpful when you\'re on stage and want to take distracting slides off the screen.\n\n---\n\n## Print your Slides\n\nDown below you can find a print icon.\n\nAfter you click on it, use the print function of your browser (either CTRL+P or cmd+P) to print the slides as PDF. [See official reveal.js instructions for details](https://github.com/hakimel/reveal.js#instructions-1)\n\n---\n\n# The End\n\n', + metadata: { + id: 'slideId', + primaryAddress: 'slide-example', + version: 2, + viewCount: 8, + updatedAt: '2021-04-30T18:38:23.000Z', + updateUsername: null, + createdAt: '2021-04-30T18:38:14.000Z', + editedBy: [], + title: 'Slide example', + tags: [], + description: '', + aliases: [ + { + noteId: 'slideId', + primaryAlias: true, + name: 'slide-example' + } + ], + permissions: { + owner: 'erik', + sharedToUsers: [ + { + username: 'tilman', + canEdit: true + }, + { + username: 'molly', + canEdit: true + } + ], + sharedToGroups: [ + { + groupName: '_LOGGED_IN', + canEdit: true + }, + { + groupName: '_EVERYONE', + canEdit: false + }, + { + groupName: 'hedgedoc-devs', + canEdit: true + } + ] + } + }, + editedByAtPosition: [] + }) +} + +export default handler diff --git a/src/pages/api/mock-backend/private/tokens.ts b/src/pages/api/mock-backend/private/tokens.ts new file mode 100644 index 000000000..cfbd2ac7c --- /dev/null +++ b/src/pages/api/mock-backend/private/tokens.ts @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import type { NextApiRequest, NextApiResponse } from 'next' +import type { AccessToken } from '../../../../api/tokens/types' +import { HttpMethod, respondToMatchingRequest } from '../../../../handler-utils/respond-to-matching-request' + +const handler = (req: NextApiRequest, res: NextApiResponse) => { + respondToMatchingRequest(HttpMethod.GET, req, res, [ + { + label: 'Demo-App', + keyId: 'demo', + createdAt: '2021-11-20T23:54:13+01:00', + lastUsedAt: '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', + lastUsedAt: '2021-11-20T23:54:13+01:00', + validUntil: '2021-11-20' + } + ]) +} + +export default handler diff --git a/src/pages/api/mock-backend/private/users/erik.ts b/src/pages/api/mock-backend/private/users/erik.ts new file mode 100644 index 000000000..7345de6d7 --- /dev/null +++ b/src/pages/api/mock-backend/private/users/erik.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import type { NextApiRequest, NextApiResponse } from 'next' +import { HttpMethod, respondToMatchingRequest } from '../../../../../handler-utils/respond-to-matching-request' +import type { UserInfo } from '../../../../../api/users/types' + +const handler = (req: NextApiRequest, res: NextApiResponse): void => { + respondToMatchingRequest(HttpMethod.GET, req, res, { + username: 'erik', + displayName: 'Erik', + photo: '/mock-public/img/avatar.png' + }) +} + +export default handler diff --git a/src/pages/api/mock-backend/private/users/molly.ts b/src/pages/api/mock-backend/private/users/molly.ts new file mode 100644 index 000000000..c5538a92f --- /dev/null +++ b/src/pages/api/mock-backend/private/users/molly.ts @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { NextApiRequest, NextApiResponse } from 'next' +import { HttpMethod, respondToMatchingRequest } from '../../../../../handler-utils/respond-to-matching-request' +import type { UserInfo } from '../../../../../api/users/types' + +const handler = (req: NextApiRequest, res: NextApiResponse): void => { + respondToMatchingRequest(HttpMethod.GET, req, res, { + username: 'molly', + displayName: 'Molly', + photo: '/mock-public/img/avatar.png' + }) +} + +export default handler diff --git a/src/pages/api/mock-backend/private/users/tilman.ts b/src/pages/api/mock-backend/private/users/tilman.ts new file mode 100644 index 000000000..a85e7af71 --- /dev/null +++ b/src/pages/api/mock-backend/private/users/tilman.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import type { NextApiRequest, NextApiResponse } from 'next' +import { HttpMethod, respondToMatchingRequest } from '../../../../../handler-utils/respond-to-matching-request' +import type { UserInfo } from '../../../../../api/users/types' + +const handler = (req: NextApiRequest, res: NextApiResponse): void => { + respondToMatchingRequest(HttpMethod.GET, req, res, { + username: 'tilman', + displayName: 'Tilman', + photo: '/mock-public/img/avatar.png' + }) +} + +export default handler diff --git a/src/pages/login.tsx b/src/pages/login.tsx index 4a20662ad..fd7aa259a 100644 --- a/src/pages/login.tsx +++ b/src/pages/login.tsx @@ -4,16 +4,19 @@ SPDX-License-Identifier: AGPL-3.0-only */ -import React, { useCallback, useMemo } from 'react' +import React, { useMemo } from 'react' import { Card, Col, Row } from 'react-bootstrap' import { Trans, useTranslation } from 'react-i18next' import { ShowIf } from '../components/common/show-if/show-if' import { ViaLocal } from '../components/login-page/auth/via-local' import { ViaLdap } from '../components/login-page/auth/via-ldap' -import { OneClickType, ViaOneClick } from '../components/login-page/auth/via-one-click' +import { ViaOneClick } from '../components/login-page/auth/via-one-click' import { useApplicationState } from '../hooks/common/use-application-state' import { LandingLayout } from '../components/landing-layout/landing-layout' import { RedirectBack } from '../components/common/redirect-back' +import type { AuthProviderWithCustomName } from '../api/config/types' +import { AuthProviderType } from '../api/config/types' +import { filterOneClickProviders } from '../components/login-page/auth/utils' /** * Renders the login page with buttons and fields for the enabled auth providers. @@ -22,44 +25,34 @@ import { RedirectBack } from '../components/common/redirect-back' export const LoginPage: React.FC = () => { useTranslation() const authProviders = useApplicationState((state) => state.config.authProviders) - const customSamlAuthName = useApplicationState((state) => state.config.customAuthNames.saml) - const customOauthAuthName = useApplicationState((state) => state.config.customAuthNames.oauth2) const userLoggedIn = useApplicationState((state) => !!state.user) - const oneClickProviders = [ - authProviders.dropbox, - authProviders.facebook, - authProviders.github, - authProviders.gitlab, - authProviders.google, - authProviders.oauth2, - authProviders.saml, - authProviders.twitter - ] + const ldapProviders = useMemo(() => { + return authProviders + .filter((provider) => provider.type === AuthProviderType.LDAP) + .map((provider) => { + const ldapProvider = provider as AuthProviderWithCustomName + return ( + + ) + }) + }, [authProviders]) - const oneClickCustomName = useCallback( - (type: OneClickType): string | undefined => { - switch (type) { - case OneClickType.SAML: - return customSamlAuthName - case OneClickType.OAUTH2: - return customOauthAuthName - default: - return undefined - } - }, - [customOauthAuthName, customSamlAuthName] - ) + const localLoginEnabled = useMemo(() => { + return authProviders.some((provider) => provider.type === AuthProviderType.LOCAL) + }, [authProviders]) - const oneClickButtonsDom = useMemo(() => { - return Object.values(OneClickType) - .filter((value) => authProviders[value]) - .map((value) => ( -
    - -
    - )) - }, [authProviders, oneClickCustomName]) + const oneClickProviders = useMemo(() => { + return authProviders.filter(filterOneClickProviders).map((provider, index) => ( +
    + +
    + )) + }, [authProviders]) if (userLoggedIn) { return @@ -69,24 +62,22 @@ export const LoginPage: React.FC = () => {
    - + 0 || localLoginEnabled}> - + - - - + {ldapProviders} - + 0}> - {oneClickButtonsDom} + {oneClickProviders} diff --git a/src/pages/profile.tsx b/src/pages/profile.tsx index 78a900f5b..6372b7b41 100644 --- a/src/pages/profile.tsx +++ b/src/pages/profile.tsx @@ -7,7 +7,6 @@ import React from 'react' import { Col, Row } from 'react-bootstrap' import { useApplicationState } from '../hooks/common/use-application-state' -import { LoginProvider } from '../redux/user/types' import { ShowIf } from '../components/common/show-if/show-if' import { ProfileAccessTokens } from '../components/profile-page/access-tokens/profile-access-tokens' import { ProfileAccountManagement } from '../components/profile-page/account-management/profile-account-management' @@ -15,13 +14,14 @@ import { ProfileChangePassword } from '../components/profile-page/settings/profi import { ProfileDisplayName } from '../components/profile-page/settings/profile-display-name' import { LandingLayout } from '../components/landing-layout/landing-layout' import { Redirect } from '../components/common/redirect' +import { AuthProviderType } from '../api/config/types' /** * Profile page that includes forms for changing display name, password (if internal login is used), * managing access tokens and deleting the account. */ export const ProfilePage: React.FC = () => { - const userProvider = useApplicationState((state) => state.user?.provider) + const userProvider = useApplicationState((state) => state.user?.authProvider) if (!userProvider) { return @@ -33,7 +33,7 @@ export const ProfilePage: React.FC = () => { - + diff --git a/src/pages/register.tsx b/src/pages/register.tsx index e0093fd90..12245cdad 100644 --- a/src/pages/register.tsx +++ b/src/pages/register.tsx @@ -11,7 +11,7 @@ import { Trans, useTranslation } from 'react-i18next' import { doLocalRegister } from '../api/auth/local' import { useApplicationState } from '../hooks/common/use-application-state' import { fetchAndSetUser } from '../components/login-page/auth/utils' -import { RegisterError as RegisterErrorType } from '../api/auth' +import { RegisterError as RegisterErrorType } from '../api/auth/types' import { RegisterInfos } from '../components/register-page/register-infos/register-infos' import { UsernameField } from '../components/common/fields/username-field' import { DisplayNameField } from '../components/common/fields/display-name-field' diff --git a/src/redux/api-url/methods.ts b/src/redux/api-url/methods.ts deleted file mode 100644 index aae7848bd..000000000 --- a/src/redux/api-url/methods.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { store } from '..' -import type { ApiUrlObject, SetApiUrlAction } from './types' -import { ApiUrlActionType } from './types' - -export const setApiUrl = (state: ApiUrlObject): void => { - store.dispatch({ - type: ApiUrlActionType.SET_API_URL, - state - } as SetApiUrlAction) -} diff --git a/src/redux/api-url/reducers.ts b/src/redux/api-url/reducers.ts deleted file mode 100644 index 70a6c9a44..000000000 --- a/src/redux/api-url/reducers.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { Reducer } from 'redux' -import type { ApiUrlActions, ApiUrlObject } from './types' -import { ApiUrlActionType } from './types' - -export const initialState: ApiUrlObject = { - apiUrl: '' -} - -export const ApiUrlReducer: Reducer = ( - state: ApiUrlObject = initialState, - action: ApiUrlActions -) => { - switch (action.type) { - case ApiUrlActionType.SET_API_URL: - return action.state - default: - return state - } -} diff --git a/src/redux/api-url/types.ts b/src/redux/api-url/types.ts deleted file mode 100644 index 4203c9cd5..000000000 --- a/src/redux/api-url/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { Action } from 'redux' - -export enum ApiUrlActionType { - SET_API_URL = 'api-url/set' -} - -export type ApiUrlActions = SetApiUrlAction - -export interface SetApiUrlAction extends Action { - type: ApiUrlActionType.SET_API_URL - state: ApiUrlObject -} - -export interface ApiUrlObject { - apiUrl: string -} diff --git a/src/redux/application-state.d.ts b/src/redux/application-state.d.ts index d1e937a95..3b9cd7f16 100644 --- a/src/redux/application-state.d.ts +++ b/src/redux/application-state.d.ts @@ -7,20 +7,18 @@ import type { OptionalUserState } from './user/types' import type { Config } from '../api/config/types' import type { OptionalMotdState } from './motd/types' -import type { HistoryEntry } from './history/types' -import type { ApiUrlObject } from './api-url/types' import type { EditorConfig } from './editor/types' import type { DarkModeConfig } from './dark-mode/types' import type { NoteDetails } from './note-details/types/note-details' import type { UiNotificationState } from './ui-notifications/types' import type { RendererStatus } from './renderer-status/types' +import type { HistoryEntryWithOrigin } from '../api/history/types' export interface ApplicationState { user: OptionalUserState config: Config motd: OptionalMotdState - history: HistoryEntry[] - apiUrl: ApiUrlObject + history: HistoryEntryWithOrigin[] editorConfig: EditorConfig darkMode: DarkModeConfig noteDetails: NoteDetails diff --git a/src/redux/config/reducers.ts b/src/redux/config/reducers.ts index 9d00b10dc..cc3a3d78a 100644 --- a/src/redux/config/reducers.ts +++ b/src/redux/config/reducers.ts @@ -12,40 +12,24 @@ import { ConfigActionType } from './types' export const initialState: Config = { allowAnonymous: true, allowRegister: true, - authProviders: { - facebook: false, - github: false, - twitter: false, - gitlab: false, - dropbox: false, - ldap: false, - google: false, - saml: false, - oauth2: false, - local: false - }, + authProviders: [], branding: { name: '', logo: '' }, - customAuthNames: { - ldap: '', - oauth2: '', - saml: '' - }, - maxDocumentLength: 0, useImageProxy: false, - plantumlServer: null, specialUrls: { - privacy: '', - termsOfUse: '', - imprint: '' + privacy: undefined, + termsOfUse: undefined, + imprint: undefined }, version: { - major: -1, - minor: -1, - patch: -1 + major: 0, + minor: 0, + patch: 0 }, + plantumlServer: undefined, + maxDocumentLength: 0, iframeCommunication: { editorOrigin: '', rendererOrigin: '' diff --git a/src/redux/history/methods.ts b/src/redux/history/methods.ts index fd61717a7..a2855a7af 100644 --- a/src/redux/history/methods.ts +++ b/src/redux/history/methods.ts @@ -1,39 +1,34 @@ /* - * 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 */ import { getGlobalState, store } from '../index' -import type { - HistoryEntry, - HistoryExportJson, - RemoveEntryAction, - SetEntriesAction, - UpdateEntryAction, - V1HistoryEntry -} from './types' -import { HistoryActionType, HistoryEntryOrigin } from './types' +import type { HistoryExportJson, RemoveEntryAction, SetEntriesAction, UpdateEntryAction, V1HistoryEntry } from './types' +import { HistoryActionType } from './types' import { download } from '../../components/common/download/download' import { DateTime } from 'luxon' import { - deleteHistory, - deleteHistoryEntry, - getHistory, - postHistory, - updateHistoryEntryPinStatus + deleteRemoteHistory, + deleteRemoteHistoryEntry, + getRemoteHistory, + setRemoteHistoryEntries, + updateRemoteHistoryEntryPinStatus } from '../../api/history' -import { - historyEntryDtoToHistoryEntry, - historyEntryToHistoryEntryPutDto, - historyEntryToHistoryEntryUpdateDto -} from '../../api/history/dto-methods' +import { addRemoteOriginToHistoryEntry, historyEntryToHistoryEntryPutDto } from '../../api/history/dto-methods' import { Logger } from '../../utils/logger' +import type { HistoryEntry, HistoryEntryWithOrigin } from '../../api/history/types' +import { HistoryEntryOrigin } from '../../api/history/types' import { showErrorNotification } from '../ui-notifications/methods' const log = new Logger('Redux > History') -export const setHistoryEntries = (entries: HistoryEntry[]): void => { +/** + * Sets the given history entries into the current redux state and updates the local-storage. + * @param entries The history entries to set into the redux state. + */ +export const setHistoryEntries = (entries: HistoryEntryWithOrigin[]): void => { store.dispatch({ type: HistoryActionType.SET_ENTRIES, entries @@ -41,20 +36,32 @@ export const setHistoryEntries = (entries: HistoryEntry[]): void => { storeLocalHistory() } -export const importHistoryEntries = (entries: HistoryEntry[]): Promise => { +/** + * Imports the given history entries into redux state and local-storage and remote based on their associated origin label. + * @param entries The history entries to import. + */ +export const importHistoryEntries = (entries: HistoryEntryWithOrigin[]): Promise => { setHistoryEntries(entries) return storeRemoteHistory() } -export const deleteAllHistoryEntries = (): Promise => { +/** + * Deletes all history entries in the redux, local-storage and on the server. + */ +export const deleteAllHistoryEntries = (): Promise => { store.dispatch({ type: HistoryActionType.SET_ENTRIES, entries: [] } as SetEntriesAction) storeLocalHistory() - return deleteHistory() + return deleteRemoteHistory() } +/** + * Updates a single history entry in the redux. + * @param noteId The note id of the history entry to update. + * @param newEntry The modified history entry. + */ export const updateHistoryEntryRedux = (noteId: string, newEntry: HistoryEntry): void => { store.dispatch({ type: HistoryActionType.UPDATE_ENTRY, @@ -63,15 +70,24 @@ export const updateHistoryEntryRedux = (noteId: string, newEntry: HistoryEntry): } as UpdateEntryAction) } +/** + * Updates a single history entry in the local-storage. + * @param noteId The note id of the history entry to update. + * @param newEntry The modified history entry. + */ export const updateLocalHistoryEntry = (noteId: string, newEntry: HistoryEntry): void => { updateHistoryEntryRedux(noteId, newEntry) storeLocalHistory() } +/** + * Removes a single history entry for a given note id. + * @param noteId The note id of the history entry to delete. + */ export const removeHistoryEntry = async (noteId: string): Promise => { const entryToDelete = getGlobalState().history.find((entry) => entry.identifier === noteId) if (entryToDelete && entryToDelete.origin === HistoryEntryOrigin.REMOTE) { - await deleteHistoryEntry(noteId) + await deleteRemoteHistoryEntry(noteId) } store.dispatch({ type: HistoryActionType.REMOVE_ENTRY, @@ -80,6 +96,10 @@ export const removeHistoryEntry = async (noteId: string): Promise => { storeLocalHistory() } +/** + * Toggles the pinning state of a single history entry. + * @param noteId The note id of the history entry to update. + */ export const toggleHistoryEntryPinning = async (noteId: string): Promise => { const state = getGlobalState().history const entryToUpdate = state.find((entry) => entry.identifier === noteId) @@ -93,15 +113,17 @@ export const toggleHistoryEntryPinning = async (noteId: string): Promise = if (entryToUpdate.origin === HistoryEntryOrigin.LOCAL) { updateLocalHistoryEntry(noteId, entryToUpdate) } else { - const historyUpdateDto = historyEntryToHistoryEntryUpdateDto(entryToUpdate) - await updateHistoryEntryPinStatus(noteId, historyUpdateDto) + await updateRemoteHistoryEntryPinStatus(noteId, entryToUpdate.pinStatus) updateHistoryEntryRedux(noteId, entryToUpdate) } } +/** + * Exports the current history redux state into a JSON file that will be downloaded by the client. + */ export const downloadHistory = (): void => { const history = getGlobalState().history - history.forEach((entry: Partial) => { + history.forEach((entry: Partial) => { delete entry.origin }) const json = JSON.stringify({ @@ -111,22 +133,39 @@ export const downloadHistory = (): void => { download(json, `history_${Date.now()}.json`, 'application/json') } -export const mergeHistoryEntries = (a: HistoryEntry[], b: HistoryEntry[]): HistoryEntry[] => { +/** + * Merges two arrays of history entries while removing duplicates. + * @param a The first input array of history entries. + * @param b The second input array of history entries. This array takes precedence when duplicates were found. + * @return The merged array of history entries without duplicates. + */ +export const mergeHistoryEntries = ( + a: HistoryEntryWithOrigin[], + b: HistoryEntryWithOrigin[] +): HistoryEntryWithOrigin[] => { const noDuplicates = a.filter((entryA) => !b.some((entryB) => entryA.identifier === entryB.identifier)) return noDuplicates.concat(b) } -export const convertV1History = (oldHistory: V1HistoryEntry[]): HistoryEntry[] => { +/** + * Converts an array of local HedgeDoc v1 history entries to HedgeDoc v2 history entries. + * @param oldHistory An array of HedgeDoc v1 history entries. + * @return An array of HedgeDoc v2 history entries associated with the local origin label. + */ +export const convertV1History = (oldHistory: V1HistoryEntry[]): HistoryEntryWithOrigin[] => { return oldHistory.map((entry) => ({ identifier: entry.id, title: entry.text, tags: entry.tags, - lastVisited: DateTime.fromMillis(entry.time).toISO(), + lastVisitedAt: DateTime.fromMillis(entry.time).toISO(), pinStatus: entry.pinned, origin: HistoryEntryOrigin.LOCAL })) } +/** + * Refreshes the history redux state by reloading the local history and fetching the remote history if the user is logged-in. + */ export const refreshHistoryState = async (): Promise => { const localEntries = loadLocalHistory() if (!getGlobalState().user) { @@ -138,10 +177,16 @@ export const refreshHistoryState = async (): Promise => { setHistoryEntries(allEntries) } +/** + * Refreshes the history state and shows an error in case of failure. + */ export const safeRefreshHistoryState = (): void => { refreshHistoryState().catch(showErrorNotification('landing.history.error.getHistory.text')) } +/** + * Stores the history entries marked as local from the redux to the user's local-storage. + */ export const storeLocalHistory = (): void => { const history = getGlobalState().history const localEntries = history.filter((entry) => entry.origin === HistoryEntryOrigin.LOCAL) @@ -152,17 +197,24 @@ export const storeLocalHistory = (): void => { window.localStorage.setItem('history', JSON.stringify(entriesWithoutOrigin)) } -export const storeRemoteHistory = (): Promise => { +/** + * Stores the history entries marked as remote from the redux to the server. + */ +export const storeRemoteHistory = (): Promise => { if (!getGlobalState().user) { return Promise.resolve() } const history = getGlobalState().history const remoteEntries = history.filter((entry) => entry.origin === HistoryEntryOrigin.REMOTE) const remoteEntryDtos = remoteEntries.map(historyEntryToHistoryEntryPutDto) - return postHistory(remoteEntryDtos) + return setRemoteHistoryEntries(remoteEntryDtos) } -const loadLocalHistory = (): HistoryEntry[] => { +/** + * Loads the local history from local-storage, converts from V1 format if necessary and returns the history entries with a local origin label. + * @return The local history entries with the origin set to local. + */ +const loadLocalHistory = (): HistoryEntryWithOrigin[] => { const localV1Json = window.localStorage.getItem('notehistory') if (localV1Json) { try { @@ -181,7 +233,7 @@ const loadLocalHistory = (): HistoryEntry[] => { } try { - const localHistory = JSON.parse(localJson) as HistoryEntry[] + const localHistory = JSON.parse(localJson) as HistoryEntryWithOrigin[] localHistory.forEach((entry) => { entry.origin = HistoryEntryOrigin.LOCAL }) @@ -192,10 +244,14 @@ const loadLocalHistory = (): HistoryEntry[] => { } } -const loadRemoteHistory = async (): Promise => { +/** + * Loads the remote history and maps each entry with a remote origin label. + * @return The remote history entries with the origin set to remote. + */ +const loadRemoteHistory = async (): Promise => { try { - const remoteHistory = await getHistory() - return remoteHistory.map(historyEntryDtoToHistoryEntry) + const remoteHistory = await getRemoteHistory() + return remoteHistory.map(addRemoteOriginToHistoryEntry) } catch (error) { log.error('Error while fetching history entries from server', error) return [] diff --git a/src/redux/history/reducers.ts b/src/redux/history/reducers.ts index 5b4ec8ae9..9ed504d50 100644 --- a/src/redux/history/reducers.ts +++ b/src/redux/history/reducers.ts @@ -5,15 +5,16 @@ */ import type { Reducer } from 'redux' -import type { HistoryActions, HistoryEntry } from './types' +import type { HistoryActions } from './types' import { HistoryActionType } from './types' +import type { HistoryEntryWithOrigin } from '../../api/history/types' // Q: Why is the reducer initialized with an empty array instead of the actual history entries like in the config reducer? // A: The history reducer will be created without entries because of async entry retrieval. // Entries will be added after reducer initialization. -export const HistoryReducer: Reducer = ( - state: HistoryEntry[] = [], +export const HistoryReducer: Reducer = ( + state: HistoryEntryWithOrigin[] = [], action: HistoryActions ) => { switch (action.type) { diff --git a/src/redux/history/types.ts b/src/redux/history/types.ts index 19ce3b17c..88d2f2dff 100644 --- a/src/redux/history/types.ts +++ b/src/redux/history/types.ts @@ -5,20 +5,7 @@ */ import type { Action } from 'redux' - -export enum HistoryEntryOrigin { - LOCAL, - REMOTE -} - -export interface HistoryEntry { - identifier: string - title: string - lastVisited: string - tags: string[] - pinStatus: boolean - origin: HistoryEntryOrigin -} +import type { HistoryEntryWithOrigin } from '../../api/history/types' export interface V1HistoryEntry { id: string @@ -30,7 +17,7 @@ export interface V1HistoryEntry { export interface HistoryExportJson { version: number - entries: HistoryEntry[] + entries: HistoryEntryWithOrigin[] } export enum HistoryActionType { @@ -44,21 +31,21 @@ export type HistoryActions = SetEntriesAction | AddEntryAction | UpdateEntryActi export interface SetEntriesAction extends Action { type: HistoryActionType.SET_ENTRIES - entries: HistoryEntry[] + entries: HistoryEntryWithOrigin[] } export interface AddEntryAction extends Action { type: HistoryActionType.ADD_ENTRY - newEntry: HistoryEntry + newEntry: HistoryEntryWithOrigin } export interface UpdateEntryAction extends Action { type: HistoryActionType.UPDATE_ENTRY noteId: string - newEntry: HistoryEntry + newEntry: HistoryEntryWithOrigin } -export interface RemoveEntryAction extends HistoryEntry { +export interface RemoveEntryAction extends Action { type: HistoryActionType.REMOVE_ENTRY noteId: string } diff --git a/src/redux/note-details/build-state-from-updated-markdown-content.ts b/src/redux/note-details/build-state-from-updated-markdown-content.ts index 0d5fcea08..cc077db27 100644 --- a/src/redux/note-details/build-state-from-updated-markdown-content.ts +++ b/src/redux/note-details/build-state-from-updated-markdown-content.ts @@ -63,7 +63,7 @@ const buildStateFromMarkdownContentAndLines = ( lineStartIndexes }, rawFrontmatter: '', - noteTitle: generateNoteTitle(initialState.frontmatter, state.firstHeading), + title: generateNoteTitle(initialState.frontmatter, state.firstHeading), frontmatter: initialState.frontmatter, frontmatterRendererInfo: initialState.frontmatterRendererInfo } @@ -89,7 +89,7 @@ const buildStateFromFrontmatterUpdate = ( ...state, rawFrontmatter: frontmatterExtraction.rawText, frontmatter: frontmatter, - noteTitle: generateNoteTitle(frontmatter, state.firstHeading), + title: generateNoteTitle(frontmatter, state.firstHeading), frontmatterRendererInfo: { lineOffset: frontmatterExtraction.lineOffset, deprecatedSyntax: frontmatter.deprecatedTagsSyntax, @@ -100,7 +100,7 @@ const buildStateFromFrontmatterUpdate = ( } catch (e) { return { ...state, - noteTitle: generateNoteTitle(initialState.frontmatter, state.firstHeading), + title: generateNoteTitle(initialState.frontmatter, state.firstHeading), rawFrontmatter: frontmatterExtraction.rawText, frontmatter: initialState.frontmatter, frontmatterRendererInfo: { diff --git a/src/redux/note-details/initial-state.ts b/src/redux/note-details/initial-state.ts index c9dfdbc18..8e1735e8d 100644 --- a/src/redux/note-details/initial-state.ts +++ b/src/redux/note-details/initial-state.ts @@ -18,6 +18,9 @@ export const initialSlideOptions: SlideOptions = { } export const initialState: NoteDetails = { + updatedAt: DateTime.fromSeconds(0), + updateUsername: null, + version: 0, markdownContent: { plain: '', lines: [], @@ -32,15 +35,17 @@ export const initialState: NoteDetails = { slideOptions: initialSlideOptions }, id: '', - createTime: DateTime.fromSeconds(0), - lastChange: { - timestamp: DateTime.fromSeconds(0), - username: '' + createdAt: DateTime.fromSeconds(0), + aliases: [], + primaryAddress: '', + permissions: { + owner: null, + sharedToGroups: [], + sharedToUsers: [] }, - alias: '', viewCount: 0, - authorship: [], - noteTitle: '', + editedBy: [], + title: '', firstHeading: '', frontmatter: { title: '', diff --git a/src/redux/note-details/methods.ts b/src/redux/note-details/methods.ts index 058b19ce8..81b4520af 100644 --- a/src/redux/note-details/methods.ts +++ b/src/redux/note-details/methods.ts @@ -1,11 +1,11 @@ /* - * 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 */ import { store } from '..' -import type { NoteDto } from '../../api/notes/types' +import type { Note, NotePermissions } from '../../api/notes/types' import type { AddTableAtCursorAction, FormatSelectionAction, @@ -14,6 +14,7 @@ import type { ReplaceInMarkdownContentAction, SetNoteDetailsFromServerAction, SetNoteDocumentContentAction, + SetNotePermissionsFromServerAction, UpdateCursorPositionAction, UpdateNoteTitleByFirstHeadingAction, UpdateTaskListCheckboxAction @@ -36,13 +37,24 @@ export const setNoteContent = (content: string): void => { * Sets the note metadata for the current note from an API response DTO to the redux. * @param apiResponse The NoteDTO received from the API to store into redux. */ -export const setNoteDataFromServer = (apiResponse: NoteDto): void => { +export const setNoteDataFromServer = (apiResponse: Note): void => { store.dispatch({ type: NoteDetailsActionType.SET_NOTE_DATA_FROM_SERVER, - dto: apiResponse + noteFromServer: apiResponse } as SetNoteDetailsFromServerAction) } +/** + * Sets the note permissions for the current note from an API response DTO to the redux. + * @param apiResponse The NotePermissionsDTO received from the API to store into redux. + */ +export const setNotePermissionsFromServer = (apiResponse: NotePermissions): void => { + store.dispatch({ + type: NoteDetailsActionType.SET_NOTE_PERMISSIONS_FROM_SERVER, + notePermissionsFromServer: apiResponse + } as SetNotePermissionsFromServerAction) +} + /** * Updates the note title in the redux by the first heading found in the markdown content. * @param firstHeading The content of the first heading found in the markdown content. diff --git a/src/redux/note-details/reducer.ts b/src/redux/note-details/reducer.ts index 8454b002a..531f16cac 100644 --- a/src/redux/note-details/reducer.ts +++ b/src/redux/note-details/reducer.ts @@ -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 */ @@ -18,6 +18,7 @@ import { buildStateFromReplaceSelection } from './reducers/build-state-from-repl import { buildStateFromTaskListUpdate } from './reducers/build-state-from-task-list-update' import { buildStateFromSelectionFormat } from './reducers/build-state-from-selection-format' import { buildStateFromReplaceInMarkdownContent } from './reducers/build-state-from-replace-in-markdown-content' +import { buildStateFromServerPermissions } from './reducers/build-state-from-server-permissions' export const NoteDetailsReducer: Reducer = ( state: NoteDetails = initialState, @@ -28,10 +29,12 @@ export const NoteDetailsReducer: Reducer = ( return buildStateFromUpdateCursorPosition(state, action.selection) case NoteDetailsActionType.SET_DOCUMENT_CONTENT: return buildStateFromUpdatedMarkdownContent(state, action.content) + case NoteDetailsActionType.SET_NOTE_PERMISSIONS_FROM_SERVER: + return buildStateFromServerPermissions(state, action.notePermissionsFromServer) case NoteDetailsActionType.UPDATE_NOTE_TITLE_BY_FIRST_HEADING: return buildStateFromFirstHeadingUpdate(state, action.firstHeading) case NoteDetailsActionType.SET_NOTE_DATA_FROM_SERVER: - return buildStateFromServerDto(action.dto) + return buildStateFromServerDto(action.noteFromServer) case NoteDetailsActionType.UPDATE_TASK_LIST_CHECKBOX: return buildStateFromTaskListUpdate(state, action.changedLine, action.checkboxChecked) case NoteDetailsActionType.REPLACE_IN_MARKDOWN_CONTENT: diff --git a/src/redux/note-details/reducers/build-state-from-first-heading-update.test.ts b/src/redux/note-details/reducers/build-state-from-first-heading-update.test.ts index 8af083ec0..3642f4f1a 100644 --- a/src/redux/note-details/reducers/build-state-from-first-heading-update.test.ts +++ b/src/redux/note-details/reducers/build-state-from-first-heading-update.test.ts @@ -19,8 +19,8 @@ describe('build state from first heading update', () => { }) it('generates a new state with the given first heading', () => { - const startState = { ...initialState, firstHeading: 'heading', noteTitle: 'noteTitle' } + const startState = { ...initialState, firstHeading: 'heading', title: 'noteTitle' } const actual = buildStateFromFirstHeadingUpdate(startState, 'new first heading') - expect(actual).toStrictEqual({ ...initialState, firstHeading: 'new first heading', noteTitle: 'generated title' }) + expect(actual).toStrictEqual({ ...initialState, firstHeading: 'new first heading', title: 'generated title' }) }) }) diff --git a/src/redux/note-details/reducers/build-state-from-first-heading-update.ts b/src/redux/note-details/reducers/build-state-from-first-heading-update.ts index 5665bec4f..228e7753f 100644 --- a/src/redux/note-details/reducers/build-state-from-first-heading-update.ts +++ b/src/redux/note-details/reducers/build-state-from-first-heading-update.ts @@ -17,6 +17,6 @@ export const buildStateFromFirstHeadingUpdate = (state: NoteDetails, firstHeadin return { ...state, firstHeading: firstHeading, - noteTitle: generateNoteTitle(state.frontmatter, firstHeading) + title: generateNoteTitle(state.frontmatter, firstHeading) } } diff --git a/src/redux/note-details/reducers/build-state-from-server-permissions.test.ts b/src/redux/note-details/reducers/build-state-from-server-permissions.test.ts new file mode 100644 index 000000000..f190e1674 --- /dev/null +++ b/src/redux/note-details/reducers/build-state-from-server-permissions.test.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { initialState } from '../initial-state' +import type { NotePermissions } from '../../../api/notes/types' +import { buildStateFromServerPermissions } from './build-state-from-server-permissions' +import type { NoteDetails } from '../types/note-details' + +describe('build state from server permissions', () => { + it('creates a new state with the given permissions', () => { + const state: NoteDetails = { ...initialState } + const permissions: NotePermissions = { + owner: 'test-owner', + sharedToUsers: [ + { + username: 'test-user', + canEdit: true + } + ], + sharedToGroups: [ + { + groupName: 'test-group', + canEdit: false + } + ] + } + expect(buildStateFromServerPermissions(state, permissions)).toStrictEqual({ ...state, permissions: permissions }) + }) +}) diff --git a/src/redux/note-details/reducers/build-state-from-server-permissions.ts b/src/redux/note-details/reducers/build-state-from-server-permissions.ts new file mode 100644 index 000000000..59b440c30 --- /dev/null +++ b/src/redux/note-details/reducers/build-state-from-server-permissions.ts @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import type { NoteDetails } from '../types/note-details' +import type { NotePermissions } from '../../../api/notes/types' + +/** + * Builds the updated state from a given previous state and updated NotePermissions data. + * @param state The previous note details state. + * @param serverPermissions The updated NotePermissions data. + */ +export const buildStateFromServerPermissions = ( + state: NoteDetails, + serverPermissions: NotePermissions +): NoteDetails => { + return { + ...state, + permissions: serverPermissions + } +} diff --git a/src/redux/note-details/reducers/build-state-from-set-note-data-from-server.test.ts b/src/redux/note-details/reducers/build-state-from-set-note-data-from-server.test.ts index 206760106..2a88d86f2 100644 --- a/src/redux/note-details/reducers/build-state-from-set-note-data-from-server.test.ts +++ b/src/redux/note-details/reducers/build-state-from-set-note-data-from-server.test.ts @@ -3,8 +3,6 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ - -import type { NoteDto } from '../../../api/notes/types' import { buildStateFromServerDto } from './build-state-from-set-note-data-from-server' import * as buildStateFromUpdatedMarkdownContentModule from '../build-state-from-updated-markdown-content' import { Mock } from 'ts-mockery' @@ -12,6 +10,7 @@ import type { NoteDetails } from '../types/note-details' import { NoteTextDirection, NoteType } from '../types/note-details' import { DateTime } from 'luxon' import { initialSlideOptions } from '../initial-state' +import type { Note } from '../../../api/notes/types' describe('build state from set note data from server', () => { const buildStateFromUpdatedMarkdownContentMock = jest.spyOn( @@ -29,54 +28,42 @@ describe('build state from set note data from server', () => { }) it('builds a new state from the given note dto', () => { - const noteDto: NoteDto = { + const noteDto: Note = { content: 'line1\nline2', metadata: { + primaryAddress: 'alias', version: 5678, - alias: 'alias', + aliases: [ + { + noteId: 'id', + primaryAlias: true, + name: 'alias' + } + ], id: 'id', - createTime: '2012-05-25T09:08:34.123', + createdAt: '2012-05-25T09:08:34.123', description: 'description', editedBy: ['editedBy'], permissions: { - owner: { - username: 'username', - photo: 'photo', - email: 'email', - displayName: 'displayName' - }, + owner: 'username', sharedToGroups: [ { canEdit: true, - group: { - displayName: 'groupdisplayname', - name: 'groupname', - special: true - } + groupName: 'groupName' } ], sharedToUsers: [ { canEdit: true, - user: { - username: 'shareusername', - email: 'shareemail', - photo: 'sharephoto', - displayName: 'sharedisplayname' - } + username: 'shareusername' } ] }, viewCount: 987, tags: ['tag'], title: 'title', - updateTime: '2020-05-25T09:08:34.123', - updateUser: { - username: 'updateusername', - photo: 'updatephoto', - email: 'updateemail', - displayName: 'updatedisplayname' - } + updatedAt: '2020-05-25T09:08:34.123', + updateUsername: 'updateusername' }, editedByAtPosition: [ { @@ -84,7 +71,7 @@ describe('build state from set note data from server', () => { createdAt: 'createdAt', startPos: 9, updatedAt: 'updatedAt', - userName: 'userName' + username: 'userName' } ] } @@ -117,7 +104,7 @@ describe('build state from set note data from server', () => { lineOffset: 0, slideOptions: initialSlideOptions }, - noteTitle: '', + title: 'title', selection: { from: 0 }, markdownContent: { plain: 'line1\nline2', @@ -127,14 +114,35 @@ describe('build state from set note data from server', () => { firstHeading: '', rawFrontmatter: '', id: 'id', - createTime: DateTime.fromISO('2012-05-25T09:08:34.123'), - lastChange: { - username: 'updateusername', - timestamp: DateTime.fromISO('2020-05-25T09:08:34.123') - }, + createdAt: DateTime.fromISO('2012-05-25T09:08:34.123'), + updatedAt: DateTime.fromISO('2020-05-25T09:08:34.123'), + updateUsername: 'updateusername', viewCount: 987, - alias: 'alias', - authorship: ['editedBy'] + aliases: [ + { + name: 'alias', + noteId: 'id', + primaryAlias: true + } + ], + primaryAddress: 'alias', + version: 5678, + editedBy: ['editedBy'], + permissions: { + owner: 'username', + sharedToGroups: [ + { + canEdit: true, + groupName: 'groupName' + } + ], + sharedToUsers: [ + { + canEdit: true, + username: 'shareusername' + } + ] + } } const result = buildStateFromServerDto(noteDto) diff --git a/src/redux/note-details/reducers/build-state-from-set-note-data-from-server.ts b/src/redux/note-details/reducers/build-state-from-set-note-data-from-server.ts index 1b5cc3561..62170b0df 100644 --- a/src/redux/note-details/reducers/build-state-from-set-note-data-from-server.ts +++ b/src/redux/note-details/reducers/build-state-from-set-note-data-from-server.ts @@ -4,19 +4,19 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import type { NoteDto } from '../../../api/notes/types' import type { NoteDetails } from '../types/note-details' import { buildStateFromUpdatedMarkdownContent } from '../build-state-from-updated-markdown-content' import { initialState } from '../initial-state' import { DateTime } from 'luxon' import { calculateLineStartIndexes } from '../calculate-line-start-indexes' +import type { Note } from '../../../api/notes/types' /** * Builds a {@link NoteDetails} redux state from a DTO received as an API response. * @param dto The first DTO received from the API containing the relevant information about the note. * @return An updated {@link NoteDetails} redux state. */ -export const buildStateFromServerDto = (dto: NoteDto): NoteDetails => { +export const buildStateFromServerDto = (dto: Note): NoteDetails => { const newState = convertNoteDtoToNoteDetails(dto) return buildStateFromUpdatedMarkdownContent(newState, newState.markdownContent.plain) } @@ -27,24 +27,26 @@ export const buildStateFromServerDto = (dto: NoteDto): NoteDetails => { * @param note The NoteDTO as defined in the backend. * @return The NoteDetails object corresponding to the DTO. */ -const convertNoteDtoToNoteDetails = (note: NoteDto): NoteDetails => { +const convertNoteDtoToNoteDetails = (note: Note): NoteDetails => { const newLines = note.content.split('\n') return { ...initialState, + updateUsername: note.metadata.updateUsername, + permissions: note.metadata.permissions, + editedBy: note.metadata.editedBy, + primaryAddress: note.metadata.primaryAddress, + id: note.metadata.id, + aliases: note.metadata.aliases, + title: note.metadata.title, + version: note.metadata.version, + viewCount: note.metadata.viewCount, markdownContent: { plain: note.content, lines: newLines, lineStartIndexes: calculateLineStartIndexes(newLines) }, rawFrontmatter: '', - id: note.metadata.id, - createTime: DateTime.fromISO(note.metadata.createTime), - lastChange: { - username: note.metadata.updateUser.username, - timestamp: DateTime.fromISO(note.metadata.updateTime) - }, - viewCount: note.metadata.viewCount, - alias: note.metadata.alias, - authorship: note.metadata.editedBy + createdAt: DateTime.fromISO(note.metadata.createdAt), + updatedAt: DateTime.fromISO(note.metadata.updatedAt) } } diff --git a/src/redux/note-details/types.ts b/src/redux/note-details/types.ts index 0afc16fae..fdb84f663 100644 --- a/src/redux/note-details/types.ts +++ b/src/redux/note-details/types.ts @@ -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 */ import type { Action } from 'redux' -import type { NoteDto } from '../../api/notes/types' +import type { Note, NotePermissions } from '../../api/notes/types' import type { CursorSelection } from '../editor/types' export enum NoteDetailsActionType { SET_DOCUMENT_CONTENT = 'note-details/content/set', SET_NOTE_DATA_FROM_SERVER = 'note-details/data/server/set', + SET_NOTE_PERMISSIONS_FROM_SERVER = 'note-details/data/permissions/set', UPDATE_NOTE_TITLE_BY_FIRST_HEADING = 'note-details/update-note-title-by-first-heading', UPDATE_TASK_LIST_CHECKBOX = 'note-details/update-task-list-checkbox', UPDATE_CURSOR_POSITION = 'note-details/updateCursorPosition', @@ -44,6 +45,7 @@ export enum FormatType { export type NoteDetailsActions = | SetNoteDocumentContentAction | SetNoteDetailsFromServerAction + | SetNotePermissionsFromServerAction | UpdateNoteTitleByFirstHeadingAction | UpdateTaskListCheckboxAction | UpdateCursorPositionAction @@ -65,7 +67,15 @@ export interface SetNoteDocumentContentAction extends Action { type: NoteDetailsActionType.SET_NOTE_DATA_FROM_SERVER - dto: NoteDto + noteFromServer: Note +} + +/** + * Action for overwriting the current permission state with the data received from the API. + */ +export interface SetNotePermissionsFromServerAction extends Action { + type: NoteDetailsActionType.SET_NOTE_PERMISSIONS_FROM_SERVER + notePermissionsFromServer: NotePermissions } /** diff --git a/src/redux/note-details/types/note-details.ts b/src/redux/note-details/types/note-details.ts index aa8962702..fae93ac84 100644 --- a/src/redux/note-details/types/note-details.ts +++ b/src/redux/note-details/types/note-details.ts @@ -8,31 +8,26 @@ import type { DateTime } from 'luxon' import type { SlideOptions } from './slide-show-options' import type { ISO6391 } from './iso6391' import type { CursorSelection } from '../../editor/types' +import type { NoteMetadata } from '../../../api/notes/types' + +type UnnecessaryNoteAttributes = 'updatedAt' | 'createdAt' | 'tags' | 'description' /** * Redux state containing the currently loaded note with its content and metadata. */ -export interface NoteDetails { +export interface NoteDetails extends Omit { + updatedAt: DateTime + createdAt: DateTime markdownContent: { plain: string lines: string[] lineStartIndexes: number[] } selection: CursorSelection + firstHeading?: string rawFrontmatter: string frontmatter: NoteFrontmatter frontmatterRendererInfo: RendererFrontmatterInfo - id: string - createTime: DateTime - lastChange: { - username: string - timestamp: DateTime - } - viewCount: number - alias: string - authorship: string[] - noteTitle: string - firstHeading?: string } export type Iso6391Language = typeof ISO6391[number] diff --git a/src/redux/reducers.ts b/src/redux/reducers.ts index b74c9f70e..994033533 100644 --- a/src/redux/reducers.ts +++ b/src/redux/reducers.ts @@ -9,7 +9,6 @@ import { combineReducers } from 'redux' import { UserReducer } from './user/reducers' import { ConfigReducer } from './config/reducers' import { MotdReducer } from './motd/reducers' -import { ApiUrlReducer } from './api-url/reducers' import { HistoryReducer } from './history/reducers' import { EditorConfigReducer } from './editor/reducers' import { DarkModeConfigReducer } from './dark-mode/reducers' @@ -22,7 +21,6 @@ export const allReducers: Reducer = combineReducers void = (state: UserState) => { +/** + * Sets the given user state into the redux. + * @param state The user state to set into the redux. + */ +export const setUser = (state: LoginUserInfo): void => { const action: SetUserAction = { type: UserActionType.SET_USER, state @@ -16,6 +21,9 @@ export const setUser: (state: UserState) => void = (state: UserState) => { store.dispatch(action) } +/** + * Clears the user state from the redux. + */ export const clearUser: () => void = () => { const action: ClearUserAction = { type: UserActionType.CLEAR_USER diff --git a/src/redux/user/types.ts b/src/redux/user/types.ts index 5c81015d6..dcf4a2a63 100644 --- a/src/redux/user/types.ts +++ b/src/redux/user/types.ts @@ -5,6 +5,7 @@ */ import type { Action } from 'redux' +import type { LoginUserInfo } from '../../api/me/types' export enum UserActionType { SET_USER = 'user/set', @@ -15,32 +16,11 @@ export type UserActions = SetUserAction | ClearUserAction export interface SetUserAction extends Action { type: UserActionType.SET_USER - state: UserState + state: LoginUserInfo } export interface ClearUserAction extends Action { type: UserActionType.CLEAR_USER } -export interface UserState { - username: string - displayName: string - email: string - photo: string - provider: LoginProvider -} - -export enum LoginProvider { - FACEBOOK = 'facebook', - GITHUB = 'github', - TWITTER = 'twitter', - GITLAB = 'gitlab', - DROPBOX = 'dropbox', - GOOGLE = 'google', - SAML = 'saml', - OAUTH2 = 'oauth2', - LOCAL = 'local', - LDAP = 'ldap' -} - -export type OptionalUserState = UserState | null +export type OptionalUserState = LoginUserInfo | null diff --git a/src/utils/api-url.ts b/src/utils/api-url.ts new file mode 100644 index 000000000..12d834f4b --- /dev/null +++ b/src/utils/api-url.ts @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { isMockMode } from './test-modes' +import { backendUrl } from './backend-url' + +export const apiUrl = isMockMode ? `/api/mock-backend/private/` : `${backendUrl}api/private/` diff --git a/src/utils/backend-url.ts b/src/utils/backend-url.ts new file mode 100644 index 000000000..64c3aea48 --- /dev/null +++ b/src/utils/backend-url.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { isMockMode } from './test-modes' + +if (!isMockMode && process.env.NEXT_PUBLIC_BACKEND_BASE_URL === undefined) { + throw new Error('NEXT_PUBLIC_BACKEND_BASE_URL is unset and mock mode is disabled') +} + +export const backendUrl = isMockMode ? '/' : (process.env.NEXT_PUBLIC_BACKEND_BASE_URL as string) diff --git a/src/utils/customize-assets-url.ts b/src/utils/customize-assets-url.ts new file mode 100644 index 000000000..2ed0bccf6 --- /dev/null +++ b/src/utils/customize-assets-url.ts @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { backendUrl } from './backend-url' +import { isMockMode } from './test-modes' + +export const customizeAssetsUrl = isMockMode + ? `/mock-public/` + : process.env.NEXT_PUBLIC_CUSTOMIZE_ASSETS_URL || `${backendUrl}public/` diff --git a/src/utils/cypress-attribute.ts b/src/utils/cypress-attribute.ts index 2d026d33b..3a452269e 100644 --- a/src/utils/cypress-attribute.ts +++ b/src/utils/cypress-attribute.ts @@ -21,7 +21,7 @@ export interface PropsWithDataCypressId { export const cypressId = ( identifier: string | undefined | PropsWithDataCypressId ): Record<'data-cypress-id', string> | undefined => { - if (!isTestMode() || !identifier) { + if (!isTestMode || !identifier) { return } @@ -45,7 +45,7 @@ export const cypressAttribute = ( attribute: string, value: string | undefined ): Record | undefined => { - if (!isTestMode()) { + if (!isTestMode) { return } diff --git a/src/utils/is-positive-answer.ts b/src/utils/is-positive-answer.ts new file mode 100644 index 000000000..e5495e370 --- /dev/null +++ b/src/utils/is-positive-answer.ts @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * Checks if the given string is a positive answer (yes, true or 1) + * @param value The value to check + */ +export const isPositiveAnswer = (value: string) => { + const lowerValue = value.toLowerCase() + return lowerValue === 'yes' || lowerValue === '1' || lowerValue === 'true' +} diff --git a/src/utils/test-modes.ts b/src/utils/test-modes.ts index bcb365b11..c7bbf2a73 100644 --- a/src/utils/test-modes.ts +++ b/src/utils/test-modes.ts @@ -4,23 +4,20 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { isPositiveAnswer } from './is-positive-answer' + /** * Checks if the current runtime is built in e2e test mode. */ -export const isTestMode = (): boolean => { - return process.env.NEXT_PUBLIC_TEST_MODE === 'true' -} +export const isTestMode = !!process.env.NEXT_PUBLIC_TEST_MODE && isPositiveAnswer(process.env.NEXT_PUBLIC_TEST_MODE) /** * Checks if the current runtime should use the mocked backend. */ -export const isMockMode = (): boolean => { - return process.env.NEXT_PUBLIC_USE_MOCK_API === 'true' -} +export const isMockMode = + !!process.env.NEXT_PUBLIC_USE_MOCK_API && isPositiveAnswer(process.env.NEXT_PUBLIC_USE_MOCK_API) /** * Checks if the current runtime was built in development mode. */ -export const isDevMode = (): boolean => { - return process.env.NODE_ENV === 'development' -} +export const isDevMode = process.env.NODE_ENV === 'development' diff --git a/yarn.lock b/yarn.lock index 3cea3a3da..8c6756bfb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2194,6 +2194,7 @@ __metadata: cypress-commands: 2.0.1 cypress-fill-command: 1.0.2 d3-graphviz: 4.1.1 + deepmerge: 4.2.2 diff: 5.0.0 dompurify: 2.3.6 emoji-picker-element: 1.11.2 @@ -9035,7 +9036,7 @@ __metadata: languageName: node linkType: hard -"deepmerge@npm:^4.2.2": +"deepmerge@npm:4.2.2, deepmerge@npm:^4.2.2": version: 4.2.2 resolution: "deepmerge@npm:4.2.2" checksum: a8c43a1ed8d6d1ed2b5bf569fa4c8eb9f0924034baf75d5d406e47e157a451075c4db353efea7b6bcc56ec48116a8ce72fccf867b6e078e7c561904b5897530b