feat: implement force logout notifications for password reset and account deletion
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m29s
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m29s
This commit is contained in:
@@ -364,7 +364,7 @@ def reset_password():
|
||||
refresh_tokens_db.remove(TokenQuery.user_id == user.id)
|
||||
|
||||
# Notify all active sessions (other tabs/devices) to sign out immediately
|
||||
send_event_to_user(user.id, Event(EventType.FORCE_LOGOUT.value, Payload({})))
|
||||
send_event_to_user(user.id, Event(EventType.FORCE_LOGOUT.value, Payload({'reason': 'password_reset'})))
|
||||
|
||||
resp = jsonify({'message': 'Password has been reset'})
|
||||
_clear_auth_cookies(resp)
|
||||
|
||||
@@ -9,6 +9,8 @@ import string
|
||||
import utils.email_sender as email_sender
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from api.utils import get_validated_user_id, normalize_email, send_event_for_current_user
|
||||
from events.sse import send_event_to_user
|
||||
from events.types.payload import Payload
|
||||
from api.error_codes import ACCOUNT_MARKED_FOR_DELETION, ALREADY_MARKED
|
||||
from events.types.event_types import EventType
|
||||
from events.types.event import Event
|
||||
@@ -243,4 +245,7 @@ def mark_for_deletion():
|
||||
# Trigger SSE event
|
||||
send_event_for_current_user(Event(EventType.USER_MARKED_FOR_DELETION.value, UserModified(user.id, UserModified.OPERATION_DELETE)))
|
||||
|
||||
# Notify all other active sessions to sign out and go to landing page
|
||||
send_event_to_user(user.id, Event(EventType.FORCE_LOGOUT.value, Payload({'reason': 'account_deleted'})))
|
||||
|
||||
return jsonify({'success': True}), 200
|
||||
|
||||
@@ -17,9 +17,13 @@ const mockRouter = createRouter({
|
||||
})
|
||||
|
||||
// Mock auth store
|
||||
const mockLogoutUser = vi.fn()
|
||||
const { mockLogoutUser, mockSuppressForceLogout } = vi.hoisted(() => ({
|
||||
mockLogoutUser: vi.fn(),
|
||||
mockSuppressForceLogout: { value: false },
|
||||
}))
|
||||
vi.mock('../stores/auth', () => ({
|
||||
logoutUser: () => mockLogoutUser(),
|
||||
suppressForceLogout: mockSuppressForceLogout,
|
||||
}))
|
||||
|
||||
describe('UserProfile - Delete Account', () => {
|
||||
@@ -147,6 +151,64 @@ describe('UserProfile - Delete Account', () => {
|
||||
expect(wrapper.vm.showDeleteSuccess).toBe(true)
|
||||
})
|
||||
|
||||
it('sets suppressForceLogout before making the API call', async () => {
|
||||
let flagDuringRequest = false
|
||||
;(global.fetch as any).mockImplementationOnce(async () => {
|
||||
flagDuringRequest = mockSuppressForceLogout.value
|
||||
return { ok: true, json: async () => ({ success: true }) }
|
||||
})
|
||||
|
||||
wrapper.vm.initialData.email = 'test@example.com'
|
||||
wrapper.vm.confirmEmail = 'test@example.com'
|
||||
|
||||
await wrapper.vm.confirmDeleteAccount()
|
||||
|
||||
expect(flagDuringRequest).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves suppressForceLogout set after successful deletion', async () => {
|
||||
;(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
})
|
||||
|
||||
wrapper.vm.initialData.email = 'test@example.com'
|
||||
wrapper.vm.confirmEmail = 'test@example.com'
|
||||
|
||||
await wrapper.vm.confirmDeleteAccount()
|
||||
await nextTick()
|
||||
|
||||
expect(mockSuppressForceLogout.value).toBe(true)
|
||||
})
|
||||
|
||||
it('clears suppressForceLogout on API failure', async () => {
|
||||
;(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: async () => ({ error: 'fail', code: 'ERROR' }),
|
||||
})
|
||||
|
||||
wrapper.vm.initialData.email = 'test@example.com'
|
||||
wrapper.vm.confirmEmail = 'test@example.com'
|
||||
|
||||
await wrapper.vm.confirmDeleteAccount()
|
||||
await nextTick()
|
||||
|
||||
expect(mockSuppressForceLogout.value).toBe(false)
|
||||
})
|
||||
|
||||
it('clears suppressForceLogout on network error', async () => {
|
||||
;(global.fetch as any).mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
wrapper.vm.initialData.email = 'test@example.com'
|
||||
wrapper.vm.confirmEmail = 'test@example.com'
|
||||
|
||||
await wrapper.vm.confirmDeleteAccount()
|
||||
await nextTick()
|
||||
|
||||
expect(mockSuppressForceLogout.value).toBe(false)
|
||||
})
|
||||
|
||||
it('shows error modal on API failure', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useBackendEvents } from '@/common/backendEvents'
|
||||
import { currentUserId, logoutUser } from '@/stores/auth'
|
||||
import { currentUserId, logoutUser, suppressForceLogout } from '@/stores/auth'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
|
||||
const userId = ref(currentUserId.value)
|
||||
@@ -15,9 +15,17 @@ watch(currentUserId, (id) => {
|
||||
// Always call useBackendEvents in setup, passing the reactive userId
|
||||
useBackendEvents(userId)
|
||||
|
||||
function handleForceLogout() {
|
||||
function handleForceLogout(event: { payload?: { reason?: string } }) {
|
||||
if (suppressForceLogout.value) {
|
||||
suppressForceLogout.value = false
|
||||
return
|
||||
}
|
||||
logoutUser()
|
||||
router.push('/')
|
||||
if (event?.payload?.reason === 'account_deleted') {
|
||||
router.push('/')
|
||||
} else {
|
||||
router.push({ name: 'Login' })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import BackendEventsListener from '../BackendEventsListener.vue'
|
||||
|
||||
// ── Hoisted mocks ────────────────────────────────────────────────────────────
|
||||
|
||||
const { mockLogoutUser, mockSuppressForceLogout, mockCurrentUserId } = vi.hoisted(() => ({
|
||||
mockLogoutUser: vi.fn(),
|
||||
mockSuppressForceLogout: { value: false },
|
||||
mockCurrentUserId: { value: 'user-1' },
|
||||
}))
|
||||
|
||||
let capturedForceLogoutHandler: ((event: any) => void) | null = null
|
||||
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
currentUserId: mockCurrentUserId,
|
||||
logoutUser: mockLogoutUser,
|
||||
suppressForceLogout: mockSuppressForceLogout,
|
||||
}))
|
||||
|
||||
vi.mock('@/common/backendEvents', () => ({
|
||||
useBackendEvents: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/common/eventBus', () => ({
|
||||
eventBus: {
|
||||
on: vi.fn((event: string, handler: (e: any) => void) => {
|
||||
if (event === 'force_logout') capturedForceLogoutHandler = handler
|
||||
}),
|
||||
off: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// ── Router ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockRouter = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'Landing', component: { template: '<div />' } },
|
||||
{ path: '/auth/login', name: 'Login', component: { template: '<div />' } },
|
||||
],
|
||||
})
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function mountListener() {
|
||||
return mount(BackendEventsListener, {
|
||||
global: { plugins: [mockRouter] },
|
||||
})
|
||||
}
|
||||
|
||||
function fireForceLogout(reason?: string) {
|
||||
capturedForceLogoutHandler?.({ payload: reason ? { reason } : {} })
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('BackendEventsListener – force_logout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
capturedForceLogoutHandler = null
|
||||
mockSuppressForceLogout.value = false
|
||||
})
|
||||
|
||||
it('navigates to Login on force_logout with reason password_reset', async () => {
|
||||
mountListener()
|
||||
const pushSpy = vi.spyOn(mockRouter, 'push')
|
||||
|
||||
fireForceLogout('password_reset')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockLogoutUser).toHaveBeenCalledOnce()
|
||||
expect(pushSpy).toHaveBeenCalledWith({ name: 'Login' })
|
||||
})
|
||||
|
||||
it('navigates to / on force_logout with reason account_deleted', async () => {
|
||||
mountListener()
|
||||
const pushSpy = vi.spyOn(mockRouter, 'push')
|
||||
|
||||
fireForceLogout('account_deleted')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockLogoutUser).toHaveBeenCalledOnce()
|
||||
expect(pushSpy).toHaveBeenCalledWith('/')
|
||||
})
|
||||
|
||||
it('navigates to Login on force_logout with unknown reason', async () => {
|
||||
mountListener()
|
||||
const pushSpy = vi.spyOn(mockRouter, 'push')
|
||||
|
||||
fireForceLogout('something_else')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockLogoutUser).toHaveBeenCalledOnce()
|
||||
expect(pushSpy).toHaveBeenCalledWith({ name: 'Login' })
|
||||
})
|
||||
|
||||
it('navigates to Login on force_logout with no reason', async () => {
|
||||
mountListener()
|
||||
const pushSpy = vi.spyOn(mockRouter, 'push')
|
||||
|
||||
fireForceLogout()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockLogoutUser).toHaveBeenCalledOnce()
|
||||
expect(pushSpy).toHaveBeenCalledWith({ name: 'Login' })
|
||||
})
|
||||
|
||||
it('skips logout and navigation when suppressForceLogout is true', async () => {
|
||||
mountListener()
|
||||
const pushSpy = vi.spyOn(mockRouter, 'push')
|
||||
mockSuppressForceLogout.value = true
|
||||
|
||||
fireForceLogout('password_reset')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockLogoutUser).not.toHaveBeenCalled()
|
||||
expect(pushSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears suppressForceLogout after consuming a suppressed event', async () => {
|
||||
mountListener()
|
||||
mockSuppressForceLogout.value = true
|
||||
|
||||
fireForceLogout('password_reset')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockSuppressForceLogout.value).toBe(false)
|
||||
})
|
||||
|
||||
it('processes the next force_logout normally after suppression is consumed', async () => {
|
||||
mountListener()
|
||||
const pushSpy = vi.spyOn(mockRouter, 'push')
|
||||
mockSuppressForceLogout.value = true
|
||||
|
||||
// First event: suppressed
|
||||
fireForceLogout('password_reset')
|
||||
await flushPromises()
|
||||
expect(mockLogoutUser).not.toHaveBeenCalled()
|
||||
|
||||
// Second event: should go through normally
|
||||
fireForceLogout('password_reset')
|
||||
await flushPromises()
|
||||
expect(mockLogoutUser).toHaveBeenCalledOnce()
|
||||
expect(pushSpy).toHaveBeenCalledWith({ name: 'Login' })
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,164 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import ResetPassword from '../ResetPassword.vue'
|
||||
|
||||
describe('ResetPassword.vue', () => {
|
||||
it('calls /api/auth/validate-reset-token endpoint (not /api/validate-reset-token)', () => {
|
||||
// This test verifies that the component uses the /auth prefix
|
||||
// The actual functionality is tested by the integration with the backend
|
||||
// which is working correctly (183 backend tests passing)
|
||||
// ── Mocks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Verify that ResetPassword imports are working
|
||||
expect(true).toBe(true)
|
||||
global.fetch = vi.fn()
|
||||
|
||||
const { mockLogoutUser } = vi.hoisted(() => ({ mockLogoutUser: vi.fn() }))
|
||||
vi.mock('@/stores/auth', () => ({ logoutUser: mockLogoutUser }))
|
||||
vi.mock('@/common/api', () => ({
|
||||
isPasswordStrong: (p: string) => p.length >= 8 && /[a-zA-Z]/.test(p) && /[0-9]/.test(p),
|
||||
}))
|
||||
|
||||
const mockRouter = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/auth/login', name: 'Login', component: { template: '<div />' } },
|
||||
{ path: '/auth', name: 'AuthLanding', component: { template: '<div />' } },
|
||||
{ path: '/auth/reset-password', name: 'ResetPassword', component: { template: '<div />' } },
|
||||
],
|
||||
})
|
||||
|
||||
function mountWithToken(token = 'valid-token') {
|
||||
return mount(ResetPassword, {
|
||||
global: {
|
||||
plugins: [mockRouter],
|
||||
stubs: { ModalDialog: { template: '<div class="modal"><slot /></div>' } },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ResetPassword', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
;(global.fetch as any).mockClear()
|
||||
// navigate to the route with a token query param
|
||||
await mockRouter.push('/auth/reset-password?token=valid-token')
|
||||
})
|
||||
|
||||
it('validates the token on mount and shows the form when valid', async () => {
|
||||
;(global.fetch as any).mockResolvedValueOnce({ ok: true })
|
||||
const wrapper = mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.tokenValid).toBe(true)
|
||||
expect(wrapper.vm.tokenChecked).toBe(true)
|
||||
})
|
||||
|
||||
it('shows error and redirects when token is invalid', async () => {
|
||||
;(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: async () => ({ error: 'Token expired' }),
|
||||
})
|
||||
const pushSpy = vi.spyOn(mockRouter, 'push')
|
||||
mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
expect(pushSpy).toHaveBeenCalledWith({ name: 'AuthLanding' })
|
||||
})
|
||||
|
||||
it('shows error and redirects on network failure during token check', async () => {
|
||||
;(global.fetch as any).mockRejectedValueOnce(new Error('Network'))
|
||||
const pushSpy = vi.spyOn(mockRouter, 'push')
|
||||
mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
expect(pushSpy).toHaveBeenCalledWith({ name: 'AuthLanding' })
|
||||
})
|
||||
|
||||
it('submit button is disabled when passwords are weak or mismatched', async () => {
|
||||
;(global.fetch as any).mockResolvedValueOnce({ ok: true })
|
||||
const wrapper = mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
wrapper.vm.password = 'weak'
|
||||
wrapper.vm.confirmPassword = 'weak'
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.formValid).toBeFalsy()
|
||||
})
|
||||
|
||||
it('submit button is enabled when passwords are strong and match', async () => {
|
||||
;(global.fetch as any).mockResolvedValueOnce({ ok: true })
|
||||
const wrapper = mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
wrapper.vm.password = 'StrongPass1'
|
||||
wrapper.vm.confirmPassword = 'StrongPass1'
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.formValid).toBeTruthy()
|
||||
})
|
||||
|
||||
it('calls /api/auth/reset-password with token and new password on submit', async () => {
|
||||
;(global.fetch as any)
|
||||
.mockResolvedValueOnce({ ok: true }) // token validation
|
||||
.mockResolvedValueOnce({ ok: true }) // reset call
|
||||
const wrapper = mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
wrapper.vm.password = 'NewPass123'
|
||||
wrapper.vm.confirmPassword = 'NewPass123'
|
||||
await wrapper.vm.submitForm()
|
||||
|
||||
expect(global.fetch).toHaveBeenLastCalledWith(
|
||||
'/api/auth/reset-password',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token: 'valid-token', password: 'NewPass123' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows success modal and calls logoutUser after successful reset', async () => {
|
||||
;(global.fetch as any).mockResolvedValueOnce({ ok: true }).mockResolvedValueOnce({ ok: true })
|
||||
const wrapper = mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
wrapper.vm.password = 'NewPass123'
|
||||
wrapper.vm.confirmPassword = 'NewPass123'
|
||||
await wrapper.vm.submitForm()
|
||||
await nextTick()
|
||||
|
||||
expect(mockLogoutUser).toHaveBeenCalledOnce()
|
||||
expect(wrapper.vm.showModal).toBe(true)
|
||||
})
|
||||
|
||||
it('shows error message on failed reset', async () => {
|
||||
;(global.fetch as any).mockResolvedValueOnce({ ok: true }).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: async () => ({ error: 'Token expired' }),
|
||||
text: async () => '',
|
||||
})
|
||||
const wrapper = mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
wrapper.vm.password = 'NewPass123'
|
||||
wrapper.vm.confirmPassword = 'NewPass123'
|
||||
await wrapper.vm.submitForm()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.errorMsg).toBe('Token expired')
|
||||
expect(wrapper.vm.showModal).toBe(false)
|
||||
})
|
||||
|
||||
it('clears password fields after successful reset', async () => {
|
||||
;(global.fetch as any).mockResolvedValueOnce({ ok: true }).mockResolvedValueOnce({ ok: true })
|
||||
const wrapper = mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
wrapper.vm.password = 'NewPass123'
|
||||
wrapper.vm.confirmPassword = 'NewPass123'
|
||||
await wrapper.vm.submitForm()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.password).toBe('')
|
||||
expect(wrapper.vm.confirmPassword).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -103,7 +103,7 @@ import EntityEditForm from '../shared/EntityEditForm.vue'
|
||||
import ModalDialog from '../shared/ModalDialog.vue'
|
||||
import { parseErrorResponse, isEmailValid } from '@/common/api'
|
||||
import { ALREADY_MARKED } from '@/common/errorCodes'
|
||||
import { logoutUser } from '@/stores/auth'
|
||||
import { logoutUser, suppressForceLogout } from '@/stores/auth'
|
||||
import '@/assets/styles.css'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -300,6 +300,9 @@ function closeDeleteWarning() {
|
||||
async function confirmDeleteAccount() {
|
||||
if (!isEmailValid(confirmEmail.value)) return
|
||||
|
||||
// Set flag before the request so it's guaranteed to be set
|
||||
// before the force_logout SSE event can arrive on this tab
|
||||
suppressForceLogout.value = true
|
||||
deletingAccount.value = true
|
||||
try {
|
||||
const res = await fetch('/api/user/mark-for-deletion', {
|
||||
@@ -309,6 +312,7 @@ async function confirmDeleteAccount() {
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
suppressForceLogout.value = false
|
||||
const { msg, code } = await parseErrorResponse(res)
|
||||
let errorMessage = msg
|
||||
if (code === ALREADY_MARKED) {
|
||||
@@ -320,10 +324,11 @@ async function confirmDeleteAccount() {
|
||||
return
|
||||
}
|
||||
|
||||
// Success
|
||||
// Success — suppressForceLogout is already set; show confirmation modal
|
||||
showDeleteWarning.value = false
|
||||
showDeleteSuccess.value = true
|
||||
} catch {
|
||||
suppressForceLogout.value = false
|
||||
deleteErrorMessage.value = 'Network error. Please try again.'
|
||||
showDeleteWarning.value = false
|
||||
showDeleteError.value = true
|
||||
|
||||
@@ -34,6 +34,7 @@ if (hasLocalStorage) {
|
||||
export const isUserLoggedIn = ref(false)
|
||||
export const isAuthReady = ref(false)
|
||||
export const currentUserId = ref('')
|
||||
export const suppressForceLogout = ref(false)
|
||||
let authSyncInitialized = false
|
||||
|
||||
// --- Background expiry watcher ---
|
||||
|
||||
Reference in New Issue
Block a user