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

This commit is contained in:
2026-03-05 16:52:11 -05:00
parent a10836d412
commit b2618361e4
8 changed files with 397 additions and 16 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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,

View File

@@ -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()
if (event?.payload?.reason === 'account_deleted') {
router.push('/')
} else {
router.push({ name: 'Login' })
}
}
onMounted(() => {

View File

@@ -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' })
})
})

View File

@@ -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('')
})
})

View File

@@ -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

View File

@@ -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 ---