Skip to content

Commit

Permalink
ui(mfa): dont require button click to validate mfa, do automatically (#…
Browse files Browse the repository at this point in the history
…5448)

* ui(mfa): dont require link click to validate mfa, do automatically

* lint: resolve eslint error level lint checks
  • Loading branch information
wssheldon authored Nov 8, 2024
1 parent 051e7c5 commit 2f17948
Show file tree
Hide file tree
Showing 2 changed files with 230 additions and 15 deletions.
69 changes: 54 additions & 15 deletions src/dispatch/static/dispatch/src/auth/Mfa.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,32 @@
Multi-Factor Authentication
</v-card-title>
<v-card-text>
<v-alert v-if="status" :type="alertType" class="mb-4">
{{ statusMessage }}
</v-alert>
<v-btn
color="primary"
block
@click="verifyMfa"
:loading="loading"
:disabled="status === MfaChallengeStatus.APPROVED"
>
Verify MFA
</v-btn>
<div class="text-center">
<!-- Show spinner while loading -->
<v-progress-circular
v-if="loading"
indeterminate
color="primary"
size="64"
class="mb-4"
></v-progress-circular>

<!-- Status message with icon -->
<v-alert
v-if="status"
:type="alertType"
:icon="statusIcon"
class="mb-4"
border="start"
>
{{ statusMessage }}
</v-alert>

<!-- Retry button only shown when denied or expired -->
<v-btn v-if="canRetry" color="primary" @click="verifyMfa" :loading="loading">
Retry Verification
</v-btn>
</div>
</v-card-text>
</v-card>
</v-col>
Expand All @@ -27,7 +41,7 @@
</template>

