From e7d6dfef909aa37c1c19408360dc7ff9455562f5 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sat, 21 Mar 2026 02:19:43 -0700 Subject: [PATCH] =?UTF-8?q?feat(settings):=20foundation=20=E2=80=94=20appC?= =?UTF-8?q?onfig=20store,=20settings=20shell,=20nested=20router?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add useAppConfigStore (isCloud, isDevMode, tier, contractedClient, inferenceProfile) - Add GET /api/config/app endpoint to dev-api.py (reads env vars) - Replace flat /settings route with nested children (9 tabs) + redirect to my-profile - Add global router.beforeEach guard for system/fine-tune/developer tab access control - Add SettingsView.vue shell: desktop sidebar with group labels, mobile chip bar, RouterView - Tab visibility driven reactively by store state (cloud mode hides system, GPU profile gates fine-tune, devMode gates developer) - Tests: 3 store tests + 3 component tests, all passing --- dev-api.py | 16 ++ web/src/router/index.ts | 35 ++++- web/src/stores/appConfig.test.ts | 41 +++++ web/src/stores/appConfig.ts | 31 ++++ web/src/views/settings/SettingsView.test.ts | 36 +++++ web/src/views/settings/SettingsView.vue | 156 ++++++++++++++++++++ 6 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 web/src/stores/appConfig.test.ts create mode 100644 web/src/stores/appConfig.ts create mode 100644 web/src/views/settings/SettingsView.test.ts create mode 100644 web/src/views/settings/SettingsView.vue diff --git a/dev-api.py b/dev-api.py index 08a81a9..77d0aca 100644 --- a/dev-api.py +++ b/dev-api.py @@ -868,6 +868,22 @@ def move_job(job_id: int, body: MoveBody): return {"ok": True} +# ── GET /api/config/app ─────────────────────────────────────────────────────── + +@app.get("/api/config/app") +def get_app_config(): + import os + profile = os.environ.get("INFERENCE_PROFILE", "cpu") + valid_profiles = {"remote", "cpu", "single-gpu", "dual-gpu"} + return { + "isCloud": os.environ.get("CLOUD_MODE", "").lower() in ("1", "true"), + "isDevMode": os.environ.get("DEV_MODE", "").lower() in ("1", "true"), + "tier": os.environ.get("APP_TIER", "free"), + "contractedClient": os.environ.get("CONTRACTED_CLIENT", "").lower() in ("1", "true"), + "inferenceProfile": profile if profile in valid_profiles else "cpu", + } + + # ── GET /api/config/user ────────────────────────────────────────────────────── @app.get("/api/config/user") diff --git a/web/src/router/index.ts b/web/src/router/index.ts index ba1a4ec..46a3b27 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -1,4 +1,5 @@ import { createRouter, createWebHistory } from 'vue-router' +import { useAppConfigStore } from '../stores/appConfig' export const router = createRouter({ history: createWebHistory(), @@ -13,8 +14,40 @@ export const router = createRouter({ { path: '/prep/:id', component: () => import('../views/InterviewPrepView.vue') }, { path: '/survey', component: () => import('../views/SurveyView.vue') }, { path: '/survey/:id', component: () => import('../views/SurveyView.vue') }, - { path: '/settings', component: () => import('../views/SettingsView.vue') }, + { + path: '/settings', + component: () => import('../views/settings/SettingsView.vue'), + redirect: '/settings/my-profile', + children: [ + { path: 'my-profile', component: () => import('../views/settings/MyProfileView.vue') }, + { path: 'resume', component: () => import('../views/settings/ResumeProfileView.vue') }, + { path: 'search', component: () => import('../views/settings/SearchPrefsView.vue') }, + { path: 'system', component: () => import('../views/settings/SystemSettingsView.vue') }, + { path: 'fine-tune', component: () => import('../views/settings/FineTuneView.vue') }, + { path: 'license', component: () => import('../views/settings/LicenseView.vue') }, + { path: 'data', component: () => import('../views/settings/DataView.vue') }, + { path: 'privacy', component: () => import('../views/settings/PrivacyView.vue') }, + { path: 'developer', component: () => import('../views/settings/DeveloperView.vue') }, + ], + }, // Catch-all — FastAPI serves index.html for all unknown routes (SPA mode) { path: '/:pathMatch(.*)*', redirect: '/' }, ], }) + +router.beforeEach((to, _from, next) => { + if (!to.path.startsWith('/settings/')) return next() + const config = useAppConfigStore() + const tab = to.path.replace('/settings/', '') + const devOverride = localStorage.getItem('dev_tier_override') + const gpuProfiles = ['single-gpu', 'dual-gpu'] + + if (tab === 'system' && config.isCloud) return next('/settings/my-profile') + if (tab === 'fine-tune') { + const cloudBlocked = config.isCloud && config.tier !== 'premium' + const selfHostedBlocked = !config.isCloud && !gpuProfiles.includes(config.inferenceProfile) + if (cloudBlocked || selfHostedBlocked) return next('/settings/my-profile') + } + if (tab === 'developer' && !config.isDevMode && !devOverride) return next('/settings/my-profile') + next() +}) diff --git a/web/src/stores/appConfig.test.ts b/web/src/stores/appConfig.test.ts new file mode 100644 index 0000000..88214c5 --- /dev/null +++ b/web/src/stores/appConfig.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useAppConfigStore } from './appConfig' + +vi.mock('../composables/useApi', () => ({ + useApiFetch: vi.fn(), +})) + +import { useApiFetch } from '../composables/useApi' +const mockFetch = vi.mocked(useApiFetch) + +describe('useAppConfigStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it('defaults to safe values before load', () => { + const store = useAppConfigStore() + expect(store.isCloud).toBe(false) + expect(store.tier).toBe('free') + }) + + it('load() populates from API response', async () => { + mockFetch.mockResolvedValue({ + data: { isCloud: true, isDevMode: false, tier: 'paid', contractedClient: false, inferenceProfile: 'cpu' }, + error: null, + }) + const store = useAppConfigStore() + await store.load() + expect(store.isCloud).toBe(true) + expect(store.tier).toBe('paid') + }) + + it('load() error leaves defaults intact', async () => { + mockFetch.mockResolvedValue({ data: null, error: { kind: 'network', message: 'fail' } }) + const store = useAppConfigStore() + await store.load() + expect(store.isCloud).toBe(false) + }) +}) diff --git a/web/src/stores/appConfig.ts b/web/src/stores/appConfig.ts new file mode 100644 index 0000000..9042f84 --- /dev/null +++ b/web/src/stores/appConfig.ts @@ -0,0 +1,31 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' +import { useApiFetch } from '../composables/useApi' + +export type Tier = 'free' | 'paid' | 'premium' | 'ultra' +export type InferenceProfile = 'remote' | 'cpu' | 'single-gpu' | 'dual-gpu' + +export const useAppConfigStore = defineStore('appConfig', () => { + const isCloud = ref(false) + const isDevMode = ref(false) + const tier = ref('free') + const contractedClient = ref(false) + const inferenceProfile = ref('cpu') + const loaded = ref(false) + + async function load() { + const { data } = await useApiFetch<{ + isCloud: boolean; isDevMode: boolean; tier: Tier + contractedClient: boolean; inferenceProfile: InferenceProfile + }>('/api/config/app') + if (!data) return + isCloud.value = data.isCloud + isDevMode.value = data.isDevMode + tier.value = data.tier + contractedClient.value = data.contractedClient + inferenceProfile.value = data.inferenceProfile + loaded.value = true + } + + return { isCloud, isDevMode, tier, contractedClient, inferenceProfile, loaded, load } +}) diff --git a/web/src/views/settings/SettingsView.test.ts b/web/src/views/settings/SettingsView.test.ts new file mode 100644 index 0000000..7507b90 --- /dev/null +++ b/web/src/views/settings/SettingsView.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { createRouter, createWebHistory } from 'vue-router' +import SettingsView from './SettingsView.vue' +import { useAppConfigStore } from '../../stores/appConfig' + +function makeRouter() { + return createRouter({ history: createWebHistory(), routes: [{ path: '/:p*', component: { template: '
' } }] }) +} + +describe('SettingsView sidebar', () => { + beforeEach(() => setActivePinia(createPinia())) + + it('hides System group items in cloud mode', async () => { + const store = useAppConfigStore() + store.isCloud = true + const wrapper = mount(SettingsView, { global: { plugins: [makeRouter()] } }) + expect(wrapper.find('[data-testid="nav-system"]').exists()).toBe(false) + }) + + it('shows System when not cloud', async () => { + const store = useAppConfigStore() + store.isCloud = false + const wrapper = mount(SettingsView, { global: { plugins: [makeRouter()] } }) + expect(wrapper.find('[data-testid="nav-system"]').exists()).toBe(true) + }) + + it('hides Developer when neither devMode nor devTierOverride', () => { + const store = useAppConfigStore() + store.isDevMode = false + localStorage.removeItem('dev_tier_override') + const wrapper = mount(SettingsView, { global: { plugins: [makeRouter()] } }) + expect(wrapper.find('[data-testid="nav-developer"]').exists()).toBe(false) + }) +}) diff --git a/web/src/views/settings/SettingsView.vue b/web/src/views/settings/SettingsView.vue new file mode 100644 index 0000000..5603347 --- /dev/null +++ b/web/src/views/settings/SettingsView.vue @@ -0,0 +1,156 @@ + + + + +