archy/neode-ui/src/views/Login.vue
Dorian 62d6c13764 Implement onboarding reset functionality and enhance backup features
- Added a new method to reset the onboarding state, allowing users to re-initiate the onboarding process.
- Integrated backup creation functionality, enabling users to create encrypted backups of their node identity.
- Updated API endpoints to handle onboarding reset and backup creation requests.
- Enhanced UI components to support the new onboarding reset and backup features, including error handling and user feedback.
- Introduced new dependencies for cryptographic operations and data encoding.
2026-03-02 08:34:13 +00:00

357 lines
12 KiB
Vue

<template>
<div class="min-h-screen flex items-center justify-center p-4 relative z-10 login-fly-perspective">
<div class="w-full max-w-md relative z-20">
<!-- Login Card - flies towards user on success -->
<div
class="glass-card p-8 pt-20 relative login-card overflow-visible"
:class="{ 'login-fly-towards': whooshAway }"
>
<!-- Logo - half in, half out of container -->
<div class="absolute -top-10 left-1/2 -translate-x-1/2 z-10">
<div class="logo-gradient-border w-20 h-20">
<AnimatedLogo no-border fit />
</div>
</div>
<!-- Title -->
<h1 class="text-2xl font-semibold text-white/96 text-center mb-8 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
<span v-if="isSetupMode && !isSetup">Set Up Your Node</span>
<span v-else>Welcome to Archipelago</span>
</h1>
<!-- Error Message -->
<div v-if="error" class="mb-4 p-3 bg-red-500/20 border border-red-500/40 rounded-lg text-red-200 text-sm">
{{ error }}
</div>
<!-- Setup Mode: Password Setup -->
<template v-if="isSetupMode && !isSetup">
<div class="mb-4 p-4 bg-white/5 border border-white/10 rounded-lg text-white/80 text-sm">
<p class="mb-2">Create a password to secure your Archipelago node.</p>
<p class="text-white/60 text-xs">This password will be required to access your node.</p>
</div>
<div class="mb-4">
<label for="password" class="block text-sm font-medium text-white/80 mb-2">
Password
</label>
<input
id="password"
v-model="password"
type="password"
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
placeholder="Enter a password (min 8 characters)"
@keyup.enter="handleSetupWithSound"
:disabled="loading"
/>
</div>
<div class="mb-6">
<label for="confirmPassword" class="block text-sm font-medium text-white/80 mb-2">
Confirm Password
</label>
<input
id="confirmPassword"
v-model="confirmPassword"
type="password"
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
placeholder="Confirm your password"
@keyup.enter="handleSetupWithSound"
:disabled="loading"
/>
</div>
<button
@click="handleSetupWithSound"
:disabled="loading || !password || password !== confirmPassword"
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span v-if="!loading">Set Up Node</span>
<span v-else class="flex items-center justify-center">
<svg class="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Setting up...
</span>
</button>
</template>
<!-- Normal Login Mode -->
<template v-else>
<div class="mb-6">
<label for="password" class="block text-sm font-medium text-white/80 mb-2">
Password
</label>
<input
id="password"
v-model="password"
type="password"
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
placeholder="Enter your password"
@keyup.enter="handleLoginWithSound"
:disabled="loading"
/>
</div>
<button
@click="handleLoginWithSound"
:disabled="loading || !password"
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span v-if="!loading">Login</span>
<span v-else class="flex items-center justify-center">
<svg class="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Logging in...
</span>
</button>
</template>
<!-- Footer Links -->
<div class="mt-6 text-center text-sm text-white/60">
<a href="#" class="hover:text-white/80 transition-colors">Forgot password?</a>
</div>
</div>
<!-- Replay Intro / Restart Onboarding - Bottom of Page -->
<div class="mt-8 text-center flex items-center justify-center gap-4">
<button
@click="replayIntro"
class="text-xs text-white/50 hover:text-white/70 transition-colors underline-offset-2 hover:underline"
>
Replay Intro
</button>
<span class="text-white/30">|</span>
<button
@click="restartOnboarding"
:disabled="isResettingOnboarding"
class="text-xs text-white/50 hover:text-white/70 transition-colors underline-offset-2 hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ isResettingOnboarding ? 'Resetting...' : 'Onboarding' }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import AnimatedLogo from '@/components/AnimatedLogo.vue'
import { useAppStore } from '../stores/app'
import { useLoginTransitionStore } from '../stores/loginTransition'
import { rpcClient } from '../api/rpc-client'
import { resumeAudioContext, startSynthwave, stopSynthwave, playLoginSuccessWhoosh, playPop } from '@/composables/useLoginSounds'
const router = useRouter()
const store = useAppStore()
const loginTransition = useLoginTransitionStore()
const password = ref('')
const confirmPassword = ref('')
const loading = ref(false)
const error = ref<string | null>(null)
const isSetup = ref(false)
const whooshAway = ref(false)
// Check if we're in setup mode (original StartOS node setup)
const isSetupMode = computed(() => {
return import.meta.env.VITE_DEV_MODE === 'setup'
})
let unlockHandler: (() => void) | null = null
function removeUnlockListeners() {
if (unlockHandler) {
document.removeEventListener('click', unlockHandler)
document.removeEventListener('touchstart', unlockHandler)
document.removeEventListener('keydown', unlockHandler)
unlockHandler = null
}
}
onBeforeUnmount(removeUnlockListeners)
onMounted(async () => {
const fromSplash = sessionStorage.getItem('archipelago_from_splash') === '1'
if (fromSplash) sessionStorage.removeItem('archipelago_from_splash')
unlockHandler = () => {
if (!fromSplash) {
resumeAudioContext()
startSynthwave()
}
removeUnlockListeners()
}
document.addEventListener('click', unlockHandler, { once: true })
document.addEventListener('touchstart', unlockHandler, { once: true })
document.addEventListener('keydown', unlockHandler, { once: true })
if (isSetupMode.value) {
try {
const result = await rpcClient.call<boolean>({ method: 'auth.isSetup', params: {}, timeout: 8000 })
isSetup.value = Boolean(result)
} catch {
isSetup.value = false
}
} else {
isSetup.value = true
}
})
function handleSetupWithSound() {
if (!loading.value && password.value && password.value === confirmPassword.value) {
playPop()
}
handleSetup()
}
async function handleSetup() {
if (!password.value || password.value.length < 8) {
error.value = 'Password must be at least 8 characters'
return
}
if (password.value !== confirmPassword.value) {
error.value = 'Passwords do not match'
return
}
loading.value = true
error.value = null
try {
await rpcClient.call({
method: 'auth.setup',
params: { password: password.value }
})
stopSynthwave()
whooshAway.value = true
playLoginSuccessWhoosh()
loginTransition.setJustLoggedIn(true)
await store.login(password.value)
await new Promise(r => setTimeout(r, 520))
await router.replace({ name: 'home' }).catch(() => {
window.location.href = '/dashboard'
})
} catch (err) {
whooshAway.value = false
const msg = err instanceof Error ? err.message : ''
if (/502|503|Bad Gateway|timeout|fetch|network/i.test(msg)) {
error.value = 'Server is starting up. Please try again in a moment.'
} else {
error.value = msg || 'Setup failed. Please try again.'
}
startSynthwave()
} finally {
loading.value = false
}
}
function handleLoginWithSound() {
if (!loading.value && password.value) {
playPop()
}
handleLogin()
}
async function handleLogin() {
if (!password.value) return
loading.value = true
error.value = null
try {
await store.login(password.value)
stopSynthwave()
whooshAway.value = true
playLoginSuccessWhoosh()
loginTransition.setJustLoggedIn(true)
await new Promise(r => setTimeout(r, 520))
await router.replace({ name: 'home' }).catch(() => {
window.location.href = '/dashboard'
})
} catch (err) {
whooshAway.value = false
const msg = err instanceof Error ? err.message : ''
if (/502|503|Bad Gateway|timeout|fetch|network/i.test(msg)) {
error.value = 'Server is starting up. Please try again in a moment.'
} else {
error.value = msg || 'Login failed. Please check your password.'
}
startSynthwave()
} finally {
loading.value = false
}
}
function replayIntro() {
// Clear the intro seen flag
localStorage.removeItem('neode_intro_seen')
// Navigate to root to trigger splash screen
window.location.href = '/'
}
const isResettingOnboarding = ref(false)
async function restartOnboarding() {
if (isResettingOnboarding.value) return
isResettingOnboarding.value = true
try {
await rpcClient.resetOnboarding()
localStorage.removeItem('neode_onboarding_complete')
localStorage.removeItem('neode_did')
localStorage.removeItem('neode_did_state')
localStorage.removeItem('neode_backup_created')
await router.push('/onboarding/intro')
window.location.reload()
} catch (err) {
console.error('Failed to reset onboarding:', err)
error.value = err instanceof Error ? err.message : 'Failed to reset onboarding'
} finally {
isResettingOnboarding.value = false
}
}
</script>
<style scoped>
/* Perspective for 3D fly effect */
.login-fly-perspective {
perspective: 1200px;
perspective-origin: center center;
}
.login-card {
transform-style: preserve-3d;
transition: transform 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94),
opacity 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94),
filter 0.5s ease-out;
}
/* Fly towards user - card zooms forward as it transitions out */
.login-fly-towards {
animation: login-fly-towards 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}
@keyframes login-fly-towards {
0% {
transform: translateZ(0) scale(1);
opacity: 1;
filter: blur(0);
}
60% {
transform: translateZ(180px) scale(1.4);
opacity: 0.95;
filter: blur(2px);
}
100% {
transform: translateZ(400px) scale(2);
opacity: 0;
filter: blur(8px);
}
}
</style>