fix: Phase 5 — XSS sanitization, cookie security, redirect validation, input trimming
- BootScreen + Settings: v-html now uses DOMPurify.sanitize() for SVG content - FileBrowser cookie: added Secure flag and 24h expiration - TOTP secret: hidden by default with reveal toggle button - Login redirect: validates URL is local-origin before redirecting - Auth fields: password inputs trimmed before submission - Route params: appId validated against safe pattern, invalid IDs redirect to /apps Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b1e54e3626
commit
d9b4478512
14
loop/plan.md
14
loop/plan.md
@ -378,7 +378,7 @@
|
|||||||
> into the page (XSS), steal login cookies, or redirect you to a fake site after login. We fix all
|
> into the page (XSS), steal login cookies, or redirect you to a fake site after login. We fix all
|
||||||
> of these and add proper input sanitization everywhere.
|
> of these and add proper input sanitization everywhere.
|
||||||
|
|
||||||
- [ ] **Fix v-html XSS in BootScreen and Settings**: In `neode-ui/src/components/BootScreen.vue` line 55, replace `v-html="icons[currentIcon]"` with a safe rendering approach:
|
- [x] **Fix v-html XSS in BootScreen and Settings**: In `neode-ui/src/components/BootScreen.vue` line 55, replace `v-html="icons[currentIcon]"` with a safe rendering approach:
|
||||||
1. Since the icons are hardcoded SVG strings, create a computed property that returns the current icon and use `v-html` with a DOMPurify sanitizer.
|
1. Since the icons are hardcoded SVG strings, create a computed property that returns the current icon and use `v-html` with a DOMPurify sanitizer.
|
||||||
2. Install DOMPurify: `cd neode-ui && npm install dompurify && npm install -D @types/dompurify`.
|
2. Install DOMPurify: `cd neode-ui && npm install dompurify && npm install -D @types/dompurify`.
|
||||||
3. Verify the package exists first: `npm view dompurify version`.
|
3. Verify the package exists first: `npm view dompurify version`.
|
||||||
@ -395,7 +395,7 @@
|
|||||||
6. Run `npm run type-check` to verify.
|
6. Run `npm run type-check` to verify.
|
||||||
7. Build and deploy. Verify boot screen animation still works. Verify TOTP QR code still renders on Settings page.
|
7. Build and deploy. Verify boot screen animation still works. Verify TOTP QR code still renders on Settings page.
|
||||||
|
|
||||||
- [ ] **Fix FileBrowser cookie security flags**: In `neode-ui/src/api/filebrowser-client.ts` line 62, find `document.cookie = \`auth=${this.token}; path=/app/filebrowser; SameSite=Strict\``. This cookie is missing security flags. Since we can't set `HttpOnly` from JavaScript (that's a server-side flag), the best we can do client-side is:
|
- [x] **Fix FileBrowser cookie security flags**: In `neode-ui/src/api/filebrowser-client.ts` line 62, find `document.cookie = \`auth=${this.token}; path=/app/filebrowser; SameSite=Strict\``. This cookie is missing security flags. Since we can't set `HttpOnly` from JavaScript (that's a server-side flag), the best we can do client-side is:
|
||||||
```typescript
|
```typescript
|
||||||
document.cookie = `auth=${this.token}; path=/app/filebrowser; SameSite=Strict; Secure`
|
document.cookie = `auth=${this.token}; path=/app/filebrowser; SameSite=Strict; Secure`
|
||||||
```
|
```
|
||||||
@ -407,7 +407,7 @@
|
|||||||
```
|
```
|
||||||
Build and deploy. Verify FileBrowser still works (login, browse, download).
|
Build and deploy. Verify FileBrowser still works (login, browse, download).
|
||||||
|
|
||||||
- [ ] **Hide TOTP secret by default**: In `neode-ui/src/views/Settings.vue`, find line 289 with `{{ totpSecretBase32 }}`. Wrap it in a reveal toggle:
|
- [x] **Hide TOTP secret by default**: In `neode-ui/src/views/Settings.vue`, find line 289 with `{{ totpSecretBase32 }}`. Wrap it in a reveal toggle:
|
||||||
1. Add a ref: `const showTotpSecret = ref(false)`
|
1. Add a ref: `const showTotpSecret = ref(false)`
|
||||||
2. Replace the display with:
|
2. Replace the display with:
|
||||||
```vue
|
```vue
|
||||||
@ -425,7 +425,7 @@
|
|||||||
3. Remove the `select-all` class — users should deliberately copy, not accidentally select.
|
3. Remove the `select-all` class — users should deliberately copy, not accidentally select.
|
||||||
Build and deploy. Verify TOTP setup flow still works.
|
Build and deploy. Verify TOTP setup flow still works.
|
||||||
|
|
||||||
- [ ] **Validate redirect URL after login**: In `neode-ui/src/router/index.ts`, find line 231 with `const redirectTo = (to.query.redirect as string) || '/dashboard'`. Replace with:
|
- [x] **Validate redirect URL after login**: In `neode-ui/src/router/index.ts`, find line 231 with `const redirectTo = (to.query.redirect as string) || '/dashboard'`. Replace with:
|
||||||
```typescript
|
```typescript
|
||||||
function isLocalRedirect(path: unknown): path is string {
|
function isLocalRedirect(path: unknown): path is string {
|
||||||
if (typeof path !== 'string') return false
|
if (typeof path !== 'string') return false
|
||||||
@ -443,13 +443,13 @@
|
|||||||
```
|
```
|
||||||
Run `npm run type-check`. Build and deploy. Test: visit `http://192.168.1.198/login?redirect=https://evil.com` — after login should go to `/dashboard`, NOT `evil.com`. Visit `http://192.168.1.198/login?redirect=/mesh` — after login should go to `/mesh`.
|
Run `npm run type-check`. Build and deploy. Test: visit `http://192.168.1.198/login?redirect=https://evil.com` — after login should go to `/dashboard`, NOT `evil.com`. Visit `http://192.168.1.198/login?redirect=/mesh` — after login should go to `/mesh`.
|
||||||
|
|
||||||
- [ ] **Add input trimming to all auth fields**: In `neode-ui/src/views/Login.vue`, find all password and input submissions. Add `.trim()` before sending:
|
- [x] **Add input trimming to all auth fields**: In `neode-ui/src/views/Login.vue`, find all password and input submissions. Add `.trim()` before sending:
|
||||||
1. Search for `password.value` in the file. Wherever it's submitted via RPC (e.g., `params: { password: password.value }`), change to `params: { password: password.value.trim() }`.
|
1. Search for `password.value` in the file. Wherever it's submitted via RPC (e.g., `params: { password: password.value }`), change to `params: { password: password.value.trim() }`.
|
||||||
2. Do the same for TOTP code inputs, setup passwords, confirm passwords.
|
2. Do the same for TOTP code inputs, setup passwords, confirm passwords.
|
||||||
3. Also check `neode-ui/src/views/Settings.vue` for password change forms — trim those too.
|
3. Also check `neode-ui/src/views/Settings.vue` for password change forms — trim those too.
|
||||||
Run `npm run type-check`. Build and deploy. Test login with a password that has trailing spaces — should still work.
|
Run `npm run type-check`. Build and deploy. Test login with a password that has trailing spaces — should still work.
|
||||||
|
|
||||||
- [ ] **Validate route parameters**: In `neode-ui/src/views/AppDetails.vue` (line ~485) and `neode-ui/src/views/AppSession.vue` (line ~267), add app ID validation:
|
- [x] **Validate route parameters**: In `neode-ui/src/views/AppDetails.vue` (line ~485) and `neode-ui/src/views/AppSession.vue` (line ~267), add app ID validation:
|
||||||
1. Create a utility function in `neode-ui/src/utils/` or inline:
|
1. Create a utility function in `neode-ui/src/utils/` or inline:
|
||||||
```typescript
|
```typescript
|
||||||
function isValidAppId(id: unknown): id is string {
|
function isValidAppId(id: unknown): id is string {
|
||||||
@ -469,7 +469,7 @@
|
|||||||
```
|
```
|
||||||
Build and deploy. Test: navigate to a valid app — should work. Navigate to `/app/../../etc/passwd` — should redirect to `/apps`.
|
Build and deploy. Test: navigate to a valid app — should work. Navigate to `/app/../../etc/passwd` — should redirect to `/apps`.
|
||||||
|
|
||||||
- [ ] **Verify Phase 5 — Frontend hardened**: Run these checks:
|
- [x] **Verify Phase 5 — Frontend hardened**: Run these checks:
|
||||||
1. `grep -rn "v-html" neode-ui/src/ --include="*.vue" | grep -v "DOMPurify\|sanitize"` — any remaining v-html should be justified.
|
1. `grep -rn "v-html" neode-ui/src/ --include="*.vue" | grep -v "DOMPurify\|sanitize"` — any remaining v-html should be justified.
|
||||||
2. `grep -rn "select-all" neode-ui/src/ --include="*.vue"` — TOTP secret should NOT have select-all.
|
2. `grep -rn "select-all" neode-ui/src/ --include="*.vue"` — TOTP secret should NOT have select-all.
|
||||||
3. `npm run type-check` — zero errors.
|
3. `npm run type-check` — zero errors.
|
||||||
|
|||||||
25
neode-ui/package-lock.json
generated
25
neode-ui/package-lock.json
generated
@ -1,14 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"version": "1.1.0",
|
"version": "1.2.0-alpha",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"version": "1.1.0",
|
"version": "1.2.0-alpha",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
|
"dompurify": "^3.3.3",
|
||||||
"fast-json-patch": "^3.1.1",
|
"fast-json-patch": "^3.1.1",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
@ -3809,6 +3811,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/dompurify": {
|
||||||
|
"version": "3.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
||||||
|
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/trusted-types": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@ -3854,7 +3865,6 @@
|
|||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@vite-pwa/assets-generator": {
|
"node_modules/@vite-pwa/assets-generator": {
|
||||||
@ -6194,6 +6204,15 @@
|
|||||||
"node": ">= 8.0"
|
"node": ">= 8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dompurify": {
|
||||||
|
"version": "3.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||||
|
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||||
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@types/trusted-types": "^2.0.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
|||||||
@ -24,7 +24,9 @@
|
|||||||
"generate-welcome-speech": "node scripts/generate-welcome-speech.js"
|
"generate-welcome-speech": "node scripts/generate-welcome-speech.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
|
"dompurify": "^3.3.3",
|
||||||
"fast-json-patch": "^3.1.1",
|
"fast-json-patch": "^3.1.1",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
|||||||
@ -59,7 +59,8 @@ class FileBrowserClient {
|
|||||||
// FileBrowser returns the JWT as a plain string (possibly quoted)
|
// FileBrowser returns the JWT as a plain string (possibly quoted)
|
||||||
this.token = text.replace(/^"|"$/g, '')
|
this.token = text.replace(/^"|"$/g, '')
|
||||||
// Store token as cookie for img/video/audio src requests (avoids token in URL)
|
// Store token as cookie for img/video/audio src requests (avoids token in URL)
|
||||||
document.cookie = `auth=${this.token}; path=/app/filebrowser; SameSite=Strict`
|
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toUTCString()
|
||||||
|
document.cookie = `auth=${this.token}; path=/app/filebrowser; SameSite=Strict; Secure; expires=${expires}`
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@ -52,7 +52,7 @@
|
|||||||
<div class="boot-icon-inner">
|
<div class="boot-icon-inner">
|
||||||
<Transition name="icon-morph" mode="out-in">
|
<Transition name="icon-morph" mode="out-in">
|
||||||
<div v-if="!bootDone" :key="currentIcon" class="boot-pixel-wrap" :class="{ 'boot-glitch': glitching }">
|
<div v-if="!bootDone" :key="currentIcon" class="boot-pixel-wrap" :class="{ 'boot-glitch': glitching }">
|
||||||
<div v-html="icons[currentIcon]" />
|
<div v-html="sanitizedIcon" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else key="logo" class="boot-logo-inner-logo">
|
<div v-else key="logo" class="boot-logo-inner-logo">
|
||||||
<AnimatedLogo size="xl" no-border fit />
|
<AnimatedLogo size="xl" no-border fit />
|
||||||
@ -70,7 +70,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||||
|
|
||||||
const props = defineProps<{ visible: boolean }>()
|
const props = defineProps<{ visible: boolean }>()
|
||||||
@ -158,6 +159,8 @@ const bootMessages = [
|
|||||||
{ delay: 23500, prefix: '***', text: 'ALL SYSTEMS OPERATIONAL', type: 'ready' },
|
{ delay: 23500, prefix: '***', text: 'ALL SYSTEMS OPERATIONAL', type: 'ready' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const sanitizedIcon = computed(() => DOMPurify.sanitize(icons[currentIcon.value] || '', { USE_PROFILES: { svg: true } }))
|
||||||
|
|
||||||
// Starfield
|
// Starfield
|
||||||
let animFrame = 0
|
let animFrame = 0
|
||||||
const stars: { x: number; y: number; z: number }[] = []
|
const stars: { x: number; y: number; z: number }[] = []
|
||||||
|
|||||||
@ -218,6 +218,17 @@ async function checkSessionWithTimeout(store: ReturnType<typeof useAppStore>): P
|
|||||||
* Navigation Guard
|
* Navigation Guard
|
||||||
* Handles authentication and onboarding flow routing
|
* Handles authentication and onboarding flow routing
|
||||||
*/
|
*/
|
||||||
|
function isLocalRedirect(path: unknown): path is string {
|
||||||
|
if (typeof path !== 'string') return false
|
||||||
|
try {
|
||||||
|
if (path.startsWith('//') || path.includes('://')) return false
|
||||||
|
const url = new URL(path, window.location.origin)
|
||||||
|
return url.origin === window.location.origin
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
router.beforeEach(async (to, _from, next) => {
|
router.beforeEach(async (to, _from, next) => {
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
const isPublic = to.meta.public
|
const isPublic = to.meta.public
|
||||||
@ -228,7 +239,8 @@ router.beforeEach(async (to, _from, next) => {
|
|||||||
// This prevents endless spinner on mobile when checkSession hangs (slow/unreachable network).
|
// This prevents endless spinner on mobile when checkSession hangs (slow/unreachable network).
|
||||||
if (to.path === '/login' && store.isAuthenticated) {
|
if (to.path === '/login' && store.isAuthenticated) {
|
||||||
// Redirect back to intended page (from ?redirect= query) or default to home
|
// Redirect back to intended page (from ?redirect= query) or default to home
|
||||||
const redirectTo = (to.query.redirect as string) || '/dashboard'
|
const rawRedirect = to.query.redirect
|
||||||
|
const redirectTo = isLocalRedirect(rawRedirect) ? rawRedirect : '/dashboard'
|
||||||
if (store.needsSessionValidation()) {
|
if (store.needsSessionValidation()) {
|
||||||
next()
|
next()
|
||||||
checkSessionWithTimeout(store).then((valid) => {
|
checkSessionWithTimeout(store).then((valid) => {
|
||||||
|
|||||||
@ -482,7 +482,14 @@ const route = useRoute()
|
|||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const appId = computed(() => route.params.id as string)
|
const appId = computed(() => {
|
||||||
|
const id = route.params.id
|
||||||
|
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || id.length > 64) {
|
||||||
|
router.replace('/apps')
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
})
|
||||||
|
|
||||||
// Web-only app detection (no container — external websites)
|
// Web-only app detection (no container — external websites)
|
||||||
const WEB_ONLY_APP_URLS: Record<string, string> = {
|
const WEB_ONLY_APP_URLS: Record<string, string> = {
|
||||||
|
|||||||
@ -264,7 +264,14 @@ const panelClasses = computed(() => {
|
|||||||
return `${base} app-session-overlay`
|
return `${base} app-session-overlay`
|
||||||
})
|
})
|
||||||
|
|
||||||
const appId = computed(() => props.appIdProp || (route.params.appId as string))
|
const appId = computed(() => {
|
||||||
|
const id = props.appIdProp || (route.params.appId as string)
|
||||||
|
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || id.length > 64) {
|
||||||
|
router.replace('/apps')
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
})
|
||||||
|
|
||||||
/** Container apps: direct port access (avoids root-relative asset breakage under /app/xxx/ proxy) */
|
/** Container apps: direct port access (avoids root-relative asset breakage under /app/xxx/ proxy) */
|
||||||
const APP_PORTS: Record<string, number> = {
|
const APP_PORTS: Record<string, number> = {
|
||||||
|
|||||||
@ -372,14 +372,14 @@ async function handleSetup() {
|
|||||||
try {
|
try {
|
||||||
await rpcClient.call({
|
await rpcClient.call({
|
||||||
method: 'auth.setup',
|
method: 'auth.setup',
|
||||||
params: { password: password.value }
|
params: { password: password.value.trim() }
|
||||||
})
|
})
|
||||||
|
|
||||||
stopSynthwave()
|
stopSynthwave()
|
||||||
whooshAway.value = true
|
whooshAway.value = true
|
||||||
playLoginSuccessWhoosh()
|
playLoginSuccessWhoosh()
|
||||||
loginTransition.setJustLoggedIn(true)
|
loginTransition.setJustLoggedIn(true)
|
||||||
await store.login(password.value)
|
await store.login(password.value.trim())
|
||||||
await new Promise(r => setTimeout(r, 520))
|
await new Promise(r => setTimeout(r, 520))
|
||||||
await router.replace(loginRedirectTo.value).catch(() => {
|
await router.replace(loginRedirectTo.value).catch(() => {
|
||||||
window.location.href = loginRedirectTo.value
|
window.location.href = loginRedirectTo.value
|
||||||
@ -412,7 +412,7 @@ async function handleLogin() {
|
|||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await store.login(password.value)
|
const result = await store.login(password.value.trim())
|
||||||
if (result?.requires_totp) {
|
if (result?.requires_totp) {
|
||||||
requiresTotp.value = true
|
requiresTotp.value = true
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
|||||||
@ -283,10 +283,16 @@
|
|||||||
<template v-else-if="totpSetupStep === 2">
|
<template v-else-if="totpSetupStep === 2">
|
||||||
<h3 class="text-lg font-semibold text-white mb-2">{{ t('settings.scanQrCode') }}</h3>
|
<h3 class="text-lg font-semibold text-white mb-2">{{ t('settings.scanQrCode') }}</h3>
|
||||||
<p class="text-white/60 text-sm mb-4">{{ t('settings.scanQrInstruction') }}</p>
|
<p class="text-white/60 text-sm mb-4">{{ t('settings.scanQrInstruction') }}</p>
|
||||||
<div class="flex justify-center mb-4 bg-white rounded-xl p-4 mx-auto w-fit" v-html="totpQrSvg" />
|
<div class="flex justify-center mb-4 bg-white rounded-xl p-4 mx-auto w-fit" v-html="sanitizedQrSvg" />
|
||||||
<div class="bg-black/30 rounded-lg px-3 py-2 mb-4">
|
<div v-if="totpSecretBase32" class="bg-black/30 rounded-lg px-3 py-2 mb-4">
|
||||||
<p class="text-xs text-white/50 mb-1">{{ t('settings.manualEntryKey') }}</p>
|
<p class="text-xs text-white/50 mb-1">Manual entry key (keep secret!):</p>
|
||||||
<p class="text-sm font-mono text-orange-400 break-all select-all">{{ totpSecretBase32 }}</p>
|
<div v-if="showTotpSecret" class="flex items-center gap-2">
|
||||||
|
<p class="text-sm font-mono text-orange-400 break-all">{{ totpSecretBase32 }}</p>
|
||||||
|
<button type="button" class="glass-button text-xs px-2 py-1" @click="showTotpSecret = false">Hide</button>
|
||||||
|
</div>
|
||||||
|
<button v-else type="button" class="glass-button text-xs px-3 py-1" @click="showTotpSecret = true">
|
||||||
|
Show manual entry key
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form @submit.prevent="confirmTotpSetup" class="space-y-4">
|
<form @submit.prevent="confirmTotpSetup" class="space-y-4">
|
||||||
<input
|
<input
|
||||||
@ -859,6 +865,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, onMounted, nextTick } from 'vue'
|
import { computed, ref, onMounted, nextTick } from 'vue'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { SUPPORTED_LOCALES, setLocale, type SupportedLocale } from '@/i18n'
|
import { SUPPORTED_LOCALES, setLocale, type SupportedLocale } from '@/i18n'
|
||||||
@ -1018,7 +1025,9 @@ const totpSetupCode = ref('')
|
|||||||
const totpSetupError = ref('')
|
const totpSetupError = ref('')
|
||||||
const totpSetupLoading = ref(false)
|
const totpSetupLoading = ref(false)
|
||||||
const totpQrSvg = ref('')
|
const totpQrSvg = ref('')
|
||||||
|
const sanitizedQrSvg = computed(() => DOMPurify.sanitize(totpQrSvg.value, { USE_PROFILES: { svg: true } }))
|
||||||
const totpSecretBase32 = ref('')
|
const totpSecretBase32 = ref('')
|
||||||
|
const showTotpSecret = ref(false)
|
||||||
const totpPendingToken = ref('')
|
const totpPendingToken = ref('')
|
||||||
const totpBackupCodes = ref<string[]>([])
|
const totpBackupCodes = ref<string[]>([])
|
||||||
const backupCodesCopied = ref(false)
|
const backupCodesCopied = ref(false)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user