From b42c7d7faef3a9f212f066c00ec5c0dce0bc986c Mon Sep 17 00:00:00 2001 From: addshore Date: Sat, 26 Sep 2026 16:36:20 +0100 Subject: [PATCH] fix: allow email verification without signing in Users should be able to follow a verification link on another device, such as a phone, without first signing in. The API verifies using a random one-time token sent to the mailbox, so the UI can safely expose this route without granting access to account features. --- src/components/Pages/EmailVerification.vue | 27 +++++--- src/main.js | 3 +- src/router/index.js | 5 -- tests/unit/email-verification.spec.js | 73 ++++++++++++++++++++++ 4 files changed, 92 insertions(+), 16 deletions(-) create mode 100644 tests/unit/email-verification.spec.js diff --git a/src/components/Pages/EmailVerification.vue b/src/components/Pages/EmailVerification.vue index 4ac8d850..a9ae4275 100644 --- a/src/components/Pages/EmailVerification.vue +++ b/src/components/Pages/EmailVerification.vue @@ -7,6 +7,9 @@ {{state.message}} + + Log in to your account. + @@ -22,23 +25,29 @@ const STATES = Object.freeze({ export default { name: 'EmailVerification', - computed: {}, + computed: { + showLoginLink () { + return this.state === STATES.VERIFIED && !this.$store.getters.isLoggedIn + }, + }, data () { return { - state: this.$store.getters.currentUser.verified ? STATES.VERIFIED : STATES.PENDING, + state: STATES.PENDING, } }, created () { - if (!this.$store.getters.currentUser.verified) { - this.$api.verifyEmail({ token: this.$route.params.token }) - .then(message => this.success()) - .catch(expired => this.fail(expired)) - } + this.$api.verifyEmail({ token: this.$route.params.token }) + .then(() => this.success()) + .catch(expired => this.fail(expired)) }, methods: { - success (message) { + success () { this.state = STATES.VERIFIED - this.$store.dispatch('markAsVerified', {}) + if (this.$store.getters.isLoggedIn) { + this.$api.checkVerified() + .then(verified => verified && this.$store.dispatch('markAsVerified')) + .catch(err => console.error(err)) + } }, fail (expired) { if (expired) { diff --git a/src/main.js b/src/main.js index 09f46070..59aa1a06 100644 --- a/src/main.js +++ b/src/main.js @@ -54,8 +54,7 @@ enableMocking().then(() => { return new Promise(function (resolve, reject) { // Unauthenticated. is the exact error message returned by the API for the auth middle ware // which is why we check for that message here... - if (err.response.config && !err.response.config.__isRetryRequest && err.response.data && err.response.data.error && err.response.data.error === 'Unauthenticated.') { - // TODO this IF should also have a condition for is logged in.... + if (store.getters.isLoggedIn && err.response.config && !err.response.config.__isRetryRequest && err.response.data && err.response.data.error && err.response.data.error === 'Unauthenticated.') { console.log('Detected logged out state, so logging out...') store .dispatch('logout') diff --git a/src/router/index.js b/src/router/index.js index b755b966..d772cdad 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -128,11 +128,6 @@ const router = new Router({ path: '/emailVerification/:token', name: 'EmailVerification', component: EmailVerification, - meta: { - requiresAuth: { - excludeFromPolicyChecks: true, - }, - }, }, { path: '/user', diff --git a/tests/unit/email-verification.spec.js b/tests/unit/email-verification.spec.js new file mode 100644 index 00000000..05af7277 --- /dev/null +++ b/tests/unit/email-verification.spec.js @@ -0,0 +1,73 @@ +import { shallowMount } from '@vue/test-utils' +import EmailVerification from '@/components/Pages/EmailVerification.vue' + +const flushPromises = () => new Promise(resolve => setTimeout(resolve)) + +const mountEmailVerification = ({ isLoggedIn = false, verifyEmail, checkVerified } = {}) => { + return shallowMount(EmailVerification, { + mocks: { + $api: { + verifyEmail: verifyEmail || jest.fn().mockResolvedValue(), + checkVerified: checkVerified || jest.fn().mockResolvedValue(true), + }, + $route: { + params: { + token: 'email-verification-token', + }, + }, + $store: { + getters: { + isLoggedIn, + }, + dispatch: jest.fn(), + }, + }, + stubs: { + 'v-main': true, + 'v-card': true, + 'v-toolbar': true, + 'v-toolbar-title': true, + 'v-card-text': true, + 'router-link': { + props: ['to'], + template: '', + }, + }, + }) +} + +describe('EmailVerification.vue', () => { + it('verifies a token without a login and offers a login link', async () => { + const verifyEmail = jest.fn().mockResolvedValue() + const wrapper = mountEmailVerification({ verifyEmail }) + + await flushPromises() + + expect(verifyEmail).toHaveBeenCalledWith({ token: 'email-verification-token' }) + expect(wrapper.text()).toContain('Email verified!') + expect(wrapper.find('a').attributes('href')).toBe('/login') + }) + + it('does not offer a login link to an existing authenticated session', async () => { + const checkVerified = jest.fn().mockResolvedValue(false) + const wrapper = mountEmailVerification({ isLoggedIn: true, checkVerified }) + + await flushPromises() + + expect(wrapper.text()).toContain('Email verified!') + expect(wrapper.find('a').exists()).toBe(false) + expect(checkVerified).toHaveBeenCalled() + expect(wrapper.vm.$store.dispatch).not.toHaveBeenCalled() + }) + + it('does not offer a login link when the token cannot be verified', async () => { + const wrapper = mountEmailVerification({ + verifyEmail: jest.fn().mockRejectedValue(true), + }) + + await flushPromises() + + expect(wrapper.text()).toContain('Verification token expired, or you are already verified!') + expect(wrapper.find('a').exists()).toBe(false) + }) +})