From 1b1a1d9087aae01a6f7551956045f3e8f05a00aa Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Tue, 9 Jun 2026 12:25:19 +0330 Subject: [PATCH] M1: minimal board UI (login, board, cartable) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A functional React/Vite SPA exercising the M1 API end-to-end: - Zustand auth store (persisted JWT) + a small fetch client that attaches the bearer token and logs out on 401. - LoginPage: sign in, or bootstrap the first owner on first run. - BoardPage: set org name, create/select a team, create tasks, move them across the backlog -> in progress -> in review -> done columns, assign to me, and a cartable panel. - React Router guards routes on the presence of a token. Mirrors the integration-tested API contracts exactly. Compiles clean (tsc + vite); still needs a manual click-through (run the web host + Postgres, or `docker compose up --build`). dnd-kit drag, TanStack Query, and an orval-generated typed client are M1+ polish — buttons/selects drive task moves for now. Co-Authored-By: Claude Opus 4.8 --- client/src/App.tsx | 87 ++---------- client/src/lib/api.ts | 31 +++++ client/src/main.tsx | 5 +- client/src/pages/BoardPage.tsx | 248 +++++++++++++++++++++++++++++++++ client/src/pages/LoginPage.tsx | 116 +++++++++++++++ client/src/store/auth.ts | 23 +++ 6 files changed, 432 insertions(+), 78 deletions(-) create mode 100644 client/src/lib/api.ts create mode 100644 client/src/pages/BoardPage.tsx create mode 100644 client/src/pages/LoginPage.tsx create mode 100644 client/src/store/auth.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index d1f7f95..176217a 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,83 +1,16 @@ -import { useEffect, useState } from 'react' - -const MODULES = [ - 'identity', - 'orgboard', - 'skills', - 'integrations', - 'memory', - 'assembler', - 'governance', -] as const - -type Status = boolean | null // null = checking - -function StatusDot({ ok }: { ok: Status }) { - const color = ok === null ? 'bg-amber-400' : ok ? 'bg-teal-400' : 'bg-rose-500' - return -} +import { Navigate, Route, Routes } from 'react-router' +import { BoardPage } from './pages/BoardPage' +import { LoginPage } from './pages/LoginPage' +import { useAuth } from './store/auth' export default function App() { - const [health, setHealth] = useState(null) - const [modules, setModules] = useState>( - Object.fromEntries(MODULES.map((m) => [m, null])), - ) - - useEffect(() => { - fetch('/health') - .then((r) => setHealth(r.ok)) - .catch(() => setHealth(false)) - - MODULES.forEach((m) => { - fetch(`/api/${m}/ping`) - .then((r) => setModules((s) => ({ ...s, [m]: r.ok }))) - .catch(() => setModules((s) => ({ ...s, [m]: false }))) - }) - }, []) + const token = useAuth((state) => state.token) return ( -
-
-

- A product of AliaSaaS -

-

TeamUp.AI

-

- Build human + AI teams. A live org chart that does work. -

- -
-
- API health - - - {health === null ? 'checking…' : health ? 'healthy' : 'unreachable'} - -
-
- -

- Modules -

-
    - {MODULES.map((m) => ( -
  • - {m} - - - {modules[m] === null ? '…' : modules[m] ? 'ok' : 'down'} - -
  • - ))} -
- -

- Pre-M1 skeleton · web + worker on one modular monolith · PostgreSQL 17 + pgvector -

