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)
|
refresh_tokens_db.remove(TokenQuery.user_id == user.id)
|
||||||
|
|
||||||
# Notify all active sessions (other tabs/devices) to sign out immediately
|
# 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'})
|
resp = jsonify({'message': 'Password has been reset'})
|
||||||
_clear_auth_cookies(resp)
|
_clear_auth_cookies(resp)
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import string
|
|||||||
import utils.email_sender as email_sender
|
import utils.email_sender as email_sender
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from api.utils import get_validated_user_id, normalize_email, send_event_for_current_user
|
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 api.error_codes import ACCOUNT_MARKED_FOR_DELETION, ALREADY_MARKED
|
||||||
from events.types.event_types import EventType
|
from events.types.event_types import EventType
|
||||||
from events.types.event import Event
|
from events.types.event import Event
|
||||||
@@ -243,4 +245,7 @@ def mark_for_deletion():
|
|||||||
# Trigger SSE event
|
# Trigger SSE event
|
||||||
send_event_for_current_user(Event(EventType.USER_MARKED_FOR_DELETION.value, UserModified(user.id, UserModified.OPERATION_DELETE)))
|
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
|
return jsonify({'success': True}), 200
|
||||||
|
|||||||
@@ -17,9 +17,13 @@ const mockRouter = createRouter({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Mock auth store
|
// Mock auth store
|
||||||
const mockLogoutUser = vi.fn()
|
const { mockLogoutUser, mockSuppressForceLogout } = vi.hoisted(() => ({
|
||||||
|
mockLogoutUser: vi.fn(),
|
||||||
|
mockSuppressForceLogout: { value: false },
|
||||||
|
}))
|
||||||
vi.mock('../stores/auth', () => ({
|
vi.mock('../stores/auth', () => ({
|
||||||
logoutUser: () => mockLogoutUser(),
|
logoutUser: () => mockLogoutUser(),
|
||||||
|
suppressForceLogout: mockSuppressForceLogout,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
describe('UserProfile - Delete Account', () => {
|
describe('UserProfile - Delete Account', () => {
|
||||||
@@ -147,6 +151,64 @@ describe('UserProfile - Delete Account', () => {
|
|||||||
expect(wrapper.vm.showDeleteSuccess).toBe(true)
|
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 () => {
|
it('shows error modal on API failure', async () => {
|
||||||
const mockResponse = {
|
const mockResponse = {
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useBackendEvents } from '@/common/backendEvents'
|
import { useBackendEvents } from '@/common/backendEvents'
|
||||||
import { currentUserId, logoutUser } from '@/stores/auth'
|
import { currentUserId, logoutUser, suppressForceLogout } from '@/stores/auth'
|
||||||
import { eventBus } from '@/common/eventBus'
|
import { eventBus } from '@/common/eventBus'
|
||||||
|
|
||||||
const userId = ref(currentUserId.value)
|
const userId = ref(currentUserId.value)
|
||||||
@@ -15,9 +15,17 @@ watch(currentUserId, (id) => {
|
|||||||
// Always call useBackendEvents in setup, passing the reactive userId
|
// Always call useBackendEvents in setup, passing the reactive userId
|
||||||
useBackendEvents(userId)
|
useBackendEvents(userId)
|
||||||
|
|
||||||
function handleForceLogout() {
|
function handleForceLogout(event: { payload?: { reason?: string } }) {
|
||||||
|
if (suppressForceLogout.value) {
|
||||||
|
suppressForceLogout.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
logoutUser()
|
logoutUser()
|
||||||
router.push('/')
|
if (event?.payload?.reason === 'account_deleted') {
|
||||||
|
router.push('/')
|
||||||
|
} else {
|
||||||
|
router.push({ name: 'Login' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
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', () => {
|
// ── Mocks ────────────────────────────────────────────────────────────────────
|
||||||
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)
|
|
||||||
|
|
||||||
// Verify that ResetPassword imports are working
|
global.fetch = vi.fn()
|
||||||
expect(true).toBe(true)
|
|
||||||
|
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 ModalDialog from '../shared/ModalDialog.vue'
|
||||||
import { parseErrorResponse, isEmailValid } from '@/common/api'
|
import { parseErrorResponse, isEmailValid } from '@/common/api'
|
||||||
import { ALREADY_MARKED } from '@/common/errorCodes'
|
import { ALREADY_MARKED } from '@/common/errorCodes'
|
||||||
import { logoutUser } from '@/stores/auth'
|
import { logoutUser, suppressForceLogout } from '@/stores/auth'
|
||||||
import '@/assets/styles.css'
|
import '@/assets/styles.css'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -300,6 +300,9 @@ function closeDeleteWarning() {
|
|||||||
async function confirmDeleteAccount() {
|
async function confirmDeleteAccount() {
|
||||||
if (!isEmailValid(confirmEmail.value)) return
|
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
|
deletingAccount.value = true
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/user/mark-for-deletion', {
|
const res = await fetch('/api/user/mark-for-deletion', {
|
||||||
@@ -309,6 +312,7 @@ async function confirmDeleteAccount() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
suppressForceLogout.value = false
|
||||||
const { msg, code } = await parseErrorResponse(res)
|
const { msg, code } = await parseErrorResponse(res)
|
||||||
let errorMessage = msg
|
let errorMessage = msg
|
||||||
if (code === ALREADY_MARKED) {
|
if (code === ALREADY_MARKED) {
|
||||||
@@ -320,10 +324,11 @@ async function confirmDeleteAccount() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success
|
// Success — suppressForceLogout is already set; show confirmation modal
|
||||||
showDeleteWarning.value = false
|
showDeleteWarning.value = false
|
||||||
showDeleteSuccess.value = true
|
showDeleteSuccess.value = true
|
||||||
} catch {
|
} catch {
|
||||||
|
suppressForceLogout.value = false
|
||||||
deleteErrorMessage.value = 'Network error. Please try again.'
|
deleteErrorMessage.value = 'Network error. Please try again.'
|
||||||
showDeleteWarning.value = false
|
showDeleteWarning.value = false
|
||||||
showDeleteError.value = true
|
showDeleteError.value = true
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ if (hasLocalStorage) {
|
|||||||
export const isUserLoggedIn = ref(false)
|
export const isUserLoggedIn = ref(false)
|
||||||
export const isAuthReady = ref(false)
|
export const isAuthReady = ref(false)
|
||||||
export const currentUserId = ref('')
|
export const currentUserId = ref('')
|
||||||
|
export const suppressForceLogout = ref(false)
|
||||||
let authSyncInitialized = false
|
let authSyncInitialized = false
|
||||||
|
|
||||||
// --- Background expiry watcher ---
|
// --- Background expiry watcher ---
|
||||||
|
|||||||
Reference in New Issue
Block a user