feat(app): add passphrase dialog and encrypted storage layer
Create PassphraseDialog.vue with create/enter passphrase flows, glass morphism styling, and skip option. Wire into App.vue to prompt on startup when crypto is enabled. Salt stored in localStorage, key derived via PBKDF2 and held in memory for the session only. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
ee7f0ede1f
commit
621a324859
@@ -2,18 +2,58 @@
|
||||
<div class="h-dvh flex flex-col" :class="currentTheme">
|
||||
<RouterView />
|
||||
<ArticleOverlay />
|
||||
<PassphraseDialog
|
||||
:visible="showPassphrase"
|
||||
:is-creating="isCreatingPassphrase"
|
||||
@submit="handlePassphraseSubmit"
|
||||
@skip="showPassphrase = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import ArticleOverlay from '@/components/content/ArticleOverlay.vue'
|
||||
import PassphraseDialog from '@/components/ui/PassphraseDialog.vue'
|
||||
import {
|
||||
isCryptoEnabled,
|
||||
deriveKey,
|
||||
generateSalt,
|
||||
setSessionKey,
|
||||
} from '@/utils/crypto'
|
||||
|
||||
const { currentTheme, initTheme } = useTheme()
|
||||
|
||||
const showPassphrase = ref(false)
|
||||
const isCreatingPassphrase = ref(false)
|
||||
|
||||
const SALT_KEY = 'aiui-crypto-salt'
|
||||
|
||||
async function handlePassphraseSubmit(passphrase: string) {
|
||||
let saltHex = localStorage.getItem(SALT_KEY)
|
||||
let salt: Uint8Array
|
||||
|
||||
if (saltHex) {
|
||||
salt = new Uint8Array(saltHex.match(/.{2}/g)!.map(b => parseInt(b, 16)))
|
||||
} else {
|
||||
salt = await generateSalt()
|
||||
saltHex = Array.from(salt).map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
localStorage.setItem(SALT_KEY, saltHex)
|
||||
}
|
||||
|
||||
const key = await deriveKey(passphrase, salt)
|
||||
setSessionKey(key, salt)
|
||||
showPassphrase.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initTheme()
|
||||
if (isCryptoEnabled()) {
|
||||
const hasSalt = !!localStorage.getItem(SALT_KEY)
|
||||
isCreatingPassphrase.value = !hasSalt
|
||||
showPassphrase.value = true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
@click.self="handleSkip"
|
||||
/>
|
||||
|
||||
<!-- Dialog -->
|
||||
<div
|
||||
class="relative w-full max-w-sm rounded-2xl p-6 space-y-4 animate-scale-in"
|
||||
:class="isDark
|
||||
? 'bg-gray-900 border border-white/10'
|
||||
: 'bg-white border border-gray-200 shadow-xl'"
|
||||
>
|
||||
<h2
|
||||
class="text-lg font-bold"
|
||||
:class="isDark ? 'text-white/90' : 'text-gray-900'"
|
||||
>
|
||||
{{ isCreating ? 'Create Passphrase' : 'Enter Passphrase' }}
|
||||
</h2>
|
||||
<p
|
||||
class="text-xs"
|
||||
:class="isDark ? 'text-white/50' : 'text-gray-500'"
|
||||
>
|
||||
{{ isCreating
|
||||
? 'Set a passphrase to encrypt your data. You\'ll need it to access your conversations.'
|
||||
: 'Enter your passphrase to decrypt your data.'
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="passphrase"
|
||||
type="password"
|
||||
:placeholder="isCreating ? 'Create a passphrase...' : 'Enter your passphrase...'"
|
||||
class="w-full px-4 py-3 rounded-xl text-sm outline-none transition-colors"
|
||||
:class="isDark
|
||||
? 'bg-white/5 text-white/90 placeholder:text-white/25 focus:bg-white/10 border border-white/10'
|
||||
: 'bg-gray-50 text-gray-800 placeholder:text-gray-400 focus:bg-gray-100 border border-gray-200'"
|
||||
style="font-size: 16px"
|
||||
@keydown.enter="handleSubmit"
|
||||
/>
|
||||
|
||||
<input
|
||||
v-if="isCreating"
|
||||
v-model="confirmPassphrase"
|
||||
type="password"
|
||||
placeholder="Confirm passphrase..."
|
||||
class="w-full px-4 py-3 rounded-xl text-sm outline-none transition-colors"
|
||||
:class="isDark
|
||||
? 'bg-white/5 text-white/90 placeholder:text-white/25 focus:bg-white/10 border border-white/10'
|
||||
: 'bg-gray-50 text-gray-800 placeholder:text-gray-400 focus:bg-gray-100 border border-gray-200'"
|
||||
style="font-size: 16px"
|
||||
@keydown.enter="handleSubmit"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="errorMessage"
|
||||
class="text-xs text-red-400"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="flex-1 py-2.5 rounded-xl text-sm font-medium transition-colors"
|
||||
:class="isDark
|
||||
? 'bg-white/5 text-white/50 hover:bg-white/10'
|
||||
: 'bg-gray-100 text-gray-500 hover:bg-gray-200'"
|
||||
@click="handleSkip"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-2.5 rounded-xl text-sm font-medium transition-colors bg-accent text-white hover:bg-accent/90"
|
||||
:disabled="!canSubmit"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': !canSubmit }"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ isCreating ? 'Create' : 'Unlock' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
isCreating: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [passphrase: string]
|
||||
skip: []
|
||||
}>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const passphrase = ref('')
|
||||
const confirmPassphrase = ref('')
|
||||
const errorMessage = ref('')
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
if (!passphrase.value.trim()) return false
|
||||
if (props.isCreating && passphrase.value !== confirmPassphrase.value) return false
|
||||
return true
|
||||
})
|
||||
|
||||
function handleSubmit() {
|
||||
if (!canSubmit.value) {
|
||||
if (props.isCreating && passphrase.value !== confirmPassphrase.value) {
|
||||
errorMessage.value = 'Passphrases do not match'
|
||||
}
|
||||
return
|
||||
}
|
||||
errorMessage.value = ''
|
||||
emit('submit', passphrase.value)
|
||||
}
|
||||
|
||||
function handleSkip() {
|
||||
emit('skip')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => inputRef.value?.focus())
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user