Merge pull request #238 from multica-ai/codex/workspace-user-and-worktree-isolation

Add workspace management and isolated worktree environments
This commit is contained in:
Jiayuan Zhang 2026-03-23 18:16:26 +08:00 committed by GitHub
commit 88ca7848b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1462 additions and 200 deletions

View file

@ -1,9 +1,15 @@
# Database
COMPOSE_PROJECT_NAME=super_multica
POSTGRES_DB=multica
POSTGRES_USER=multica
POSTGRES_PASSWORD=multica
POSTGRES_PORT=5432
DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
# Server
PORT=8080
JWT_SECRET=change-me-in-production
MULTICA_SERVER_URL=ws://localhost:8080/ws
# Google OAuth
GOOGLE_CLIENT_ID=
@ -11,5 +17,7 @@ GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
# Frontend
FRONTEND_PORT=3000
FRONTEND_ORIGIN=http://localhost:3000
NEXT_PUBLIC_API_URL=http://localhost:8080
NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws

View file

@ -1,18 +1,45 @@
.PHONY: dev daemon build test migrate-up migrate-down sqlc seed clean setup start stop check
.PHONY: dev daemon build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree
MAIN_ENV_FILE ?= .env
WORKTREE_ENV_FILE ?= .env.worktree
ENV_FILE ?= $(if $(wildcard $(MAIN_ENV_FILE)),$(MAIN_ENV_FILE),$(if $(wildcard $(WORKTREE_ENV_FILE)),$(WORKTREE_ENV_FILE),$(MAIN_ENV_FILE)))
ifneq ($(wildcard $(ENV_FILE)),)
include $(ENV_FILE)
endif
POSTGRES_DB ?= multica
POSTGRES_USER ?= multica
POSTGRES_PASSWORD ?= multica
POSTGRES_PORT ?= 5432
PORT ?= 8080
FRONTEND_PORT ?= 3000
FRONTEND_ORIGIN ?= http://localhost:$(FRONTEND_PORT)
DATABASE_URL ?= postgres://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@localhost:$(POSTGRES_PORT)/$(POSTGRES_DB)?sslmode=disable
NEXT_PUBLIC_API_URL ?= http://localhost:$(PORT)
NEXT_PUBLIC_WS_URL ?= ws://localhost:$(PORT)/ws
GOOGLE_REDIRECT_URI ?= $(FRONTEND_ORIGIN)/auth/callback
MULTICA_SERVER_URL ?= ws://localhost:$(PORT)/ws
COMPOSE_PROJECT_NAME ?= super_multica
export
COMPOSE := docker compose --env-file $(ENV_FILE)
# ---------- One-click commands ----------
# First-time setup: install deps, start DB, run migrations, seed data
setup:
@echo "==> Using env file: $(ENV_FILE)"
@echo "==> Installing dependencies..."
pnpm install
@echo "==> Starting PostgreSQL..."
@if pg_isready -h localhost -p 5432 -U multica > /dev/null 2>&1; then \
@if pg_isready -h localhost -p $(POSTGRES_PORT) -U $(POSTGRES_USER) -d $(POSTGRES_DB) > /dev/null 2>&1; then \
echo " PostgreSQL already running, skipping docker compose up."; \
else \
docker compose up -d; \
$(COMPOSE) up -d; \
echo "==> Waiting for PostgreSQL to be ready..."; \
until docker compose exec -T postgres pg_isready -U multica > /dev/null 2>&1; do \
until $(COMPOSE) exec -T postgres pg_isready -U $(POSTGRES_USER) -d $(POSTGRES_DB) > /dev/null 2>&1; do \
sleep 1; \
done; \
fi
@ -25,11 +52,14 @@ setup:
# Start all services (backend + frontend)
start:
@if pg_isready -h localhost -p 5432 -U multica > /dev/null 2>&1; then \
@echo "Using env file: $(ENV_FILE)"
@echo "Backend: http://localhost:$(PORT)"
@echo "Frontend: http://localhost:$(FRONTEND_PORT)"
@if pg_isready -h localhost -p $(POSTGRES_PORT) -U $(POSTGRES_USER) -d $(POSTGRES_DB) > /dev/null 2>&1; then \
echo "PostgreSQL already running, skipping docker compose up."; \
else \
docker compose up -d; \
until docker compose exec -T postgres pg_isready -U multica > /dev/null 2>&1; do \
$(COMPOSE) up -d; \
until $(COMPOSE) exec -T postgres pg_isready -U $(POSTGRES_USER) -d $(POSTGRES_DB) > /dev/null 2>&1; do \
sleep 1; \
done; \
fi
@ -42,15 +72,42 @@ start:
# Stop all services
stop:
@echo "Stopping services..."
@-lsof -ti:8080 | xargs kill -9 2>/dev/null
@-lsof -ti:3000 | xargs kill -9 2>/dev/null
docker compose down
@-lsof -ti:$(PORT) | xargs kill -9 2>/dev/null
@-lsof -ti:$(FRONTEND_PORT) | xargs kill -9 2>/dev/null
$(COMPOSE) down
@echo "✓ All services stopped."
# Full verification: typecheck + unit tests + Go tests + E2E
check:
@bash scripts/check.sh
worktree-env:
@bash scripts/init-worktree-env.sh .env.worktree
setup-main:
@$(MAKE) setup ENV_FILE=$(MAIN_ENV_FILE)
start-main:
@$(MAKE) start ENV_FILE=$(MAIN_ENV_FILE)
stop-main:
@$(MAKE) stop ENV_FILE=$(MAIN_ENV_FILE)
check-main:
@ENV_FILE=$(MAIN_ENV_FILE) bash scripts/check.sh
setup-worktree:
@$(MAKE) setup ENV_FILE=$(WORKTREE_ENV_FILE)
start-worktree:
@$(MAKE) start ENV_FILE=$(WORKTREE_ENV_FILE)
stop-worktree:
@$(MAKE) stop ENV_FILE=$(WORKTREE_ENV_FILE)
check-worktree:
@ENV_FILE=$(WORKTREE_ENV_FILE) bash scripts/check.sh
# ---------- Individual commands ----------
# Go server

View file

@ -15,23 +15,27 @@ AI-native task management platform — like Linear, but with AI agents as first-
# 1. Install dependencies
pnpm install
# 2. Copy environment variables
# 2. Copy environment variables for the shared main environment
cp .env.example .env
# 3. Start PostgreSQL
docker compose up -d
# 3. One-time setup: start DB, run migrations, seed data
make setup
# 4. Run database migrations
make migrate-up
# 5. Start the Go backend (port 8080)
make dev
# 6. In another terminal, start the frontend (port 3000)
pnpm dev:web
# 4. Start backend + frontend
make start
```
Open [http://localhost:3000](http://localhost:3000) in your browser.
Open your configured `FRONTEND_ORIGIN` in the browser. By default that is [http://localhost:3000](http://localhost:3000).
Default behavior now prefers the shared main environment in `.env`. If you want an isolated environment for a Git worktree, generate `.env.worktree` and use the explicit worktree targets:
```bash
make worktree-env
make setup-worktree
make start-worktree
```
This lets you keep `.env` connected to your main database while using `.env.worktree` only for isolated feature testing.
## Project Structure
@ -61,7 +65,7 @@ Open [http://localhost:3000](http://localhost:3000) in your browser.
| Command | Description |
|---------|-------------|
| `pnpm dev:web` | Start Next.js dev server (port 3000) |
| `pnpm dev:web` | Start Next.js dev server (uses `FRONTEND_PORT`, default `3000`) |
| `pnpm build` | Build all TypeScript packages |
| `pnpm typecheck` | Run TypeScript type checking |
| `pnpm test` | Run TypeScript tests |
@ -70,7 +74,7 @@ Open [http://localhost:3000](http://localhost:3000) in your browser.
| Command | Description |
|---------|-------------|
| `make dev` | Run Go server (port 8080) |
| `make dev` | Run Go server (uses `PORT`, default `8080`) |
| `make daemon` | Run local agent daemon |
| `make test` | Run Go tests |
| `make build` | Build server & daemon binaries |
@ -85,13 +89,19 @@ Open [http://localhost:3000](http://localhost:3000) in your browser.
| `make migrate-up` | Run database migrations |
| `make migrate-down` | Rollback database migrations |
| `make seed` | Seed test data |
| `make worktree-env` | Generate an isolated `.env.worktree` for the current worktree |
| `make setup-main` / `make start-main` | Force use of the shared main `.env` |
| `make setup-worktree` / `make start-worktree` | Force use of isolated `.env.worktree` |
## Environment Variables
See [`.env.example`](.env.example) for all available variables:
- `DATABASE_URL` — PostgreSQL connection string
- `COMPOSE_PROJECT_NAME` — Docker Compose project name
- `POSTGRES_DB` / `POSTGRES_PORT` — Per-worktree PostgreSQL database and host port
- `PORT` — Backend server port (default: 8080)
- `FRONTEND_PORT` / `FRONTEND_ORIGIN` — Frontend port and browser origin
- `JWT_SECRET` — JWT signing secret
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` — Google OAuth (optional)
- `NEXT_PUBLIC_API_URL` — Frontend → backend API URL

