diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cc2758c620..9ed2e1f2df 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -101,6 +101,7 @@ jobs: - google-drive - google-drive-encrypted - linkwarden + - karakeep test-name: - test browsers: @@ -176,6 +177,16 @@ jobs: image: mariadb:10.5 # see https://github.com/nextcloud/docker/issues/1536 env: MYSQL_ROOT_PASSWORD: ${{env.MYSQL_PASSWORD}} + karakeep: + image: ghcr.io/karakeep-app/karakeep:release + ports: + - 3000:3000 + volumes: + - data:/data + env: + NEXTAUTH_SECRET: super_random_string + NEXTAUTH_URL: http://localhost:3000 + DATA_DIR: /data steps: @@ -263,6 +274,7 @@ jobs: GOOGLE_API_REFRESH_TOKEN: ${{ secrets.GOOGLE_API_REFRESH_TOKEN }} LINKWARDEN_TOKEN: ${{ secrets.LINKWARDEN_TOKEN }} APP_VERSION: ${{ matrix.app-version }} + KARAKEEP_TEST_HOST: 172.17.0.1:3000 run: | npm run test if: matrix.floccus-adapter != 'git-xbel' && matrix.floccus-adapter != 'git-html' diff --git a/_locales/en/messages.json b/_locales/en/messages.json index c519dc3572..ad2482b3a2 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -164,6 +164,9 @@ "DescriptionServerfolderlinkwarden": { "message": "When syncing, your bookmarks in this browser will be stored as links under this collection and only links under this collection will be synced with this browser." }, + "DescriptionServerfolderkarakeep": { + "message": "When syncing, your bookmarks in this browser will be stored as links under this collection and only links under this collection will be synced with this browser." + }, "LabelLocaltarget": { "message": "Local target" }, @@ -801,6 +804,21 @@ "DescriptionReportproblem": { "message": "If you would like to directly contact the developers with a concrete issue, you can do so here:" }, + "LabelAdapterKarakeep": { + "message": "Karakeep" + }, + "DescriptionAdapterKarakeep": { + "message": "Sync your bookmarks with the open-source self-hosted Karakeep app. This option cannot make use of end-to-end encryption." + }, + "LabelApiKey": { + "message": "API key" + }, + "LabelKarakeepurl": { + "message": "The URL of your Karakeep server" + }, + "LabelKarakeepconnectionerror": { + "message": "Failed to connect to your Karakeep server" + }, "LabelAdapterlinkwarden": { "message": "Linkwarden" }, diff --git a/src/lib/Account.ts b/src/lib/Account.ts index 0bebcc297d..dcf2768401 100644 --- a/src/lib/Account.ts +++ b/src/lib/Account.ts @@ -20,6 +20,7 @@ declare const DEBUG: boolean // register Adapters AdapterFactory.register('linkwarden', async() => (await import('./adapters/Linkwarden')).default) +AdapterFactory.register('karakeep', async() => (await import('./adapters/Karakeep')).default) AdapterFactory.register('nextcloud-folders', async() => (await import('./adapters/NextcloudBookmarks')).default) AdapterFactory.register('nextcloud-bookmarks', async() => (await import('./adapters/NextcloudBookmarks')).default) AdapterFactory.register('webdav', async() => (await import('./adapters/WebDav')).default) diff --git a/src/lib/adapters/Karakeep.ts b/src/lib/adapters/Karakeep.ts new file mode 100644 index 0000000000..e2c64ceec5 --- /dev/null +++ b/src/lib/adapters/Karakeep.ts @@ -0,0 +1,557 @@ +import Adapter from '../interfaces/Adapter' +import { Bookmark, Folder, ItemLocation } from '../Tree' +import PQueue from 'p-queue' +import { IResource } from '../interfaces/Resource' +import Logger from '../Logger' +import { + AuthenticationError, + CancelledSyncError, + HttpError, + NetworkError, + ParseResponseError, + RedirectError, + RequestTimeoutError, +} from '../../errors/Error' +import { Capacitor, CapacitorHttp as Http } from '@capacitor/core' + +export interface KarakeepConfig { + type: 'karakeep' + url: string + password: string + serverFolder: string + includeCredentials?: boolean + allowRedirects?: boolean + allowNetwork?: boolean + label?: string +} + +const TIMEOUT = 300000 + +export default class KarakeepAdapter + implements Adapter, IResource +{ + private server: KarakeepConfig + private fetchQueue: PQueue + private abortController: AbortController + private abortSignal: AbortSignal + private canceled: boolean + + constructor(server: KarakeepConfig) { + this.server = server + this.fetchQueue = new PQueue({ concurrency: 12 }) + this.abortController = new AbortController() + this.abortSignal = this.abortController.signal + } + + static getDefaultValues(): KarakeepConfig { + return { + type: 'karakeep', + url: 'https://example.org', + password: 's3cret', + serverFolder: 'Floccus', + includeCredentials: false, + allowRedirects: false, + allowNetwork: false, + } + } + + parseBookmarkId(id: string | number): [string, string] { + if (typeof id === 'number') { + throw new Error('IDs should be strings') + } + const s = id.split(';') + return [s[0], s[1]] + } + + acceptsBookmark(bm: Bookmark): boolean { + try { + return ['https:', 'http:'].includes(new URL(bm.url).protocol) + } catch (e) { + return false + } + } + + cancel(): void { + this.canceled = true + this.abortController.abort() + } + + setData(data: KarakeepConfig): void { + this.server = { ...data } + } + + getData(): KarakeepConfig { + return { ...KarakeepAdapter.getDefaultValues(), ...this.server } + } + + getLabel(): string { + const data = this.getData() + return data.label || new URL(data.url).hostname + } + + onSyncComplete(): Promise { + return Promise.resolve(undefined) + } + + onSyncFail(): Promise { + return Promise.resolve(undefined) + } + + onSyncStart( + needLock?: boolean, + forceLock?: boolean + ): Promise { + this.canceled = false + return Promise.resolve(undefined) + } + + async createBookmark(bookmark: { + id: string | number + url: string + title: string + parentId: string | number + }): Promise { + Logger.log('(karakeep)CREATE', { bookmark }) + const response = await this.sendRequest( + 'POST', + '/api/v1/bookmarks', + 'application/json', + { + type: 'link', + url: bookmark.url, + title: bookmark.title, + } + ) + if (response.alreadyExists) { + await this.sendRequest( + 'PATCH', + `/api/v1/bookmarks/${response.id}`, + 'application/json', + { + title: bookmark.title, + } + ) + } + await this.sendRequest( + 'PUT', + `/api/v1/lists/${bookmark.parentId}/bookmarks/${response.id}`, + 'application/json', + undefined, + /* returnRawResponse */ true + ) + return `${response.id};${bookmark.parentId}` + } + + async updateBookmark(bookmark: { + id: string | number + url: string + title: string + parentId: string | number + }): Promise { + Logger.log('(karakeep)UPDATE', { bookmark }) + const [id, oldParentId] = this.parseBookmarkId(bookmark.id) + await this.sendRequest( + 'PATCH', + `/api/v1/bookmarks/${id}`, + 'application/json', + { + url: bookmark.url, + title: bookmark.title, + } + ) + + if (oldParentId !== bookmark.parentId) { + await Promise.all([ + this.sendRequest( + 'DELETE', + `/api/v1/lists/${oldParentId}/bookmarks/${id}`, + 'application/json', + undefined, + /* returnRawResponse */ true + ), + this.sendRequest( + 'PUT', + `/api/v1/lists/${bookmark.parentId}/bookmarks/${id}`, + 'application/json', + undefined, + /* returnRawResponse */ true + ), + ]) + } + bookmark.id = `${id};${bookmark.parentId}` + } + + async removeBookmark(bookmark: { + id: string | number + parentId: string | number + }): Promise { + Logger.log('(karakeep)DELETE', { bookmark }) + + const [id, parentId] = this.parseBookmarkId(bookmark.id) + + // Remove the bookmark from the list + await this.sendRequest( + 'DELETE', + `/api/v1/lists/${parentId}/bookmarks/${id}`, + 'application/json', + undefined, + /* returnRawResponse */ true + ) + + // If the bookmark is not in any list, delete it from the server + const bookmarkLists = await this.getListsOfBookmark(id) + if (bookmarkLists.size === 0) { + await this.sendRequest( + 'DELETE', + `/api/v1/bookmarks/${id}`, + 'application/json', + undefined, + /* returnRawResponse */ true + ) + } + } + + async createFolder(folder: { + id: string | number + title?: string + parentId: string | number + }): Promise { + Logger.log('(karakeep)CREATEFOLDER', { folder }) + const response = await this.sendRequest( + 'POST', + '/api/v1/lists', + 'application/json', + { + name: folder.title, + icon: '📔', + type: 'manual', + parentId: folder.parentId, + } + ) + return response.id + } + + async updateFolder(folder: { + id: string | number + title?: string + parentId: string | number + }): Promise { + Logger.log('(karakeep)UPDATEFOLDER', { folder }) + await this.sendRequest( + 'PATCH', + `/api/v1/lists/${folder.id}`, + 'application/json', + { + name: folder.title, + parentId: folder.parentId, + } + ) + } + + /** + * Removes the list from karakeep, but also all its content recursively + */ + async removeFolder(folder: { id: string | number }): Promise { + Logger.log('(karakeep)DELETEFOLDER', { folder }) + + const deleteListContent = async () => { + // Get the list of bookmarks in the list + const bookmarkIds = [] + let nextCursor = null + do { + let response = await this.sendRequest( + 'GET', + `/api/v1/lists/${folder.id}/bookmarks?includeContent=false&${ + nextCursor ? 'cursor=' + nextCursor : '' + }` + ) + nextCursor = response.nextCursor + bookmarkIds.push(...response.bookmarks.map((b) => b.id)) + } while (nextCursor !== null) + await Promise.all( + bookmarkIds.map((id) => + this.removeBookmark({ + id: `${id};${folder.id}`, + parentId: folder.id, + }) + ) + ) + } + + const deleteListFolders = async () => { + // Get the list of lists in the list + const { lists } = await this.sendRequest('GET', `/api/v1/lists`) + let childrenListIds = lists + .filter((list) => list.parentId === folder.id) + .map((l) => l.id) + + await Promise.all( + childrenListIds.map((listId) => + this.removeFolder({ + id: listId, + }) + ) + ) + } + + await Promise.all([deleteListContent(), deleteListFolders()]) + + // Delete the list itself "after" deleting all its content in case any failure occurs in the previous steps + await this.sendRequest( + 'DELETE', + `/api/v1/lists/${folder.id}`, + 'application/json', + undefined, + /* returnRawResponse */ true + ) + } + + async getBookmarksTree( + loadAll?: boolean + ): Promise> { + const fetchBookmarks = async (listId: string) => { + const links = [] + let nextCursor = null + do { + let response = await this.sendRequest( + 'GET', + `/api/v1/lists/${listId}/bookmarks?includeContent=false&${ + nextCursor ? 'cursor=' + nextCursor : '' + }` + ) + nextCursor = response.nextCursor + links.push(...response.bookmarks) + } while (nextCursor !== null) + return links + } + + const { lists } = await this.sendRequest('GET', `/api/v1/lists`) + + let rootList = lists.find( + (list) => list.name === this.server.serverFolder && list.parentId === null + ) + if (!rootList) { + rootList = await this.sendRequest( + 'POST', + '/api/v1/lists', + 'application/json', + { + name: this.server.serverFolder, + icon: '📔', + type: 'manual', + } + ) + } + const rootId = rootList.id + + const listIdtoList = { + [rootId]: rootList, + } + lists.forEach((list) => { + listIdtoList[list.id] = list + }) + + const listTree: Record = { + [rootId]: [], + ...lists.reduce((acc, list) => { + acc[list.id] = [] + return acc + }, {}), + } + lists.forEach((list) => { + if (list.parentId === null) { + return + } + listTree[list.parentId].push(list.id) + }) + + const buildTree = async (listId, isRoot = false) => { + const list = listIdtoList[listId] + + const childrenBookmarks = (await fetchBookmarks(listId)) + .filter((b) => b.content.type === 'link') + .map( + (b) => + new Bookmark({ + id: `${b.id};${listId}`, + title: b.title ?? b.content.title, + parentId: listId, + url: b.content.url, + location: ItemLocation.SERVER, + }) + ) + const childrenFolders = await Promise.all( + listTree[listId].map((l) => buildTree(l, false)) + ) + + return new Folder({ + id: list.id, + title: list.name, + parentId: list.parentId, + location: ItemLocation.SERVER, + isRoot, + children: [...childrenFolders, ...childrenBookmarks], + }) + } + + return await buildTree(rootId, true) + } + + async getListsOfBookmark(bookmarkId: string | number): Promise> { + const { lists } = await this.sendRequest( + 'GET', + `/api/v1/bookmarks/${bookmarkId}/lists` + ) + + return new Set(lists.map((list) => list.id)) + } + + async isAvailable(): Promise { + return true + } + + async sendRequest( + verb: string, + relUrl: string, + type: string = null, + body: any = null, + returnRawResponse = false + ): Promise { + const url = this.server.url + relUrl + let res + let timedOut = false + + if (type && type.includes('application/json')) { + body = JSON.stringify(body) + } else if (type && type.includes('application/x-www-form-urlencoded')) { + const params = new URLSearchParams() + for (const [key, value] of Object.entries(body || {})) { + params.set(key, value as any) + } + body = params.toString() + } + + Logger.log(`QUEUING ${verb} ${url}`) + + if (Capacitor.getPlatform() !== 'web') { + return this.sendRequestNative(verb, url, type, body, returnRawResponse) + } + + try { + res = await this.fetchQueue.add(() => { + Logger.log(`FETCHING ${verb} ${url}`) + return Promise.race([ + fetch(url, { + method: verb, + credentials: this.server.includeCredentials ? 'include' : 'omit', + headers: { + ...(type && + type !== 'multipart/form-data' && { 'Content-type': type }), + Authorization: 'Bearer ' + this.server.password, + }, + signal: this.abortSignal, + ...(body && + !['get', 'head'].includes(verb.toLowerCase()) && { body }), + }), + new Promise((resolve, reject) => + setTimeout(() => { + timedOut = true + reject(new RequestTimeoutError()) + }, TIMEOUT) + ), + ]) + }) + } catch (e) { + if (timedOut) throw e + if (this.canceled) throw new CancelledSyncError() + console.log(e) + throw new NetworkError() + } + + Logger.log(`Receiving response for ${verb} ${url}`) + + if (res.redirected && !this.server.allowRedirects) { + throw new RedirectError() + } + + if (returnRawResponse) { + return res + } + + if (res.status === 403) { + throw new AuthenticationError() + } + if (res.status === 503 || res.status >= 400) { + throw new HttpError(res.status, verb) + } + let json + try { + json = await res.json() + } catch (e) { + throw new ParseResponseError(e.message) + } + + return json + } + + private async sendRequestNative( + verb: string, + url: string, + type: string, + body: any, + returnRawResponse: boolean + ) { + let res + let timedOut = false + try { + res = await this.fetchQueue.add(() => { + Logger.log(`FETCHING ${verb} ${url}`) + return Promise.race([ + Http.request({ + url, + method: verb, + disableRedirects: !this.server.allowRedirects, + headers: { + ...(type && + type !== 'multipart/form-data' && { 'Content-type': type }), + Authorization: 'Bearer ' + this.server.password, + }, + responseType: 'json', + ...(body && + !['get', 'head'].includes(verb.toLowerCase()) && { data: body }), + }), + new Promise((resolve, reject) => + setTimeout(() => { + timedOut = true + reject(new RequestTimeoutError()) + }, TIMEOUT) + ), + ]) + }) + } catch (e) { + if (timedOut) throw e + console.log(e) + throw new NetworkError() + } + + Logger.log(`Receiving response for ${verb} ${url}`) + + if (res.status < 400 && res.status >= 300) { + throw new RedirectError() + } + + if (returnRawResponse) { + return res + } + + if (res.status === 401 || res.status === 403) { + throw new AuthenticationError() + } + if (res.status === 503 || res.status >= 400) { + throw new HttpError(res.status, verb) + } + const json = res.data + + return json + } +} diff --git a/src/test/test.js b/src/test/test.js index 8acc1c8576..2564517534 100644 --- a/src/test/test.js +++ b/src/test/test.js @@ -162,6 +162,12 @@ describe('Floccus', function() { serverFolder: 'Floccus-' + Math.random(), ...CREDENTIALS, }, + { + type: 'karakeep', + url: SERVER, + serverFolder: 'Floccus-' + Math.random(), + ...CREDENTIALS, + }, ] before(async function() { @@ -364,6 +370,9 @@ describe('Floccus', function() { ) }) it('should create local javascript bookmarks on the server', async function() { + if (ACCOUNT_DATA.type === 'karakeep') { + return this.skip() + } const localRoot = account.getData().localRoot const fooFolder = await browser.bookmarks.create({ title: 'foo', @@ -539,7 +548,7 @@ describe('Floccus', function() { ] }), new Bookmark({ - title: ACCOUNT_DATA.type === 'nextcloud-bookmarks' ? newData.title : bookmark2.title, + title: ACCOUNT_DATA.type === 'nextcloud-bookmarks' || ACCOUNT_DATA.type === 'karakeep' ? newData.title : bookmark2.title, url: bookmark1.url }), ] @@ -594,13 +603,13 @@ describe('Floccus', function() { title: 'bar', children: [ new Bookmark({ - title: bookmark2.title, + title: (ACCOUNT_DATA.type === 'karakeep') ? bookmark1.title : bookmark2.title, url: bookmark2.url }) ] }), new Bookmark({ - title: ACCOUNT_DATA.type === 'nextcloud-bookmarks' ? bookmark2.title : bookmark1.title, + title: (ACCOUNT_DATA.type === 'nextcloud-bookmarks') ? bookmark2.title : bookmark1.title, url: newData.url }), ] @@ -2581,7 +2590,7 @@ describe('Floccus', function() { this.skip() return } - if (ACCOUNT_DATA.type === 'linkwarden') { + if (ACCOUNT_DATA.type === 'linkwarden' || ACCOUNT_DATA.type === 'karakeep') { return this.skip() } const localRoot = account.getData().localRoot @@ -2710,7 +2719,7 @@ describe('Floccus', function() { this.skip() return } - if (ACCOUNT_DATA.type === 'linkwarden') { + if (ACCOUNT_DATA.type === 'linkwarden' || ACCOUNT_DATA.type === 'karakeep') { return this.skip() } const localRoot = account.getData().localRoot @@ -2916,7 +2925,7 @@ describe('Floccus', function() { if (ACCOUNT_DATA.noCache) { return this.skip() } - if (ACCOUNT_DATA.type === 'linkwarden') { + if (ACCOUNT_DATA.type === 'linkwarden' || ACCOUNT_DATA.type === 'karakeep') { return this.skip() } @@ -3997,7 +4006,7 @@ describe('Floccus', function() { if (ACCOUNT_DATA.type === 'nextcloud-bookmarks' && ['v1.1.2', 'v2.3.4', 'stable3', 'stable4'].includes(APP_VERSION)) { return this.skip() } - if (ACCOUNT_DATA.type === 'linkwarden') { + if (ACCOUNT_DATA.type === 'linkwarden' || ACCOUNT_DATA.type === 'karakeep') { return this.skip() } const localRoot = account1.getData().localRoot @@ -4695,7 +4704,7 @@ describe('Floccus', function() { ) }) it('should handle complex hierarchy reversals 2', async function() { - if (ACCOUNT_DATA.type === 'linkwarden') { + if (ACCOUNT_DATA.type === 'linkwarden' || ACCOUNT_DATA.type === 'karakeep') { return this.skip() } const localRoot = account1.getData().localRoot @@ -5121,7 +5130,7 @@ describe('Floccus', function() { ) }) it('should synchronize ordering', async function() { - if (ACCOUNT_DATA.type === 'linkwarden') { + if (ACCOUNT_DATA.type === 'linkwarden' || ACCOUNT_DATA.type === 'karakeep') { return this.skip() } expect( @@ -5222,7 +5231,7 @@ describe('Floccus', function() { // isn't able to track bookmarks across dirs, thus in this // scenario both bookmarks survive :/ it('should propagate moves using "last write wins"', async function() { - if (ACCOUNT_DATA.type === 'nextcloud-bookmarks') { + if (ACCOUNT_DATA.type === 'nextcloud-bookmarks' || ACCOUNT_DATA.type === 'karakeep') { return this.skip() } const localRoot = account1.getData().localRoot @@ -5346,7 +5355,7 @@ describe('Floccus', function() { }) context('with tabs', function() { - if (ACCOUNT_DATA.type === 'linkwarden') { + if (ACCOUNT_DATA.type === 'linkwarden' || ACCOUNT_DATA.type === 'karakeep') { return } let account diff --git a/src/ui/components/OptionsKarakeep.vue b/src/ui/components/OptionsKarakeep.vue new file mode 100644 index 0000000000..ab274bc90f --- /dev/null +++ b/src/ui/components/OptionsKarakeep.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/src/ui/components/native/Drawer.vue b/src/ui/components/native/Drawer.vue index 3d5fbd85cf..6d67043fd5 100644 --- a/src/ui/components/native/Drawer.vue +++ b/src/ui/components/native/Drawer.vue @@ -94,6 +94,7 @@ export default { const icons = { 'nextcloud-bookmarks': 'mdi-cloud', 'linkwarden': 'mdi-link-box-variant-outline', + 'karakeep': 'mdi-bookmark-box', 'webdav': 'mdi-folder-network', 'git': 'mdi-source-repository', 'google-drive': 'mdi-google-drive' diff --git a/src/ui/store/actions.js b/src/ui/store/actions.js index 6c01eb9bbf..39a2632595 100644 --- a/src/ui/store/actions.js +++ b/src/ui/store/actions.js @@ -150,6 +150,21 @@ export const actionsDefinition = { } return true }, + async [actions.TEST_KARAKEEP_SERVER]({commit, dispatch, state}, {rootUrl, token}) { + await dispatch(actions.REQUEST_NETWORK_PERMISSIONS) + let res = await fetch(`${rootUrl}/api/v1/users/me`, { + method: 'GET', + credentials: 'omit', + headers: { + 'User-Agent': 'Floccus bookmarks sync', + Authorization: 'Bearer ' + token, + } + }) + if (res.status !== 200) { + throw new Error(browser.i18n.getMessage('LabelKarakeepconnectionerror')) + } + return true + }, async [actions.START_LOGIN_FLOW]({commit, dispatch, state}, rootUrl) { commit(mutations.SET_LOGIN_FLOW_STATE, true) let res = await fetch(`${rootUrl}/index.php/login/v2`, {method: 'POST', headers: {'User-Agent': 'Floccus bookmarks sync'}}) diff --git a/src/ui/store/definitions.js b/src/ui/store/definitions.js index 99a4a41b13..f5297c03fb 100644 --- a/src/ui/store/definitions.js +++ b/src/ui/store/definitions.js @@ -32,6 +32,7 @@ export const actions = { TEST_WEBDAV_SERVER: 'TEST_WEBDAV_SERVER', TEST_NEXTCLOUD_SERVER: 'TEST_NEXTCLOUD_SERVER', TEST_LINKWARDEN_SERVER: 'TEST_LINKWARDEN_SERVER', + TEST_KARAKEEP_SERVER: 'TEST_KARAKEEP_SERVER', START_LOGIN_FLOW: 'START_LOGIN_FLOW', STOP_LOGIN_FLOW: 'STOP_LOGIN_FLOW', REQUEST_NETWORK_PERMISSIONS: 'REQUEST_NETWORK_PERMISSIONS', diff --git a/src/ui/store/native/actions.js b/src/ui/store/native/actions.js index 34915f1958..8ef0feaacf 100644 --- a/src/ui/store/native/actions.js +++ b/src/ui/store/native/actions.js @@ -226,6 +226,20 @@ export const actionsDefinition = { } return true }, + async [actions.TEST_KARAKEEP_SERVER]({commit, dispatch, state}, {rootUrl, token}) { + let res = await Http.request({ + url: `${rootUrl}/api/v1/users/me`, + method: 'GET', + headers: { + 'User-Agent': 'Floccus bookmarks sync', + Authorization: 'Bearer ' + token, + } + }) + if (res.status !== 200) { + throw new Error(i18n.getMessage('LabelKarakeepconnectionerror')) + } + return true + }, async [actions.TEST_NEXTCLOUD_SERVER]({commit, dispatch, state}, rootUrl) { let res = await Http.request({ url: `${rootUrl}/index.php/login/v2`, diff --git a/src/ui/views/AccountOptions.vue b/src/ui/views/AccountOptions.vue index fbd6d91b69..c7f92e5172 100644 --- a/src/ui/views/AccountOptions.vue +++ b/src/ui/views/AccountOptions.vue @@ -143,6 +143,11 @@ v-bind.sync="data" @reset="onReset" @delete="onDelete" /> + + + + +