main
vue 312 lines 8.89 KB
Raw
1 <template>
2 <div>
3 <!-- 2FA Section -->
4 <n-card title="Two-Factor Authentication (TOTP)">
5 <template #header-extra>
6 <n-tag :type="twoFaEnabled ? 'success' : 'default'" size="small">
7 {{ twoFaEnabled ? "Enabled" : "Disabled" }}
8 </n-tag>
9 </template>
10
11 <n-spin :show="twoFaLoading">
12 <!-- ── 2FA disabled — show enable button ── -->
13 <div v-if="!twoFaEnabled && !setupData">
14 <p class="text-secondary mb-4">
15 Add an extra layer of security to your account. You will need an authenticator app like Google
16 Authenticator, Authy, or Microsoft Authenticator.
17 </p>
18 <n-button type="primary" @click="startSetup">
19 <template #icon>
20 <Icon name="carbon:locked" />
21 </template>
22 Enable Two-Factor Authentication
23 </n-button>
24 </div>
25
26 <!-- ── Setup flow: QR + verify ── -->
27 <div v-if="setupData && (!twoFaEnabled || setupStep === 3)" class="flex flex-col gap-6">
28 <n-steps :current="setupStep" size="small">
29 <n-step title="Scan QR code" />
30 <n-step title="Verify code" />
31 <n-step title="Save backup codes" />
32 </n-steps>
33
34 <!-- Step 1: QR -->
35 <div v-if="setupStep === 1" class="flex flex-col gap-4">
36 <p>Scan this QR code with your authenticator app:</p>
37
38 <img :src="setupData.qr_data_uri" alt="TOTP QR Code" class="rounded border" width="200" />
39
40 <div>
41 <strong>Manual entry key:</strong>
42 <n-code class="ml-2">{{ setupData.secret }}</n-code>
43 </div>
44
45 <n-alert type="warning">
46 <template #icon>
47 <Icon name="carbon:time" />
48 </template>
49 <strong>Important:</strong>
50 Make sure your device clock is accurate. TOTP codes are time-sensitive and allow only a
51 &plusmn;30 second tolerance window.
52 </n-alert>
53
54 <div class="flex justify-between">
55 <n-button @click="finishSetup()">Cancel</n-button>
56 <n-button type="primary" @click="setupStep = 2">Next</n-button>
57 </div>
58 </div>
59
60 <!-- Step 2: Verify -->
61 <div v-if="setupStep === 2" class="flex flex-col gap-4">
62 <p>Enter the 6-digit code from your authenticator app to confirm:</p>
63
64 <n-input-otp
65 v-model:value="verifyCode"
66 block
67 size="large"
68 :input-props="{ autocomplete: 'one-time-code', inputmode: 'numeric' }"
69 class="max-w-100"
70 @finish="confirmSetup()"
71 @keydown.enter="confirmSetup()"
72 />
73
74 <div class="flex justify-between gap-2">
75 <n-button @click="setupStep = 1">Back</n-button>
76 <n-button
77 type="primary"
78 :loading="verifying"
79 :disabled="verifyCode.length < 6"
80 @click="confirmSetup"
81 >
82 Verify &amp; Enable
83 </n-button>
84 </div>
85 </div>
86
87 <!-- Step 3: Backup codes -->
88 <div v-if="setupStep === 3" class="flex flex-col gap-4">
89 <n-alert type="success">
90 <template #icon>
91 <Icon name="carbon:checkmark-filled" />
92 </template>
93 Two-factor authentication is now enabled!
94 </n-alert>
95
96 <BackupCodesPanel :codes="setupData?.backup_codes || []" />
97
98 <n-button type="primary" class="ml-auto!" @click="finishSetup">Done</n-button>
99 </div>
100 </div>
101
102 <!-- ── 2FA enabled — show disable + regenerate ── -->
103 <div v-if="twoFaEnabled && !setupStep" class="flex flex-col gap-4">
104 <p class="text-secondary">
105 Two-factor authentication is active. You will be asked for a code from your authenticator app
106 every time you log in.
107 </p>
108 <div class="flex flex-wrap gap-3">
109 <n-popconfirm @positive-click="showDisableModal = true">
110 <template #trigger>
111 <n-button type="error">
112 <template #icon>
113 <Icon name="carbon:unlocked" />
114 </template>
115 Disable 2FA
116 </n-button>
117 </template>
118 Are you sure you want to disable two-factor authentication?
119 </n-popconfirm>
120 <n-button @click="showRegenModal = true">
121 <template #icon>
122 <Icon name="carbon:renew" />
123 </template>
124 Regenerate backup codes
125 </n-button>
126 </div>
127 </div>
128 </n-spin>
129 </n-card>
130
131 <!-- Disable 2FA Modal -->
132 <n-modal v-model:show="showDisableModal" preset="card" title="Disable 2FA" :style="{ maxWidth: '400px' }">
133 <p class="mb-4">Enter your TOTP code or a backup code to disable 2FA:</p>
134 <n-input
135 v-model:value="disableCode"
136 placeholder="6-digit code or backup code"
137 class="mb-4"
138 @keydown.enter="disableTwoFa"
139 />
140 <div class="flex justify-end gap-2">
141 <n-button @click="showDisableModal = false">Cancel</n-button>
142 <n-button type="error" :loading="disabling" :disabled="!disableCode" @click="disableTwoFa">
143 Disable
144 </n-button>
145 </div>
146 </n-modal>
147
148 <!-- Regenerate Backup Codes Modal -->
149 <n-modal
150 v-model:show="showRegenModal"
151 preset="card"
152 title="Regenerate Backup Codes"
153 :style="{ maxWidth: '500px' }"
154 >
155 <div v-if="!regenCodes" class="flex flex-col gap-4">
156 <p>Enter your TOTP code to generate new backup codes. Old codes will be invalidated.</p>
157 <n-input-otp
158 v-model:value="regenCode"
159 block
160 size="large"
161 :input-props="{ autocomplete: 'one-time-code', inputmode: 'numeric' }"
162 @finish="regenBackupCodes()"
163 @keydown.enter="regenBackupCodes()"
164 />
165 <div class="flex justify-end gap-2">
166 <n-button @click="showRegenModal = false">Cancel</n-button>
167 <n-button
168 type="primary"
169 :loading="regenerating"
170 :disabled="regenCode.length < 6"
171 @click="regenBackupCodes"
172 >
173 Regenerate
174 </n-button>
175 </div>
176 </div>
177 <div v-else class="flex flex-col gap-4">
178 <BackupCodesPanel :codes="regenCodes || []" />
179 <n-button type="primary" class="ml-auto!" @click="closeRegenModal">Done</n-button>
180 </div>
181 </n-modal>
182 </div>
183 </template>
184
185 <script lang="ts" setup>
186 import type { TOTPSetupResponse } from "@/api/endpoints/totp"
187 import {
188 NAlert,
189 NButton,
190 NCard,
191 NInput,
192 NInputOtp,
193 NModal,
194 NPopconfirm,
195 NSpin,
196 NStep,
197 NSteps,
198 NTag,
199 useMessage
200 } from "naive-ui"
201 import { onBeforeMount, ref } from "vue"
202 import Api from "@/api"
203 import BackupCodesPanel from "@/components/auth/BackupCodesPanel.vue"
204 import Icon from "@/components/common/Icon.vue"
205
206 const message = useMessage()
207
208 // ── 2FA state ────────────────────────────────────────────────────────────────
209 const twoFaLoading = ref(false)
210 const twoFaEnabled = ref(false)
211 const setupData = ref<TOTPSetupResponse | null>(null)
212 const setupStep = ref(0)
213 const verifyCode = ref<string[]>([])
214 const verifying = ref(false)
215
216 const showDisableModal = ref(false)
217 const disableCode = ref("")
218 const disabling = ref(false)
219
220 const showRegenModal = ref(false)
221 const regenCode = ref<string[]>([])
222 const regenCodes = ref<string[] | null>(null)
223 const regenerating = ref(false)
224
225 async function load2faStatus() {
226 twoFaLoading.value = true
227
228 try {
229 const res = await Api.totp.getStatus()
230 twoFaEnabled.value = res.data.enabled
231 } finally {
232 twoFaLoading.value = false
233 }
234 }
235
236 async function startSetup() {
237 twoFaLoading.value = true
238
239 try {
240 const res = await Api.totp.setup()
241 setupData.value = res.data
242 setupStep.value = 1
243 } catch (err: any) {
244 message.error(err.response?.data?.detail || "Failed to start 2FA setup")
245 } finally {
246 twoFaLoading.value = false
247 }
248 }
249
250 async function confirmSetup() {
251 verifying.value = true
252
253 try {
254 await Api.totp.verifySetup(verifyCode.value.join(""))
255 twoFaEnabled.value = true
256 setupStep.value = 3
257 message.success("Two-factor authentication enabled!")
258 } catch (err: any) {
259 message.error(err.response?.data?.message || err.response?.data?.detail || "Invalid code. Try again.")
260 } finally {
261 verifying.value = false
262 }
263 }
264
265 function finishSetup() {
266 setupData.value = null
267 setupStep.value = 0
268 verifyCode.value = []
269 }
270
271 async function disableTwoFa() {
272 disabling.value = true
273 const isBackup = disableCode.value.length > 6
274
275 try {
276 await Api.totp.disable(isBackup ? { backup_code: disableCode.value } : { code: disableCode.value })
277 twoFaEnabled.value = false
278 showDisableModal.value = false
279 disableCode.value = ""
280 message.success("Two-factor authentication disabled")
281 } catch (err: any) {
282 message.error(err.response?.data?.message || err.response?.data?.detail || "Failed to disable 2FA")
283 } finally {
284 disabling.value = false
285 }
286 }
287
288 async function regenBackupCodes() {
289 regenerating.value = true
290
291 try {
292 const res = await Api.totp.regenerateBackupCodes(regenCode.value.join(""))
293 regenCodes.value = res.data.backup_codes
294 regenCode.value = []
295 message.success("Backup codes regenerated")
296 } catch (err: any) {
297 message.error(err.response?.data?.message || err.response?.data?.detail || "Failed to regenerate codes")
298 } finally {
299 regenerating.value = false
300 }
301 }
302
303 function closeRegenModal() {
304 showRegenModal.value = false
305 regenCodes.value = null
306 regenCode.value = []
307 }
308
309 onBeforeMount(() => {
310 load2faStatus()
311 })
312 </script>