feat: master password unlock UI with first-run vault creation

Add the unlock screen that gates entry to the main app. Includes
app store (unlocked state, firstRun flag), a centered dark-themed
unlock card with WRAITH branding, password validation for first-run
vault creation, and conditional rendering in App.vue.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-17 06:32:10 -04:00
parent 8b891dca00
commit d67e183d72
5 changed files with 238 additions and 4 deletions

View File

@ -1,8 +1,30 @@
<template>
<div class="h-screen flex items-center justify-center">
<div class="text-center">
<h1 class="text-3xl font-bold text-[#58a6ff]">WRAITH</h1>
<p class="text-[#8b949e] mt-2">Desktop shell loading...</p>
<div class="h-screen w-screen bg-[var(--wraith-bg-primary)]">
<!-- Loading state -->
<div v-if="appStore.isLoading" class="h-full flex items-center justify-center">
<div class="text-center">
<h1 class="text-3xl font-bold text-[var(--wraith-accent-blue)]">WRAITH</h1>
<p class="text-[var(--wraith-text-secondary)] mt-2">Loading...</p>
</div>
</div>
<!-- Unlock screen -->
<UnlockLayout v-else-if="!appStore.isUnlocked" />
<!-- Main application -->
<MainLayout v-else />
</div>
</template>
<script setup lang="ts">
import { onMounted } from "vue";
import { useAppStore } from "@/stores/app.store";
import UnlockLayout from "@/layouts/UnlockLayout.vue";
import MainLayout from "@/layouts/MainLayout.vue";
const appStore = useAppStore();
onMounted(() => {
appStore.checkFirstRun();
});
</script>

7
frontend/src/env.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<object, object, unknown>;
export default component;
}

View File

@ -0,0 +1,122 @@
<template>
<div class="h-screen w-screen flex items-center justify-center bg-[var(--wraith-bg-primary)]">
<div class="w-full max-w-sm px-6">
<!-- Branding -->
<div class="text-center mb-8">
<h1 class="text-4xl font-bold tracking-widest text-[var(--wraith-accent-blue)]">
WRAITH
</h1>
<p class="text-[var(--wraith-text-secondary)] mt-2 text-sm">
{{ appStore.isFirstRun ? "Create a master password" : "Enter your master password" }}
</p>
</div>
<!-- Card -->
<form
class="bg-[var(--wraith-bg-secondary)] border border-[var(--wraith-border)] rounded-lg p-6 space-y-4"
@submit.prevent="handleSubmit"
>
<!-- Error -->
<div
v-if="appStore.error"
class="text-sm text-[var(--wraith-accent-red)] bg-[var(--wraith-accent-red)]/10 border border-[var(--wraith-accent-red)]/20 rounded px-3 py-2"
>
{{ appStore.error }}
</div>
<!-- Password -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1.5">
Master Password
</label>
<input
ref="passwordInput"
v-model="password"
type="password"
autocomplete="current-password"
placeholder="Enter password..."
class="w-full px-3 py-2 text-sm rounded bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
@input="appStore.clearError()"
/>
</div>
<!-- Confirm password (first run only) -->
<div v-if="appStore.isFirstRun">
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1.5">
Confirm Password
</label>
<input
v-model="confirmPassword"
type="password"
autocomplete="new-password"
placeholder="Confirm password..."
class="w-full px-3 py-2 text-sm rounded bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
@input="appStore.clearError()"
/>
</div>
<!-- Submit -->
<button
type="submit"
:disabled="submitting"
class="w-full py-2 text-sm font-medium rounded bg-[var(--wraith-accent-blue)] text-white hover:opacity-90 disabled:opacity-50 transition-opacity cursor-pointer disabled:cursor-not-allowed"
>
<span v-if="submitting">{{ appStore.isFirstRun ? "Creating..." : "Unlocking..." }}</span>
<span v-else>{{ appStore.isFirstRun ? "Create Vault" : "Unlock" }}</span>
</button>
</form>
<!-- Version -->
<p class="text-center text-xs text-[var(--wraith-text-muted)] mt-4">
v1.0.0-alpha
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useAppStore } from "@/stores/app.store";
const appStore = useAppStore();
const password = ref("");
const confirmPassword = ref("");
const submitting = ref(false);
const passwordInput = ref<HTMLInputElement | null>(null);
onMounted(() => {
passwordInput.value?.focus();
});
async function handleSubmit(): Promise<void> {
if (!password.value) {
appStore.error = "Password is required";
return;
}
if (appStore.isFirstRun) {
if (password.value.length < 8) {
appStore.error = "Password must be at least 8 characters";
return;
}
if (password.value !== confirmPassword.value) {
appStore.error = "Passwords do not match";
return;
}
}
submitting.value = true;
try {
if (appStore.isFirstRun) {
await appStore.createVault(password.value);
} else {
await appStore.unlock(password.value);
}
} catch {
// Error is set in the store
} finally {
submitting.value = false;
}
}
</script>

View File

@ -0,0 +1,77 @@
import { defineStore } from "pinia";
import { ref } from "vue";
/**
* Wraith application store.
* Manages unlock state, first-run detection, and vault operations.
*
* Once Wails v3 bindings are generated, the mock calls below will be
* replaced with actual WraithApp.IsFirstRun(), CreateVault(), Unlock(), etc.
*/
export const useAppStore = defineStore("app", () => {
const isUnlocked = ref(false);
const isFirstRun = ref(true);
const isLoading = ref(true);
const error = ref<string | null>(null);
/** Check whether the vault has been created before. */
async function checkFirstRun(): Promise<void> {
try {
// TODO: replace with Wails binding — WraithApp.IsFirstRun()
isFirstRun.value = true;
} catch {
isFirstRun.value = true;
} finally {
isLoading.value = false;
}
}
/** Create a new vault with the given master password. */
async function createVault(password: string): Promise<void> {
error.value = null;
try {
// TODO: replace with Wails binding — WraithApp.CreateVault(password)
void password;
isFirstRun.value = false;
isUnlocked.value = true;
} catch (e) {
error.value = e instanceof Error ? e.message : "Failed to create vault";
throw e;
}
}
/** Unlock an existing vault with the master password. */
async function unlock(password: string): Promise<void> {
error.value = null;
try {
// TODO: replace with Wails binding — WraithApp.Unlock(password)
void password;
isUnlocked.value = true;
} catch (e) {
error.value = e instanceof Error ? e.message : "Invalid master password";
throw e;
}
}
/** Lock the vault (return to unlock screen). */
function lock(): void {
isUnlocked.value = false;
}
/** Clear the current error message. */
function clearError(): void {
error.value = null;
}
return {
isUnlocked,
isFirstRun,
isLoading,
error,
checkFirstRun,
createVault,
unlock,
lock,
clearError,
};
});

View File

@ -1,7 +1,13 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
import { resolve } from "path";
export default defineConfig({
plugins: [vue(), tailwindcss()],
resolve: {
alias: {
"@": resolve(__dirname, "src"),
},
},
});