diff --git a/apps/settings/lib/Settings/Personal/Security/Authtokens.php b/apps/settings/lib/Settings/Personal/Security/Authtokens.php index f5cf701460336..9da095a6cc765 100644 --- a/apps/settings/lib/Settings/Personal/Security/Authtokens.php +++ b/apps/settings/lib/Settings/Personal/Security/Authtokens.php @@ -12,6 +12,7 @@ use OC\Authentication\Token\INamedToken; use OC\Authentication\Token\IProvider as IAuthTokenProvider; use OC\Authentication\Token\IToken; +use OCA\Settings\AppInfo\Application; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; use OCP\Authentication\Exceptions\InvalidTokenException; @@ -20,6 +21,7 @@ use OCP\IUserSession; use OCP\Session\Exceptions\SessionNotAvailableException; use OCP\Settings\ISettings; +use OCP\Util; use function array_map; class Authtokens implements ISettings { @@ -47,6 +49,9 @@ public function getForm(): TemplateResponse { && $this->serverConfig->getSystemValueBool('auth_can_create_app_token', true) ); + Util::addStyle(Application::APP_ID, 'personal-security-authtokens'); + Util::addScript(Application::APP_ID, 'personal-security-authtokens'); + return new TemplateResponse('settings', 'settings/personal/security/authtokens'); } diff --git a/apps/settings/src/components/AuthToken.spec.ts b/apps/settings/src/authtokens/components/AuthToken.spec.ts similarity index 58% rename from apps/settings/src/components/AuthToken.spec.ts rename to apps/settings/src/authtokens/components/AuthToken.spec.ts index 75b483247f66f..b1a565e770a39 100644 --- a/apps/settings/src/components/AuthToken.spec.ts +++ b/apps/settings/src/authtokens/components/AuthToken.spec.ts @@ -6,15 +6,10 @@ import type { IToken } from '../store/authtoken.ts' import { createTestingPinia } from '@pinia/testing' -import { mount } from '@vue/test-utils' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// AuthToken.vue reads window.OC.theme.productName at module evaluation time. -// vi.hoisted runs before imports, so this guarantees the property is set on -// the existing jsdom window before the SFC is first parsed. -vi.hoisted(() => { - (window as unknown as { OC: { theme: { productName: string } } }).OC.theme = { productName: 'Nextcloud' } -}) +import { getByRole } from '@testing-library/vue' +import { enableAutoUnmount, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' // Mock @nextcloud/dialogs so the wipe action's showConfirmation call resolves // synchronously in tests. Hoisted so it's installed before AuthToken.vue imports. @@ -26,9 +21,15 @@ vi.mock('@nextcloud/dialogs', () => ({ import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' import AuthToken from './AuthToken.vue' import AuthTokenDeleteDialog from './AuthTokenDeleteDialog.vue' +import AuthTokenSetupDialog from './AuthTokenSetupDialog.vue' import { TokenType, useAuthTokenStore } from '../store/authtoken.ts' import { detect } from '../utils/userAgentDetect.ts' +// AuthToken.vue reads window.OC.theme.productName, which the test window lacks +;(window as unknown as { OC: { theme: { productName: string } } }).OC.theme = { productName: 'Nextcloud' } + +enableAutoUnmount(afterEach) + function makeToken(overrides: Partial = {}): IToken { return { id: 1, @@ -42,37 +43,36 @@ function makeToken(overrides: Partial = {}): IToken { } } -function mountAuthToken(token: IToken) { +function mountAuthToken(token: IToken, { stubs = {}, attachTo }: { stubs?: Record, attachTo?: HTMLElement } = {}) { return mount(AuthToken, { - // Vue Test Utils v1 (legacy pipeline) uses propsData; v2 also accepts it - propsData: { token }, - mocks: { - t: (_: string, text: string) => text, + props: { token }, + attachTo, + global: { + stubs: { + NcActions: true, + NcActionButton: true, + NcActionCheckbox: true, + NcButton: true, + NcDateTime: true, + NcIconSvgWrapper: true, + NcTextField: true, + ...stubs, + }, + plugins: [createTestingPinia({ + createSpy: vi.fn, + initialState: { 'auth-token': { tokens: [token] } }, + })], }, - stubs: { - NcActions: true, - NcActionButton: true, - NcActionCheckbox: true, - NcButton: true, - NcDateTime: true, - NcIconSvgWrapper: true, - NcTextField: true, - }, - pinia: createTestingPinia({ - createSpy: vi.fn, - initialState: { 'auth-token': { tokens: [token] } }, - }), }) } function mountDeleteDialog(token: IToken, open = true) { return mount(AuthTokenDeleteDialog, { - propsData: { token, open }, - mocks: { - t: (_: string, text: string) => text, - }, - stubs: { - NcDialog: { template: '
' }, + props: { token, open }, + global: { + stubs: { + NcDialog: { template: '
' }, + }, }, }) } @@ -88,7 +88,7 @@ describe('AuthToken revoke flow', () => { const store = useAuthTokenStore() ;(wrapper.vm as unknown as { revoke: () => void }).revoke() - await wrapper.vm.$nextTick() + await nextTick() const dialog = wrapper.findComponent(AuthTokenDeleteDialog) expect(dialog.exists()).toBe(true) @@ -102,12 +102,12 @@ describe('AuthToken revoke flow', () => { const store = useAuthTokenStore() ;(wrapper.vm as unknown as { revoke: () => void }).revoke() - await wrapper.vm.$nextTick() + await nextTick() const dialog = wrapper.findComponent(AuthTokenDeleteDialog) dialog.vm.$emit('confirm') dialog.vm.$emit('update:open', false) - await wrapper.vm.$nextTick() + await nextTick() expect(store.deleteToken).toHaveBeenCalledTimes(1) expect(store.deleteToken).toHaveBeenCalledWith(token) @@ -119,11 +119,11 @@ describe('AuthToken revoke flow', () => { const store = useAuthTokenStore() ;(wrapper.vm as unknown as { revoke: () => void }).revoke() - await wrapper.vm.$nextTick() + await nextTick() const dialog = wrapper.findComponent(AuthTokenDeleteDialog) dialog.vm.$emit('update:open', false) - await wrapper.vm.$nextTick() + await nextTick() // Dialog is v-if'd off the tree once closed expect(wrapper.findComponent(AuthTokenDeleteDialog).exists()).toBe(false) @@ -135,7 +135,7 @@ describe('AuthToken revoke flow', () => { const wrapper = mountAuthToken(token) ;(wrapper.vm as unknown as { revoke: () => void }).revoke() - await wrapper.vm.$nextTick() + await nextTick() const dialog = wrapper.findComponent(AuthTokenDeleteDialog) expect(dialog.exists()).toBe(true) @@ -144,6 +144,109 @@ describe('AuthToken revoke flow', () => { }) }) +describe('AuthToken rename focus', () => { + function mountRenamable(token: IToken) { + return mountAuthToken(token, { + attachTo: document.body, + stubs: { + NcActions: { template: '
' }, + NcTextField: { template: '', methods: { select() {} } }, + }, + }) + } + + function actionsButton(wrapper: ReturnType) { + return getByRole(wrapper.element, 'button', { name: 'Device settings' }) + } + + it('returns focus to the actions button after cancelling with Escape', async () => { + const wrapper = mountRenamable(makeToken()) + + ;(wrapper.vm as unknown as { startRename: () => void }).startRename() + await nextTick() + await wrapper.find('input').trigger('keyup', { key: 'Escape' }) + await nextTick() + + expect(wrapper.find('form').exists()).toBe(false) + expect(actionsButton(wrapper)).toHaveFocus() + }) + + it('returns focus to the actions button after saving the new name', async () => { + const token = makeToken() + const wrapper = mountRenamable(token) + const store = useAuthTokenStore() + + ;(wrapper.vm as unknown as { startRename: () => void }).startRename() + await nextTick() + await wrapper.find('form').trigger('submit') + await nextTick() + + expect(store.renameToken).toHaveBeenCalledWith(token, token.name) + expect(actionsButton(wrapper)).toHaveFocus() + }) + + it('returns focus to the actions button once the password confirmation closes', async () => { + const token = makeToken() + const wrapper = mountRenamable(token) + const store = useAuthTokenStore() + const dialogField = document.createElement('input') + document.body.appendChild(dialogField) + const dialog = Promise.withResolvers() + vi.mocked(store.renameToken).mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve)) + dialogField.focus() + await dialog.promise + dialogField.remove() + return true + }) + + ;(wrapper.vm as unknown as { startRename: () => void }).startRename() + await nextTick() + await wrapper.find('form').trigger('submit') + await vi.waitFor(() => expect(dialogField).toHaveFocus(), { interval: 1 }) + dialog.resolve() + + await vi.waitFor(() => expect(actionsButton(wrapper)).toHaveFocus(), { interval: 1 }) + }) +}) + +describe('AuthToken action labels', () => { + it('labels each action with its own text', () => { + const wrapper = mountAuthToken(makeToken(), { + stubs: { + NcActions: { template: '
' }, + NcActionButton: false, + NcButton: false, + }, + }) + + const labels = wrapper.findAll('button').map((button) => button.text()) + expect(labels).toEqual(expect.arrayContaining(['Rename', 'Revoke', 'Wipe device'])) + }) +}) + +describe('AuthTokenSetupDialog QR code', () => { + // The credentials are shown as text, so the QR code is redundant for screen readers + it('hides the QR code from assistive technology', async () => { + const wrapper = mount(AuthTokenSetupDialog, { + props: { token: { token: 'app-password', loginName: 'admin', deviceToken: makeToken() } }, + global: { + stubs: { + NcDialog: { template: '
' }, + NcIconSvgWrapper: true, + // jsdom has no canvas 2D context to draw the QR code on + VueQrcode: { template: '' }, + }, + }, + }) + + getByRole(wrapper.element, 'button', { name: 'Show QR code for mobile apps' }).click() + await nextTick() + + expect(wrapper.find('canvas').element).toHaveAttribute('aria-hidden', 'true') + }) +}) + describe('AuthToken wipe flow', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/settings/src/authtokens/components/AuthToken.vue b/apps/settings/src/authtokens/components/AuthToken.vue new file mode 100644 index 0000000000000..7cc7a06b8c7f7 --- /dev/null +++ b/apps/settings/src/authtokens/components/AuthToken.vue @@ -0,0 +1,363 @@ + + + + + + + diff --git a/apps/settings/src/components/AuthTokenDeleteDialog.vue b/apps/settings/src/authtokens/components/AuthTokenDeleteDialog.vue similarity index 97% rename from apps/settings/src/components/AuthTokenDeleteDialog.vue rename to apps/settings/src/authtokens/components/AuthTokenDeleteDialog.vue index 0085c3b223100..d4e0c8d95b1af 100644 --- a/apps/settings/src/components/AuthTokenDeleteDialog.vue +++ b/apps/settings/src/authtokens/components/AuthTokenDeleteDialog.vue @@ -7,7 +7,7 @@ import type { IDialogButton } from '@nextcloud/dialogs' import type { IToken } from '../store/authtoken.ts' -import { translate as t } from '@nextcloud/l10n' +import { t } from '@nextcloud/l10n' import { computed } from 'vue' import NcDialog from '@nextcloud/vue/components/NcDialog' import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' diff --git a/apps/settings/src/components/AuthTokenList.vue b/apps/settings/src/authtokens/components/AuthTokenList.vue similarity index 68% rename from apps/settings/src/components/AuthTokenList.vue rename to apps/settings/src/authtokens/components/AuthTokenList.vue index f8be3e7771959..2556053dcd291 100644 --- a/apps/settings/src/components/AuthTokenList.vue +++ b/apps/settings/src/authtokens/components/AuthTokenList.vue @@ -3,6 +3,19 @@ - SPDX-License-Identifier: AGPL-3.0-or-later --> + + - - diff --git a/apps/settings/src/authtokens/main.ts b/apps/settings/src/authtokens/main.ts new file mode 100644 index 0000000000000..cba424cfff20c --- /dev/null +++ b/apps/settings/src/authtokens/main.ts @@ -0,0 +1,12 @@ +/*! + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { createPinia } from 'pinia' +import { createApp } from 'vue' +import AuthTokenSection from './components/AuthTokenSection.vue' + +const app = createApp(AuthTokenSection) +app.use(createPinia()) +app.mount('#security-authtokens') diff --git a/apps/settings/src/store/authtoken.spec.ts b/apps/settings/src/authtokens/store/authtoken.spec.ts similarity index 96% rename from apps/settings/src/store/authtoken.spec.ts rename to apps/settings/src/authtokens/store/authtoken.spec.ts index e51558dc23dda..9e4387f26397d 100644 --- a/apps/settings/src/store/authtoken.spec.ts +++ b/apps/settings/src/authtokens/store/authtoken.spec.ts @@ -38,7 +38,7 @@ describe('store:authtoken addToken', () => { const response = await store.addToken('Laptop') expect(response.deviceToken).toBe(deviceToken) - expect(store.tokens).toContain(deviceToken) + expect(store.tokens).toContainEqual(deviceToken) }) it('lets a failed request reach the caller so it can be reported', async () => { diff --git a/apps/settings/src/store/authtoken.ts b/apps/settings/src/authtokens/store/authtoken.ts similarity index 98% rename from apps/settings/src/store/authtoken.ts rename to apps/settings/src/authtokens/store/authtoken.ts index db6aad42b5461..131cb60afb7e1 100644 --- a/apps/settings/src/store/authtoken.ts +++ b/apps/settings/src/authtokens/store/authtoken.ts @@ -1,15 +1,15 @@ -import axios from '@nextcloud/axios' /** * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ +import axios from '@nextcloud/axios' import { showError, showSuccess } from '@nextcloud/dialogs' import { loadState } from '@nextcloud/initial-state' -import { translatePlural as n, translate as t } from '@nextcloud/l10n' +import { n, t } from '@nextcloud/l10n' import { addPasswordConfirmationInterceptors, confirmPassword, PwdConfirmationMode } from '@nextcloud/password-confirmation' import { generateUrl } from '@nextcloud/router' import { defineStore } from 'pinia' -import logger from '../logger.ts' +import logger from '../../logger.ts' const BASE_URL = generateUrl('/settings/personal/authtokens') addPasswordConfirmationInterceptors(axios) diff --git a/apps/settings/src/utils/userAgentDetect.ts b/apps/settings/src/authtokens/utils/userAgentDetect.ts similarity index 100% rename from apps/settings/src/utils/userAgentDetect.ts rename to apps/settings/src/authtokens/utils/userAgentDetect.ts diff --git a/apps/settings/src/utils/userAgentMap.ts b/apps/settings/src/authtokens/utils/userAgentMap.ts similarity index 100% rename from apps/settings/src/utils/userAgentMap.ts rename to apps/settings/src/authtokens/utils/userAgentMap.ts diff --git a/apps/settings/src/components/AuthToken.vue b/apps/settings/src/components/AuthToken.vue deleted file mode 100644 index 716ed27edb0a4..0000000000000 --- a/apps/settings/src/components/AuthToken.vue +++ /dev/null @@ -1,364 +0,0 @@ - - - - - - - diff --git a/apps/settings/src/components/AuthTokenSetupDialog.vue b/apps/settings/src/components/AuthTokenSetupDialog.vue deleted file mode 100644 index 0b85adb0c68dc..0000000000000 --- a/apps/settings/src/components/AuthTokenSetupDialog.vue +++ /dev/null @@ -1,221 +0,0 @@ - - - - - - diff --git a/apps/settings/src/main-personal-security.js b/apps/settings/src/main-personal-security.js deleted file mode 100644 index eabad1bca6041..0000000000000 --- a/apps/settings/src/main-personal-security.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { getCSPNonce } from '@nextcloud/auth' -import { createPinia, PiniaVuePlugin } from 'pinia' -import VTooltipPlugin from 'v-tooltip' -import Vue from 'vue' -import AuthTokenSection from './components/AuthTokenSection.vue' - -__webpack_nonce__ = getCSPNonce() - -const pinia = createPinia() - -Vue.use(PiniaVuePlugin) -Vue.use(VTooltipPlugin, { defaultHtml: false }) -Vue.prototype.t = t - -const View = Vue.extend(AuthTokenSection) -new View({ pinia }).$mount('#security-authtokens') diff --git a/apps/settings/templates/settings/personal/security/authtokens.php b/apps/settings/templates/settings/personal/security/authtokens.php index e59266695bf7a..8f96f647cdef0 100644 --- a/apps/settings/templates/settings/personal/security/authtokens.php +++ b/apps/settings/templates/settings/personal/security/authtokens.php @@ -5,8 +5,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -\OCP\Util::addScript('settings', 'vue-settings-personal-security', 'core'); - ?> -
+
diff --git a/build/frontend-legacy/eslint.config.mjs b/build/frontend-legacy/eslint.config.mjs index 6e696e29f04a7..3efd882d40510 100644 --- a/build/frontend-legacy/eslint.config.mjs +++ b/build/frontend-legacy/eslint.config.mjs @@ -61,6 +61,8 @@ export default defineConfig([ '**/vendor/', // different vendors // the public share entry point is Vue 3, it is linted by the Vue 3 frontend '**/apps/files_sharing/src/public/', + // the app password section is Vue 3, it is linted by the Vue 3 frontend + '**/apps/settings/src/authtokens/', ], }, ]) diff --git a/build/frontend-legacy/tsconfig.json b/build/frontend-legacy/tsconfig.json index b3ceea7928082..ed691cde3c95c 100644 --- a/build/frontend-legacy/tsconfig.json +++ b/build/frontend-legacy/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "@vue/tsconfig/tsconfig.json", "include": ["./apps/**/*.ts", "./apps/**/*.vue", "./*.d.ts"], - "exclude": ["./**/*.cy.ts"], + "exclude": ["./**/*.cy.ts", "./apps/settings/src/authtokens"], "compilerOptions": { "lib": ["DOM", "DOM.Iterable", "ESNext"], "types": ["node", "vue", "vue-router"], diff --git a/build/frontend-legacy/vitest.config.mts b/build/frontend-legacy/vitest.config.mts index 5fc0a49fe6658..d2f783556b43e 100644 --- a/build/frontend-legacy/vitest.config.mts +++ b/build/frontend-legacy/vitest.config.mts @@ -57,7 +57,7 @@ export default defineConfig({ }, coverage: { include: ['./apps/*/src/**'], - exclude: ['**.spec.*', '**.test.*', '**.cy.*'], + exclude: ['**.spec.*', '**.test.*', '**.cy.*', './apps/settings/src/authtokens/**'], reporter: ['lcov', 'text'], reportsDirectory: resolve(import.meta.dirname, '../../coverage/legacy'), }, @@ -68,6 +68,8 @@ export default defineConfig({ exclude: [ ...defaultExclude, ...gitIgnore, + // the app password section is Vue 3, it is tested by the Vue 3 frontend + '**/apps/settings/src/authtokens/**', ], globalSetup: './__tests__/setup-global.js', server: { diff --git a/build/frontend-legacy/webpack.modules.cjs b/build/frontend-legacy/webpack.modules.cjs index e7395b002bcc6..bee1648064ae5 100644 --- a/build/frontend-legacy/webpack.modules.cjs +++ b/build/frontend-legacy/webpack.modules.cjs @@ -28,7 +28,6 @@ module.exports = { 'vue-settings-profile-contact': path.join(__dirname, 'apps/settings/src', 'main-profile-contact.js'), 'vue-settings-language-locale': path.join(__dirname, 'apps/settings/src', 'main-language-locale.js'), 'vue-settings-personal-password': path.join(__dirname, 'apps/settings/src', 'main-personal-password.js'), - 'vue-settings-personal-security': path.join(__dirname, 'apps/settings/src', 'main-personal-security.js'), 'vue-settings-personal-webauthn': path.join(__dirname, 'apps/settings/src', 'main-personal-webauth.js'), 'vue-settings-users-management': path.join(__dirname, 'apps/settings/src', 'main-users-management.ts'), 'declarative-settings-forms': path.join(__dirname, 'apps/settings/src', 'main-declarative-settings-forms.ts'), diff --git a/build/frontend/package.json b/build/frontend/package.json index 00a19f5607d93..72b06ab69c612 100644 --- a/build/frontend/package.json +++ b/build/frontend/package.json @@ -9,8 +9,8 @@ "scripts": { "build": "vite build", "dev": "NODE_ENV=development vite build --mode development", - "lint": "eslint --suppressions-location ../eslint-baseline.json --no-error-on-unmatched-pattern ./apps/*/ ./core/ ../../apps/files_sharing/src/public/", - "lint:fix": "eslint --suppressions-location ../eslint-baseline.json --fix --no-error-on-unmatched-pattern ./apps/*/ ./core/ ../../apps/files_sharing/src/public/", + "lint": "eslint --suppressions-location ../eslint-baseline.json --no-error-on-unmatched-pattern ./apps/*/ ./core/ ../../apps/files_sharing/src/public/ ../../apps/settings/src/authtokens/", + "lint:fix": "eslint --suppressions-location ../eslint-baseline.json --fix --no-error-on-unmatched-pattern ./apps/*/ ./core/ ../../apps/files_sharing/src/public/ ../../apps/settings/src/authtokens/", "test": "vitest run", "test:coverage": "vitest run --coverage --reporter=default", "test:update-snapshots": "vitest run --update", diff --git a/build/frontend/tsconfig.json b/build/frontend/tsconfig.json index 24655181b3dbf..66b4e722e3e2f 100644 --- a/build/frontend/tsconfig.json +++ b/build/frontend/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../../tsconfig.json", - "include": ["./apps", "./core"] + "include": ["./apps", "./core", "../../apps/settings/src/authtokens"] } \ No newline at end of file diff --git a/build/frontend/vite.config.ts b/build/frontend/vite.config.ts index 97fb47d25caef..c3010631d59db 100644 --- a/build/frontend/vite.config.ts +++ b/build/frontend/vite.config.ts @@ -95,6 +95,12 @@ const modules = { main: resolve(import.meta.dirname, 'apps/profile/src', 'main.ts'), reference: resolve(import.meta.dirname, 'apps/profile/src', 'reference.js'), }, + settings: { + // Only the app password section is migrated to Vue 3 so far, the rest + // of the app is still built by the Vue 2 frontend. It is referenced by path + // rather than through `apps/` so it resolves the Vue 3 dependencies. + 'personal-security-authtokens': resolve(import.meta.dirname, '../..', 'apps/settings/src/authtokens', 'main.ts'), + }, sharebymail: { 'admin-settings': resolve(import.meta.dirname, 'apps/sharebymail/src', 'settings-admin.ts'), }, diff --git a/build/frontend/vitest.config.ts b/build/frontend/vitest.config.ts index cb6309ba09202..9589ee89cd265 100644 --- a/build/frontend/vitest.config.ts +++ b/build/frontend/vitest.config.ts @@ -44,7 +44,10 @@ export default defineConfig({ }, }, test: { - include: ['{apps,core}/**/*.{test,spec}.?(c|m)[jt]s?(x)'], + include: [ + '{apps,core}/**/*.{test,spec}.?(c|m)[jt]s?(x)', + '../../apps/settings/src/authtokens/**/*.spec.ts', + ], env: { LANG: 'en_US', TZ: 'UTC', @@ -58,6 +61,7 @@ export default defineConfig({ coverage: { include: [ 'apps/*/src/**', + '../../apps/settings/src/authtokens/**', 'core/src/**', ], exclude: ['**.spec.*', '**.test.*', '**.cy.*', 'core/src/tests/**'], diff --git a/core/src/components/AccountMenu/AccountMenuProfileEntry.vue b/core/src/components/AccountMenu/AccountMenuProfileEntry.vue index f2598c07c55b9..b307436b2c543 100644 --- a/core/src/components/AccountMenu/AccountMenuProfileEntry.vue +++ b/core/src/components/AccountMenu/AccountMenuProfileEntry.vue @@ -4,7 +4,7 @@ -->