View file

@ -38,6 +38,12 @@ export default function DashboardLayout({
const [newSlug, setNewSlug] = useState("");
const [creating, setCreating] = useState(false);
useEffect(() => {
if (!isLoading && user && workspaces.length === 0) {
setShowCreateDialog(true);
}
}, [isLoading, user, workspaces.length]);
const handleNameChange = (value: string) => {
setNewName(value);
setNewSlug(value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""));
@ -75,6 +81,100 @@ export default function DashboardLayout({
if (!user) return null;
if (!workspace) {
return (
<>
<div className="flex min-h-screen items-center justify-center bg-canvas p-6">
<div className="w-full max-w-md rounded-2xl border bg-background p-8 shadow-sm">
<div className="flex items-center gap-3">
<div className="flex size-11 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<MulticaIcon className="size-5" noSpin />
</div>
<div>
<h1 className="text-lg font-semibold">Create your first workspace</h1>
<p className="text-sm text-muted-foreground">{user.email}</p>
</div>
</div>
<p className="mt-6 text-sm text-muted-foreground">
You need a workspace before you can manage issues, agents, and inbox items.
</p>
<div className="mt-6 flex gap-2">
<button
onClick={() => setShowCreateDialog(true)}
className="rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Create workspace
</button>
<button
onClick={logout}
className="rounded-md border px-3 py-2 text-sm hover:bg-accent"
>
Sign out
</button>
</div>
</div>
</div>
{showCreateDialog && (
<>
<div
className="fixed inset-0 z-50 bg-black/10 backdrop-blur-xs"
onClick={() => setShowCreateDialog(false)}
/>
<div className="fixed top-1/2 left-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-xl bg-background p-6 shadow-lg ring-1 ring-foreground/10">
<div className="flex flex-col gap-1.5">
<h2 className="text-lg font-semibold leading-none">Create workspace</h2>
<p className="text-sm text-muted-foreground">
Create a new workspace for your team.
</p>
</div>
<div className="mt-4 space-y-3">
<div>
<label className="text-xs font-medium text-muted-foreground">Name</label>
<input
autoFocus
type="text"
value={newName}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="My Workspace"
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Slug</label>
<input
type="text"
value={newSlug}
onChange={(e) => setNewSlug(e.target.value)}
placeholder="my-workspace"
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={logout}
className="rounded-md px-3 py-1.5 text-sm hover:bg-accent"
>
Sign out
</button>
<button
onClick={handleCreateWorkspace}
disabled={creating || !newName.trim() || !newSlug.trim()}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{creating ? "Creating..." : "Create"}
</button>
</div>
</div>
</>
)}
</>
);
}
return (
<div className="flex h-screen bg-canvas">
{/* Sidebar */}

View file

@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Settings, Users, Building2, Save, Crown, Shield, User } from "lucide-react";
import { useEffect, useState } from "react";
import { Settings, Users, Building2, Save, Crown, Shield, User, Plus, Trash2, LogOut } from "lucide-react";
import type { MemberWithUser, MemberRole } from "@multica/types";
import { useAuth } from "../../../lib/auth-context";
import { api } from "../../../lib/api";
@ -12,9 +12,27 @@ const roleConfig: Record<MemberRole, { label: string; icon: typeof Crown }> = {
member: { label: "Member", icon: User },
};
function MemberRow({ member }: { member: MemberWithUser }) {
function MemberRow({
member,
canManage,
canManageOwners,
isSelf,
busy,
onRoleChange,
onRemove,
}: {
member: MemberWithUser;
canManage: boolean;
canManageOwners: boolean;
isSelf: boolean;
busy: boolean;
onRoleChange: (role: MemberRole) => void;
onRemove: () => void;
}) {
const rc = roleConfig[member.role];
const RoleIcon = rc.icon;
const canEditRole = canManage && (!isSelf || canManageOwners) && (member.role !== "owner" || canManageOwners);
const canRemove = canManage && !isSelf && (member.role !== "owner" || canManageOwners);
return (
<div className="flex items-center gap-3 rounded-lg border px-4 py-3">
@ -30,27 +48,84 @@ function MemberRow({ member }: { member: MemberWithUser }) {
<div className="text-sm font-medium">{member.name}</div>
<div className="text-xs text-muted-foreground">{member.email}</div>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<RoleIcon className="h-3 w-3" />
{rc.label}
</div>
{canEditRole ? (
<select
value={member.role}
onChange={(e) => onRoleChange(e.target.value as MemberRole)}
disabled={busy}
className="rounded-md border bg-background px-2 py-1 text-xs"
>
<option value="member">Member</option>
<option value="admin">Admin</option>
{canManageOwners && <option value="owner">Owner</option>}
</select>
) : (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<RoleIcon className="h-3 w-3" />
{rc.label}
</div>
)}
{canRemove && (
<button
onClick={onRemove}
disabled={busy}
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
aria-label={`Remove ${member.name}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
);
}
export default function SettingsPage() {
const { workspace, members, updateWorkspace } = useAuth();
const {
user,
workspace,
members,
updateWorkspace,
updateCurrentUser,
refreshMembers,
leaveWorkspace,
deleteWorkspace,
} = useAuth();
const [name, setName] = useState(workspace?.name ?? "");
const [description, setDescription] = useState(
workspace?.description ?? "",
);
const [profileName, setProfileName] = useState(user?.name ?? "");
const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url ?? "");
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [profileSaving, setProfileSaving] = useState(false);
const [profileSaved, setProfileSaved] = useState(false);
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState<MemberRole>("member");
const [inviteLoading, setInviteLoading] = useState(false);
const [memberActionId, setMemberActionId] = useState<string | null>(null);
const [workspaceError, setWorkspaceError] = useState("");
const [profileError, setProfileError] = useState("");
const [memberError, setMemberError] = useState("");
const currentMember = members.find((member) => member.user_id === user?.id) ?? null;
const canManageWorkspace = currentMember?.role === "owner" || currentMember?.role === "admin";
const isOwner = currentMember?.role === "owner";
useEffect(() => {
setName(workspace?.name ?? "");
setDescription(workspace?.description ?? "");
}, [workspace]);
useEffect(() => {
setProfileName(user?.name ?? "");
setAvatarUrl(user?.avatar_url ?? "");
}, [user]);
const handleSave = async () => {
if (!workspace) return;
setSaving(true);
setWorkspaceError("");
try {
const updated = await api.updateWorkspace(workspace.id, {
name,
@ -60,12 +135,109 @@ export default function SettingsPage() {
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (e) {
console.error("Failed to update workspace", e);
setWorkspaceError(e instanceof Error ? e.message : "Failed to update workspace");
} finally {
setSaving(false);
}
};
const handleProfileSave = async () => {
setProfileSaving(true);
setProfileError("");
try {
const updated = await api.updateMe({
name: profileName,
avatar_url: avatarUrl || undefined,
});
updateCurrentUser(updated);
setProfileSaved(true);
setTimeout(() => setProfileSaved(false), 2000);
} catch (e) {
setProfileError(e instanceof Error ? e.message : "Failed to update profile");
} finally {
setProfileSaving(false);
}
};
const handleAddMember = async () => {
if (!workspace) return;
setInviteLoading(true);
setMemberError("");
try {
await api.createMember(workspace.id, {
email: inviteEmail,
role: inviteRole,
});
setInviteEmail("");
setInviteRole("member");
await refreshMembers();
} catch (e) {
setMemberError(e instanceof Error ? e.message : "Failed to add member");
} finally {
setInviteLoading(false);
}
};
const handleRoleChange = async (memberId: string, role: MemberRole) => {
if (!workspace) return;
setMemberActionId(memberId);
setMemberError("");
try {
await api.updateMember(workspace.id, memberId, { role });
await refreshMembers();
} catch (e) {
setMemberError(e instanceof Error ? e.message : "Failed to update member");
} finally {
setMemberActionId(null);
}
};
const handleRemoveMember = async (member: MemberWithUser) => {
if (!workspace) return;
if (!window.confirm(`Remove ${member.name} from ${workspace.name}?`)) return;
setMemberActionId(member.id);
setMemberError("");
try {
await api.deleteMember(workspace.id, member.id);
await refreshMembers();
} catch (e) {
setMemberError(e instanceof Error ? e.message : "Failed to remove member");
} finally {
setMemberActionId(null);
}
};
const handleLeaveWorkspace = async () => {
if (!workspace) return;
if (!window.confirm(`Leave ${workspace.name}?`)) return;
setMemberActionId("leave");
setMemberError("");
try {
await leaveWorkspace(workspace.id);
} catch (e) {
setMemberError(e instanceof Error ? e.message : "Failed to leave workspace");
} finally {
setMemberActionId(null);
}
};
const handleDeleteWorkspace = async () => {
if (!workspace) return;
if (!window.confirm(`Delete ${workspace.name}? This cannot be undone.`)) return;
setMemberActionId("delete-workspace");
setMemberError("");
try {
await deleteWorkspace(workspace.id);
} catch (e) {
setMemberError(e instanceof Error ? e.message : "Failed to delete workspace");
} finally {
setMemberActionId(null);
}
};
if (!workspace) return null;
return (
@ -76,6 +248,55 @@ export default function SettingsPage() {
<h1 className="text-lg font-semibold">Settings</h1>
</div>
<section className="space-y-4">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Profile</h2>
</div>
<div className="space-y-3 rounded-lg border p-4">
<div>
<label className="text-xs font-medium text-muted-foreground">
Name
</label>
<input
type="text"
value={profileName}
onChange={(e) => setProfileName(e.target.value)}
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">
Avatar URL
</label>
<input
type="url"
value={avatarUrl}
onChange={(e) => setAvatarUrl(e.target.value)}
placeholder="https://example.com/avatar.png"
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{profileError && (
<p className="text-xs text-red-500">{profileError}</p>
)}
<div className="flex items-center justify-end gap-2 pt-1">
{profileSaved && (
<span className="text-xs text-green-600">Saved!</span>
)}
<button
onClick={handleProfileSave}
disabled={profileSaving || !profileName.trim()}
className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
<Save className="h-3 w-3" />
{profileSaving ? "Saving..." : "Save"}
</button>
</div>
</div>
</section>
{/* Workspace info */}
<section className="space-y-4">
<div className="flex items-center gap-2">
@ -92,6 +313,7 @@ export default function SettingsPage() {
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={!canManageWorkspace}
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
@ -103,6 +325,7 @@ export default function SettingsPage() {
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
disabled={!canManageWorkspace}
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-none"
placeholder="What does this workspace focus on?"
/>
@ -116,18 +339,26 @@ export default function SettingsPage() {
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
{workspaceError && (
<span className="text-xs text-red-500">{workspaceError}</span>
)}
{saved && (
<span className="text-xs text-green-600">Saved!</span>
)}
<button
onClick={handleSave}
disabled={saving || !name.trim()}
disabled={saving || !name.trim() || !canManageWorkspace}
className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
<Save className="h-3 w-3" />
{saving ? "Saving..." : "Save"}
</button>
</div>
{!canManageWorkspace && (
<p className="text-xs text-muted-foreground">
Only admins and owners can update workspace settings.
</p>
)}
</div>
</section>
@ -142,15 +373,105 @@ export default function SettingsPage() {
</div>
</div>
{memberError && (
<p className="text-sm text-red-500">{memberError}</p>
)}
{canManageWorkspace && (
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center gap-2">
<Plus className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Add member</h3>
</div>
<div className="grid gap-3 sm:grid-cols-[1fr_120px_auto]">
<input
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="user@company.com"
className="rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<select
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value as MemberRole)}
className="rounded-md border bg-background px-3 py-2 text-sm"
>
<option value="member">Member</option>
<option value="admin">Admin</option>
{isOwner && <option value="owner">Owner</option>}
</select>
<button
onClick={handleAddMember}
disabled={inviteLoading || !inviteEmail.trim()}
className="rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{inviteLoading ? "Adding..." : "Add"}
</button>
</div>
</div>
)}
<div className="space-y-2">
{members.map((m) => (
<MemberRow key={m.id} member={m} />
<MemberRow
key={m.id}
member={m}
canManage={canManageWorkspace}
canManageOwners={isOwner}
isSelf={m.user_id === user?.id}
busy={memberActionId === m.id}
onRoleChange={(role) => handleRoleChange(m.id, role)}
onRemove={() => handleRemoveMember(m)}
/>
))}
{members.length === 0 && (
<p className="text-sm text-muted-foreground">No members found.</p>
)}
</div>
</section>
<section className="space-y-4">
<div className="flex items-center gap-2">
<LogOut className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Danger Zone</h2>
</div>
<div className="rounded-lg border p-4 space-y-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-medium">Leave workspace</p>
<p className="text-xs text-muted-foreground">
Remove yourself from this workspace.
</p>
</div>
<button
onClick={handleLeaveWorkspace}
disabled={memberActionId === "leave"}
className="rounded-md border px-3 py-2 text-sm hover:bg-accent disabled:opacity-50"
>
{memberActionId === "leave" ? "Leaving..." : "Leave workspace"}
</button>
</div>
{isOwner && (
<div className="flex flex-col gap-2 border-t pt-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-medium text-red-600">Delete workspace</p>
<p className="text-xs text-muted-foreground">
Permanently delete this workspace and its data.
</p>
</div>
<button
onClick={handleDeleteWorkspace}
disabled={memberActionId === "delete-workspace"}
className="rounded-md bg-red-600 px-3 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
>
{memberActionId === "delete-workspace" ? "Deleting..." : "Delete workspace"}
</button>
</div>
)}
</div>
</section>
</div>
);
}

View file

@ -4,8 +4,9 @@ import type { User, Workspace, MemberWithUser, Agent } from "@multica/types";
// Mock next/navigation
const mockPush = vi.fn();
const mockRefresh = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
useRouter: () => ({ push: mockPush, refresh: mockRefresh }),
}));
// Must use vi.hoisted so the mock object is defined before vi.mock factory runs
@ -18,6 +19,9 @@ const mockApi = vi.hoisted(() => ({
listMembers: vi.fn(),
listAgents: vi.fn(),
createWorkspace: vi.fn(),
updateMe: vi.fn(),
leaveWorkspace: vi.fn(),
deleteWorkspace: vi.fn(),
}));
vi.mock("./api", () => ({
@ -393,12 +397,6 @@ describe("AuthContext", () => {
});
it("switchWorkspace updates context and calls setWorkspaceId", async () => {
const reloadMock = vi.fn();
Object.defineProperty(window, "location", {
value: { ...window.location, reload: reloadMock },
writable: true,
});
const mockWorkspace2: Workspace = {
id: "ws-2",
name: "Second WS",
@ -434,6 +432,6 @@ describe("AuthContext", () => {
expect(mockApi.setWorkspaceId).toHaveBeenCalledWith("ws-2");
expect(localStorage.getItem("multica_workspace_id")).toBe("ws-2");
expect(reloadMock).toHaveBeenCalled();
expect(mockRefresh).toHaveBeenCalled();
});
});

View file

@ -24,6 +24,10 @@ interface AuthContextValue {
switchWorkspace: (workspaceId: string) => Promise<void>;
createWorkspace: (data: { name: string; slug: string; description?: string }) => Promise<Workspace>;
updateWorkspace: (ws: Workspace) => void;
updateCurrentUser: (nextUser: User) => void;
leaveWorkspace: (workspaceId: string) => Promise<void>;
deleteWorkspace: (workspaceId: string) => Promise<void>;
refreshWorkspaces: () => Promise<Workspace[]>;
refreshMembers: () => Promise<void>;
refreshAgents: () => Promise<void>;
getMemberName: (userId: string) => string;
@ -43,6 +47,44 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
const hydrateWorkspace = useCallback(async (wsList: Workspace[], preferredWorkspaceId?: string | null) => {
setWorkspaces(wsList);
const nextWorkspace =
(preferredWorkspaceId ? wsList.find((item) => item.id === preferredWorkspaceId) : null) ??
wsList[0] ??
null;
if (!nextWorkspace) {
api.setWorkspaceId(null);
localStorage.removeItem("multica_workspace_id");
setWorkspace(null);
setMembers([]);
setAgents([]);
return null;
}
api.setWorkspaceId(nextWorkspace.id);
localStorage.setItem("multica_workspace_id", nextWorkspace.id);
setWorkspace(nextWorkspace);
const [nextMembers, nextAgents] = await Promise.all([
api.listMembers(nextWorkspace.id),
api.listAgents({ workspace_id: nextWorkspace.id }),
]);
setMembers(nextMembers);
setAgents(nextAgents);
return nextWorkspace;
}, []);
const refreshWorkspaces = useCallback(async () => {
const storedWorkspaceId = localStorage.getItem("multica_workspace_id");
const wsList = await api.listWorkspaces();
await hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId);
return wsList;
}, [hydrateWorkspace, workspace]);
// Initialize from stored token
useEffect(() => {
const token = localStorage.getItem("multica_token");
@ -53,7 +95,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
api.setToken(token);
if (wsId) api.setWorkspaceId(wsId);
api.setWorkspaceId(wsId);
(async () => {
try {
@ -61,30 +103,23 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(me);
const wsList = await api.listWorkspaces();
setWorkspaces(wsList);
if (wsList.length > 0) {
const stored = wsId ? wsList.find(w => w.id === wsId) : null;
const ws = stored ?? wsList[0]!;
setWorkspace(ws);
api.setWorkspaceId(ws.id);
localStorage.setItem("multica_workspace_id", ws.id);
const [m, a] = await Promise.all([
api.listMembers(ws.id),
api.listAgents({ workspace_id: ws.id }),
]);
setMembers(m);
setAgents(a);
}
await hydrateWorkspace(wsList, wsId);
} catch {
// Token invalid, clear it
api.setToken(null);
api.setWorkspaceId(null);
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
setUser(null);
setWorkspace(null);
setWorkspaces([]);
setMembers([]);
setAgents([]);
} finally {
setIsLoading(false);
}
})();
}, []);
}, [hydrateWorkspace]);
const login = useCallback(async (email: string, name?: string) => {
const { token, user: u } = await api.login(email, name);
@ -92,27 +127,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
localStorage.setItem("multica_token", token);
setUser(u);
// Load workspace
const wsList = await api.listWorkspaces();
setWorkspaces(wsList);
if (wsList.length > 0) {
const ws = wsList[0]!;
setWorkspace(ws);
api.setWorkspaceId(ws.id);
localStorage.setItem("multica_workspace_id", ws.id);
const [m, a] = await Promise.all([
api.listMembers(ws.id),
api.listAgents({ workspace_id: ws.id }),
]);
setMembers(m);
setAgents(a);
}
await hydrateWorkspace(wsList);
router.push("/issues");
}, [router]);
}, [hydrateWorkspace, router]);
const logout = useCallback(() => {
api.setToken(null);
api.setWorkspaceId(null);
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
setUser(null);
@ -124,33 +147,45 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, [router]);
const switchWorkspace = useCallback(async (workspaceId: string) => {
const ws = workspaces.find(w => w.id === workspaceId);
const ws = workspaces.find((item) => item.id === workspaceId);
if (!ws) return;
api.setWorkspaceId(ws.id);
localStorage.setItem("multica_workspace_id", ws.id);
setWorkspace(ws);
const [m, a] = await Promise.all([
api.listMembers(ws.id),
api.listAgents({ workspace_id: ws.id }),
]);
setMembers(m);
setAgents(a);
window.location.reload();
}, [workspaces]);
await hydrateWorkspace(workspaces, ws.id);
router.refresh();
}, [hydrateWorkspace, router, workspaces]);
const createNewWorkspace = useCallback(async (data: { name: string; slug: string; description?: string }) => {
const ws = await api.createWorkspace(data);
setWorkspaces(prev => [...prev, ws]);
setWorkspaces((prev) => [...prev, ws]);
return ws;
}, []);
const updateWorkspaceState = useCallback((ws: Workspace) => {
setWorkspace(ws);
setWorkspaces((prev) => prev.map((item) => (item.id === ws.id ? ws : item)));
}, []);
const updateCurrentUser = useCallback((nextUser: User) => {
setUser(nextUser);
}, []);
const reloadAfterWorkspaceRemoval = useCallback(async (removedWorkspaceId: string) => {
const wsList = await api.listWorkspaces();
const preferredWorkspaceId = workspace?.id === removedWorkspaceId ? null : workspace?.id ?? null;
await hydrateWorkspace(wsList, preferredWorkspaceId);
router.refresh();
}, [hydrateWorkspace, router, workspace]);
const leaveWorkspace = useCallback(async (workspaceId: string) => {
await api.leaveWorkspace(workspaceId);
await reloadAfterWorkspaceRemoval(workspaceId);
}, [reloadAfterWorkspaceRemoval]);
const deleteWorkspace = useCallback(async (workspaceId: string) => {
await api.deleteWorkspace(workspaceId);
await reloadAfterWorkspaceRemoval(workspaceId);
}, [reloadAfterWorkspaceRemoval]);
const refreshMembers = useCallback(async () => {
if (!workspace) return;
const m = await api.listMembers(workspace.id);
@ -215,6 +250,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
switchWorkspace,
createWorkspace: createNewWorkspace,
updateWorkspace: updateWorkspaceState,
updateCurrentUser,
leaveWorkspace,
deleteWorkspace,
refreshWorkspaces,
refreshMembers,
refreshAgents,
getMemberName,

View file

@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --port 3000",
"dev": "sh -c 'next dev --port \"${FRONTEND_PORT:-3000}\"'",
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit",

View file

@ -66,6 +66,14 @@ export const mockAuthValue: Record<string, any> = {
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
workspaces: [mockWorkspace],
switchWorkspace: vi.fn(),
createWorkspace: vi.fn(),
updateWorkspace: vi.fn(),
updateCurrentUser: vi.fn(),
leaveWorkspace: vi.fn(),
deleteWorkspace: vi.fn(),
refreshWorkspaces: vi.fn(),
refreshMembers: vi.fn(),
refreshAgents: vi.fn(),
getMemberName: (userId: string) => {

View file

@ -2,11 +2,11 @@ services:
postgres:
image: pgvector/pgvector:pg17
environment:
POSTGRES_DB: multica
POSTGRES_USER: multica
POSTGRES_PASSWORD: multica
POSTGRES_DB: ${POSTGRES_DB:-multica}
POSTGRES_USER: ${POSTGRES_USER:-multica}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-multica}
ports:
- "5432:5432"
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- pgdata:/var/lib/postgresql/data

View file

@ -5,7 +5,7 @@
* have zero build-time coupling to monorepo packages.
*/
const API_BASE = "http://localhost:8080";
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? `http://localhost:${process.env.PORT ?? "8080"}`;
export class TestApiClient {
private token: string | null = null;

View file

@ -3,6 +3,9 @@ import type {
CreateIssueRequest,
UpdateIssueRequest,
ListIssuesResponse,
UpdateMeRequest,
CreateMemberRequest,
UpdateMemberRequest,
Agent,
InboxItem,
Comment,
@ -25,11 +28,11 @@ export class ApiClient {
this.baseUrl = baseUrl;
}
setToken(token: string) {
setToken(token: string | null) {
this.token = token;
}
setWorkspaceId(id: string) {
setWorkspaceId(id: string | null) {
this.workspaceId = id;
}
@ -51,7 +54,16 @@ export class ApiClient {
});
if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`);
let message = `API error: ${res.status} ${res.statusText}`;
try {
const data = await res.json() as { error?: string };
if (typeof data.error === "string" && data.error) {
message = data.error;
}
} catch {
// Ignore non-JSON error bodies.
}
throw new Error(message);
}
// Handle 204 No Content
@ -74,6 +86,13 @@ export class ApiClient {
return this.fetch("/api/me");
}
async updateMe(data: UpdateMeRequest): Promise<User> {
return this.fetch("/api/me", {
method: "PATCH",
body: JSON.stringify(data),
});
}
// Issues
async listIssues(params?: { limit?: number; offset?: number; workspace_id?: string }): Promise<ListIssuesResponse> {
const search = new URLSearchParams();
@ -163,7 +182,7 @@ export class ApiClient {
async updateWorkspace(id: string, data: { name?: string; description?: string; settings?: Record<string, unknown> }): Promise<Workspace> {
return this.fetch(`/api/workspaces/${id}`, {
method: "PUT",
method: "PATCH",
body: JSON.stringify(data),
});
}
@ -172,4 +191,36 @@ export class ApiClient {
async listMembers(workspaceId: string): Promise<MemberWithUser[]> {
return this.fetch(`/api/workspaces/${workspaceId}/members`);
}
async createMember(workspaceId: string, data: CreateMemberRequest): Promise<MemberWithUser> {
return this.fetch(`/api/workspaces/${workspaceId}/members`, {
method: "POST",
body: JSON.stringify(data),
});
}
async updateMember(workspaceId: string, memberId: string, data: UpdateMemberRequest): Promise<MemberWithUser> {
return this.fetch(`/api/workspaces/${workspaceId}/members/${memberId}`, {
method: "PATCH",
body: JSON.stringify(data),
});
}
async deleteMember(workspaceId: string, memberId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}/members/${memberId}`, {
method: "DELETE",
});
}
async leaveWorkspace(workspaceId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}/leave`, {
method: "POST",
});
}
async deleteWorkspace(workspaceId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}`, {
method: "DELETE",
});
}
}

View file

@ -1,4 +1,5 @@
import type { Issue, IssueStatus, IssuePriority, IssueAssigneeType } from "./issue.js";
import type { MemberRole } from "./workspace.js";
// Issue API
export interface CreateIssueRequest {
@ -29,6 +30,20 @@ export interface ListIssuesResponse {
total: number;
}
export interface UpdateMeRequest {
name?: string;
avatar_url?: string;
}
export interface CreateMemberRequest {
email: string;
role?: MemberRole;
}
export interface UpdateMemberRequest {
role: MemberRole;
}
// Pagination
export interface PaginationParams {
limit?: number;

View file

@ -5,7 +5,7 @@ export default defineConfig({
timeout: 30000,
retries: 0,
use: {
baseURL: "http://localhost:3000",
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? process.env.FRONTEND_ORIGIN ?? "http://localhost:3000",
headless: true,
},
projects: [

View file

@ -6,6 +6,24 @@ set -euo pipefail
# Usage: bash scripts/check.sh
# ==========================================================================
ENV_FILE="${ENV_FILE:-$(if [ -f .env ]; then echo .env; elif [ -f .env.worktree ]; then echo .env.worktree; else echo .env; fi)}"
if [ -f "$ENV_FILE" ]; then
set -a
# shellcheck disable=SC1090
. "$ENV_FILE"
set +a
fi
POSTGRES_DB="${POSTGRES_DB:-multica}"
POSTGRES_USER="${POSTGRES_USER:-multica}"
POSTGRES_PORT="${POSTGRES_PORT:-5432}"
PORT="${PORT:-8080}"
FRONTEND_PORT="${FRONTEND_PORT:-3000}"
PLAYWRIGHT_BASE_URL="${PLAYWRIGHT_BASE_URL:-http://localhost:${FRONTEND_PORT}}"
export PLAYWRIGHT_BASE_URL
COMPOSE_CMD=(docker compose --env-file "$ENV_FILE")
BACKEND_PID=""
FRONTEND_PID=""
STARTED_BACKEND=false
@ -57,13 +75,14 @@ wait_for_port() {
# --------------------------------------------------------------------------
# Step 0: Ensure DB
# --------------------------------------------------------------------------
echo "==> Using env file: $ENV_FILE"
echo "==> Checking PostgreSQL..."
if pg_isready -h localhost -p 5432 -U multica > /dev/null 2>&1; then
if pg_isready -h localhost -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" > /dev/null 2>&1; then
echo " Already running."
else
echo " Starting via docker compose..."
docker compose up -d
until docker compose exec -T postgres pg_isready -U multica > /dev/null 2>&1; do
"${COMPOSE_CMD[@]}" up -d
until "${COMPOSE_CMD[@]}" exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" > /dev/null 2>&1; do
sleep 1
done
echo " PostgreSQL ready."
@ -96,24 +115,24 @@ echo "==> [3/5] Go tests..."
echo ""
echo "==> [4/5] Starting services for E2E..."
if curl -sf http://localhost:8080/health > /dev/null 2>&1; then
echo " Backend already running on :8080"
if curl -sf "http://localhost:${PORT}/health" > /dev/null 2>&1; then
echo " Backend already running on :$PORT"
else
echo " Starting backend..."
(cd server && go run ./cmd/server) > /tmp/multica-check-backend.log 2>&1 &
BACKEND_PID=$!
STARTED_BACKEND=true
wait_for_port 8080 "Backend" 90 "/health"
wait_for_port "$PORT" "Backend" 90 "/health"
fi
if curl -sf http://localhost:3000 > /dev/null 2>&1; then
echo " Frontend already running on :3000"
if curl -sf "http://localhost:${FRONTEND_PORT}" > /dev/null 2>&1; then
echo " Frontend already running on :$FRONTEND_PORT"
else
echo " Starting frontend..."
pnpm dev:web > /tmp/multica-check-frontend.log 2>&1 &
FRONTEND_PID=$!
STARTED_FRONTEND=true
wait_for_port 3000 "Frontend" 120 "/"
wait_for_port "$FRONTEND_PORT" "Frontend" 120 "/"
fi
# --------------------------------------------------------------------------

View file

@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
ENV_FILE="${1:-.env.worktree}"
if [ -f "$ENV_FILE" ] && [ "${FORCE:-0}" != "1" ]; then
echo "Refusing to overwrite existing $ENV_FILE. Re-run with FORCE=1 if you want to regenerate it."
exit 1
fi
worktree_name="${WORKTREE_NAME:-$(basename "$PWD")}"
slug="$(printf '%s' "$worktree_name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g; s/__*/_/g; s/^_//; s/_$//')"
if [ -z "$slug" ]; then
slug="multica"
fi
hash_value="$(printf '%s' "$PWD" | cksum | awk '{print $1}')"
offset=$((hash_value % 1000))
postgres_db="multica_${slug}"
postgres_port=$((15432 + offset))
backend_port=$((18080 + offset))
frontend_port=$((13000 + offset))
frontend_origin="http://localhost:${frontend_port}"
compose_project_name="multica_${slug}_${offset}"
cat > "$ENV_FILE" <<EOF
COMPOSE_PROJECT_NAME=${compose_project_name}
POSTGRES_DB=${postgres_db}
POSTGRES_USER=multica
POSTGRES_PASSWORD=multica
POSTGRES_PORT=${postgres_port}
DATABASE_URL=postgres://multica:multica@localhost:${postgres_port}/${postgres_db}?sslmode=disable
PORT=${backend_port}
JWT_SECRET=change-me-in-production
MULTICA_SERVER_URL=ws://localhost:${backend_port}/ws
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=${frontend_origin}/auth/callback
FRONTEND_PORT=${frontend_port}
FRONTEND_ORIGIN=${frontend_origin}
NEXT_PUBLIC_API_URL=http://localhost:${backend_port}
NEXT_PUBLIC_WS_URL=ws://localhost:${backend_port}/ws
EOF
echo "Generated $ENV_FILE for worktree '$worktree_name'"
echo " Postgres: ${postgres_db} on localhost:${postgres_port}"
echo " Backend: http://localhost:${backend_port}"
echo " Frontend: ${frontend_origin}"
echo ""
echo "Next steps:"
echo " make setup"
echo " make start"

View file

@ -11,7 +11,11 @@ import (
func main() {
serverURL := os.Getenv("MULTICA_SERVER_URL")
if serverURL == "" {
serverURL = "ws://localhost:8080/ws"
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
serverURL = "ws://localhost:" + port + "/ws"
}
fmt.Println("Multica Daemon starting...")

View file

@ -59,7 +59,8 @@ func TestMain(m *testing.M) {
hub := realtime.NewHub()
go hub.Run()
router := NewRouter(queries, hub)
_ = queries
router := NewRouter(pool, hub)
testServer = httptest.NewServer(router)
defer testServer.Close()

View file

@ -12,7 +12,6 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/multica-ai/multica/server/internal/realtime"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
func main() {
@ -39,11 +38,10 @@ func main() {
}
log.Println("Connected to database")
queries := db.New(pool)
hub := realtime.NewHub()
go hub.Run()
r := NewRouter(queries, hub)
r := NewRouter(pool, hub)
srv := &http.Server{
Addr: ":" + port,

View file

@ -2,10 +2,13 @@ package main
import (
"net/http"
"os"
"strings"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/multica-ai/multica/server/internal/handler"
"github.com/multica-ai/multica/server/internal/middleware"
@ -13,9 +16,33 @@ import (
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
func allowedOrigins() []string {
raw := strings.TrimSpace(os.Getenv("CORS_ALLOWED_ORIGINS"))
if raw == "" {
raw = strings.TrimSpace(os.Getenv("FRONTEND_ORIGIN"))
}
if raw == "" {
return []string{"http://localhost:3000"}
}
parts := strings.Split(raw, ",")
origins := make([]string, 0, len(parts))
for _, part := range parts {
origin := strings.TrimSpace(part)
if origin != "" {
origins = append(origins, origin)
}
}
if len(origins) == 0 {
return []string{"http://localhost:3000"}
}
return origins
}
// NewRouter creates the fully-configured Chi router with all middleware and routes.
func NewRouter(queries *db.Queries, hub *realtime.Hub) chi.Router {
h := handler.New(queries, hub)
func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub) chi.Router {
queries := db.New(pool)
h := handler.New(queries, pool, hub)
r := chi.NewRouter()
@ -24,7 +51,7 @@ func NewRouter(queries *db.Queries, hub *realtime.Hub) chi.Router {
r.Use(chimw.Recoverer)
r.Use(chimw.RequestID)
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"http://localhost:3000"},
AllowedOrigins: allowedOrigins(),
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Workspace-ID"},
AllowCredentials: true,
@ -51,6 +78,7 @@ func NewRouter(queries *db.Queries, hub *realtime.Hub) chi.Router {
// Auth
r.Get("/api/me", h.GetMe)
r.Patch("/api/me", h.UpdateMe)
// Issues
r.Route("/api/issues", func(r chi.Router) {
@ -89,7 +117,15 @@ func NewRouter(queries *db.Queries, hub *realtime.Hub) chi.Router {
r.Route("/{id}", func(r chi.Router) {
r.Get("/", h.GetWorkspace)
r.Put("/", h.UpdateWorkspace)
r.Patch("/", h.UpdateWorkspace)
r.Delete("/", h.DeleteWorkspace)
r.Get("/members", h.ListMembersWithUser)
r.Post("/members", h.CreateMember)
r.Post("/leave", h.LeaveWorkspace)
r.Route("/members/{memberId}", func(r chi.Router) {
r.Patch("/", h.UpdateMember)
r.Delete("/", h.DeleteMember)
})
})
})
})

View file

@ -0,0 +1,25 @@
package auth
import (
"os"
"sync"
)
const defaultJWTSecret = "multica-dev-secret-change-in-production"
var (
jwtSecret []byte
jwtSecretOnce sync.Once
)
func JWTSecret() []byte {
jwtSecretOnce.Do(func() {
secret := os.Getenv("JWT_SECRET")
if secret == "" {
secret = defaultJWTSecret
}
jwtSecret = []byte(secret)
})
return jwtSecret
}

View file

@ -49,12 +49,8 @@ func agentToResponse(a db.Agent) AgentResponse {
}
func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
workspaceID := r.URL.Query().Get("workspace_id")
if workspaceID == "" {
workspaceID = r.Header.Get("X-Workspace-ID")
}
if workspaceID == "" {
writeError(w, http.StatusBadRequest, "workspace_id is required")
workspaceID := resolveWorkspaceID(r)
if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {
return
}
@ -74,9 +70,8 @@ func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
agent, err := h.Queries.GetAgent(r.Context(), parseUUID(id))
if err != nil {
writeError(w, http.StatusNotFound, "agent not found")
agent, ok := h.loadAgentForUser(w, r, id)
if !ok {
return
}
writeJSON(w, http.StatusOK, agentToResponse(agent))
@ -92,17 +87,21 @@ type CreateAgentRequest struct {
}
func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
workspaceID := resolveWorkspaceID(r)
if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok {
return
}
var req CreateAgentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
workspaceID := r.URL.Query().Get("workspace_id")
if workspaceID == "" {
workspaceID = r.Header.Get("X-Workspace-ID")
ownerID, ok := requireUserID(w, r)
if !ok {
return
}
ownerID := r.Header.Get("X-User-ID")
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
@ -152,6 +151,13 @@ type UpdateAgentRequest struct {
func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
agent, ok := h.loadAgentForUser(w, r, id)
if !ok {
return
}
if _, ok := h.requireWorkspaceRole(w, r, uuidToString(agent.WorkspaceID), "agent not found", "owner", "admin"); !ok {
return
}
var req UpdateAgentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {

View file

@ -3,14 +3,15 @@ package handler
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/auth"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
var jwtSecret = []byte("multica-dev-secret-change-in-production")
type UserResponse struct {
ID string `json:"id"`
Name string `json:"name"`
@ -48,6 +49,9 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
return
}
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
req.Name = strings.TrimSpace(req.Name)
if req.Email == "" {
writeError(w, http.StatusBadRequest, "email is required")
return
@ -56,6 +60,11 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
// Try to find existing user
user, err := h.Queries.GetUserByEmail(r.Context(), req.Email)
if err != nil {
if !isNotFound(err) {
writeError(w, http.StatusInternalServerError, "failed to load user")
return
}
// Create new user
name := req.Name
if name == "" {
@ -69,6 +78,15 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "failed to create user: "+err.Error())
return
}
} else if req.Name != "" && req.Name != user.Name {
user, err = h.Queries.UpdateUser(r.Context(), db.UpdateUserParams{
ID: user.ID,
Name: req.Name,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update user")
return
}
}
// Generate JWT
@ -80,7 +98,7 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
"iat": time.Now().Unix(),
})
tokenString, err := token.SignedString(jwtSecret)
tokenString, err := token.SignedString(auth.JWTSecret())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate token")
return
@ -93,9 +111,8 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("X-User-ID")
if userID == "" {
writeError(w, http.StatusUnauthorized, "user not authenticated")
userID, ok := requireUserID(w, r)
if !ok {
return
}
@ -107,3 +124,52 @@ func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, userToResponse(user))
}
type UpdateMeRequest struct {
Name *string `json:"name"`
AvatarURL *string `json:"avatar_url"`
}
func (h *Handler) UpdateMe(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
var req UpdateMeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
currentUser, err := h.Queries.GetUser(r.Context(), parseUUID(userID))
if err != nil {
writeError(w, http.StatusNotFound, "user not found")
return
}
name := currentUser.Name
if req.Name != nil {
name = strings.TrimSpace(*req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
}
params := db.UpdateUserParams{
ID: currentUser.ID,
Name: name,
}
if req.AvatarURL != nil {
params.AvatarUrl = pgtype.Text{String: strings.TrimSpace(*req.AvatarURL), Valid: true}
}
updatedUser, err := h.Queries.UpdateUser(r.Context(), params)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update user")
return
}
writeJSON(w, http.StatusOK, userToResponse(updatedUser))
}

View file

@ -34,7 +34,12 @@ func commentToResponse(c db.Comment) CommentResponse {
func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) {
issueID := chi.URLParam(r, "id")
comments, err := h.Queries.ListComments(r.Context(), parseUUID(issueID))
issue, ok := h.loadIssueForUser(w, r, issueID)
if !ok {
return
}
comments, err := h.Queries.ListComments(r.Context(), issue.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list comments")
return
@ -55,9 +60,13 @@ type CreateCommentRequest struct {
func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
issueID := chi.URLParam(r, "id")
userID := r.Header.Get("X-User-ID")
if userID == "" {
writeError(w, http.StatusUnauthorized, "user not authenticated")
issue, ok := h.loadIssueForUser(w, r, issueID)
if !ok {
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
@ -76,7 +85,7 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
}
comment, err := h.Queries.CreateComment(r.Context(), db.CreateCommentParams{
IssueID: parseUUID(issueID),
IssueID: issue.ID,
AuthorType: "member",
AuthorID: parseUUID(userID),
Content: req.Content,

View file

@ -1,24 +1,33 @@
package handler
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/internal/realtime"
)
type Handler struct {
Queries *db.Queries
Hub *realtime.Hub
type txStarter interface {
Begin(ctx context.Context) (pgx.Tx, error)
}
func New(queries *db.Queries, hub *realtime.Hub) *Handler {
return &Handler{Queries: queries, Hub: hub}
type Handler struct {
Queries *db.Queries
TxStarter txStarter
Hub *realtime.Hub
}
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub) *Handler {
return &Handler{Queries: queries, TxStarter: txStarter, Hub: hub}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
@ -112,3 +121,147 @@ func (h *Handler) broadcast(eventType string, payload any) {
}
h.Hub.Broadcast(data)
}
func isNotFound(err error) bool {
return errors.Is(err, pgx.ErrNoRows)
}
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
func requestUserID(r *http.Request) string {
return r.Header.Get("X-User-ID")
}
func requireUserID(w http.ResponseWriter, r *http.Request) (string, bool) {
userID := requestUserID(r)
if userID == "" {
writeError(w, http.StatusUnauthorized, "user not authenticated")
return "", false
}
return userID, true
}
func resolveWorkspaceID(r *http.Request) string {
workspaceID := r.URL.Query().Get("workspace_id")
if workspaceID != "" {
return workspaceID
}
return r.Header.Get("X-Workspace-ID")
}
func roleAllowed(role string, roles ...string) bool {
for _, candidate := range roles {
if role == candidate {
return true
}
}
return false
}
func countOwners(members []db.Member) int {
owners := 0
for _, member := range members {
if member.Role == "owner" {
owners++
}
}
return owners
}
func (h *Handler) getWorkspaceMember(ctx context.Context, userID, workspaceID string) (db.Member, error) {
return h.Queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
UserID: parseUUID(userID),
WorkspaceID: parseUUID(workspaceID),
})
}
func (h *Handler) requireWorkspaceMember(w http.ResponseWriter, r *http.Request, workspaceID, notFoundMsg string) (db.Member, bool) {
if workspaceID == "" {
writeError(w, http.StatusBadRequest, "workspace_id is required")
return db.Member{}, false
}
userID, ok := requireUserID(w, r)
if !ok {
return db.Member{}, false
}
member, err := h.getWorkspaceMember(r.Context(), userID, workspaceID)
if err != nil {
writeError(w, http.StatusNotFound, notFoundMsg)
return db.Member{}, false
}
return member, true
}
func (h *Handler) requireWorkspaceRole(w http.ResponseWriter, r *http.Request, workspaceID, notFoundMsg string, roles ...string) (db.Member, bool) {
member, ok := h.requireWorkspaceMember(w, r, workspaceID, notFoundMsg)
if !ok {
return db.Member{}, false
}
if !roleAllowed(member.Role, roles...) {
writeError(w, http.StatusForbidden, "insufficient permissions")
return db.Member{}, false
}
return member, true
}
func (h *Handler) loadIssueForUser(w http.ResponseWriter, r *http.Request, issueID string) (db.Issue, bool) {
if _, ok := requireUserID(w, r); !ok {
return db.Issue{}, false
}
issue, err := h.Queries.GetIssue(r.Context(), parseUUID(issueID))
if err != nil {
writeError(w, http.StatusNotFound, "issue not found")
return db.Issue{}, false
}
if _, ok := h.requireWorkspaceMember(w, r, uuidToString(issue.WorkspaceID), "issue not found"); !ok {
return db.Issue{}, false
}
return issue, true
}
func (h *Handler) loadAgentForUser(w http.ResponseWriter, r *http.Request, agentID string) (db.Agent, bool) {
if _, ok := requireUserID(w, r); !ok {
return db.Agent{}, false
}
agent, err := h.Queries.GetAgent(r.Context(), parseUUID(agentID))
if err != nil {
writeError(w, http.StatusNotFound, "agent not found")
return db.Agent{}, false
}
if _, ok := h.requireWorkspaceMember(w, r, uuidToString(agent.WorkspaceID), "agent not found"); !ok {
return db.Agent{}, false
}
return agent, true
}
func (h *Handler) loadInboxItemForUser(w http.ResponseWriter, r *http.Request, itemID string) (db.InboxItem, bool) {
userID, ok := requireUserID(w, r)
if !ok {
return db.InboxItem{}, false
}
item, err := h.Queries.GetInboxItem(r.Context(), parseUUID(itemID))
if err != nil {
writeError(w, http.StatusNotFound, "inbox item not found")
return db.InboxItem{}, false
}
if item.RecipientType != "member" || uuidToString(item.RecipientID) != userID {
writeError(w, http.StatusNotFound, "inbox item not found")
return db.InboxItem{}, false
}
return item, true
}

View file

@ -37,7 +37,7 @@ func TestMain(m *testing.M) {
queries := db.New(pool)
hub := realtime.NewHub()
go hub.Run()
testHandler = New(queries, hub)
testHandler = New(queries, pool, hub)
// Get seed user and workspace IDs
row := pool.QueryRow(context.Background(), `SELECT id FROM "user" WHERE email = 'jiayuan@multica.ai'`)

View file

@ -41,9 +41,8 @@ func inboxToResponse(i db.InboxItem) InboxItemResponse {
}
func (h *Handler) ListInbox(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("X-User-ID")
if userID == "" {
writeError(w, http.StatusUnauthorized, "user not authenticated")
userID, ok := requireUserID(w, r)
if !ok {
return
}
@ -81,6 +80,9 @@ func (h *Handler) ListInbox(w http.ResponseWriter, r *http.Request) {
func (h *Handler) MarkInboxRead(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, ok := h.loadInboxItemForUser(w, r, id); !ok {
return
}
item, err := h.Queries.MarkInboxRead(r.Context(), parseUUID(id))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to mark read")
@ -91,6 +93,9 @@ func (h *Handler) MarkInboxRead(w http.ResponseWriter, r *http.Request) {
func (h *Handler) ArchiveInboxItem(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, ok := h.loadInboxItemForUser(w, r, id); !ok {
return
}
item, err := h.Queries.ArchiveInboxItem(r.Context(), parseUUID(id))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to archive")

View file

@ -79,12 +79,8 @@ func issueToResponse(i db.Issue) IssueResponse {
func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
workspaceID := r.URL.Query().Get("workspace_id")
if workspaceID == "" {
workspaceID = r.Header.Get("X-Workspace-ID")
}
if workspaceID == "" {
writeError(w, http.StatusBadRequest, "workspace_id is required")
workspaceID := resolveWorkspaceID(r)
if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {
return
}
@ -124,9 +120,8 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
func (h *Handler) GetIssue(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
issue, err := h.Queries.GetIssue(r.Context(), parseUUID(id))
if err != nil {
writeError(w, http.StatusNotFound, "issue not found")
issue, ok := h.loadIssueForUser(w, r, id)
if !ok {
return
}
writeJSON(w, http.StatusOK, issueToResponse(issue))
@ -157,19 +152,14 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
return
}
workspaceID := r.URL.Query().Get("workspace_id")
if workspaceID == "" {
workspaceID = r.Header.Get("X-Workspace-ID")
}
if workspaceID == "" {
writeError(w, http.StatusBadRequest, "workspace_id is required")
workspaceID := resolveWorkspaceID(r)
if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {
return
}
// Get creator from context (set by auth middleware)
creatorID := r.Header.Get("X-User-ID")
if creatorID == "" {
writeError(w, http.StatusUnauthorized, "user not authenticated")
creatorID, ok := requireUserID(w, r)
if !ok {
return
}
@ -265,6 +255,9 @@ type UpdateIssueRequest struct {
func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, ok := h.loadIssueForUser(w, r, id); !ok {
return
}
var req UpdateIssueRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@ -330,6 +323,10 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
func (h *Handler) DeleteIssue(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, ok := h.loadIssueForUser(w, r, id); !ok {
return
}
err := h.Queries.DeleteIssue(r.Context(), parseUUID(id))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete issue")

View file

@ -3,6 +3,7 @@ package handler
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
@ -57,9 +58,8 @@ func memberToResponse(m db.Member) MemberResponse {
}
func (h *Handler) ListWorkspaces(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("X-User-ID")
if userID == "" {
writeError(w, http.StatusUnauthorized, "user not authenticated")
userID, ok := requireUserID(w, r)
if !ok {
return
}
@ -79,6 +79,10 @@ func (h *Handler) ListWorkspaces(w http.ResponseWriter, r *http.Request) {
func (h *Handler) GetWorkspace(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, ok := h.requireWorkspaceMember(w, r, id, "workspace not found"); !ok {
return
}
ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(id))
if err != nil {
writeError(w, http.StatusNotFound, "workspace not found")
@ -94,9 +98,8 @@ type CreateWorkspaceRequest struct {
}
func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("X-User-ID")
if userID == "" {
writeError(w, http.StatusUnauthorized, "user not authenticated")
userID, ok := requireUserID(w, r)
if !ok {
return
}
@ -106,23 +109,36 @@ func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
return
}
req.Name = strings.TrimSpace(req.Name)
req.Slug = strings.ToLower(strings.TrimSpace(req.Slug))
if req.Name == "" || req.Slug == "" {
writeError(w, http.StatusBadRequest, "name and slug are required")
return
}
ws, err := h.Queries.CreateWorkspace(r.Context(), db.CreateWorkspaceParams{
tx, err := h.TxStarter.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create workspace")
return
}
defer tx.Rollback(r.Context())
qtx := h.Queries.WithTx(tx)
ws, err := qtx.CreateWorkspace(r.Context(), db.CreateWorkspaceParams{
Name: req.Name,
Slug: req.Slug,
Description: ptrToText(req.Description),
})
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "workspace slug already exists")
return
}
writeError(w, http.StatusInternalServerError, "failed to create workspace: "+err.Error())
return
}
// Add creator as owner
_, err = h.Queries.CreateMember(r.Context(), db.CreateMemberParams{
_, err = qtx.CreateMember(r.Context(), db.CreateMemberParams{
WorkspaceID: ws.ID,
UserID: parseUUID(userID),
Role: "owner",
@ -132,6 +148,11 @@ func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create workspace")
return
}
writeJSON(w, http.StatusCreated, workspaceToResponse(ws))
}
@ -143,6 +164,9 @@ type UpdateWorkspaceRequest struct {
func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, ok := h.requireWorkspaceRole(w, r, id, "workspace not found", "owner", "admin"); !ok {
return
}
var req UpdateWorkspaceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@ -154,7 +178,12 @@ func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) {
ID: parseUUID(id),
}
if req.Name != nil {
params.Name = pgtype.Text{String: *req.Name, Valid: true}
name := strings.TrimSpace(*req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
params.Name = pgtype.Text{String: name, Valid: true}
}
if req.Description != nil {
params.Description = pgtype.Text{String: *req.Description, Valid: true}
@ -175,6 +204,10 @@ func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) {
func (h *Handler) ListMembers(w http.ResponseWriter, r *http.Request) {
workspaceID := chi.URLParam(r, "id")
if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {
return
}
members, err := h.Queries.ListMembers(r.Context(), parseUUID(workspaceID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list members")
@ -202,6 +235,10 @@ type MemberWithUserResponse struct {
func (h *Handler) ListMembersWithUser(w http.ResponseWriter, r *http.Request) {
workspaceID := chi.URLParam(r, "id")
if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {
return
}
members, err := h.Queries.ListMembersWithUser(r.Context(), parseUUID(workspaceID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list members")
@ -224,3 +261,240 @@ func (h *Handler) ListMembersWithUser(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
type CreateMemberRequest struct {
Email string `json:"email"`
Role string `json:"role"`
}
func memberWithUserResponse(member db.Member, user db.User) MemberWithUserResponse {
return MemberWithUserResponse{
ID: uuidToString(member.ID),
WorkspaceID: uuidToString(member.WorkspaceID),
UserID: uuidToString(member.UserID),
Role: member.Role,
CreatedAt: timestampToString(member.CreatedAt),
Name: user.Name,
Email: user.Email,
AvatarURL: textToPtr(user.AvatarUrl),
}
}
func normalizeMemberRole(role string) (string, bool) {
if role == "" {
return "member", true
}
role = strings.TrimSpace(role)
switch role {
case "owner", "admin", "member":
return role, true
default:
return "", false
}
}
func (h *Handler) CreateMember(w http.ResponseWriter, r *http.Request) {
workspaceID := chi.URLParam(r, "id")
requester, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin")
if !ok {
return
}
var req CreateMemberRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
email := strings.ToLower(strings.TrimSpace(req.Email))
if email == "" {
writeError(w, http.StatusBadRequest, "email is required")
return
}
role, valid := normalizeMemberRole(req.Role)
if !valid {
writeError(w, http.StatusBadRequest, "invalid member role")
return
}
if role == "owner" && requester.Role != "owner" {
writeError(w, http.StatusForbidden, "insufficient permissions")
return
}
user, err := h.Queries.GetUserByEmail(r.Context(), email)
if err != nil {
if isNotFound(err) {
writeError(w, http.StatusNotFound, "user not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load user")
return
}
member, err := h.Queries.CreateMember(r.Context(), db.CreateMemberParams{
WorkspaceID: parseUUID(workspaceID),
UserID: user.ID,
Role: role,
})
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "user is already a member")
return
}
writeError(w, http.StatusInternalServerError, "failed to create member")
return
}
writeJSON(w, http.StatusCreated, memberWithUserResponse(member, user))
}
type UpdateMemberRequest struct {
Role string `json:"role"`
}
func (h *Handler) UpdateMember(w http.ResponseWriter, r *http.Request) {
workspaceID := chi.URLParam(r, "id")
requester, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin")
if !ok {
return
}
memberID := chi.URLParam(r, "memberId")
target, err := h.Queries.GetMember(r.Context(), parseUUID(memberID))
if err != nil || uuidToString(target.WorkspaceID) != workspaceID {
writeError(w, http.StatusNotFound, "member not found")
return
}
var req UpdateMemberRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if strings.TrimSpace(req.Role) == "" {
writeError(w, http.StatusBadRequest, "role is required")
return
}
role, valid := normalizeMemberRole(req.Role)
if !valid {
writeError(w, http.StatusBadRequest, "invalid member role")
return
}
if (target.Role == "owner" || role == "owner") && requester.Role != "owner" {
writeError(w, http.StatusForbidden, "insufficient permissions")
return
}
if target.Role == "owner" && role != "owner" {
members, err := h.Queries.ListMembers(r.Context(), target.WorkspaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update member")
return
}
if countOwners(members) <= 1 {
writeError(w, http.StatusBadRequest, "workspace must have at least one owner")
return
}
}
updatedMember, err := h.Queries.UpdateMemberRole(r.Context(), db.UpdateMemberRoleParams{
ID: target.ID,
Role: role,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update member")
return
}
user, err := h.Queries.GetUser(r.Context(), updatedMember.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load member")
return
}
writeJSON(w, http.StatusOK, memberWithUserResponse(updatedMember, user))
}
func (h *Handler) DeleteMember(w http.ResponseWriter, r *http.Request) {
workspaceID := chi.URLParam(r, "id")
requester, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin")
if !ok {
return
}
memberID := chi.URLParam(r, "memberId")
target, err := h.Queries.GetMember(r.Context(), parseUUID(memberID))
if err != nil || uuidToString(target.WorkspaceID) != workspaceID {
writeError(w, http.StatusNotFound, "member not found")
return
}
if target.Role == "owner" && requester.Role != "owner" {
writeError(w, http.StatusForbidden, "insufficient permissions")
return
}
if target.Role == "owner" {
members, err := h.Queries.ListMembers(r.Context(), target.WorkspaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete member")
return
}
if countOwners(members) <= 1 {
writeError(w, http.StatusBadRequest, "workspace must have at least one owner")
return
}
}
if err := h.Queries.DeleteMember(r.Context(), target.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete member")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) LeaveWorkspace(w http.ResponseWriter, r *http.Request) {
workspaceID := chi.URLParam(r, "id")
member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found")
if !ok {
return
}
if member.Role == "owner" {
members, err := h.Queries.ListMembers(r.Context(), member.WorkspaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to leave workspace")
return
}
if countOwners(members) <= 1 {
writeError(w, http.StatusBadRequest, "workspace must have at least one owner")
return
}
}
if err := h.Queries.DeleteMember(r.Context(), member.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to leave workspace")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) DeleteWorkspace(w http.ResponseWriter, r *http.Request) {
workspaceID := chi.URLParam(r, "id")
if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner"); !ok {
return
}
if err := h.Queries.DeleteWorkspace(r.Context(), parseUUID(workspaceID)); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete workspace")
return
}
w.WriteHeader(http.StatusNoContent)
}

View file

@ -5,10 +5,9 @@ import (
"strings"
"github.com/golang-jwt/jwt/v5"
"github.com/multica-ai/multica/server/internal/auth"
)
var jwtSecret = []byte("multica-dev-secret-change-in-production")
// Auth middleware validates JWT tokens from the Authorization header.
// Sets X-User-ID and X-User-Email headers on the request for downstream handlers.
func Auth(next http.Handler) http.Handler {
@ -29,7 +28,7 @@ func Auth(next http.Handler) http.Handler {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return jwtSecret, nil
return auth.JWTSecret(), nil
})
if err != nil || !token.Valid {
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
@ -42,9 +41,12 @@ func Auth(next http.Handler) http.Handler {
return
}
if sub, ok := claims["sub"].(string); ok {
r.Header.Set("X-User-ID", sub)
sub, ok := claims["sub"].(string)
if !ok || strings.TrimSpace(sub) == "" {
http.Error(w, `{"error":"invalid claims"}`, http.StatusUnauthorized)
return
}
r.Header.Set("X-User-ID", sub)
if email, ok := claims["email"].(string); ok {
r.Header.Set("X-User-Email", email)
}

View file

@ -7,6 +7,7 @@ import (
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/multica-ai/multica/server/internal/auth"
)
func generateToken(claims jwt.MapClaims, secret []byte) string {
@ -80,7 +81,7 @@ func TestAuth_ExpiredToken(t *testing.T) {
claims := validClaims()
claims["exp"] = time.Now().Add(-time.Hour).Unix()
token := generateToken(claims, jwtSecret)
token := generateToken(claims, auth.JWTSecret())
req := httptest.NewRequest("GET", "/api/me", nil)
req.Header.Set("Authorization", "Bearer "+token)
@ -136,7 +137,7 @@ func TestAuth_ValidToken(t *testing.T) {
w.WriteHeader(http.StatusOK)
}))
token := generateToken(validClaims(), jwtSecret)
token := generateToken(validClaims(), auth.JWTSecret())
req := httptest.NewRequest("GET", "/api/me", nil)
req.Header.Set("Authorization", "Bearer "+token)
@ -155,31 +156,22 @@ func TestAuth_ValidToken(t *testing.T) {
}
func TestAuth_MissingClaims(t *testing.T) {
var gotUserID, gotEmail string
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotUserID = r.Header.Get("X-User-ID")
gotEmail = r.Header.Get("X-User-Email")
w.WriteHeader(http.StatusOK)
t.Fatal("next handler should not be called")
}))
// Token with no sub or email claims, only exp
claims := jwt.MapClaims{
"exp": time.Now().Add(time.Hour).Unix(),
}
token := generateToken(claims, jwtSecret)
token := generateToken(claims, auth.JWTSecret())
req := httptest.NewRequest("GET", "/api/me", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if gotUserID != "" {
t.Fatalf("expected empty X-User-ID, got '%s'", gotUserID)
}
if gotEmail != "" {
t.Fatalf("expected empty X-User-Email, got '%s'", gotEmail)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
}

View file

@ -1,6 +1,18 @@
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["tsconfig.base.json"],
"globalEnv": [
"DATABASE_URL",
"PORT",
"FRONTEND_PORT",
"FRONTEND_ORIGIN",
"NEXT_PUBLIC_API_URL",
"NEXT_PUBLIC_WS_URL",
"MULTICA_SERVER_URL",
"COMPOSE_PROJECT_NAME",
"POSTGRES_DB",
"POSTGRES_PORT"
],
"tasks": {
"build": {
"dependsOn": ["^build"],