Initial commit

This commit is contained in:
root
2025-12-21 10:00:51 -05:00
committed by Sebastian Krupinski
commit b3d456d453
17 changed files with 3693 additions and 0 deletions

View 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>