<script setup lang="ts">
import { ref, computed } from "vue"
import { ref, computed, onMounted } from "vue"
import { useRoute } from "vue-router"
import authApi from "@/auth/api"
Expand All @@ -52,14 +66,29 @@ const statusMessage = computed(() => {
case MfaChallengeStatus.EXPIRED:
return "MFA challenge has expired. Please request a new one."
case MfaChallengeStatus.PENDING:
return "MFA verification is pending."
return "Verifying your authentication..."
case null:
return "Please verify your multi-factor authentication."
return "Initializing verification..."
default:
return "An unknown error occurred."
}
})
const statusIcon = computed(() => {
switch (status.value) {
case MfaChallengeStatus.APPROVED:
return "mdi-check-circle"
case MfaChallengeStatus.DENIED:
return "mdi-close-circle"
case MfaChallengeStatus.EXPIRED:
return "mdi-clock-alert"
case MfaChallengeStatus.PENDING:
return "mdi-progress-clock"
default:
return "mdi-alert"
}
})
const alertType = computed(() => {
switch (status.value) {
case MfaChallengeStatus.APPROVED:
Expand All @@ -75,8 +104,14 @@ const alertType = computed(() => {
}
})
const canRetry = computed(() => {
return status.value === MfaChallengeStatus.DENIED || status.value === MfaChallengeStatus.EXPIRED
})
const verifyMfa = async () => {
loading.value = true
status.value = MfaChallengeStatus.PENDING
try {
const challengeId = route.query.challenge_id as string
const projectId = parseInt(route.query.project_id as string)
Expand All @@ -103,4 +138,8 @@ const verifyMfa = async () => {
loading.value = false
}
}
onMounted(() => {
verifyMfa()
})
</script>
176 changes: 176 additions & 0 deletions src/dispatch/static/dispatch/src/tests/Mfa.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { mount, flushPromises } from "@vue/test-utils"
import { expect, test, vi, beforeEach, afterEach } from "vitest"
import { createVuetify } from "vuetify"
import * as components from "vuetify/components"
import * as directives from "vuetify/directives"
import MfaVerification from "@/auth/mfa.vue"
import authApi from "@/auth/api"

vi.mock("vue-router", () => ({
useRoute: () => ({
query: {
challenge_id: "test-challenge",
project_id: "123",
action: "test-action",
},
}),
}))

vi.mock("@/auth/api", () => ({
default: {
verifyMfa: vi.fn(),
},
}))

const vuetify = createVuetify({
components,
directives,
})

global.ResizeObserver = require("resize-observer-polyfill")

const windowCloseMock = vi.fn()
const originalClose = window.close

beforeEach(() => {
vi.useFakeTimers()
Object.defineProperty(window, "close", {
value: windowCloseMock,
writable: true,
})
vi.clearAllMocks()
})

afterEach(() => {
vi.useRealTimers()
Object.defineProperty(window, "close", {
value: originalClose,
writable: true,
})
})

test("mounts correctly and starts verification automatically", async () => {
const wrapper = mount(MfaVerification, {
global: {
plugins: [vuetify],
},
})

await flushPromises()

expect(wrapper.exists()).toBe(true)
expect(authApi.verifyMfa).toHaveBeenCalledWith({
challenge_id: "test-challenge",
project_id: 123,
action: "test-action",
})
})

test("shows loading state while verifying", async () => {
vi.mocked(authApi.verifyMfa).mockImplementationOnce(
() => new Promise(() => {}) // Never resolving promise
)

const wrapper = mount(MfaVerification, {
global: {
plugins: [vuetify],
},
})

await flushPromises()

const loadingSpinner = wrapper.findComponent({ name: "v-progress-circular" })
expect(loadingSpinner.exists()).toBe(true)
expect(loadingSpinner.isVisible()).toBe(true)
})

test("shows success message and closes window on approval", async () => {
vi.mocked(authApi.verifyMfa).mockResolvedValueOnce({
data: { status: "approved" },
})

const wrapper = mount(MfaVerification, {
global: {
plugins: [vuetify],
},
})

await flushPromises()

const alert = wrapper.findComponent({ name: "v-alert" })
expect(alert.exists()).toBe(true)
expect(alert.props("type")).toBe("success")
expect(alert.text()).toContain("MFA verification successful")

vi.advanceTimersByTime(5000)
expect(windowCloseMock).toHaveBeenCalled()
})

test("shows error message and retry button on denial", async () => {
vi.mocked(authApi.verifyMfa).mockResolvedValueOnce({
data: { status: "denied" },
})

const wrapper = mount(MfaVerification, {
global: {
plugins: [vuetify],
},
})

await flushPromises()

const alert = wrapper.findComponent({ name: "v-alert" })
expect(alert.exists()).toBe(true)
expect(alert.props("type")).toBe("error")
expect(alert.text()).toContain("MFA verification denied")

const retryButton = wrapper.findComponent({ name: "v-btn" })
expect(retryButton.exists()).toBe(true)
expect(retryButton.text()).toContain("Retry Verification")
})

test("retry button triggers new verification attempt", async () => {
const verifyMfaMock = vi
.mocked(authApi.verifyMfa)
.mockResolvedValueOnce({
data: { status: "denied" },
})
.mockResolvedValueOnce({
data: { status: "approved" },
})

const wrapper = mount(MfaVerification, {
global: {
plugins: [vuetify],
},
})

await flushPromises()

const retryButton = wrapper.findComponent({ name: "v-btn" })
await retryButton.trigger("click")

await flushPromises()

expect(verifyMfaMock).toHaveBeenCalledTimes(2)

const alert = wrapper.findComponent({ name: "v-alert" })
expect(alert.props("type")).toBe("success")
})

test("handles API errors gracefully", async () => {
vi.mocked(authApi.verifyMfa).mockRejectedValueOnce(new Error("API Error"))

const wrapper = mount(MfaVerification, {
global: {
plugins: [vuetify],
},
})

await flushPromises()

const alert = wrapper.findComponent({ name: "v-alert" })
expect(alert.exists()).toBe(true)
expect(alert.props("type")).toBe("error")
expect(alert.text()).toContain("MFA verification denied")
})

0 comments on commit 2f17948

Please sign in to comment.