Skip to content
This repository was archived by the owner on Sep 3, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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

Check warning on line 12 in src/dispatch/static/dispatch/src/auth/Mfa.vue

View workflow job for this annotation

GitHub Actions / build

Require self-closing on Vue.js custom components (<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 @@
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 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 @@
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")
})
Loading