diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 5e8e16e54..c2115f247 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -14,6 +14,7 @@ use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoTwoFactorRequired; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\OCSController; use OCP\IRequest; @@ -44,6 +45,7 @@ public function __construct( * 503: Gateway wasn't configured yed */ #[NoAdminRequired] + #[NoTwoFactorRequired] #[ApiRoute(verb: 'GET', url: '/settings/{gateway}/verification')] public function getVerificationState(string $gateway): JSONResponse { $user = $this->userSession->getUser(); @@ -71,6 +73,7 @@ public function getVerificationState(string $gateway): JSONResponse { * 400: User not found */ #[NoAdminRequired] + #[NoTwoFactorRequired] #[ApiRoute(verb: 'POST', url: '/settings/{gateway}/verification/start')] public function startVerification(string $gateway, string $identifier): JSONResponse { $user = $this->userSession->getUser(); @@ -102,6 +105,7 @@ public function startVerification(string $gateway, string $identifier): JSONResp * 400: User not found */ #[NoAdminRequired] + #[NoTwoFactorRequired] #[ApiRoute(verb: 'POST', url: '/settings/{gateway}/verification/finish')] public function finishVerification(string $gateway, string $verificationCode): JSONResponse { $user = $this->userSession->getUser(); @@ -112,8 +116,8 @@ public function finishVerification(string $gateway, string $verificationCode): J try { $this->setup->finishSetup($user, $gateway, $verificationCode); - } catch (VerificationException) { - return new JSONResponse([], Http::STATUS_BAD_REQUEST); + } catch (VerificationException $e) { + return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST); } return new JSONResponse([]); diff --git a/lib/Provider/AProvider.php b/lib/Provider/AProvider.php index 48b005d6f..a45f51be8 100644 --- a/lib/Provider/AProvider.php +++ b/lib/Provider/AProvider.php @@ -17,7 +17,9 @@ use OCA\TwoFactorGateway\Service\StateStorage; use OCA\TwoFactorGateway\Settings\PersonalSettings; use OCP\AppFramework\Services\IInitialState; +use OCP\Authentication\TwoFactorAuth\IActivatableAtLogin; use OCP\Authentication\TwoFactorAuth\IDeactivatableByAdmin; +use OCP\Authentication\TwoFactorAuth\ILoginSetupProvider; use OCP\Authentication\TwoFactorAuth\IPersonalProviderSettings; use OCP\Authentication\TwoFactorAuth\IProvider; use OCP\Authentication\TwoFactorAuth\IProvidesIcons; @@ -31,7 +33,7 @@ use OCP\Template\ITemplate; use OCP\Template\ITemplateManager; -abstract class AProvider implements IProvider, IProvidesIcons, IDeactivatableByAdmin, IProvidesPersonalSettings { +abstract class AProvider implements IProvider, IProvidesIcons, IDeactivatableByAdmin, IProvidesPersonalSettings, IActivatableAtLogin { protected string $gatewayName = ''; protected IGateway $gateway; @@ -130,6 +132,15 @@ public function getPersonalSettings(IUser $user): IPersonalProviderSettings { ); } + #[\Override] + public function getLoginSetup(IUser $user): ILoginSetupProvider { + $this->initialState->provideInitialState('settings-' . $this->gateway->getProviderId(), $this->gateway->getSettings()); + return new AtLoginProvider( + $this->getGatewayName(), + $this->gateway->isComplete(), + ); + } + #[\Override] public function getLightIcon(): String { return Server::get(IURLGenerator::class)->imagePath(Application::APP_ID, 'app.svg'); diff --git a/lib/Provider/AtLoginProvider.php b/lib/Provider/AtLoginProvider.php new file mode 100644 index 000000000..87bec0715 --- /dev/null +++ b/lib/Provider/AtLoginProvider.php @@ -0,0 +1,32 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\TwoFactorGateway\Provider; + +use OCP\Authentication\TwoFactorAuth\ILoginSetupProvider; +use OCP\Server; +use OCP\Template\ITemplate; +use OCP\Template\ITemplateManager; + +class AtLoginProvider implements ILoginSetupProvider { + + public function __construct( + private string $gateway, + private bool $isComplete, + ) { + } + + #[\Override] + public function getBody(): ITemplate { + $template = Server::get(ITemplateManager::class)->getTemplate('twofactor_gateway', 'loginsetup'); + $template->assign('gateway', $this->gateway); + $template->assign('isComplete', $this->isComplete); + return $template; + } +} diff --git a/src/login-setup.ts b/src/login-setup.ts new file mode 100644 index 000000000..0e8a4cbaa --- /dev/null +++ b/src/login-setup.ts @@ -0,0 +1,24 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { createApp } from 'vue' +import { loadState } from '@nextcloud/initial-state' +import LoginSetup from './views/LoginSetup.vue' + +const el = document.getElementById('twofactor-gateway-login-setup') +if (el) { + const gateway = (document.getElementById('twofactor-gateway-login-setup-gateway') as HTMLInputElement | null)?.value ?? '' + const isComplete = (document.getElementById('twofactor-gateway-login-setup-is-complete') as HTMLInputElement | null)?.value === '1' + + const state = loadState('twofactor_gateway', `settings-${gateway}`, { + name: '', + }) + + createApp(LoginSetup, { + gatewayName: gateway, + displayName: state.name, + isComplete, + }).mount(el) +} diff --git a/src/tests/views/LoginSetup.spec.ts b/src/tests/views/LoginSetup.spec.ts new file mode 100644 index 000000000..75524b10b --- /dev/null +++ b/src/tests/views/LoginSetup.spec.ts @@ -0,0 +1,163 @@ +// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { defineComponent } from 'vue' +import LoginSetup from '../../views/LoginSetup.vue' + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), +}) + +vi.mock('@nextcloud/l10n', () => ({ + t: (_app: string, text: string, parameters?: Record) => { + if (parameters === undefined) { + return `tr:${text}` + } + return Object.entries(parameters).reduce( + (translated, [key, value]) => translated.replace(`{${key}}`, String(value)), + `tr:${text}`, + ) + }, +})) + +vi.mock('@nextcloud/axios', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + }, +})) + +vi.mock('@nextcloud/router', () => ({ + generateOcsUrl: (url: string, params: Record = {}) => Object.entries(params).reduce( + (acc, [key, value]) => acc.replace(`{${key}}`, value), + url, + ), +})) + +vi.mock('dompurify', () => ({ + default: { sanitize: (value: string) => value }, +})) + +vi.mock('@nextcloud/vue/components/NcButton', () => ({ + default: defineComponent({ + emits: ['click'], + template: '', + }), +})) + +vi.mock('@nextcloud/vue/components/NcLoadingIcon', () => ({ + default: defineComponent({ template: '
' }), +})) + +vi.mock('@nextcloud/vue/components/NcTextField', () => ({ + default: defineComponent({ + props: ['modelValue', 'error', 'helperText'], + emits: ['update:modelValue'], + template: '', + }), +})) + +const makeProps = (overrides: Record = {}) => ({ + gatewayName: 'signal', + displayName: 'Signal', + instructions: 'Install Signal first', + isComplete: true, + ...overrides, +}) + +describe('LoginSetup', () => { + it('shows the not-available message when the gateway is not configured', async () => { + const wrapper = mount(LoginSetup, { props: makeProps({ isComplete: false }) }) + await flushPromises() + + expect(wrapper.text()).toContain('tr:Signal is not available. Please ask your administrator to finish setting it up.') + expect(wrapper.find('.nc-loading-icon').exists()).toBe(false) + }) + + it('starts at the identifier step when the user has no in-flight verification', async () => { + const axios = (await import('@nextcloud/axios')).default + vi.mocked(axios.get).mockResolvedValueOnce({ data: { state: 0, phoneNumber: null } }) + + const wrapper = mount(LoginSetup, { props: makeProps() }) + await flushPromises() + + expect((wrapper.vm as unknown as { state: number }).state).toBe(1) + expect(wrapper.text()).toContain('tr:Enter your identification (e.g. phone number to start the verification):') + }) + + it('resumes at the confirmation step when the server reports state 2', async () => { + const axios = (await import('@nextcloud/axios')).default + vi.mocked(axios.get).mockResolvedValueOnce({ data: { state: 2, phoneNumber: '+33 6 ** ** ** 12' } }) + + const wrapper = mount(LoginSetup, { props: makeProps() }) + await flushPromises() + + expect((wrapper.vm as unknown as { state: number }).state).toBe(2) + expect(wrapper.text()).toContain('+33 6 ** ** ** 12') + }) + + it('moves to the confirmation step after a successful verify call', async () => { + const axios = (await import('@nextcloud/axios')).default + vi.mocked(axios.get).mockResolvedValueOnce({ data: { state: 0, phoneNumber: null } }) + vi.mocked(axios.post).mockResolvedValueOnce({ data: { phoneNumber: '+33 6 ** ** ** 12' } }) + + const wrapper = mount(LoginSetup, { props: makeProps() }) + await flushPromises() + + const vm = wrapper.vm as unknown as { identifier: string; verify: () => Promise; state: number; phoneNumber: string } + vm.identifier = '+33612345612' + await vm.verify() + await flushPromises() + + expect(vm.state).toBe(2) + expect(vm.phoneNumber).toBe('+33 6 ** ** ** 12') + }) + + it('surfaces a verification error when the confirm call fails', async () => { + const axios = (await import('@nextcloud/axios')).default + vi.mocked(axios.get).mockResolvedValueOnce({ data: { state: 2, phoneNumber: '+33' } }) + vi.mocked(axios.post).mockRejectedValueOnce({ response: { data: { ocs: { data: { message: 'Wrong code' } } } } }) + + const wrapper = mount(LoginSetup, { props: makeProps() }) + await flushPromises() + + const vm = wrapper.vm as unknown as { confirmationCode: string; confirm: () => Promise; state: number; verificationError: string } + vm.confirmationCode = '000000' + await vm.confirm() + await flushPromises() + + expect(vm.state).toBe(1) + expect(vm.verificationError).toBe('Wrong code') + }) + + it('submits the redirect form after a successful confirmation', async () => { + const axios = (await import('@nextcloud/axios')).default + vi.mocked(axios.get).mockResolvedValueOnce({ data: { state: 2, phoneNumber: '+33' } }) + vi.mocked(axios.post).mockResolvedValueOnce({ data: {} }) + + const wrapper = mount(LoginSetup, { props: makeProps() }) + await flushPromises() + + const submit = vi.fn() + const vm = wrapper.vm as unknown as { + confirmationCode: string + confirm: () => Promise + state: number + $refs: { redirectForm: HTMLFormElement } + } + vm.$refs.redirectForm.submit = submit + vm.confirmationCode = '123456' + await vm.confirm() + await flushPromises() + + expect(vm.state).toBe(3) + expect(submit).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/views/LoginSetup.vue b/src/views/LoginSetup.vue new file mode 100644 index 000000000..1ed315201 --- /dev/null +++ b/src/views/LoginSetup.vue @@ -0,0 +1,185 @@ + + + + + + diff --git a/templates/loginsetup.php b/templates/loginsetup.php new file mode 100644 index 000000000..abeb80854 --- /dev/null +++ b/templates/loginsetup.php @@ -0,0 +1,12 @@ + + + +
diff --git a/vite.config.ts b/vite.config.ts index 6fcb66623..dd4f5c72f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,8 +12,9 @@ import { vueDocsBlockPlugin } from './src/styleguide/vueDocsBlockPlugin.js' const isProduction = process.env.NODE_ENV === 'production' export default createAppConfig({ - main: path.join(__dirname, 'src', 'main.js'), + main: path.join(__dirname, 'src', 'main.ts'), admin: path.join(__dirname, 'src', 'admin.ts'), + 'login-setup': path.join(__dirname, 'src', 'login-setup.ts'), }, { config: { resolve: {