refactor(08-03): migrate session-revoked inline toast to toastStore.show()
- Remove sessionRevokedToast ref + setTimeout + inline HTML from SettingsAccountTab.vue
- Remove sessionRevokedToast ref + setTimeout + inline HTML from TotpEnrollment.vue
- Add toastStore.show('Other sessions have been terminated.', 'success') in changePassword
- Add toastStore.show('Other sessions have been terminated.', 'success') in disableTotp
- Add toastStore.show('Other sessions have been terminated.', 'success') in confirmEnrollment
- Update SettingsAccountTab.test.js to assert on mockShow instead of DOM text
- Update TotpEnrollment.test.js to assert on mockShow instead of DOM text
This commit is contained in:
@@ -1,27 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
|
||||||
<!-- Sessions-revoked inline alert -->
|
|
||||||
<div
|
|
||||||
v-if="sessionRevokedToast"
|
|
||||||
class="flex items-center gap-3 bg-white border border-green-200 rounded-xl px-5 py-4"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
<p class="flex-1 text-sm font-semibold text-gray-900">Other sessions have been terminated.</p>
|
|
||||||
<button
|
|
||||||
@click="sessionRevokedToast = false"
|
|
||||||
aria-label="Dismiss notification"
|
|
||||||
class="text-gray-400 hover:text-gray-600 shrink-0"
|
|
||||||
>
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step: setup — initial prompt to begin enrollment -->
|
<!-- Step: setup — initial prompt to begin enrollment -->
|
||||||
<template v-if="step === 'setup'">
|
<template v-if="step === 'setup'">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@@ -131,11 +110,14 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
import * as api from '../../api/client.js'
|
import * as api from '../../api/client.js'
|
||||||
|
import { useToastStore } from '../../stores/toast.js'
|
||||||
import AppSpinner from '../ui/AppSpinner.vue'
|
import AppSpinner from '../ui/AppSpinner.vue'
|
||||||
import BackupCodesDisplay from './BackupCodesDisplay.vue'
|
import BackupCodesDisplay from './BackupCodesDisplay.vue'
|
||||||
|
|
||||||
const emit = defineEmits(['enrolled'])
|
const emit = defineEmits(['enrolled'])
|
||||||
|
|
||||||
|
const toastStore = useToastStore()
|
||||||
|
|
||||||
const step = ref('setup')
|
const step = ref('setup')
|
||||||
const qrUri = ref('')
|
const qrUri = ref('')
|
||||||
const qrDataUrl = ref('')
|
const qrDataUrl = ref('')
|
||||||
@@ -146,7 +128,6 @@ const error = ref(null)
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const verified = ref(false)
|
const verified = ref(false)
|
||||||
const secretCopied = ref(false)
|
const secretCopied = ref(false)
|
||||||
const sessionRevokedToast = ref(false)
|
|
||||||
|
|
||||||
async function startSetup() {
|
async function startSetup() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -172,8 +153,7 @@ async function confirmEnrollment() {
|
|||||||
backupCodes.value = data.backup_codes
|
backupCodes.value = data.backup_codes
|
||||||
verified.value = true
|
verified.value = true
|
||||||
if (data.sessions_revoked > 0) {
|
if (data.sessions_revoked > 0) {
|
||||||
sessionRevokedToast.value = true
|
toastStore.show('Other sessions have been terminated.', 'success')
|
||||||
setTimeout(() => { sessionRevokedToast.value = false }, 5000)
|
|
||||||
}
|
}
|
||||||
// Brief success flash before transitioning to backup codes screen
|
// Brief success flash before transitioning to backup codes screen
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -18,12 +18,19 @@ vi.mock('../../../api/client.js', () => ({
|
|||||||
totpEnable: vi.fn(),
|
totpEnable: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Mock toast store — capture show() calls so we can assert on them
|
||||||
|
const mockShow = vi.fn()
|
||||||
|
vi.mock('../../../stores/toast.js', () => ({
|
||||||
|
useToastStore: () => ({ show: mockShow }),
|
||||||
|
}))
|
||||||
|
|
||||||
import { totpEnable as totpEnableMock } from '../../../api/client.js'
|
import { totpEnable as totpEnableMock } from '../../../api/client.js'
|
||||||
import TotpEnrollment from '../TotpEnrollment.vue'
|
import TotpEnrollment from '../TotpEnrollment.vue'
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
mockShow.mockClear()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('TotpEnrollment — QR code rendering (AUTH-03)', () => {
|
describe('TotpEnrollment — QR code rendering (AUTH-03)', () => {
|
||||||
@@ -90,7 +97,7 @@ describe('TotpEnrollment — sessions revoked toast (CR-02)', () => {
|
|||||||
BackupCodesDisplay: { template: '<div />', props: ['codes'] },
|
BackupCodesDisplay: { template: '<div />', props: ['codes'] },
|
||||||
}
|
}
|
||||||
|
|
||||||
it('renders "Other sessions have been terminated." after totpEnable returns sessions_revoked > 0 (CR-02)', async () => {
|
it('calls toastStore.show("Other sessions have been terminated.") after totpEnable returns sessions_revoked > 0 (CR-02)', async () => {
|
||||||
vi.mocked(totpEnableMock).mockResolvedValueOnce({
|
vi.mocked(totpEnableMock).mockResolvedValueOnce({
|
||||||
backup_codes: ['CODE1', 'CODE2'],
|
backup_codes: ['CODE1', 'CODE2'],
|
||||||
sessions_revoked: 1,
|
sessions_revoked: 1,
|
||||||
@@ -118,10 +125,10 @@ describe('TotpEnrollment — sessions revoked toast (CR-02)', () => {
|
|||||||
await verifyBtn.trigger('click')
|
await verifyBtn.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Other sessions have been terminated.')
|
expect(mockShow).toHaveBeenCalledWith('Other sessions have been terminated.', 'success')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does NOT render the alert when totpEnable returns sessions_revoked is 0 (CR-02 negative)', async () => {
|
it('does NOT call toastStore.show when totpEnable returns sessions_revoked is 0 (CR-02 negative)', async () => {
|
||||||
vi.mocked(totpEnableMock).mockResolvedValueOnce({
|
vi.mocked(totpEnableMock).mockResolvedValueOnce({
|
||||||
backup_codes: [],
|
backup_codes: [],
|
||||||
sessions_revoked: 0,
|
sessions_revoked: 0,
|
||||||
@@ -144,6 +151,6 @@ describe('TotpEnrollment — sessions revoked toast (CR-02)', () => {
|
|||||||
await verifyBtn.trigger('click')
|
await verifyBtn.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.text()).not.toContain('Other sessions have been terminated.')
|
expect(mockShow).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,29 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
|
||||||
<!-- Sessions-revoked toast (fixed top-right, auto-dismisses after 5s) -->
|
|
||||||
<div
|
|
||||||
v-if="sessionRevokedToast"
|
|
||||||
class="fixed top-4 right-4 z-50 flex items-center gap-3 bg-white border border-green-200 rounded-xl shadow-lg px-5 py-4 max-w-sm"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<p class="text-sm font-semibold text-gray-900">Other sessions have been terminated.</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
@click="sessionRevokedToast = false"
|
|
||||||
aria-label="Dismiss notification"
|
|
||||||
class="text-gray-400 hover:text-gray-600 shrink-0"
|
|
||||||
>
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 1. Account information -->
|
<!-- 1. Account information -->
|
||||||
<section class="bg-white border border-gray-200 rounded-xl p-6">
|
<section class="bg-white border border-gray-200 rounded-xl p-6">
|
||||||
<h3 class="font-semibold text-gray-800 mb-4">Account information</h3>
|
<h3 class="font-semibold text-gray-800 mb-4">Account information</h3>
|
||||||
@@ -192,6 +169,7 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '../../stores/auth.js'
|
import { useAuthStore } from '../../stores/auth.js'
|
||||||
|
import { useToastStore } from '../../stores/toast.js'
|
||||||
import * as api from '../../api/client.js'
|
import * as api from '../../api/client.js'
|
||||||
import PasswordStrengthBar from '../auth/PasswordStrengthBar.vue'
|
import PasswordStrengthBar from '../auth/PasswordStrengthBar.vue'
|
||||||
import TotpEnrollment from '../auth/TotpEnrollment.vue'
|
import TotpEnrollment from '../auth/TotpEnrollment.vue'
|
||||||
@@ -199,6 +177,7 @@ import ConfirmBlock from '../ui/ConfirmBlock.vue'
|
|||||||
import AppSpinner from '../ui/AppSpinner.vue'
|
import AppSpinner from '../ui/AppSpinner.vue'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const toastStore = useToastStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
// ── Change password ─────────────────────────────────────────────────────────
|
// ── Change password ─────────────────────────────────────────────────────────
|
||||||
@@ -208,7 +187,6 @@ const newPassword = ref('')
|
|||||||
const changingPassword = ref(false)
|
const changingPassword = ref(false)
|
||||||
const passwordError = ref(null)
|
const passwordError = ref(null)
|
||||||
const passwordSuccess = ref(null)
|
const passwordSuccess = ref(null)
|
||||||
const sessionRevokedToast = ref(false)
|
|
||||||
|
|
||||||
async function changePassword() {
|
async function changePassword() {
|
||||||
changingPassword.value = true
|
changingPassword.value = true
|
||||||
@@ -223,8 +201,7 @@ async function changePassword() {
|
|||||||
currentPassword.value = ''
|
currentPassword.value = ''
|
||||||
newPassword.value = ''
|
newPassword.value = ''
|
||||||
if (data.sessions_revoked > 0) {
|
if (data.sessions_revoked > 0) {
|
||||||
sessionRevokedToast.value = true
|
toastStore.show('Other sessions have been terminated.', 'success')
|
||||||
setTimeout(() => { sessionRevokedToast.value = false }, 5000)
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e.message || ''
|
const msg = e.message || ''
|
||||||
@@ -260,8 +237,7 @@ async function disableTotp() {
|
|||||||
}
|
}
|
||||||
confirmDisable2fa.value = false
|
confirmDisable2fa.value = false
|
||||||
if (data.sessions_revoked > 0) {
|
if (data.sessions_revoked > 0) {
|
||||||
sessionRevokedToast.value = true
|
toastStore.show('Other sessions have been terminated.', 'success')
|
||||||
setTimeout(() => { sessionRevokedToast.value = false }, 5000)
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
totpError.value = e.message
|
totpError.value = e.message
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ vi.mock('../../../api/client.js', () => ({
|
|||||||
totpDisable: vi.fn(),
|
totpDisable: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Mock toast store — capture show() calls so we can assert on them
|
||||||
|
const mockShow = vi.fn()
|
||||||
|
vi.mock('../../../stores/toast.js', () => ({
|
||||||
|
useToastStore: () => ({ show: mockShow }),
|
||||||
|
}))
|
||||||
|
|
||||||
import { useAuthStore } from '../../../stores/auth.js'
|
import { useAuthStore } from '../../../stores/auth.js'
|
||||||
import { changePassword as changePasswordMock, totpDisable as totpDisableMock } from '../../../api/client.js'
|
import { changePassword as changePasswordMock, totpDisable as totpDisableMock } from '../../../api/client.js'
|
||||||
import SettingsAccountTab from '../SettingsAccountTab.vue'
|
import SettingsAccountTab from '../SettingsAccountTab.vue'
|
||||||
@@ -38,6 +44,7 @@ const globalStubs = {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
mockShow.mockClear()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─── GAP 1 & 2: Sessions revoked toast (CR-01, CR-03) ──────────────────────
|
// ─── GAP 1 & 2: Sessions revoked toast (CR-01, CR-03) ──────────────────────
|
||||||
@@ -51,7 +58,7 @@ describe('SettingsAccountTab — sessions revoked toast (CR-01, CR-03)', () => {
|
|||||||
emits: ['confirmed', 'cancelled'],
|
emits: ['confirmed', 'cancelled'],
|
||||||
}
|
}
|
||||||
|
|
||||||
it('shows "Other sessions have been terminated." toast after changePassword returns sessions_revoked > 0 (CR-01)', async () => {
|
it('calls toastStore.show("Other sessions have been terminated.") after changePassword returns sessions_revoked > 0 (CR-01)', async () => {
|
||||||
useAuthStore.mockReturnValue({
|
useAuthStore.mockReturnValue({
|
||||||
user: { email: 'test@example.com', handle: 'testuser', role: 'user', totp_enabled: false },
|
user: { email: 'test@example.com', handle: 'testuser', role: 'user', totp_enabled: false },
|
||||||
logoutAll: vi.fn(),
|
logoutAll: vi.fn(),
|
||||||
@@ -73,10 +80,10 @@ describe('SettingsAccountTab — sessions revoked toast (CR-01, CR-03)', () => {
|
|||||||
await wrapper.find('form').trigger('submit')
|
await wrapper.find('form').trigger('submit')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Other sessions have been terminated.')
|
expect(mockShow).toHaveBeenCalledWith('Other sessions have been terminated.', 'success')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does NOT show toast after changePassword when sessions_revoked is 0 (CR-01 negative)', async () => {
|
it('does NOT call toastStore.show after changePassword when sessions_revoked is 0 (CR-01 negative)', async () => {
|
||||||
useAuthStore.mockReturnValue({
|
useAuthStore.mockReturnValue({
|
||||||
user: { email: 'test@example.com', handle: 'testuser', role: 'user', totp_enabled: false },
|
user: { email: 'test@example.com', handle: 'testuser', role: 'user', totp_enabled: false },
|
||||||
logoutAll: vi.fn(),
|
logoutAll: vi.fn(),
|
||||||
@@ -97,10 +104,10 @@ describe('SettingsAccountTab — sessions revoked toast (CR-01, CR-03)', () => {
|
|||||||
await wrapper.find('form').trigger('submit')
|
await wrapper.find('form').trigger('submit')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.text()).not.toContain('Other sessions have been terminated.')
|
expect(mockShow).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows "Other sessions have been terminated." toast after disableTotp returns sessions_revoked > 0 (CR-03)', async () => {
|
it('calls toastStore.show("Other sessions have been terminated.") after disableTotp returns sessions_revoked > 0 (CR-03)', async () => {
|
||||||
const user = { email: 'test@example.com', handle: 'testuser', role: 'user', totp_enabled: true }
|
const user = { email: 'test@example.com', handle: 'testuser', role: 'user', totp_enabled: true }
|
||||||
useAuthStore.mockReturnValue({
|
useAuthStore.mockReturnValue({
|
||||||
user,
|
user,
|
||||||
@@ -127,7 +134,7 @@ describe('SettingsAccountTab — sessions revoked toast (CR-01, CR-03)', () => {
|
|||||||
await wrapper.find('[data-action="confirm"]').trigger('click')
|
await wrapper.find('[data-action="confirm"]').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Other sessions have been terminated.')
|
expect(mockShow).toHaveBeenCalledWith('Other sessions have been terminated.', 'success')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user