-
-
+ + : } /> + : } /> + } /> + ) } diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts new file mode 100644 index 0000000..7f67bc0 --- /dev/null +++ b/client/src/lib/api.ts @@ -0,0 +1,31 @@ +import { useAuth } from '../store/auth' + +async function request(method: string, url: string, body?: unknown): Promise { + const token = useAuth.getState().token + const response = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + + if (response.status === 401) { + useAuth.getState().logout() + } + + if (!response.ok) { + const text = await response.text() + throw new Error(`${response.status} ${response.statusText}${text ? `: ${text}` : ''}`) + } + + const contentType = response.headers.get('content-type') ?? '' + return contentType.includes('application/json') ? ((await response.json()) as T) : (undefined as T) +} + +export const api = { + get: (url: string) => request('GET', url), + post: (url: string, body?: unknown) => request('POST', url, body), + patch: (url: string, body?: unknown) => request('PATCH', url, body), +} diff --git a/client/src/main.tsx b/client/src/main.tsx index bef5202..ae3d161 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,10 +1,13 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router' import './index.css' import App from './App.tsx' createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/client/src/pages/BoardPage.tsx b/client/src/pages/BoardPage.tsx new file mode 100644 index 0000000..bcbaf84 --- /dev/null +++ b/client/src/pages/BoardPage.tsx @@ -0,0 +1,248 @@ +import { useCallback, useEffect, useState } from 'react' +import { api } from '../lib/api' +import { useAuth } from '../store/auth' + +const COLUMNS = ['Backlog', 'InProgress', 'InReview', 'Done'] as const + +interface Team { + id: string + organizationId: string + name: string +} + +interface Task { + id: string + teamId: string + title: string + status: string + assigneeKind: string + assigneeId?: string | null +} + +interface Board { + teamId: string + columns: { status: string; items: Task[] }[] +} + +export function BoardPage() { + const memberId = useAuth((s) => s.memberId) + const organizationId = useAuth((s) => s.organizationId) + const logout = useAuth((s) => s.logout) + + const [orgName, setOrgName] = useState('') + const [teams, setTeams] = useState([]) + const [teamId, setTeamId] = useState(null) + const [board, setBoard] = useState(null) + const [cartable, setCartable] = useState([]) + const [newTeam, setNewTeam] = useState('') + const [newTask, setNewTask] = useState('') + const [error, setError] = useState(null) + + const loadTeams = useCallback(async () => { + if (!organizationId) { + return + } + try { + const result = await api.get(`/api/orgboard/teams?organizationId=${organizationId}`) + setTeams(result) + if (!teamId && result.length > 0) { + setTeamId(result[0].id) + } + } catch (err) { + setError((err as Error).message) + } + }, [organizationId, teamId]) + + const loadBoard = useCallback(async (id: string) => { + try { + setBoard(await api.get(`/api/orgboard/board?teamId=${id}`)) + setCartable(await api.get('/api/orgboard/cartable')) + } catch (err) { + setError((err as Error).message) + } + }, []) + + useEffect(() => { + void loadTeams() + }, [loadTeams]) + + useEffect(() => { + if (teamId) { + void loadBoard(teamId) + } + }, [teamId, loadBoard]) + + async function run(action: () => Promise) { + setError(null) + try { + await action() + } catch (err) { + setError((err as Error).message) + } + } + + const saveOrg = () => + run(async () => { + await api.post('/api/orgboard/organizations', { organizationId, name: orgName }) + }) + + const createTeam = () => + run(async () => { + const team = await api.post('/api/orgboard/teams', { organizationId, name: newTeam }) + setNewTeam('') + await loadTeams() + setTeamId(team.id) + }) + + const createTask = () => + run(async () => { + if (!teamId) { + return + } + await api.post('/api/orgboard/tasks', { teamId, title: newTask, type: 'Story' }) + setNewTask('') + await loadBoard(teamId) + }) + + const move = (id: string, status: string) => + run(async () => { + await api.patch(`/api/orgboard/tasks/${id}/move`, { status }) + if (teamId) { + await loadBoard(teamId) + } + }) + + const assignToMe = (id: string) => + run(async () => { + await api.patch(`/api/orgboard/tasks/${id}/assign`, { memberId }) + if (teamId) { + await loadBoard(teamId) + } + }) + + return ( +
+
+ TeamUp.AI + +
+ +
+ {error &&

{error}

} + +
+ + + +
+ + {teamId && ( +
+ +
+ )} + +
+ {COLUMNS.map((column) => { + const items = board?.columns.find((c) => c.status === column)?.items ?? [] + return ( +
+

+ {column} ({items.length}) +

+
    + {items.map((task) => ( +
  • +
    {task.title}
    +
    + {task.assigneeKind === 'Member' ? 'assigned' : 'unassigned'} +
    +
    + + +
    +
  • + ))} +
+
+ ) + })} +
+ +
+

+ My cartable ({cartable.length}) +

+
    + {cartable.map((task) => ( +
  • + {task.title} · {task.status} +
  • + ))} + {cartable.length === 0 &&
  • Nothing assigned to you yet.
  • } +
+
+
+
+ ) +} + +function Inline(props: { + label: string + value: string + onChange: (value: string) => void + onSubmit: () => void + cta: string +}) { + return ( + + ) +} diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx new file mode 100644 index 0000000..839d1fb --- /dev/null +++ b/client/src/pages/LoginPage.tsx @@ -0,0 +1,116 @@ +import { type FormEvent, useState } from 'react' +import { api } from '../lib/api' +import { useAuth } from '../store/auth' + +interface AuthResponse { + token: string + memberId: string +} + +interface BootstrapResponse { + token: string + memberId: string + organizationId: string +} + +interface MeResponse { + memberships: { scopeType: string; scopeId: string; role: string }[] +} + +export function LoginPage() { + const setAuth = useAuth((state) => state.setAuth) + const [mode, setMode] = useState<'login' | 'bootstrap'>('login') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [displayName, setDisplayName] = useState('') + const [orgName, setOrgName] = useState('') + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + async function submit(event: FormEvent) { + event.preventDefault() + setError(null) + setBusy(true) + try { + if (mode === 'bootstrap') { + const result = await api.post('/api/identity/bootstrap', { + organizationName: orgName, + ownerEmail: email, + ownerDisplayName: displayName, + ownerPassword: password, + }) + setAuth(result.token, result.memberId, result.organizationId) + } else { + const result = await api.post('/api/identity/auth/login', { email, password }) + setAuth(result.token, result.memberId, null) + const me = await api.get('/api/identity/me') + const org = me.memberships.find((m) => m.scopeType === 'Organization') + setAuth(result.token, result.memberId, org?.scopeId ?? null) + } + } catch (err) { + setError((err as Error).message) + } finally { + setBusy(false) + } + } + + return ( +
+
+

TeamUp.AI

+

+ {mode === 'login' ? 'Sign in' : 'Create the first owner'} +

+ +
+ {mode === 'bootstrap' && ( + + )} + {mode === 'bootstrap' && ( + + )} + + +
+ + {error &&

{error}

} + + + + +
+
+ ) +} + +function Field(props: { + label: string + value: string + onChange: (value: string) => void + type?: string +}) { + return ( + + ) +} diff --git a/client/src/store/auth.ts b/client/src/store/auth.ts new file mode 100644 index 0000000..4afe4da --- /dev/null +++ b/client/src/store/auth.ts @@ -0,0 +1,23 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' + +interface AuthState { + token: string | null + memberId: string | null + organizationId: string | null + setAuth: (token: string, memberId: string, organizationId: string | null) => void + logout: () => void +} + +export const useAuth = create()( + persist( + (set) => ({ + token: null, + memberId: null, + organizationId: null, + setAuth: (token, memberId, organizationId) => set({ token, memberId, organizationId }), + logout: () => set({ token: null, memberId: null, organizationId: null }), + }), + { name: 'teamup-auth' }, + ), +)