feat(settings): foundation — appConfig store, settings shell, nested router
- 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
This commit is contained in:
parent
5e22067ab5
commit
e7d6dfef90
6 changed files with 314 additions and 1 deletions
16
dev-api.py
16
dev-api.py
|
|
@ -868,6 +868,22 @@ def move_job(job_id: int, body: MoveBody):
|
||||||
return {"ok": True}
|
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 ──────────────────────────────────────────────────────
|
# ── GET /api/config/user ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/api/config/user")
|
@app.get("/api/config/user")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { useAppConfigStore } from '../stores/appConfig'
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
|
|
@ -13,8 +14,40 @@ export const router = createRouter({
|
||||||
{ path: '/prep/:id', component: () => import('../views/InterviewPrepView.vue') },
|
{ path: '/prep/:id', component: () => import('../views/InterviewPrepView.vue') },
|
||||||
{ path: '/survey', component: () => import('../views/SurveyView.vue') },
|
{ path: '/survey', component: () => import('../views/SurveyView.vue') },
|
||||||
{ path: '/survey/:id', 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)
|
// Catch-all — FastAPI serves index.html for all unknown routes (SPA mode)
|
||||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
{ 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()
|
||||||
|
})
|
||||||
|
|
|
||||||
41
web/src/stores/appConfig.test.ts
Normal file
41
web/src/stores/appConfig.test.ts
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
31
web/src/stores/appConfig.ts
Normal file
31
web/src/stores/appConfig.ts
Normal file
|
|
@ -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<Tier>('free')
|
||||||
|
const contractedClient = ref(false)
|
||||||
|
const inferenceProfile = ref<InferenceProfile>('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 }
|
||||||
|
})
|
||||||
36
web/src/views/settings/SettingsView.test.ts
Normal file
36
web/src/views/settings/SettingsView.test.ts
Normal file
|
|
@ -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: '<div/>' } }] })
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
156
web/src/views/settings/SettingsView.vue
Normal file
156
web/src/views/settings/SettingsView.vue
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
<template>
|
||||||
|
<div class="settings-layout">
|
||||||
|
<!-- Desktop sidebar -->
|
||||||
|
<nav class="settings-sidebar" aria-label="Settings navigation">
|
||||||
|
<template v-for="group in visibleGroups" :key="group.label">
|
||||||
|
<div class="nav-group-label">{{ group.label }}</div>
|
||||||
|
<RouterLink
|
||||||
|
v-for="item in group.items"
|
||||||
|
:key="item.path"
|
||||||
|
:to="item.path"
|
||||||
|
:data-testid="`nav-${item.key}`"
|
||||||
|
class="nav-item"
|
||||||
|
active-class="nav-item--active"
|
||||||
|
>{{ item.label }}</RouterLink>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Mobile chip bar -->
|
||||||
|
<div class="settings-chip-bar" role="tablist">
|
||||||
|
<RouterLink
|
||||||
|
v-for="item in visibleTabs"
|
||||||
|
:key="item.path"
|
||||||
|
:to="item.path"
|
||||||
|
class="chip"
|
||||||
|
active-class="chip--active"
|
||||||
|
role="tab"
|
||||||
|
>{{ item.label }}</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="settings-content">
|
||||||
|
<RouterView />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useAppConfigStore } from '../../stores/appConfig'
|
||||||
|
|
||||||
|
const config = useAppConfigStore()
|
||||||
|
const devOverride = computed(() => !!localStorage.getItem('dev_tier_override'))
|
||||||
|
const gpuProfiles = ['single-gpu', 'dual-gpu']
|
||||||
|
|
||||||
|
const showSystem = computed(() => !config.isCloud)
|
||||||
|
const showFineTune = computed(() => {
|
||||||
|
if (config.isCloud) return config.tier === 'premium'
|
||||||
|
return gpuProfiles.includes(config.inferenceProfile)
|
||||||
|
})
|
||||||
|
const showDeveloper = computed(() => config.isDevMode || devOverride.value)
|
||||||
|
|
||||||
|
// IMPORTANT: `show` values must be ComputedRef<boolean> objects (e.g. showSystem),
|
||||||
|
// NOT raw booleans (e.g. showSystem.value). Using .value here would capture a static
|
||||||
|
// boolean at setup time and break reactivity.
|
||||||
|
const allGroups = [
|
||||||
|
{ label: 'Profile', items: [
|
||||||
|
{ key: 'my-profile', path: '/settings/my-profile', label: 'My Profile', show: true },
|
||||||
|
{ key: 'resume', path: '/settings/resume', label: 'Resume Profile', show: true },
|
||||||
|
]},
|
||||||
|
{ label: 'Search', items: [
|
||||||
|
{ key: 'search', path: '/settings/search', label: 'Search Prefs', show: true },
|
||||||
|
]},
|
||||||
|
{ label: 'App', items: [
|
||||||
|
{ key: 'system', path: '/settings/system', label: 'System', show: showSystem },
|
||||||
|
{ key: 'fine-tune', path: '/settings/fine-tune', label: 'Fine-Tune', show: showFineTune },
|
||||||
|
]},
|
||||||
|
{ label: 'Account', items: [
|
||||||
|
{ key: 'license', path: '/settings/license', label: 'License', show: true },
|
||||||
|
{ key: 'data', path: '/settings/data', label: 'Data', show: true },
|
||||||
|
{ key: 'privacy', path: '/settings/privacy', label: 'Privacy', show: true },
|
||||||
|
]},
|
||||||
|
{ label: 'Dev', items: [
|
||||||
|
{ key: 'developer', path: '/settings/developer', label: 'Developer', show: showDeveloper },
|
||||||
|
]},
|
||||||
|
]
|
||||||
|
|
||||||
|
const visibleGroups = computed(() =>
|
||||||
|
allGroups
|
||||||
|
.map(g => ({ ...g, items: g.items.filter(i => i.show === true || (typeof i.show !== 'boolean' && i.show.value)) }))
|
||||||
|
.filter(g => g.items.length > 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
const visibleTabs = computed(() => visibleGroups.value.flatMap(g => g.items))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 180px 1fr;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
min-height: calc(100vh - var(--header-height, 56px));
|
||||||
|
}
|
||||||
|
.settings-sidebar {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1 / -1;
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
padding: var(--space-4) 0;
|
||||||
|
}
|
||||||
|
.nav-group-label {
|
||||||
|
padding: var(--space-3) var(--space-4) var(--space-1);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.nav-item {
|
||||||
|
display: block;
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
border-right: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.nav-item--active {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-right-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.settings-chip-bar {
|
||||||
|
display: none;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
overflow-x: auto;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
white-space: nowrap;
|
||||||
|
-webkit-mask-image: linear-gradient(to right, black 85%, transparent);
|
||||||
|
mask-image: linear-gradient(to right, black 85%, transparent);
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
display: inline-block;
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-decoration: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.chip--active {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 20%, transparent);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.settings-content {
|
||||||
|
grid-column: 2;
|
||||||
|
padding: var(--space-6) var(--space-8);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.settings-layout { grid-template-columns: 1fr; }
|
||||||
|
.settings-sidebar { display: none; }
|
||||||
|
.settings-chip-bar { display: flex; }
|
||||||
|
.settings-content { grid-column: 1; padding: var(--space-4); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in a new issue