Initial commit
This commit is contained in:
491
src/views/UserSettingsSecurityPanel.vue
Normal file
491
src/views/UserSettingsSecurityPanel.vue
Normal file
@@ -0,0 +1,491 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
// Enrollment state
|
||||
const isLoading = ref(true)
|
||||
const isEnrolled = ref(false)
|
||||
const enrollmentStep = ref<'status' | 'setup' | 'verify' | 'success' | 'disable'>('status')
|
||||
|
||||
// Enrollment data
|
||||
const secret = ref('')
|
||||
const provisioningUri = ref('')
|
||||
const qrCodeDataUrl = ref('')
|
||||
const recoveryCodes = ref<string[]>([])
|
||||
|
||||
// Verification
|
||||
const verificationCode = ref('')
|
||||
const isVerifying = ref(false)
|
||||
const verificationError = ref('')
|
||||
|
||||
// Disable
|
||||
const disableCode = ref('')
|
||||
const isDisabling = ref(false)
|
||||
const disableError = ref('')
|
||||
|
||||
// Dialog state
|
||||
const showRecoveryCodesDialog = ref(false)
|
||||
const recoveryCodesCopied = ref(false)
|
||||
|
||||
// API base URL - module routes are prefixed with /m/{module_handle}
|
||||
const apiBase = '/m/authentication_provider_totp'
|
||||
|
||||
// Load initial status
|
||||
onMounted(async () => {
|
||||
await loadStatus()
|
||||
})
|
||||
|
||||
async function loadStatus() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/totp/status`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
const data = await response.json()
|
||||
isEnrolled.value = data.enrolled ?? false
|
||||
enrollmentStep.value = isEnrolled.value ? 'status' : 'status'
|
||||
} catch (error) {
|
||||
console.error('Failed to load TOTP status:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function startEnrollment() {
|
||||
isLoading.value = true
|
||||
verificationError.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/totp/enroll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
verificationError.value = data.error || 'Failed to start enrollment'
|
||||
return
|
||||
}
|
||||
|
||||
secret.value = data.secret
|
||||
provisioningUri.value = data.provisioning_uri
|
||||
recoveryCodes.value = data.recovery_codes || []
|
||||
|
||||
// Generate QR code client-side
|
||||
if (provisioningUri.value) {
|
||||
qrCodeDataUrl.value = await QRCode.toDataURL(provisioningUri.value, {
|
||||
width: 256,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
enrollmentStep.value = 'setup'
|
||||
} catch (error) {
|
||||
console.error('Failed to start enrollment:', error)
|
||||
verificationError.value = 'Failed to start enrollment. Please try again.'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyEnrollment() {
|
||||
if (!verificationCode.value || verificationCode.value.length !== 6) {
|
||||
verificationError.value = 'Please enter a valid 6-digit code'
|
||||
return
|
||||
}
|
||||
|
||||
isVerifying.value = true
|
||||
verificationError.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/totp/enroll/verify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ code: verificationCode.value }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
verificationError.value = data.error || 'Invalid verification code'
|
||||
return
|
||||
}
|
||||
|
||||
isEnrolled.value = true
|
||||
enrollmentStep.value = 'success'
|
||||
showRecoveryCodesDialog.value = true
|
||||
} catch (error) {
|
||||
console.error('Failed to verify enrollment:', error)
|
||||
verificationError.value = 'Verification failed. Please try again.'
|
||||
} finally {
|
||||
isVerifying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function disableTotp() {
|
||||
if (!disableCode.value || disableCode.value.length !== 6) {
|
||||
disableError.value = 'Please enter a valid 6-digit code'
|
||||
return
|
||||
}
|
||||
|
||||
isDisabling.value = true
|
||||
disableError.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/totp/enroll?code=${encodeURIComponent(disableCode.value)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
disableError.value = data.error || 'Invalid verification code'
|
||||
return
|
||||
}
|
||||
|
||||
isEnrolled.value = false
|
||||
enrollmentStep.value = 'status'
|
||||
disableCode.value = ''
|
||||
|
||||
// Reset enrollment data
|
||||
secret.value = ''
|
||||
provisioningUri.value = ''
|
||||
qrCodeDataUrl.value = ''
|
||||
recoveryCodes.value = []
|
||||
verificationCode.value = ''
|
||||
} catch (error) {
|
||||
console.error('Failed to disable TOTP:', error)
|
||||
disableError.value = 'Failed to disable TOTP. Please try again.'
|
||||
} finally {
|
||||
isDisabling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEnrollment() {
|
||||
enrollmentStep.value = 'status'
|
||||
verificationCode.value = ''
|
||||
verificationError.value = ''
|
||||
}
|
||||
|
||||
function cancelDisable() {
|
||||
enrollmentStep.value = 'status'
|
||||
disableCode.value = ''
|
||||
disableError.value = ''
|
||||
}
|
||||
|
||||
function showDisableDialog() {
|
||||
enrollmentStep.value = 'disable'
|
||||
}
|
||||
|
||||
async function copyRecoveryCodes() {
|
||||
const codesText = recoveryCodes.value.join('\n')
|
||||
await navigator.clipboard.writeText(codesText)
|
||||
recoveryCodesCopied.value = true
|
||||
setTimeout(() => {
|
||||
recoveryCodesCopied.value = false
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function downloadRecoveryCodes() {
|
||||
const codesText = recoveryCodes.value.join('\n')
|
||||
const blob = new Blob([codesText], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'totp-recovery-codes.txt'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function finishSetup() {
|
||||
showRecoveryCodesDialog.value = false
|
||||
enrollmentStep.value = 'status'
|
||||
}
|
||||
|
||||
const formattedSecret = computed(() => {
|
||||
// Format secret in groups of 4 for easier reading
|
||||
return secret.value.match(/.{1,4}/g)?.join(' ') || secret.value
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCol cols="12" md="8" lg="4">
|
||||
<VCard>
|
||||
<VCardTitle class="d-flex align-center">
|
||||
<VIcon start icon="mdi-shield-check" />
|
||||
Two-Factor Authentication (TOTP)
|
||||
</VCardTitle>
|
||||
|
||||
<VCardText>
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="text-center py-8">
|
||||
<VProgressCircular indeterminate color="primary" />
|
||||
<p class="mt-4 text-body-2">Loading...</p>
|
||||
</div>
|
||||
|
||||
<!-- Status View (Not Enrolled) -->
|
||||
<template v-else-if="enrollmentStep === 'status' && !isEnrolled">
|
||||
<VAlert
|
||||
type="info"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
>
|
||||
<VAlertTitle>Two-factor authentication is not enabled</VAlertTitle>
|
||||
<p class="mb-0">
|
||||
Add an extra layer of security to your account by enabling two-factor authentication
|
||||
using an authenticator app like Google Authenticator, Authy, or Microsoft Authenticator.
|
||||
</p>
|
||||
</VAlert>
|
||||
|
||||
<VBtn
|
||||
color="primary"
|
||||
@click="startEnrollment"
|
||||
>
|
||||
<VIcon start icon="mdi-shield-plus" />
|
||||
Enable Two-Factor Authentication
|
||||
</VBtn>
|
||||
</template>
|
||||
|
||||
<!-- Status View (Enrolled) -->
|
||||
<template v-else-if="enrollmentStep === 'status' && isEnrolled">
|
||||
<VAlert
|
||||
type="success"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
>
|
||||
<VAlertTitle>Two-factor authentication is enabled</VAlertTitle>
|
||||
<p class="mb-0">
|
||||
Your account is protected with TOTP two-factor authentication.
|
||||
</p>
|
||||
</VAlert>
|
||||
|
||||
<VBtn
|
||||
color="error"
|
||||
variant="outlined"
|
||||
@click="showDisableDialog"
|
||||
>
|
||||
<VIcon start icon="mdi-shield-off" />
|
||||
Disable Two-Factor Authentication
|
||||
</VBtn>
|
||||
</template>
|
||||
|
||||
<!-- Setup View -->
|
||||
<template v-else-if="enrollmentStep === 'setup'">
|
||||
<div class="mb-4">
|
||||
<h3 class="text-h6 mb-2">Step 1: Scan QR Code</h3>
|
||||
<p class="text-body-2 mb-4">
|
||||
Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.)
|
||||
</p>
|
||||
|
||||
<div class="d-flex justify-center mb-4">
|
||||
<VCard variant="outlined" class="pa-4">
|
||||
<img
|
||||
v-if="qrCodeDataUrl"
|
||||
:src="qrCodeDataUrl"
|
||||
alt="TOTP QR Code"
|
||||
width="256"
|
||||
height="256"
|
||||
/>
|
||||
<VProgressCircular v-else indeterminate color="primary" />
|
||||
</VCard>
|
||||
</div>
|
||||
|
||||
<VExpansionPanels variant="accordion">
|
||||
<VExpansionPanel>
|
||||
<VExpansionPanelTitle>
|
||||
<VIcon start icon="mdi-keyboard" />
|
||||
Can't scan? Enter manually
|
||||
</VExpansionPanelTitle>
|
||||
<VExpansionPanelText>
|
||||
<p class="text-body-2 mb-2">Enter this secret key in your authenticator app:</p>
|
||||
<VTextField
|
||||
:model-value="formattedSecret"
|
||||
readonly
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
append-inner-icon="mdi-content-copy"
|
||||
@click:append-inner="navigator.clipboard.writeText(secret)"
|
||||
/>
|
||||
</VExpansionPanelText>
|
||||
</VExpansionPanel>
|
||||
</VExpansionPanels>
|
||||
</div>
|
||||
|
||||
<VDivider class="my-4" />
|
||||
|
||||
<div>
|
||||
<h3 class="text-h6 mb-2">Step 2: Verify Setup</h3>
|
||||
<p class="text-body-2 mb-4">
|
||||
Enter the 6-digit code from your authenticator app to verify the setup.
|
||||
</p>
|
||||
|
||||
<VTextField
|
||||
v-model="verificationCode"
|
||||
label="Verification Code"
|
||||
placeholder="000000"
|
||||
maxlength="6"
|
||||
variant="outlined"
|
||||
:error-messages="verificationError"
|
||||
class="mb-4"
|
||||
@keyup.enter="verifyEnrollment"
|
||||
/>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<VBtn
|
||||
color="primary"
|
||||
:loading="isVerifying"
|
||||
:disabled="verificationCode.length !== 6"
|
||||
@click="verifyEnrollment"
|
||||
>
|
||||
<VIcon start icon="mdi-check" />
|
||||
Verify and Enable
|
||||
</VBtn>
|
||||
<VBtn
|
||||
variant="outlined"
|
||||
@click="cancelEnrollment"
|
||||
>
|
||||
Cancel
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Success View (after enrollment, before closing dialog) -->
|
||||
<template v-else-if="enrollmentStep === 'success'">
|
||||
<VAlert
|
||||
type="success"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
>
|
||||
<VAlertTitle>Two-factor authentication enabled!</VAlertTitle>
|
||||
<p class="mb-0">
|
||||
Your account is now protected with TOTP two-factor authentication.
|
||||
</p>
|
||||
</VAlert>
|
||||
</template>
|
||||
|
||||
<!-- Disable View -->
|
||||
<template v-else-if="enrollmentStep === 'disable'">
|
||||
<VAlert
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
>
|
||||
<VAlertTitle>Disable Two-Factor Authentication</VAlertTitle>
|
||||
<p class="mb-0">
|
||||
This will remove the extra security layer from your account.
|
||||
Enter your current 6-digit code to confirm.
|
||||
</p>
|
||||
</VAlert>
|
||||
|
||||
<VTextField
|
||||
v-model="disableCode"
|
||||
label="Current Verification Code"
|
||||
placeholder="000000"
|
||||
maxlength="6"
|
||||
variant="outlined"
|
||||
:error-messages="disableError"
|
||||
class="mb-4"
|
||||
@keyup.enter="disableTotp"
|
||||
/>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<VBtn
|
||||
color="error"
|
||||
:loading="isDisabling"
|
||||
:disabled="disableCode.length !== 6"
|
||||
@click="disableTotp"
|
||||
>
|
||||
<VIcon start icon="mdi-shield-off" />
|
||||
Disable TOTP
|
||||
</VBtn>
|
||||
<VBtn
|
||||
variant="outlined"
|
||||
@click="cancelDisable"
|
||||
>
|
||||
Cancel
|
||||
</VBtn>
|
||||
</div>
|
||||
</template>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Recovery Codes Dialog -->
|
||||
<VDialog
|
||||
v-model="showRecoveryCodesDialog"
|
||||
max-width="500"
|
||||
persistent
|
||||
>
|
||||
<VCard>
|
||||
<VCardTitle class="d-flex align-center">
|
||||
<VIcon start icon="mdi-key" color="warning" />
|
||||
Save Your Recovery Codes
|
||||
</VCardTitle>
|
||||
|
||||
<VCardText>
|
||||
<VAlert
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
>
|
||||
<p class="mb-0">
|
||||
<strong>Important:</strong> Save these recovery codes in a safe place.
|
||||
If you lose access to your authenticator app, you can use one of these codes to regain access to your account.
|
||||
Each code can only be used once.
|
||||
</p>
|
||||
</VAlert>
|
||||
|
||||
<VCard variant="outlined" class="pa-4 mb-4">
|
||||
<div class="d-flex flex-wrap gap-2 justify-center">
|
||||
<VChip
|
||||
v-for="(code, index) in recoveryCodes"
|
||||
:key="index"
|
||||
variant="tonal"
|
||||
class="font-weight-medium"
|
||||
>
|
||||
{{ code }}
|
||||
</VChip>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<div class="d-flex gap-2 justify-center">
|
||||
<VBtn
|
||||
variant="outlined"
|
||||
size="small"
|
||||
@click="copyRecoveryCodes"
|
||||
>
|
||||
<VIcon start :icon="recoveryCodesCopied ? 'mdi-check' : 'mdi-content-copy'" />
|
||||
{{ recoveryCodesCopied ? 'Copied!' : 'Copy Codes' }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
variant="outlined"
|
||||
size="small"
|
||||
@click="downloadRecoveryCodes"
|
||||
>
|
||||
<VIcon start icon="mdi-download" />
|
||||
Download
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="justify-end pa-4">
|
||||
<VBtn
|
||||
color="primary"
|
||||
@click="finishSetup"
|
||||
>
|
||||
I've Saved My Codes
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</VCol>
|
||||
</template>
|
||||
Reference in New Issue
Block a user