feat(02-06): Account tab in SettingsView + QR code in TotpEnrollment (GAPs 3, 5)
- frontend/package.json: add qrcode@1.5.4 to runtime dependencies - TotpEnrollment.vue: import QRCode; generate data URL in startSetup(); render img tag - SettingsAccountTab.vue: new component with all AccountView content (2FA, password, sessions) - SettingsView.vue: add Account tab rendering SettingsAccountTab; import SettingsAccountTab
This commit is contained in:
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pinia": "^2.1.0",
|
"pinia": "^2.1.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
"vue-router": "^4.3.0"
|
"vue-router": "^4.3.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,17 +28,10 @@
|
|||||||
Open your authenticator app and scan this QR code, or enter the key manually.
|
Open your authenticator app and scan this QR code, or enter the key manually.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- QR code — rendered via provisioning URI link (no QR library dependency) -->
|
<!-- QR code — rendered as an inline image via qrcode library -->
|
||||||
<div class="bg-white border border-gray-200 rounded-xl p-6 flex flex-col items-center gap-4">
|
<div class="bg-white border border-gray-200 rounded-xl p-6 flex flex-col items-center gap-4">
|
||||||
<p class="text-xs text-gray-500 text-center">Scan with your authenticator app using the link below, or enter the secret key manually:</p>
|
<p class="text-xs text-gray-500 text-center">Scan this QR code with your authenticator app:</p>
|
||||||
<a
|
<img v-if="qrDataUrl" :src="qrDataUrl" alt="TOTP QR code" class="w-48 h-48 rounded-xl border border-gray-200" />
|
||||||
:href="qrUri"
|
|
||||||
class="text-sm font-semibold text-indigo-600 hover:text-indigo-700 underline break-all text-center"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Open in authenticator app
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -115,6 +108,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import QRCode from 'qrcode'
|
||||||
import * as api from '../../api/client.js'
|
import * as api from '../../api/client.js'
|
||||||
import AppSpinner from '../ui/AppSpinner.vue'
|
import AppSpinner from '../ui/AppSpinner.vue'
|
||||||
import BackupCodesDisplay from './BackupCodesDisplay.vue'
|
import BackupCodesDisplay from './BackupCodesDisplay.vue'
|
||||||
@@ -123,6 +117,7 @@ const emit = defineEmits(['enrolled'])
|
|||||||
|
|
||||||
const step = ref('setup')
|
const step = ref('setup')
|
||||||
const qrUri = ref('')
|
const qrUri = ref('')
|
||||||
|
const qrDataUrl = ref('')
|
||||||
const secret = ref('')
|
const secret = ref('')
|
||||||
const verifyCode = ref('')
|
const verifyCode = ref('')
|
||||||
const backupCodes = ref([])
|
const backupCodes = ref([])
|
||||||
@@ -138,6 +133,7 @@ async function startSetup() {
|
|||||||
const data = await api.totpSetup()
|
const data = await api.totpSetup()
|
||||||
qrUri.value = data.provisioning_uri
|
qrUri.value = data.provisioning_uri
|
||||||
secret.value = data.secret
|
secret.value = data.secret
|
||||||
|
qrDataUrl.value = await QRCode.toDataURL(qrUri.value, { width: 200, margin: 1 })
|
||||||
step.value = 'verify'
|
step.value = 'verify'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
|
||||||
|
<!-- 1. Account information -->
|
||||||
|
<section class="bg-white border border-gray-200 rounded-xl p-6">
|
||||||
|
<h3 class="font-semibold text-gray-800 mb-4">Account information</h3>
|
||||||
|
<div class="space-y-2 text-sm text-gray-700">
|
||||||
|
<div><span class="text-gray-500">Email:</span> {{ authStore.user?.email }}</div>
|
||||||
|
<div><span class="text-gray-500">Username:</span> @{{ authStore.user?.handle }}</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-gray-500">Role:</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2 py-1 rounded text-xs font-semibold"
|
||||||
|
:class="authStore.user?.role === 'admin'
|
||||||
|
? 'bg-indigo-100 text-indigo-700'
|
||||||
|
: 'bg-gray-100 text-gray-600'"
|
||||||
|
>
|
||||||
|
{{ authStore.user?.role }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 2. Two-factor authentication -->
|
||||||
|
<section class="bg-white border border-gray-200 rounded-xl p-6">
|
||||||
|
<h3 class="font-semibold text-gray-800 mb-4">Two-factor authentication</h3>
|
||||||
|
|
||||||
|
<!-- TOTP enabled: show status + disable option -->
|
||||||
|
<template v-if="authStore.user?.totp_enabled">
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<span class="inline-flex items-center gap-1.5 text-sm text-green-700 font-semibold">
|
||||||
|
<!-- Checkmark -->
|
||||||
|
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
Enabled
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="!confirmDisable2fa">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="confirmDisable2fa = true"
|
||||||
|
class="text-sm px-4 py-2 border border-red-300 text-red-600 rounded-lg hover:bg-red-50 transition-colors"
|
||||||
|
>
|
||||||
|
Disable 2FA
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<ConfirmBlock
|
||||||
|
message="This will disable two-factor authentication. Your account will be less secure."
|
||||||
|
confirm-label="Disable 2FA"
|
||||||
|
cancel-label="Keep enabled"
|
||||||
|
@confirmed="disableTotp"
|
||||||
|
@cancelled="confirmDisable2fa = false"
|
||||||
|
/>
|
||||||
|
<p v-if="totpError" class="mt-2 text-xs text-red-600">{{ totpError }}</p>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- TOTP not enabled: show enrollment component -->
|
||||||
|
<template v-else>
|
||||||
|
<TotpEnrollment @enrolled="onTotpEnrolled" />
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 3. Change password -->
|
||||||
|
<section class="bg-white border border-gray-200 rounded-xl p-6">
|
||||||
|
<h3 class="font-semibold text-gray-800 mb-4">Change password</h3>
|
||||||
|
|
||||||
|
<form @submit.prevent="changePassword" class="space-y-4 max-w-sm">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-1">Current password</label>
|
||||||
|
<input
|
||||||
|
v-model="currentPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="block w-full rounded-lg px-3 py-3 text-sm border bg-white text-gray-900 transition-colors focus:ring-2 focus:outline-none"
|
||||||
|
:class="passwordError && passwordError.includes('Current')
|
||||||
|
? 'border-red-500 focus:ring-red-500 focus:border-red-500'
|
||||||
|
: 'border-gray-300 focus:ring-indigo-500 focus:border-indigo-500'"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="passwordError && passwordError.includes('Current')"
|
||||||
|
class="mt-1 text-xs text-red-600"
|
||||||
|
>
|
||||||
|
{{ passwordError }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-1">New password</label>
|
||||||
|
<input
|
||||||
|
v-model="newPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="block w-full rounded-lg px-3 py-3 text-sm border bg-white text-gray-900 transition-colors focus:ring-2 focus:outline-none"
|
||||||
|
:class="passwordError && !passwordError.includes('Current')
|
||||||
|
? 'border-red-500 focus:ring-red-500 focus:border-red-500'
|
||||||
|
: 'border-gray-300 focus:ring-indigo-500 focus:border-indigo-500'"
|
||||||
|
/>
|
||||||
|
<PasswordStrengthBar :password="newPassword" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form-level error (e.g. breach check, strength failure) -->
|
||||||
|
<div
|
||||||
|
v-if="passwordError && !passwordError.includes('Current')"
|
||||||
|
class="p-3 rounded-lg bg-red-50 border border-red-200 text-sm text-red-700"
|
||||||
|
>
|
||||||
|
{{ passwordError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="passwordSuccess" class="text-sm text-green-600">{{ passwordSuccess }}</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="changingPassword"
|
||||||
|
class="flex items-center gap-2 px-6 py-2.5 bg-indigo-600 text-white rounded-lg text-sm font-semibold hover:bg-indigo-700 transition-colors disabled:opacity-75 min-h-[44px]"
|
||||||
|
>
|
||||||
|
<AppSpinner v-if="changingPassword" />
|
||||||
|
{{ changingPassword ? 'Updating…' : 'Update password' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 4. Sessions — sign out all devices -->
|
||||||
|
<section class="bg-white border border-gray-200 rounded-xl p-6">
|
||||||
|
<h3 class="font-semibold text-gray-800 mb-4">Sessions</h3>
|
||||||
|
|
||||||
|
<template v-if="!confirmSignOutAll">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="confirmSignOutAll = true"
|
||||||
|
class="text-sm px-4 py-2 border border-red-300 text-red-600 rounded-lg hover:bg-red-50 transition-colors"
|
||||||
|
>
|
||||||
|
Sign out all devices
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<ConfirmBlock
|
||||||
|
message="This will sign you out of all devices, including this one. You will need to sign in again."
|
||||||
|
confirm-label="Sign out all devices"
|
||||||
|
cancel-label="Keep signed in"
|
||||||
|
@confirmed="signOutAll"
|
||||||
|
@cancelled="confirmSignOutAll = false"
|
||||||
|
>
|
||||||
|
<template #confirm-button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="signOutAll"
|
||||||
|
:disabled="signingOutAll"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-semibold rounded-lg disabled:opacity-75 min-h-[44px]"
|
||||||
|
>
|
||||||
|
<AppSpinner v-if="signingOutAll" />
|
||||||
|
Sign out all devices
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</ConfirmBlock>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../../stores/auth.js'
|
||||||
|
import * as api from '../../api/client.js'
|
||||||
|
import PasswordStrengthBar from '../auth/PasswordStrengthBar.vue'
|
||||||
|
import TotpEnrollment from '../auth/TotpEnrollment.vue'
|
||||||
|
import ConfirmBlock from '../ui/ConfirmBlock.vue'
|
||||||
|
import AppSpinner from '../ui/AppSpinner.vue'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// ── Change password ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const currentPassword = ref('')
|
||||||
|
const newPassword = ref('')
|
||||||
|
const changingPassword = ref(false)
|
||||||
|
const passwordError = ref(null)
|
||||||
|
const passwordSuccess = ref(null)
|
||||||
|
|
||||||
|
async function changePassword() {
|
||||||
|
changingPassword.value = true
|
||||||
|
passwordError.value = null
|
||||||
|
passwordSuccess.value = null
|
||||||
|
try {
|
||||||
|
await api.changePassword({
|
||||||
|
current_password: currentPassword.value,
|
||||||
|
new_password: newPassword.value,
|
||||||
|
})
|
||||||
|
passwordSuccess.value = 'Password updated.'
|
||||||
|
currentPassword.value = ''
|
||||||
|
newPassword.value = ''
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e.message || ''
|
||||||
|
if (msg.toLowerCase().includes('current') || msg.toLowerCase().includes('incorrect')) {
|
||||||
|
passwordError.value = 'Current password is incorrect'
|
||||||
|
} else if (msg.toLowerCase().includes('breach')) {
|
||||||
|
passwordError.value = 'This password has appeared in a data breach. Choose a different password.'
|
||||||
|
} else {
|
||||||
|
passwordError.value = msg
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
changingPassword.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TOTP enrollment ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const confirmDisable2fa = ref(false)
|
||||||
|
const totpError = ref(null)
|
||||||
|
|
||||||
|
function onTotpEnrolled() {
|
||||||
|
if (authStore.user) {
|
||||||
|
authStore.user.totp_enabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disableTotp() {
|
||||||
|
totpError.value = null
|
||||||
|
try {
|
||||||
|
await api.totpDisable()
|
||||||
|
if (authStore.user) {
|
||||||
|
authStore.user.totp_enabled = false
|
||||||
|
}
|
||||||
|
confirmDisable2fa.value = false
|
||||||
|
} catch (e) {
|
||||||
|
totpError.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sign out all devices ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const confirmSignOutAll = ref(false)
|
||||||
|
const signingOutAll = ref(false)
|
||||||
|
|
||||||
|
async function signOutAll() {
|
||||||
|
signingOutAll.value = true
|
||||||
|
try {
|
||||||
|
await authStore.logoutAll()
|
||||||
|
await router.push('/login')
|
||||||
|
} finally {
|
||||||
|
signingOutAll.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -48,6 +48,9 @@
|
|||||||
<!-- Tab: AI Configuration -->
|
<!-- Tab: AI Configuration -->
|
||||||
<SettingsAiTab v-if="activeTab === 'ai'" />
|
<SettingsAiTab v-if="activeTab === 'ai'" />
|
||||||
|
|
||||||
|
<!-- Tab: Account -->
|
||||||
|
<SettingsAccountTab v-if="activeTab === 'account'" />
|
||||||
|
|
||||||
<!-- Tab: Cloud Storage -->
|
<!-- Tab: Cloud Storage -->
|
||||||
<div v-if="activeTab === 'cloud'">
|
<div v-if="activeTab === 'cloud'">
|
||||||
<!-- OAuth error banner (persistent until dismissed) -->
|
<!-- OAuth error banner (persistent until dismissed) -->
|
||||||
@@ -86,6 +89,7 @@ import { useRouter } from 'vue-router'
|
|||||||
import SettingsPreferencesTab from '../components/settings/SettingsPreferencesTab.vue'
|
import SettingsPreferencesTab from '../components/settings/SettingsPreferencesTab.vue'
|
||||||
import SettingsAiTab from '../components/settings/SettingsAiTab.vue'
|
import SettingsAiTab from '../components/settings/SettingsAiTab.vue'
|
||||||
import SettingsCloudTab from '../components/settings/SettingsCloudTab.vue'
|
import SettingsCloudTab from '../components/settings/SettingsCloudTab.vue'
|
||||||
|
import SettingsAccountTab from '../components/settings/SettingsAccountTab.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -93,6 +97,7 @@ const tabs = [
|
|||||||
{ id: 'preferences', label: 'Preferences' },
|
{ id: 'preferences', label: 'Preferences' },
|
||||||
{ id: 'ai', label: 'AI Configuration' },
|
{ id: 'ai', label: 'AI Configuration' },
|
||||||
{ id: 'cloud', label: 'Cloud Storage' },
|
{ id: 'cloud', label: 'Cloud Storage' },
|
||||||
|
{ id: 'account', label: 'Account' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const activeTab = ref('preferences')
|
const activeTab = ref('preferences')
|
||||||
|
|||||||
Reference in New Issue
Block a user