Files
archy/neode-ui/src/router/index.ts
T
DorianandClaude Opus 4.7 1709149ebd release(v1.7.29-alpha): VPS as default app registry + settings UI
- New Settings → App registries page (/dashboard/settings/registries)
  that mirrors the update-mirrors experience: list of configured
  registries, test reachability, set primary, add/remove. New
  registry.set-primary RPC; existing registry.{list,add,remove,test}
  reused.
- Default RegistryConfig flipped: VPS (23.182.128.160:3000/lfg2025) is
  now Server 1 (primary), tx1138 is Server 2 (fallback).
- Install pipeline now rewrites the first pull to the primary registry
  URL before attempting it. Before this, installs always hit whichever
  registry the image was hardcoded to, so changing the primary didn't
  actually affect where images came from. On failure, the existing
  fallback walk skips the primary (already tried) and walks the rest.
- App catalog proxy UPSTREAMS order flipped so the catalog follows the
  same VPS-first rule.
- Reboot overlay: animated "a" logo now sits in the center of the ring
  (matches the screensaver composition). Extracted the logo-wrapper
  pattern inline.

7/7 registry tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:54:07 -04:00

373 lines
11 KiB
TypeScript

import { createRouter, createWebHistory } from 'vue-router'
import { nextTick } from 'vue'
import { useAppStore } from '../stores/app'
import { stopAllAudio } from '../composables/useLoginSounds'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: () => import('../views/OnboardingWrapper.vue'),
meta: { public: true },
children: [
{
path: '',
component: () => import('../views/RootRedirect.vue'),
},
{
path: 'login',
name: 'login',
component: () => import('../views/Login.vue'),
},
{
path: 'onboarding/intro',
name: 'onboarding-intro',
component: () => import('../views/OnboardingIntro.vue'),
},
{
path: 'onboarding/options',
name: 'onboarding-options',
component: () => import('../views/OnboardingOptions.vue'),
},
{
path: 'onboarding/path',
name: 'onboarding-path',
component: () => import('../views/OnboardingPath.vue'),
},
{
path: 'onboarding/seed',
name: 'onboarding-seed',
component: () => import('../views/OnboardingSeedGenerate.vue'),
},
{
path: 'onboarding/seed-verify',
name: 'onboarding-seed-verify',
component: () => import('../views/OnboardingSeedVerify.vue'),
},
{
path: 'onboarding/seed-restore',
name: 'onboarding-seed-restore',
component: () => import('../views/OnboardingSeedRestore.vue'),
},
{
path: 'onboarding/did',
name: 'onboarding-did',
component: () => import('../views/OnboardingDid.vue'),
},
{
path: 'onboarding/identity',
name: 'onboarding-identity',
component: () => import('../views/OnboardingIdentity.vue'),
},
{
path: 'onboarding/backup',
name: 'onboarding-backup',
component: () => import('../views/OnboardingBackup.vue'),
},
{
path: 'onboarding/verify',
name: 'onboarding-verify',
component: () => import('../views/OnboardingVerify.vue'),
},
{
path: 'onboarding/done',
name: 'onboarding-done',
component: () => import('../views/OnboardingDone.vue'),
},
],
},
{
path: '/recovery',
name: 'recovery',
component: () => import('../views/KioskRecovery.vue'),
meta: { public: true },
},
{
path: '/kiosk',
name: 'kiosk',
redirect: '/',
beforeEnter: () => {
// Persist kiosk mode before redirect so App.vue can skip the remote relay
// (relay duplicates xdotool input on the kiosk display)
localStorage.setItem('kiosk', 'true')
},
},
{
path: '/dashboard',
component: () => import('../views/Dashboard.vue'),
children: [
{
path: '',
name: 'home',
component: () => import('../views/Home.vue'),
},
{
path: 'apps',
name: 'apps',
component: () => import('../views/Apps.vue'),
},
{
path: 'apps/:id',
name: 'app-details',
component: () => import('../views/AppDetails.vue'),
},
{
path: 'apps/lnd/channels',
name: 'lightning-channels',
component: () => import('../views/apps/LightningChannels.vue'),
},
{
path: 'discover',
name: 'discover',
component: () => import('../views/Discover.vue'),
},
{
path: 'marketplace',
name: 'marketplace',
component: () => import('../views/Marketplace.vue'),
},
{
path: 'marketplace/:id',
name: 'marketplace-app-detail',
component: () => import('../views/MarketplaceAppDetails.vue'),
},
{
path: 'cloud',
name: 'cloud',
component: () => import('../views/Cloud.vue'),
},
{
path: 'cloud/peers/:peerId?',
name: 'peer-files',
component: () => import('../views/PeerFiles.vue'),
props: true,
},
{
path: 'cloud/:folderId',
name: 'cloud-folder',
component: () => import('../views/CloudFolder.vue'),
},
{
path: 'server',
name: 'server',
component: () => import('../views/Server.vue'),
},
{
path: 'monitoring',
name: 'monitoring',
component: () => import('../views/Monitoring.vue'),
},
{
path: 'fleet',
name: 'fleet',
component: () => import('../views/Fleet.vue'),
},
{
path: 'server/federation',
name: 'federation',
component: () => import('../views/Federation.vue'),
},
{
path: 'mesh',
name: 'mesh',
component: () => import('../views/Mesh.vue'),
},
{
path: 'web5',
name: 'web5',
component: () => import('../views/web5/Web5.vue'),
},
{
path: 'web5/credentials',
name: 'credentials',
component: () => import('../views/Credentials.vue'),
},
{
path: 'settings',
name: 'settings',
component: () => import('../views/Settings.vue'),
},
{
path: 'settings/update',
name: 'system-update',
component: () => import('../views/SystemUpdate.vue'),
},
{
path: 'settings/registries',
name: 'app-registries',
component: () => import('../views/AppRegistries.vue'),
},
{
path: 'goals/:goalId',
name: 'goal-detail',
component: () => import('../views/GoalDetail.vue'),
},
{
path: 'chat',
name: 'chat',
component: () => import('../views/Chat.vue'),
},
{
path: 'app-session/:appId',
name: 'app-session',
component: () => import('../views/AppSession.vue'),
},
// Containers removed: My Apps serves the same purpose. Redirect old links.
{
path: 'containers',
redirect: () => ({ path: '/dashboard/apps' }),
},
{
path: 'containers/:id',
redirect: (to) => ({ path: `/dashboard/apps/${to.params.id}` }),
},
],
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('../views/NotFound.vue'),
},
],
})
// Session check with timeout - avoids endless spinner on mobile/slow networks
const SESSION_CHECK_TIMEOUT_MS = 8000
async function checkSessionWithTimeout(store: ReturnType<typeof useAppStore>): Promise<boolean> {
try {
return await Promise.race([
store.checkSession(),
new Promise<boolean>((resolve) =>
setTimeout(() => resolve(false), SESSION_CHECK_TIMEOUT_MS)
),
])
} catch {
return false
}
}
/**
* Navigation Guard
* Handles authentication and onboarding flow routing
*/
export 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) => {
const store = useAppStore()
const isPublic = to.meta.public
// Allow all public routes (login, onboarding) without auth check
if (isPublic) {
// If authenticated and visiting /login: show login immediately, validate in background.
// This prevents endless spinner on mobile when checkSession hangs (slow/unreachable network).
if (to.path === '/login' && store.isAuthenticated) {
// Redirect back to intended page (from ?redirect= query) or default to home
const rawRedirect = to.query.redirect
const redirectTo = isLocalRedirect(rawRedirect) ? rawRedirect : '/dashboard'
if (store.needsSessionValidation()) {
next()
checkSessionWithTimeout(store).then((valid) => {
if (valid) {
router.replace(redirectTo).catch(() => {})
}
})
return
}
next(redirectTo)
return
}
next()
return
}
// Protected routes: validate session if stale auth from localStorage
if (store.needsSessionValidation()) {
// localStorage says we're authed — proceed immediately, revalidate in background.
// No timeout wrapper here: a slow server shouldn't bounce the user to login.
next()
store.checkSession().then((valid) => {
if (!valid) {
router.replace({ path: '/login', query: { redirect: to.fullPath } }).catch(() => {})
}
})
return
}
// Not authenticated at all (with timeout to avoid endless spinner on mobile)
if (!store.isAuthenticated) {
const hasSession = await checkSessionWithTimeout(store)
if (hasSession) {
next()
return
}
// Check if this is a fresh install that needs onboarding
try {
const { isOnboardingComplete, getSavedOnboardingStep } = await import('@/composables/useOnboarding')
const setupDone = await isOnboardingComplete()
if (!setupDone) {
const step = getSavedOnboardingStep()
next(`/onboarding/${step}`)
return
}
} catch {
// If we can't check, assume fresh install and show onboarding
next('/onboarding/intro')
return
}
next({ path: '/login', query: { redirect: to.fullPath } })
return
}
// Validated and authenticated - ensure WebSocket is connected
if (!store.isConnected && !store.isReconnecting) {
store.connectWebSocket().catch((err) => {
if (import.meta.env.DEV) console.warn('[Router] WebSocket connection failed:', err)
})
}
next()
})
// Persist onboarding step so page refresh resumes where user left off
router.afterEach((to) => {
const match = to.path.match(/^\/onboarding\/(.+)/)
if (match && match[1] !== 'intro') {
localStorage.setItem('neode_onboarding_step', match[1]!)
}
})
// Stop all login/splash audio when entering the dashboard
router.afterEach((to, from) => {
if (to.path.startsWith('/dashboard') && !from.path.startsWith('/dashboard')) {
stopAllAudio()
}
})
// Focus Home nav item for gamepad when landing on dashboard home (e.g. after login)
router.afterEach((to) => {
if (to.path === '/dashboard' || to.path === '/dashboard/') {
nextTick(() => {
setTimeout(() => {
const homeLink = document.querySelector<HTMLAnchorElement>(
'[data-controller-zone="sidebar"] a[href="/dashboard"], [data-controller-zone="sidebar"] a[href="/dashboard/"]'
)
if (homeLink) homeLink.focus()
}, 150)
})
}
})
export default router