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:
commit
88ca7848b5
32 changed files with 1462 additions and 200 deletions
|
|
@ -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
|
||||
|
|
|
|||
77
Makefile
77
Makefile
|
|
@ -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
|
||||
|
|
|
|||
38
README.md
38
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
|
|
|||
56
scripts/init-worktree-env.sh
Normal file
56
scripts/init-worktree-env.sh
Normal 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"
|
||||
|
|
@ -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...")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
25
server/internal/auth/jwt.go
Normal file
25
server/internal/auth/jwt.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'`)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
12
turbo.json
12
turbo.json
|
|
@ -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"],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue