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:
Dorian
2026-03-03 20:52:53 +00:00
co-authored by Claude Opus 4.6
parent ee7f0ede1f
commit 621a324859
2 changed files with 180 additions and 1 deletions
+41 -1
View File
@@ -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>