feat: add Security settings view with password update and 2FA management
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1,322 @@
|
|||||||
<template><div></div></template>
|
<template>
|
||||||
|
<div class="space-y-6 max-w-lg">
|
||||||
|
<!-- Password change -->
|
||||||
|
<form @submit.prevent="savePassword" class="p-6 bg-white rounded-2xl border border-[#e5ded7] space-y-5">
|
||||||
|
<h2 class="text-lg font-black text-[#4a3f3b]">Change password</h2>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-bold text-[#4a3f3b]">Current password</label>
|
||||||
|
<input
|
||||||
|
v-model="passwordForm.current_password"
|
||||||
|
type="password"
|
||||||
|
class="w-full h-12 px-4 bg-[#faf6f3] border rounded-xl font-medium text-[#4a3f3b] focus:outline-none focus:ring-2 focus:ring-[#bb5b3e]"
|
||||||
|
:class="passwordErrors.current_password ? 'border-red-400' : 'border-[#e5ded7]'"
|
||||||
|
/>
|
||||||
|
<p v-if="passwordErrors.current_password" class="text-xs text-red-600">{{ passwordErrors.current_password[0] }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-bold text-[#4a3f3b]">New password</label>
|
||||||
|
<input
|
||||||
|
v-model="passwordForm.password"
|
||||||
|
type="password"
|
||||||
|
class="w-full h-12 px-4 bg-[#faf6f3] border rounded-xl font-medium text-[#4a3f3b] focus:outline-none focus:ring-2 focus:ring-[#bb5b3e]"
|
||||||
|
:class="passwordErrors.password ? 'border-red-400' : 'border-[#e5ded7]'"
|
||||||
|
/>
|
||||||
|
<p v-if="passwordErrors.password" class="text-xs text-red-600">{{ passwordErrors.password[0] }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-bold text-[#4a3f3b]">Confirm new password</label>
|
||||||
|
<input
|
||||||
|
v-model="passwordForm.password_confirmation"
|
||||||
|
type="password"
|
||||||
|
class="w-full h-12 px-4 bg-[#faf6f3] border rounded-xl font-medium text-[#4a3f3b] focus:outline-none focus:ring-2 focus:ring-[#bb5b3e]"
|
||||||
|
:class="passwordErrors.password_confirmation ? 'border-red-400' : 'border-[#e5ded7]'"
|
||||||
|
/>
|
||||||
|
<p v-if="passwordErrors.password_confirmation" class="text-xs text-red-600">{{ passwordErrors.password_confirmation[0] }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="passwordNetworkError" class="text-sm text-red-600">{{ passwordNetworkError }}</p>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="passwordSaving"
|
||||||
|
class="px-8 py-3 bg-[#bb5b3e] text-white rounded-xl font-bold hover:bg-[#a34a31] transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ passwordSaving ? 'Updating…' : 'Update password' }}
|
||||||
|
</button>
|
||||||
|
<p v-if="passwordSaved" class="text-sm font-bold text-green-600">Password updated!</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Two-factor authentication -->
|
||||||
|
<div class="p-6 bg-white rounded-2xl border border-[#e5ded7] space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-black text-[#4a3f3b]">Two-factor authentication</h2>
|
||||||
|
<p class="text-sm text-[#89726c] mt-0.5">
|
||||||
|
{{ twoFactorEnabled ? 'Enabled — your account is extra secure.' : 'Add extra security to your account.' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="px-3 py-1 rounded-full text-xs font-black"
|
||||||
|
:class="twoFactorEnabled ? 'bg-green-100 text-green-700' : 'bg-[#faf6f3] text-[#89726c]'"
|
||||||
|
>
|
||||||
|
{{ twoFactorEnabled ? 'ON' : 'OFF' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2FA enabling flow -->
|
||||||
|
<div v-if="enablingTwoFactor" class="space-y-4 border-t border-[#e5ded7] pt-4">
|
||||||
|
<p class="text-sm font-bold text-[#4a3f3b]">Scan this QR code in your authenticator app:</p>
|
||||||
|
|
||||||
|
<div v-if="setupData" class="flex flex-col items-start gap-4">
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
|
<div v-html="setupData.svg" class="[&_svg]:w-40 [&_svg]:h-40"></div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="text-xs font-bold text-[#89726c] uppercase tracking-widest">Or enter setup key manually</p>
|
||||||
|
<code class="text-xs bg-[#faf6f3] px-3 py-2 rounded-lg font-mono text-[#4a3f3b] break-all block">{{ setupData.secretKey }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!twoFactorEnabled" class="space-y-2">
|
||||||
|
<label class="text-sm font-bold text-[#4a3f3b]">Enter the 6-digit code to confirm</label>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<input
|
||||||
|
v-model="confirmCode"
|
||||||
|
type="text"
|
||||||
|
maxlength="6"
|
||||||
|
inputmode="numeric"
|
||||||
|
placeholder="000000"
|
||||||
|
class="w-36 h-12 px-4 bg-[#faf6f3] border rounded-xl font-mono text-center text-[#4a3f3b] focus:outline-none focus:ring-2 focus:ring-[#bb5b3e]"
|
||||||
|
:class="confirmError ? 'border-red-400' : 'border-[#e5ded7]'"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="confirmTwoFactor"
|
||||||
|
:disabled="confirmCode.length !== 6 || confirming"
|
||||||
|
class="px-6 py-3 bg-[#bb5b3e] text-white rounded-xl text-sm font-bold hover:bg-[#a34a31] transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ confirming ? 'Verifying…' : 'Confirm' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="confirmError" class="text-xs text-red-600">{{ confirmError }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="twoFactorEnabled" class="text-sm font-bold text-green-600">
|
||||||
|
Two-factor authentication is now active.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="!twoFactorEnabled"
|
||||||
|
@click="cancelEnable"
|
||||||
|
class="text-sm text-[#89726c] hover:text-[#4a3f3b]"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recovery codes (when 2FA enabled and not mid-setup) -->
|
||||||
|
<div v-if="twoFactorEnabled && !enablingTwoFactor" class="border-t border-[#e5ded7] pt-4 space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-sm font-bold text-[#4a3f3b]">Recovery codes</p>
|
||||||
|
<button
|
||||||
|
@click="toggleRecoveryCodes"
|
||||||
|
class="text-xs font-bold text-[#bb5b3e] hover:underline"
|
||||||
|
>
|
||||||
|
{{ showRecoveryCodes ? 'Hide' : 'Show' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="showRecoveryCodes" class="space-y-2">
|
||||||
|
<div class="grid grid-cols-2 gap-1 bg-[#faf6f3] rounded-xl p-4">
|
||||||
|
<code
|
||||||
|
v-for="code in recoveryCodes"
|
||||||
|
:key="code"
|
||||||
|
class="text-xs font-mono text-[#4a3f3b]"
|
||||||
|
>{{ code }}</code>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="regenRecoveryCodes"
|
||||||
|
:disabled="regenLoading"
|
||||||
|
class="text-xs font-bold text-[#89726c] hover:text-[#4a3f3b]"
|
||||||
|
>
|
||||||
|
{{ regenLoading ? 'Regenerating…' : 'Regenerate codes' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<div class="flex gap-3 border-t border-[#e5ded7] pt-4">
|
||||||
|
<button
|
||||||
|
v-if="!twoFactorEnabled && !enablingTwoFactor"
|
||||||
|
@click="enableTwoFactor"
|
||||||
|
:disabled="tfaActionLoading"
|
||||||
|
class="px-6 py-2.5 bg-[#bb5b3e] text-white rounded-xl text-sm font-bold hover:bg-[#a34a31] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ tfaActionLoading ? 'Enabling…' : 'Enable 2FA' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="twoFactorEnabled && !enablingTwoFactor"
|
||||||
|
@click="disableTwoFactor"
|
||||||
|
:disabled="tfaActionLoading"
|
||||||
|
class="px-6 py-2.5 bg-white border border-[#e5ded7] rounded-xl text-sm font-bold text-[#89726c] hover:border-red-300 hover:text-red-600 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ tfaActionLoading ? 'Disabling…' : 'Disable 2FA' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="tfaError" class="text-sm text-red-600">{{ tfaError }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { useAuth } from '../../../composables/useAuth.js'
|
||||||
|
|
||||||
|
const { user, updatePassword } = useAuth()
|
||||||
|
|
||||||
|
// Separate axios instance for Fortify web routes (base URL is / not /api)
|
||||||
|
const web = axios.create({
|
||||||
|
baseURL: '/',
|
||||||
|
withCredentials: true,
|
||||||
|
withXSRFToken: true,
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Password form
|
||||||
|
const passwordForm = ref({ current_password: '', password: '', password_confirmation: '' })
|
||||||
|
const passwordErrors = ref({})
|
||||||
|
const passwordNetworkError = ref('')
|
||||||
|
const passwordSaving = ref(false)
|
||||||
|
const passwordSaved = ref(false)
|
||||||
|
|
||||||
|
async function savePassword() {
|
||||||
|
passwordSaving.value = true
|
||||||
|
passwordSaved.value = false
|
||||||
|
passwordErrors.value = {}
|
||||||
|
passwordNetworkError.value = ''
|
||||||
|
try {
|
||||||
|
await updatePassword(passwordForm.value)
|
||||||
|
passwordForm.value = { current_password: '', password: '', password_confirmation: '' }
|
||||||
|
passwordSaved.value = true
|
||||||
|
setTimeout(() => { passwordSaved.value = false }, 3000)
|
||||||
|
} catch (err) {
|
||||||
|
if (err.response?.status === 422) {
|
||||||
|
passwordErrors.value = err.response.data.errors ?? {}
|
||||||
|
} else {
|
||||||
|
passwordNetworkError.value = 'Something went wrong. Please try again.'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
passwordSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2FA
|
||||||
|
const twoFactorEnabled = ref(false)
|
||||||
|
const enablingTwoFactor = ref(false)
|
||||||
|
const setupData = ref(null)
|
||||||
|
const confirmCode = ref('')
|
||||||
|
const confirmError = ref('')
|
||||||
|
const confirming = ref(false)
|
||||||
|
const showRecoveryCodes = ref(false)
|
||||||
|
const recoveryCodes = ref([])
|
||||||
|
const regenLoading = ref(false)
|
||||||
|
const tfaActionLoading = ref(false)
|
||||||
|
const tfaError = ref('')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
twoFactorEnabled.value = !!user.value?.two_factor_confirmed_at
|
||||||
|
// Ensure CSRF cookie is fresh for Fortify web routes
|
||||||
|
await axios.get('/sanctum/csrf-cookie', { withCredentials: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
async function enableTwoFactor() {
|
||||||
|
tfaActionLoading.value = true
|
||||||
|
tfaError.value = ''
|
||||||
|
try {
|
||||||
|
await web.post('/user/two-factor-authentication')
|
||||||
|
const [qrRes, keyRes] = await Promise.all([
|
||||||
|
web.get('/user/two-factor-qr-code'),
|
||||||
|
web.get('/user/two-factor-secret-key'),
|
||||||
|
])
|
||||||
|
setupData.value = { svg: qrRes.data.svg, secretKey: keyRes.data.secretKey }
|
||||||
|
enablingTwoFactor.value = true
|
||||||
|
} catch {
|
||||||
|
tfaError.value = 'Failed to enable 2FA. Please try again.'
|
||||||
|
} finally {
|
||||||
|
tfaActionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmTwoFactor() {
|
||||||
|
confirming.value = true
|
||||||
|
confirmError.value = ''
|
||||||
|
try {
|
||||||
|
await web.post('/user/confirmed-two-factor-authentication', { code: confirmCode.value })
|
||||||
|
twoFactorEnabled.value = true
|
||||||
|
confirmCode.value = ''
|
||||||
|
await loadRecoveryCodes()
|
||||||
|
} catch (err) {
|
||||||
|
confirmError.value = err.response?.data?.errors?.code?.[0] ?? 'Invalid code. Please try again.'
|
||||||
|
confirmCode.value = ''
|
||||||
|
} finally {
|
||||||
|
confirming.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disableTwoFactor() {
|
||||||
|
tfaActionLoading.value = true
|
||||||
|
tfaError.value = ''
|
||||||
|
try {
|
||||||
|
await web.delete('/user/two-factor-authentication')
|
||||||
|
twoFactorEnabled.value = false
|
||||||
|
showRecoveryCodes.value = false
|
||||||
|
recoveryCodes.value = []
|
||||||
|
} catch {
|
||||||
|
tfaError.value = 'Failed to disable 2FA. Please try again.'
|
||||||
|
} finally {
|
||||||
|
tfaActionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEnable() {
|
||||||
|
enablingTwoFactor.value = false
|
||||||
|
setupData.value = null
|
||||||
|
confirmCode.value = ''
|
||||||
|
confirmError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecoveryCodes() {
|
||||||
|
try {
|
||||||
|
const response = await web.get('/user/two-factor-recovery-codes')
|
||||||
|
recoveryCodes.value = response.data
|
||||||
|
} catch {
|
||||||
|
// Non-fatal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleRecoveryCodes() {
|
||||||
|
if (!showRecoveryCodes.value && recoveryCodes.value.length === 0) {
|
||||||
|
await loadRecoveryCodes()
|
||||||
|
}
|
||||||
|
showRecoveryCodes.value = !showRecoveryCodes.value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function regenRecoveryCodes() {
|
||||||
|
regenLoading.value = true
|
||||||
|
try {
|
||||||
|
await web.post('/user/two-factor-recovery-codes')
|
||||||
|
await loadRecoveryCodes()
|
||||||
|
} catch {
|
||||||
|
// Silently fail
|
||||||
|
} finally {
|
||||||
|
regenLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user