Ver Fonte

feat(webui): auth store, login page, protected routes

Fszontagh há 2 meses atrás
pai
commit
ad16aa5951
4 ficheiros alterados com 160 adições e 10 exclusões
  1. 38 1
      webui/src/App.tsx
  2. 68 0
      webui/src/pages/Login.tsx
  3. 30 9
      webui/src/stores/authStore.ts
  4. 24 0
      webui/src/stores/toastStore.ts

+ 38 - 1
webui/src/App.tsx

@@ -1,4 +1,41 @@
+import { Navigate, Outlet } from 'react-router-dom'
 import type { RouteObject } from 'react-router-dom'
+import { useAuthStore } from '@/stores/authStore'
+import Login from '@/pages/Login'
+
+function ProtectedRoute() {
+  const loggedIn = useAuthStore((s) => s.loggedIn)
+  if (!loggedIn) return <Navigate to="/login" replace />
+  return <AppShell />
+}
+
+// Minimal placeholder shell — replaced by the real AppShell in W4.
+function AppShell() {
+  const logout = useAuthStore((s) => s.logout)
+  return (
+    <div className="min-h-screen bg-[#0b0f17] text-slate-100">
+      <header className="flex items-center justify-between px-6 py-3 border-b border-slate-800">
+        <span className="font-semibold text-slate-200">VectorAPI</span>
+        <button
+          onClick={() => logout()}
+          className="text-sm text-slate-400 hover:text-slate-200 transition"
+        >
+          Sign out
+        </button>
+      </header>
+      <main className="p-8">
+        <Outlet />
+      </main>
+    </div>
+  )
+}
+
 export const routes: RouteObject[] = [
-  { path: '/', element: <div className="p-8 text-xl">smartbotic-vectorapi web UI</div> },
+  { path: '/login', element: <Login /> },
+  {
+    element: <ProtectedRoute />,
+    children: [
+      { index: true, element: <div className="text-xl text-slate-300">Dashboard — coming in W5</div> },
+    ],
+  },
 ]

+ 68 - 0
webui/src/pages/Login.tsx

@@ -0,0 +1,68 @@
+import { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { useAuthStore } from '@/stores/authStore'
+
+export default function Login() {
+  const [key, setKey] = useState('')
+  const [error, setError] = useState<string | null>(null)
+  const [loading, setLoading] = useState(false)
+  const login = useAuthStore((s) => s.login)
+  const navigate = useNavigate()
+
+  async function handleSubmit(e: React.FormEvent) {
+    e.preventDefault()
+    if (!key.trim()) return
+    setLoading(true)
+    setError(null)
+    try {
+      await login(key.trim())
+      navigate('/')
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Login failed')
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  return (
+    <div className="min-h-screen flex items-center justify-center bg-[#0b0f17]">
+      <div className="w-full max-w-sm rounded-2xl border border-slate-700 bg-slate-900 p-8 shadow-2xl">
+        <div className="mb-8 text-center">
+          <h1 className="text-2xl font-semibold text-slate-100">VectorAPI</h1>
+          <p className="mt-1 text-sm text-slate-400">Sign in with your API key</p>
+        </div>
+
+        <form onSubmit={handleSubmit} className="space-y-5">
+          <div>
+            <label htmlFor="api-key" className="block text-sm font-medium text-slate-300 mb-1.5">
+              API Key
+            </label>
+            <input
+              id="api-key"
+              type="password"
+              value={key}
+              onChange={(e) => setKey(e.target.value)}
+              placeholder="sk-…"
+              autoComplete="current-password"
+              className="w-full rounded-lg border border-slate-600 bg-slate-800 px-3.5 py-2.5 text-sm text-slate-100 placeholder-slate-500 outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition"
+            />
+          </div>
+
+          {error && (
+            <p className="text-sm text-red-400 bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2">
+              {error}
+            </p>
+          )}
+
+          <button
+            type="submit"
+            disabled={loading || !key.trim()}
+            className="w-full rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed transition"
+          >
+            {loading ? 'Signing in…' : 'Sign in'}
+          </button>
+        </form>
+      </div>
+    </div>
+  )
+}

+ 30 - 9
webui/src/stores/authStore.ts

@@ -1,14 +1,35 @@
-// STUB — replaced by W3 with the real zustand store.
-// Minimal shape so the dynamic import in api/client.ts resolves at build time.
+import { create } from 'zustand'
+import { persist } from 'zustand/middleware'
+import { api } from '@/api/client'
 
 interface AuthState {
+  loggedIn: boolean
+  admin: boolean
+  projects: string[]          // [] with admin=true means "all" (resolve via listProjects)
+  currentProject: string | null
+  login: (key: string) => Promise<void>
+  logout: () => Promise<void>
+  setCurrentProject: (p: string) => void
   reset: () => void
 }
 
-const state: AuthState = {
-  reset: () => {},
-}
-
-export const useAuthStore = {
-  getState: (): AuthState => state,
-}
+export const useAuthStore = create<AuthState>()(
+  persist(
+    (set, get) => ({
+      loggedIn: false, admin: false, projects: [], currentProject: null,
+      login: async (key) => {
+        const r = await api.login(key)
+        // For admin or "*" grants, fetch the real project list to populate the selector.
+        let projects = r.projects
+        if (r.admin || projects.includes('*')) {
+          try { projects = (await api.listProjects()).projects } catch { /* keep */ }
+        }
+        set({ loggedIn: true, admin: r.admin, projects, currentProject: projects[0] ?? 'default' })
+      },
+      logout: async () => { try { await api.logout() } finally { get().reset() } },
+      setCurrentProject: (p) => set({ currentProject: p }),
+      reset: () => set({ loggedIn: false, admin: false, projects: [], currentProject: null }),
+    }),
+    { name: 'svapi-auth', partialize: (s) => ({ loggedIn: s.loggedIn, admin: s.admin, projects: s.projects, currentProject: s.currentProject }) },
+  ),
+)

+ 24 - 0
webui/src/stores/toastStore.ts

@@ -0,0 +1,24 @@
+import { create } from 'zustand'
+
+export type ToastKind = 'success' | 'error' | 'info'
+
+export interface Toast {
+  id: string
+  msg: string
+  kind: ToastKind
+}
+
+interface ToastState {
+  toasts: Toast[]
+  push: (msg: string, kind: ToastKind) => void
+  dismiss: (id: string) => void
+}
+
+export const useToastStore = create<ToastState>()((set) => ({
+  toasts: [],
+  push: (msg, kind) => {
+    const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
+    set((s) => ({ toasts: [...s.toasts, { id, msg, kind }] }))
+  },
+  dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
+}))