Add workspace management and isolated worktree environments
This commit is contained in:
parent
e9555b8a22
commit
81e64e9fce
32 changed files with 1462 additions and 200 deletions
|
|
@ -1,9 +1,15 @@
|
||||||
# Database
|
# 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
|
DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
|
||||||
|
|
||||||
# Server
|
# Server
|
||||||
PORT=8080
|
PORT=8080
|
||||||
JWT_SECRET=change-me-in-production
|
JWT_SECRET=change-me-in-production
|
||||||
|
MULTICA_SERVER_URL=ws://localhost:8080/ws
|
||||||
|
|
||||||
# Google OAuth
|
# Google OAuth
|
||||||
GOOGLE_CLIENT_ID=
|
GOOGLE_CLIENT_ID=
|
||||||
|
|
@ -11,5 +17,7 @@ GOOGLE_CLIENT_SECRET=
|
||||||
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
|
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
|
FRONTEND_PORT=3000
|
||||||
|
FRONTEND_ORIGIN=http://localhost:3000
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:8080
|
NEXT_PUBLIC_API_URL=http://localhost:8080
|
||||||
NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws
|
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 ----------
|
# ---------- One-click commands ----------
|
||||||
|
|
||||||
# First-time setup: install deps, start DB, run migrations, seed data
|
# First-time setup: install deps, start DB, run migrations, seed data
|
||||||
setup:
|
setup:
|
||||||
|
@echo "==> Using env file: $(ENV_FILE)"
|
||||||
@echo "==> Installing dependencies..."
|
@echo "==> Installing dependencies..."
|
||||||
pnpm install
|
pnpm install
|
||||||
@echo "==> Starting PostgreSQL..."
|
@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."; \
|
echo " PostgreSQL already running, skipping docker compose up."; \
|
||||||
else \
|
else \
|
||||||
docker compose up -d; \
|
$(COMPOSE) up -d; \
|
||||||
echo "==> Waiting for PostgreSQL to be ready..."; \
|
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; \
|
sleep 1; \
|
||||||
done; \
|
done; \
|
||||||
fi
|
fi
|
||||||
|
|
@ -25,11 +52,14 @@ setup:
|
||||||
|
|
||||||
# Start all services (backend + frontend)
|
# Start all services (backend + frontend)
|
||||||
start:
|
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."; \
|
echo "PostgreSQL already running, skipping docker compose up."; \
|
||||||
else \
|
else \
|
||||||
docker compose up -d; \
|
$(COMPOSE) up -d; \
|
||||||
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; \
|
sleep 1; \
|
||||||
done; \
|
done; \
|
||||||
fi
|
fi
|
||||||
|
|
@ -42,15 +72,42 @@ start:
|
||||||
# Stop all services
|
# Stop all services
|
||||||
stop:
|
stop:
|
||||||
@echo "Stopping services..."
|
@echo "Stopping services..."
|
||||||
@-lsof -ti:8080 | xargs kill -9 2>/dev/null
|
@-lsof -ti:$(PORT) | xargs kill -9 2>/dev/null
|
||||||
@-lsof -ti:3000 | xargs kill -9 2>/dev/null
|
@-lsof -ti:$(FRONTEND_PORT) | xargs kill -9 2>/dev/null
|
||||||
docker compose down
|
$(COMPOSE) down
|
||||||
@echo "✓ All services stopped."
|
@echo "✓ All services stopped."
|
||||||
|
|
||||||
# Full verification: typecheck + unit tests + Go tests + E2E
|
# Full verification: typecheck + unit tests + Go tests + E2E
|
||||||
check:
|
check:
|
||||||
@bash scripts/check.sh
|
@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 ----------
|
# ---------- Individual commands ----------
|
||||||
|
|
||||||
# Go server
|
# 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
|
# 1. Install dependencies
|
||||||
pnpm install
|
pnpm install
|
||||||
|
|
||||||
# 2. Copy environment variables
|
# 2. Copy environment variables for the shared main environment
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|
||||||
# 3. Start PostgreSQL
|
# 3. One-time setup: start DB, run migrations, seed data
|
||||||
docker compose up -d
|
make setup
|
||||||
|
|
||||||
# 4. Run database migrations
|
# 4. Start backend + frontend
|
||||||
make migrate-up
|
make start
|
||||||
|
|
||||||
# 5. Start the Go backend (port 8080)
|
|
||||||
make dev
|
|
||||||
|
|
||||||
# 6. In another terminal, start the frontend (port 3000)
|
|
||||||
pnpm dev:web
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
## Project Structure
|
||||||
|
|
||||||
|
|
@ -61,7 +65,7 @@ Open [http://localhost:3000](http://localhost:3000) in your browser.
|
||||||
|
|
||||||
| Command | Description |
|
| 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 build` | Build all TypeScript packages |
|
||||||
| `pnpm typecheck` | Run TypeScript type checking |
|
| `pnpm typecheck` | Run TypeScript type checking |
|
||||||
| `pnpm test` | Run TypeScript tests |
|
| `pnpm test` | Run TypeScript tests |
|
||||||
|
|
@ -70,7 +74,7 @@ Open [http://localhost:3000](http://localhost:3000) in your browser.
|
||||||
|
|
||||||
| Command | Description |
|
| 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 daemon` | Run local agent daemon |
|
||||||
| `make test` | Run Go tests |
|
| `make test` | Run Go tests |
|
||||||
| `make build` | Build server & daemon binaries |
|
| `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-up` | Run database migrations |
|
||||||
| `make migrate-down` | Rollback database migrations |
|
| `make migrate-down` | Rollback database migrations |
|
||||||
| `make seed` | Seed test data |
|
| `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
|
## Environment Variables
|
||||||
|
|
||||||
See [`.env.example`](.env.example) for all available variables:
|
See [`.env.example`](.env.example) for all available variables:
|
||||||
|
|
||||||
- `DATABASE_URL` — PostgreSQL connection string
|
- `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)
|
- `PORT` — Backend server port (default: 8080)
|
||||||
|
- `FRONTEND_PORT` / `FRONTEND_ORIGIN` — Frontend port and browser origin
|
||||||
- `JWT_SECRET` — JWT signing secret
|
- `JWT_SECRET` — JWT signing secret
|
||||||
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` — Google OAuth (optional)
|
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` — Google OAuth (optional)
|
||||||
- `NEXT_PUBLIC_API_URL` — Frontend → backend API URL
|
- `NEXT_PUBLIC_API_URL` — Frontend → backend API URL
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,12 @@ export default function DashboardLayout({
|
||||||
const [newSlug, setNewSlug] = useState("");
|
const [newSlug, setNewSlug] = useState("");
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && user && workspaces.length === 0) {
|
||||||
|
setShowCreateDialog(true);
|
||||||
|
}
|
||||||
|
}, [isLoading, user, workspaces.length]);
|
||||||
|
|
||||||
const handleNameChange = (value: string) => {
|
const handleNameChange = (value: string) => {
|
||||||
setNewName(value);
|
setNewName(value);
|
||||||
setNewSlug(value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""));
|
setNewSlug(value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""));
|
||||||
|
|
@ -75,6 +81,100 @@ export default function DashboardLayout({
|
||||||
|
|
||||||
if (!user) return null;
|
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 (
|
return (
|
||||||
<div className="flex h-screen bg-canvas">
|
<div className="flex h-screen bg-canvas">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Settings, Users, Building2, Save, Crown, Shield, User } from "lucide-react";
|
import { Settings, Users, Building2, Save, Crown, Shield, User, Plus, Trash2, LogOut } from "lucide-react";
|
||||||
import type { MemberWithUser, MemberRole } from "@multica/types";
|
import type { MemberWithUser, MemberRole } from "@multica/types";
|
||||||
import { useAuth } from "../../../lib/auth-context";
|
import { useAuth } from "../../../lib/auth-context";
|
||||||
import { api } from "../../../lib/api";
|
import { api } from "../../../lib/api";
|
||||||
|
|
@ -12,9 +12,27 @@ const roleConfig: Record<MemberRole, { label: string; icon: typeof Crown }> = {
|
||||||
member: { label: "Member", icon: User },
|
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 rc = roleConfig[member.role];
|
||||||
const RoleIcon = rc.icon;
|
const RoleIcon = rc.icon;
|
||||||
|
const canEditRole = canManage && (!isSelf || canManageOwners) && (member.role !== "owner" || canManageOwners);
|
||||||
|
const canRemove = canManage && !isSelf && (member.role !== "owner" || canManageOwners);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 rounded-lg border px-4 py-3">
|
<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-sm font-medium">{member.name}</div>
|
||||||
<div className="text-xs text-muted-foreground">{member.email}</div>
|
<div className="text-xs text-muted-foreground">{member.email}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
{canEditRole ? (
|
||||||
<RoleIcon className="h-3 w-3" />
|
<select
|
||||||
{rc.label}
|
value={member.role}
|
||||||
</div>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
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 [name, setName] = useState(workspace?.name ?? "");
|
||||||
const [description, setDescription] = useState(
|
const [description, setDescription] = useState(
|
||||||
workspace?.description ?? "",
|
workspace?.description ?? "",
|
||||||
);
|
);
|
||||||
|
const [profileName, setProfileName] = useState(user?.name ?? "");
|
||||||
|
const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url ?? "");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saved, setSaved] = 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 () => {
|
const handleSave = async () => {
|
||||||
if (!workspace) return;
|
if (!workspace) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
setWorkspaceError("");
|
||||||
try {
|
try {
|
||||||
const updated = await api.updateWorkspace(workspace.id, {
|
const updated = await api.updateWorkspace(workspace.id, {
|
||||||
name,
|
name,
|
||||||
|
|
@ -60,12 +135,109 @@ export default function SettingsPage() {
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
setTimeout(() => setSaved(false), 2000);
|
setTimeout(() => setSaved(false), 2000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to update workspace", e);
|
setWorkspaceError(e instanceof Error ? e.message : "Failed to update workspace");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
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;
|
if (!workspace) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -76,6 +248,55 @@ export default function SettingsPage() {
|
||||||
<h1 className="text-lg font-semibold">Settings</h1>
|
<h1 className="text-lg font-semibold">Settings</h1>
|
||||||
</div>
|
</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 */}
|
{/* Workspace info */}
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -92,6 +313,7 @@ export default function SettingsPage() {
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
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"
|
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>
|
||||||
|
|
@ -103,6 +325,7 @@ export default function SettingsPage() {
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
rows={3}
|
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"
|
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?"
|
placeholder="What does this workspace focus on?"
|
||||||
/>
|
/>
|
||||||
|
|
@ -116,18 +339,26 @@ export default function SettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-2 pt-1">
|
<div className="flex items-center justify-end gap-2 pt-1">
|
||||||
|
{workspaceError && (
|
||||||
|
<span className="text-xs text-red-500">{workspaceError}</span>
|
||||||
|
)}
|
||||||
{saved && (
|
{saved && (
|
||||||
<span className="text-xs text-green-600">Saved!</span>
|
<span className="text-xs text-green-600">Saved!</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
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"
|
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" />
|
<Save className="h-3 w-3" />
|
||||||
{saving ? "Saving..." : "Save"}
|
{saving ? "Saving..." : "Save"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{!canManageWorkspace && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Only admins and owners can update workspace settings.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -142,15 +373,105 @@ export default function SettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="space-y-2">
|
||||||
{members.map((m) => (
|
{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 && (
|
{members.length === 0 && (
|
||||||
<p className="text-sm text-muted-foreground">No members found.</p>
|
<p className="text-sm text-muted-foreground">No members found.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,9 @@ import type { User, Workspace, MemberWithUser, Agent } from "@multica/types";
|
||||||
|
|
||||||
// Mock next/navigation
|
// Mock next/navigation
|
||||||
const mockPush = vi.fn();
|
const mockPush = vi.fn();
|
||||||
|
const mockRefresh = vi.fn();
|
||||||
vi.mock("next/navigation", () => ({
|
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
|
// 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(),
|
listMembers: vi.fn(),
|
||||||
listAgents: vi.fn(),
|
listAgents: vi.fn(),
|
||||||
createWorkspace: vi.fn(),
|
createWorkspace: vi.fn(),
|
||||||
|
updateMe: vi.fn(),
|
||||||
|
leaveWorkspace: vi.fn(),
|
||||||
|
deleteWorkspace: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./api", () => ({
|
vi.mock("./api", () => ({
|
||||||
|
|
@ -393,12 +397,6 @@ describe("AuthContext", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("switchWorkspace updates context and calls setWorkspaceId", async () => {
|
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 = {
|
const mockWorkspace2: Workspace = {
|
||||||
id: "ws-2",
|
id: "ws-2",
|
||||||
name: "Second WS",
|
name: "Second WS",
|
||||||
|
|
@ -434,6 +432,6 @@ describe("AuthContext", () => {
|
||||||
|
|
||||||
expect(mockApi.setWorkspaceId).toHaveBeenCalledWith("ws-2");
|
expect(mockApi.setWorkspaceId).toHaveBeenCalledWith("ws-2");
|
||||||
expect(localStorage.getItem("multica_workspace_id")).toBe("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>;
|
switchWorkspace: (workspaceId: string) => Promise<void>;
|
||||||
createWorkspace: (data: { name: string; slug: string; description?: string }) => Promise<Workspace>;
|
createWorkspace: (data: { name: string; slug: string; description?: string }) => Promise<Workspace>;
|
||||||
updateWorkspace: (ws: Workspace) => void;
|
updateWorkspace: (ws: Workspace) => void;
|
||||||
|
updateCurrentUser: (nextUser: User) => void;
|
||||||
|
leaveWorkspace: (workspaceId: string) => Promise<void>;
|
||||||
|
deleteWorkspace: (workspaceId: string) => Promise<void>;
|
||||||
|
refreshWorkspaces: () => Promise<Workspace[]>;
|
||||||
refreshMembers: () => Promise<void>;
|
refreshMembers: () => Promise<void>;
|
||||||
refreshAgents: () => Promise<void>;
|
refreshAgents: () => Promise<void>;
|
||||||
getMemberName: (userId: string) => string;
|
getMemberName: (userId: string) => string;
|
||||||
|
|
@ -43,6 +47,44 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const router = useRouter();
|
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
|
// Initialize from stored token
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem("multica_token");
|
const token = localStorage.getItem("multica_token");
|
||||||
|
|
@ -53,7 +95,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
api.setToken(token);
|
api.setToken(token);
|
||||||
if (wsId) api.setWorkspaceId(wsId);
|
api.setWorkspaceId(wsId);
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -61,30 +103,23 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
setUser(me);
|
setUser(me);
|
||||||
|
|
||||||
const wsList = await api.listWorkspaces();
|
const wsList = await api.listWorkspaces();
|
||||||
setWorkspaces(wsList);
|
await hydrateWorkspace(wsList, wsId);
|
||||||
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);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Token invalid, clear it
|
// Token invalid, clear it
|
||||||
|
api.setToken(null);
|
||||||
|
api.setWorkspaceId(null);
|
||||||
localStorage.removeItem("multica_token");
|
localStorage.removeItem("multica_token");
|
||||||
localStorage.removeItem("multica_workspace_id");
|
localStorage.removeItem("multica_workspace_id");
|
||||||
|
setUser(null);
|
||||||
|
setWorkspace(null);
|
||||||
|
setWorkspaces([]);
|
||||||
|
setMembers([]);
|
||||||
|
setAgents([]);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, [hydrateWorkspace]);
|
||||||
|
|
||||||
const login = useCallback(async (email: string, name?: string) => {
|
const login = useCallback(async (email: string, name?: string) => {
|
||||||
const { token, user: u } = await api.login(email, name);
|
const { token, user: u } = await api.login(email, name);
|
||||||
|
|
@ -92,27 +127,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
localStorage.setItem("multica_token", token);
|
localStorage.setItem("multica_token", token);
|
||||||
setUser(u);
|
setUser(u);
|
||||||
|
|
||||||
// Load workspace
|
|
||||||
const wsList = await api.listWorkspaces();
|
const wsList = await api.listWorkspaces();
|
||||||
setWorkspaces(wsList);
|
await hydrateWorkspace(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);
|
|
||||||
}
|
|
||||||
|
|
||||||
router.push("/issues");
|
router.push("/issues");
|
||||||
}, [router]);
|
}, [hydrateWorkspace, router]);
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
|
api.setToken(null);
|
||||||
|
api.setWorkspaceId(null);
|
||||||
localStorage.removeItem("multica_token");
|
localStorage.removeItem("multica_token");
|
||||||
localStorage.removeItem("multica_workspace_id");
|
localStorage.removeItem("multica_workspace_id");
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
|
@ -124,33 +147,45 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
const switchWorkspace = useCallback(async (workspaceId: string) => {
|
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;
|
if (!ws) return;
|
||||||
|
|
||||||
api.setWorkspaceId(ws.id);
|
await hydrateWorkspace(workspaces, ws.id);
|
||||||
localStorage.setItem("multica_workspace_id", ws.id);
|
router.refresh();
|
||||||
setWorkspace(ws);
|
}, [hydrateWorkspace, router, workspaces]);
|
||||||
|
|
||||||
const [m, a] = await Promise.all([
|
|
||||||
api.listMembers(ws.id),
|
|
||||||
api.listAgents({ workspace_id: ws.id }),
|
|
||||||
]);
|
|
||||||
setMembers(m);
|
|
||||||
setAgents(a);
|
|
||||||
|
|
||||||
window.location.reload();
|
|
||||||
}, [workspaces]);
|
|
||||||
|
|
||||||
const createNewWorkspace = useCallback(async (data: { name: string; slug: string; description?: string }) => {
|
const createNewWorkspace = useCallback(async (data: { name: string; slug: string; description?: string }) => {
|
||||||
const ws = await api.createWorkspace(data);
|
const ws = await api.createWorkspace(data);
|
||||||
setWorkspaces(prev => [...prev, ws]);
|
setWorkspaces((prev) => [...prev, ws]);
|
||||||
return ws;
|
return ws;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateWorkspaceState = useCallback((ws: Workspace) => {
|
const updateWorkspaceState = useCallback((ws: Workspace) => {
|
||||||
setWorkspace(ws);
|
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 () => {
|
const refreshMembers = useCallback(async () => {
|
||||||
if (!workspace) return;
|
if (!workspace) return;
|
||||||
const m = await api.listMembers(workspace.id);
|
const m = await api.listMembers(workspace.id);
|
||||||
|
|
@ -215,6 +250,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
switchWorkspace,
|
switchWorkspace,
|
||||||
createWorkspace: createNewWorkspace,
|
createWorkspace: createNewWorkspace,
|
||||||
updateWorkspace: updateWorkspaceState,
|
updateWorkspace: updateWorkspaceState,
|
||||||
|
updateCurrentUser,
|
||||||
|
leaveWorkspace,
|
||||||
|
deleteWorkspace,
|
||||||
|
refreshWorkspaces,
|
||||||
refreshMembers,
|
refreshMembers,
|
||||||
refreshAgents,
|
refreshAgents,
|
||||||
getMemberName,
|
getMemberName,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --port 3000",
|
"dev": "sh -c 'next dev --port \"${FRONTEND_PORT:-3000}\"'",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,14 @@ export const mockAuthValue: Record<string, any> = {
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
login: vi.fn(),
|
login: vi.fn(),
|
||||||
logout: 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(),
|
refreshMembers: vi.fn(),
|
||||||
refreshAgents: vi.fn(),
|
refreshAgents: vi.fn(),
|
||||||
getMemberName: (userId: string) => {
|
getMemberName: (userId: string) => {
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@ services:
|
||||||
postgres:
|
postgres:
|
||||||
image: pgvector/pgvector:pg17
|
image: pgvector/pgvector:pg17
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: multica
|
POSTGRES_DB: ${POSTGRES_DB:-multica}
|
||||||
POSTGRES_USER: multica
|
POSTGRES_USER: ${POSTGRES_USER:-multica}
|
||||||
POSTGRES_PASSWORD: multica
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-multica}
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "${POSTGRES_PORT:-5432}:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* have zero build-time coupling to monorepo packages.
|
* 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 {
|
export class TestApiClient {
|
||||||
private token: string | null = null;
|
private token: string | null = null;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ import type {
|
||||||
CreateIssueRequest,
|
CreateIssueRequest,
|
||||||
UpdateIssueRequest,
|
UpdateIssueRequest,
|
||||||
ListIssuesResponse,
|
ListIssuesResponse,
|
||||||
|
UpdateMeRequest,
|
||||||
|
CreateMemberRequest,
|
||||||
|
UpdateMemberRequest,
|
||||||
Agent,
|
Agent,
|
||||||
InboxItem,
|
InboxItem,
|
||||||
Comment,
|
Comment,
|
||||||
|
|
@ -25,11 +28,11 @@ export class ApiClient {
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
setToken(token: string) {
|
setToken(token: string | null) {
|
||||||
this.token = token;
|
this.token = token;
|
||||||
}
|
}
|
||||||
|
|
||||||
setWorkspaceId(id: string) {
|
setWorkspaceId(id: string | null) {
|
||||||
this.workspaceId = id;
|
this.workspaceId = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,7 +54,16 @@ export class ApiClient {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
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
|
// Handle 204 No Content
|
||||||
|
|
@ -74,6 +86,13 @@ export class ApiClient {
|
||||||
return this.fetch("/api/me");
|
return this.fetch("/api/me");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateMe(data: UpdateMeRequest): Promise<User> {
|
||||||
|
return this.fetch("/api/me", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Issues
|
// Issues
|
||||||
async listIssues(params?: { limit?: number; offset?: number; workspace_id?: string }): Promise<ListIssuesResponse> {
|
async listIssues(params?: { limit?: number; offset?: number; workspace_id?: string }): Promise<ListIssuesResponse> {
|
||||||
const search = new URLSearchParams();
|
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> {
|
async updateWorkspace(id: string, data: { name?: string; description?: string; settings?: Record<string, unknown> }): Promise<Workspace> {
|
||||||
return this.fetch(`/api/workspaces/${id}`, {
|
return this.fetch(`/api/workspaces/${id}`, {
|
||||||
method: "PUT",
|
method: "PATCH",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -172,4 +191,36 @@ export class ApiClient {
|
||||||
async listMembers(workspaceId: string): Promise<MemberWithUser[]> {
|
async listMembers(workspaceId: string): Promise<MemberWithUser[]> {
|
||||||
return this.fetch(`/api/workspaces/${workspaceId}/members`);
|
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 { Issue, IssueStatus, IssuePriority, IssueAssigneeType } from "./issue.js";
|
||||||
|
import type { MemberRole } from "./workspace.js";
|
||||||
|
|
||||||
// Issue API
|
// Issue API
|
||||||
export interface CreateIssueRequest {
|
export interface CreateIssueRequest {
|
||||||
|
|
@ -29,6 +30,20 @@ export interface ListIssuesResponse {
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateMeRequest {
|
||||||
|
name?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateMemberRequest {
|
||||||
|
email: string;
|
||||||
|
role?: MemberRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateMemberRequest {
|
||||||
|
role: MemberRole;
|
||||||
|
}
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
export interface PaginationParams {
|
export interface PaginationParams {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ export default defineConfig({
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
use: {
|
use: {
|
||||||
baseURL: "http://localhost:3000",
|
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? process.env.FRONTEND_ORIGIN ?? "http://localhost:3000",
|
||||||
headless: true,
|
headless: true,
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,24 @@ set -euo pipefail
|
||||||
# Usage: bash scripts/check.sh
|
# 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=""
|
BACKEND_PID=""
|
||||||
FRONTEND_PID=""
|
FRONTEND_PID=""
|
||||||
STARTED_BACKEND=false
|
STARTED_BACKEND=false
|
||||||
|
|
@ -57,13 +75,14 @@ wait_for_port() {
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# Step 0: Ensure DB
|
# Step 0: Ensure DB
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
echo "==> Using env file: $ENV_FILE"
|
||||||
echo "==> Checking PostgreSQL..."
|
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."
|
echo " Already running."
|
||||||
else
|
else
|
||||||
echo " Starting via docker compose..."
|
echo " Starting via docker compose..."
|
||||||
docker compose up -d
|
"${COMPOSE_CMD[@]}" up -d
|
||||||
until docker compose exec -T postgres pg_isready -U multica > /dev/null 2>&1; do
|
until "${COMPOSE_CMD[@]}" exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" > /dev/null 2>&1; do
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
echo " PostgreSQL ready."
|
echo " PostgreSQL ready."
|
||||||
|
|
@ -96,24 +115,24 @@ echo "==> [3/5] Go tests..."
|
||||||
echo ""
|
echo ""
|
||||||
echo "==> [4/5] Starting services for E2E..."
|
echo "==> [4/5] Starting services for E2E..."
|
||||||
|
|
||||||
if curl -sf http://localhost:8080/health > /dev/null 2>&1; then
|
if curl -sf "http://localhost:${PORT}/health" > /dev/null 2>&1; then
|
||||||
echo " Backend already running on :8080"
|
echo " Backend already running on :$PORT"
|
||||||
else
|
else
|
||||||
echo " Starting backend..."
|
echo " Starting backend..."
|
||||||
(cd server && go run ./cmd/server) > /tmp/multica-check-backend.log 2>&1 &
|
(cd server && go run ./cmd/server) > /tmp/multica-check-backend.log 2>&1 &
|
||||||
BACKEND_PID=$!
|
BACKEND_PID=$!
|
||||||
STARTED_BACKEND=true
|
STARTED_BACKEND=true
|
||||||
wait_for_port 8080 "Backend" 90 "/health"
|
wait_for_port "$PORT" "Backend" 90 "/health"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if curl -sf http://localhost:3000 > /dev/null 2>&1; then
|
if curl -sf "http://localhost:${FRONTEND_PORT}" > /dev/null 2>&1; then
|
||||||
echo " Frontend already running on :3000"
|
echo " Frontend already running on :$FRONTEND_PORT"
|
||||||
else
|
else
|
||||||
echo " Starting frontend..."
|
echo " Starting frontend..."
|
||||||
pnpm dev:web > /tmp/multica-check-frontend.log 2>&1 &
|
pnpm dev:web > /tmp/multica-check-frontend.log 2>&1 &
|
||||||
FRONTEND_PID=$!
|
FRONTEND_PID=$!
|
||||||
STARTED_FRONTEND=true
|
STARTED_FRONTEND=true
|
||||||
wait_for_port 3000 "Frontend" 120 "/"
|
wait_for_port "$FRONTEND_PORT" "Frontend" 120 "/"
|
||||||
fi
|
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() {
|
func main() {
|
||||||
serverURL := os.Getenv("MULTICA_SERVER_URL")
|
serverURL := os.Getenv("MULTICA_SERVER_URL")
|
||||||
if serverURL == "" {
|
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...")
|
fmt.Println("Multica Daemon starting...")
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,8 @@ func TestMain(m *testing.M) {
|
||||||
hub := realtime.NewHub()
|
hub := realtime.NewHub()
|
||||||
go hub.Run()
|
go hub.Run()
|
||||||
|
|
||||||
router := NewRouter(queries, hub)
|
_ = queries
|
||||||
|
router := NewRouter(pool, hub)
|
||||||
testServer = httptest.NewServer(router)
|
testServer = httptest.NewServer(router)
|
||||||
defer testServer.Close()
|
defer testServer.Close()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import (
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"github.com/multica-ai/multica/server/internal/realtime"
|
"github.com/multica-ai/multica/server/internal/realtime"
|
||||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
@ -39,11 +38,10 @@ func main() {
|
||||||
}
|
}
|
||||||
log.Println("Connected to database")
|
log.Println("Connected to database")
|
||||||
|
|
||||||
queries := db.New(pool)
|
|
||||||
hub := realtime.NewHub()
|
hub := realtime.NewHub()
|
||||||
go hub.Run()
|
go hub.Run()
|
||||||
|
|
||||||
r := NewRouter(queries, hub)
|
r := NewRouter(pool, hub)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: ":" + port,
|
Addr: ":" + port,
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,13 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
chimw "github.com/go-chi/chi/v5/middleware"
|
chimw "github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/go-chi/cors"
|
"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/handler"
|
||||||
"github.com/multica-ai/multica/server/internal/middleware"
|
"github.com/multica-ai/multica/server/internal/middleware"
|
||||||
|
|
@ -13,9 +16,33 @@ import (
|
||||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
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.
|
// NewRouter creates the fully-configured Chi router with all middleware and routes.
|
||||||
func NewRouter(queries *db.Queries, hub *realtime.Hub) chi.Router {
|
func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub) chi.Router {
|
||||||
h := handler.New(queries, hub)
|
queries := db.New(pool)
|
||||||
|
h := handler.New(queries, pool, hub)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
|
@ -24,7 +51,7 @@ func NewRouter(queries *db.Queries, hub *realtime.Hub) chi.Router {
|
||||||
r.Use(chimw.Recoverer)
|
r.Use(chimw.Recoverer)
|
||||||
r.Use(chimw.RequestID)
|
r.Use(chimw.RequestID)
|
||||||
r.Use(cors.Handler(cors.Options{
|
r.Use(cors.Handler(cors.Options{
|
||||||
AllowedOrigins: []string{"http://localhost:3000"},
|
AllowedOrigins: allowedOrigins(),
|
||||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Workspace-ID"},
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Workspace-ID"},
|
||||||
AllowCredentials: true,
|
AllowCredentials: true,
|
||||||
|
|
@ -51,6 +78,7 @@ func NewRouter(queries *db.Queries, hub *realtime.Hub) chi.Router {
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
r.Get("/api/me", h.GetMe)
|
r.Get("/api/me", h.GetMe)
|
||||||
|
r.Patch("/api/me", h.UpdateMe)
|
||||||
|
|
||||||
// Issues
|
// Issues
|
||||||
r.Route("/api/issues", func(r chi.Router) {
|
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.Route("/{id}", func(r chi.Router) {
|
||||||
r.Get("/", h.GetWorkspace)
|
r.Get("/", h.GetWorkspace)
|
||||||
r.Put("/", h.UpdateWorkspace)
|
r.Put("/", h.UpdateWorkspace)
|
||||||
|
r.Patch("/", h.UpdateWorkspace)
|
||||||
|
r.Delete("/", h.DeleteWorkspace)
|
||||||
r.Get("/members", h.ListMembersWithUser)
|
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) {
|
func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
|
||||||
workspaceID := r.URL.Query().Get("workspace_id")
|
workspaceID := resolveWorkspaceID(r)
|
||||||
if workspaceID == "" {
|
if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {
|
||||||
workspaceID = r.Header.Get("X-Workspace-ID")
|
|
||||||
}
|
|
||||||
if workspaceID == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "workspace_id is required")
|
|
||||||
return
|
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) {
|
func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
agent, err := h.Queries.GetAgent(r.Context(), parseUUID(id))
|
agent, ok := h.loadAgentForUser(w, r, id)
|
||||||
if err != nil {
|
if !ok {
|
||||||
writeError(w, http.StatusNotFound, "agent not found")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, agentToResponse(agent))
|
writeJSON(w, http.StatusOK, agentToResponse(agent))
|
||||||
|
|
@ -92,17 +87,21 @@ type CreateAgentRequest struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
|
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
|
var req CreateAgentRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
workspaceID := r.URL.Query().Get("workspace_id")
|
ownerID, ok := requireUserID(w, r)
|
||||||
if workspaceID == "" {
|
if !ok {
|
||||||
workspaceID = r.Header.Get("X-Workspace-ID")
|
return
|
||||||
}
|
}
|
||||||
ownerID := r.Header.Get("X-User-ID")
|
|
||||||
|
|
||||||
if req.Name == "" {
|
if req.Name == "" {
|
||||||
writeError(w, http.StatusBadRequest, "name is required")
|
writeError(w, http.StatusBadRequest, "name is required")
|
||||||
|
|
@ -152,6 +151,13 @@ type UpdateAgentRequest struct {
|
||||||
|
|
||||||
func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
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
|
var req UpdateAgentRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,15 @@ package handler
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"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"
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||||
)
|
)
|
||||||
|
|
||||||
var jwtSecret = []byte("multica-dev-secret-change-in-production")
|
|
||||||
|
|
||||||
type UserResponse struct {
|
type UserResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|
@ -48,6 +49,9 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
|
||||||
if req.Email == "" {
|
if req.Email == "" {
|
||||||
writeError(w, http.StatusBadRequest, "email is required")
|
writeError(w, http.StatusBadRequest, "email is required")
|
||||||
return
|
return
|
||||||
|
|
@ -56,6 +60,11 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
// Try to find existing user
|
// Try to find existing user
|
||||||
user, err := h.Queries.GetUserByEmail(r.Context(), req.Email)
|
user, err := h.Queries.GetUserByEmail(r.Context(), req.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if !isNotFound(err) {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to load user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Create new user
|
// Create new user
|
||||||
name := req.Name
|
name := req.Name
|
||||||
if 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())
|
writeError(w, http.StatusInternalServerError, "failed to create user: "+err.Error())
|
||||||
return
|
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
|
// Generate JWT
|
||||||
|
|
@ -80,7 +98,7 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
"iat": time.Now().Unix(),
|
"iat": time.Now().Unix(),
|
||||||
})
|
})
|
||||||
|
|
||||||
tokenString, err := token.SignedString(jwtSecret)
|
tokenString, err := token.SignedString(auth.JWTSecret())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to generate token")
|
writeError(w, http.StatusInternalServerError, "failed to generate token")
|
||||||
return
|
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) {
|
func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := r.Header.Get("X-User-ID")
|
userID, ok := requireUserID(w, r)
|
||||||
if userID == "" {
|
if !ok {
|
||||||
writeError(w, http.StatusUnauthorized, "user not authenticated")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,3 +124,52 @@ func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, userToResponse(user))
|
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) {
|
func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) {
|
||||||
issueID := chi.URLParam(r, "id")
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to list comments")
|
writeError(w, http.StatusInternalServerError, "failed to list comments")
|
||||||
return
|
return
|
||||||
|
|
@ -55,9 +60,13 @@ type CreateCommentRequest struct {
|
||||||
|
|
||||||
func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
||||||
issueID := chi.URLParam(r, "id")
|
issueID := chi.URLParam(r, "id")
|
||||||
userID := r.Header.Get("X-User-ID")
|
issue, ok := h.loadIssueForUser(w, r, issueID)
|
||||||
if userID == "" {
|
if !ok {
|
||||||
writeError(w, http.StatusUnauthorized, "user not authenticated")
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, ok := requireUserID(w, r)
|
||||||
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,7 +85,7 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
comment, err := h.Queries.CreateComment(r.Context(), db.CreateCommentParams{
|
comment, err := h.Queries.CreateComment(r.Context(), db.CreateCommentParams{
|
||||||
IssueID: parseUUID(issueID),
|
IssueID: issue.ID,
|
||||||
AuthorType: "member",
|
AuthorType: "member",
|
||||||
AuthorID: parseUUID(userID),
|
AuthorID: parseUUID(userID),
|
||||||
Content: req.Content,
|
Content: req.Content,
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,33 @@
|
||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||||
"github.com/multica-ai/multica/server/internal/realtime"
|
"github.com/multica-ai/multica/server/internal/realtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type txStarter interface {
|
||||||
Queries *db.Queries
|
Begin(ctx context.Context) (pgx.Tx, error)
|
||||||
Hub *realtime.Hub
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(queries *db.Queries, hub *realtime.Hub) *Handler {
|
type Handler struct {
|
||||||
return &Handler{Queries: queries, Hub: hub}
|
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) {
|
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)
|
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)
|
queries := db.New(pool)
|
||||||
hub := realtime.NewHub()
|
hub := realtime.NewHub()
|
||||||
go hub.Run()
|
go hub.Run()
|
||||||
testHandler = New(queries, hub)
|
testHandler = New(queries, pool, hub)
|
||||||
|
|
||||||
// Get seed user and workspace IDs
|
// Get seed user and workspace IDs
|
||||||
row := pool.QueryRow(context.Background(), `SELECT id FROM "user" WHERE email = 'jiayuan@multica.ai'`)
|
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) {
|
func (h *Handler) ListInbox(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := r.Header.Get("X-User-ID")
|
userID, ok := requireUserID(w, r)
|
||||||
if userID == "" {
|
if !ok {
|
||||||
writeError(w, http.StatusUnauthorized, "user not authenticated")
|
|
||||||
return
|
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) {
|
func (h *Handler) MarkInboxRead(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
|
if _, ok := h.loadInboxItemForUser(w, r, id); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
item, err := h.Queries.MarkInboxRead(r.Context(), parseUUID(id))
|
item, err := h.Queries.MarkInboxRead(r.Context(), parseUUID(id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to mark read")
|
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) {
|
func (h *Handler) ArchiveInboxItem(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
|
if _, ok := h.loadInboxItemForUser(w, r, id); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
item, err := h.Queries.ArchiveInboxItem(r.Context(), parseUUID(id))
|
item, err := h.Queries.ArchiveInboxItem(r.Context(), parseUUID(id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to archive")
|
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) {
|
func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
workspaceID := r.URL.Query().Get("workspace_id")
|
workspaceID := resolveWorkspaceID(r)
|
||||||
if workspaceID == "" {
|
if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {
|
||||||
workspaceID = r.Header.Get("X-Workspace-ID")
|
|
||||||
}
|
|
||||||
if workspaceID == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "workspace_id is required")
|
|
||||||
return
|
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) {
|
func (h *Handler) GetIssue(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
issue, err := h.Queries.GetIssue(r.Context(), parseUUID(id))
|
issue, ok := h.loadIssueForUser(w, r, id)
|
||||||
if err != nil {
|
if !ok {
|
||||||
writeError(w, http.StatusNotFound, "issue not found")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, issueToResponse(issue))
|
writeJSON(w, http.StatusOK, issueToResponse(issue))
|
||||||
|
|
@ -157,19 +152,14 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
workspaceID := r.URL.Query().Get("workspace_id")
|
workspaceID := resolveWorkspaceID(r)
|
||||||
if workspaceID == "" {
|
if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {
|
||||||
workspaceID = r.Header.Get("X-Workspace-ID")
|
|
||||||
}
|
|
||||||
if workspaceID == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "workspace_id is required")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get creator from context (set by auth middleware)
|
// Get creator from context (set by auth middleware)
|
||||||
creatorID := r.Header.Get("X-User-ID")
|
creatorID, ok := requireUserID(w, r)
|
||||||
if creatorID == "" {
|
if !ok {
|
||||||
writeError(w, http.StatusUnauthorized, "user not authenticated")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -265,6 +255,9 @@ type UpdateIssueRequest struct {
|
||||||
|
|
||||||
func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
|
if _, ok := h.loadIssueForUser(w, r, id); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req UpdateIssueRequest
|
var req UpdateIssueRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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) {
|
func (h *Handler) DeleteIssue(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
|
if _, ok := h.loadIssueForUser(w, r, id); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
err := h.Queries.DeleteIssue(r.Context(), parseUUID(id))
|
err := h.Queries.DeleteIssue(r.Context(), parseUUID(id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to delete issue")
|
writeError(w, http.StatusInternalServerError, "failed to delete issue")
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package handler
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"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) {
|
func (h *Handler) ListWorkspaces(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := r.Header.Get("X-User-ID")
|
userID, ok := requireUserID(w, r)
|
||||||
if userID == "" {
|
if !ok {
|
||||||
writeError(w, http.StatusUnauthorized, "user not authenticated")
|
|
||||||
return
|
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) {
|
func (h *Handler) GetWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
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))
|
ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusNotFound, "workspace not found")
|
writeError(w, http.StatusNotFound, "workspace not found")
|
||||||
|
|
@ -94,9 +98,8 @@ type CreateWorkspaceRequest struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := r.Header.Get("X-User-ID")
|
userID, ok := requireUserID(w, r)
|
||||||
if userID == "" {
|
if !ok {
|
||||||
writeError(w, http.StatusUnauthorized, "user not authenticated")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,23 +109,36 @@ func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
req.Slug = strings.ToLower(strings.TrimSpace(req.Slug))
|
||||||
if req.Name == "" || req.Slug == "" {
|
if req.Name == "" || req.Slug == "" {
|
||||||
writeError(w, http.StatusBadRequest, "name and slug are required")
|
writeError(w, http.StatusBadRequest, "name and slug are required")
|
||||||
return
|
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,
|
Name: req.Name,
|
||||||
Slug: req.Slug,
|
Slug: req.Slug,
|
||||||
Description: ptrToText(req.Description),
|
Description: ptrToText(req.Description),
|
||||||
})
|
})
|
||||||
if err != nil {
|
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())
|
writeError(w, http.StatusInternalServerError, "failed to create workspace: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add creator as owner
|
_, err = qtx.CreateMember(r.Context(), db.CreateMemberParams{
|
||||||
_, err = h.Queries.CreateMember(r.Context(), db.CreateMemberParams{
|
|
||||||
WorkspaceID: ws.ID,
|
WorkspaceID: ws.ID,
|
||||||
UserID: parseUUID(userID),
|
UserID: parseUUID(userID),
|
||||||
Role: "owner",
|
Role: "owner",
|
||||||
|
|
@ -132,6 +148,11 @@ func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(r.Context()); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create workspace")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusCreated, workspaceToResponse(ws))
|
writeJSON(w, http.StatusCreated, workspaceToResponse(ws))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,6 +164,9 @@ type UpdateWorkspaceRequest struct {
|
||||||
|
|
||||||
func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
|
if _, ok := h.requireWorkspaceRole(w, r, id, "workspace not found", "owner", "admin"); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req UpdateWorkspaceRequest
|
var req UpdateWorkspaceRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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),
|
ID: parseUUID(id),
|
||||||
}
|
}
|
||||||
if req.Name != nil {
|
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 {
|
if req.Description != nil {
|
||||||
params.Description = pgtype.Text{String: *req.Description, Valid: true}
|
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) {
|
func (h *Handler) ListMembers(w http.ResponseWriter, r *http.Request) {
|
||||||
workspaceID := chi.URLParam(r, "id")
|
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))
|
members, err := h.Queries.ListMembers(r.Context(), parseUUID(workspaceID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to list members")
|
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) {
|
func (h *Handler) ListMembersWithUser(w http.ResponseWriter, r *http.Request) {
|
||||||
workspaceID := chi.URLParam(r, "id")
|
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))
|
members, err := h.Queries.ListMembersWithUser(r.Context(), parseUUID(workspaceID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to list members")
|
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)
|
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"
|
"strings"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"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.
|
// Auth middleware validates JWT tokens from the Authorization header.
|
||||||
// Sets X-User-ID and X-User-Email headers on the request for downstream handlers.
|
// Sets X-User-ID and X-User-Email headers on the request for downstream handlers.
|
||||||
func Auth(next http.Handler) http.Handler {
|
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 {
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
return nil, jwt.ErrSignatureInvalid
|
return nil, jwt.ErrSignatureInvalid
|
||||||
}
|
}
|
||||||
return jwtSecret, nil
|
return auth.JWTSecret(), nil
|
||||||
})
|
})
|
||||||
if err != nil || !token.Valid {
|
if err != nil || !token.Valid {
|
||||||
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
|
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
|
||||||
|
|
@ -42,9 +41,12 @@ func Auth(next http.Handler) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if sub, ok := claims["sub"].(string); ok {
|
sub, ok := claims["sub"].(string)
|
||||||
r.Header.Set("X-User-ID", sub)
|
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 {
|
if email, ok := claims["email"].(string); ok {
|
||||||
r.Header.Set("X-User-Email", email)
|
r.Header.Set("X-User-Email", email)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/multica-ai/multica/server/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
func generateToken(claims jwt.MapClaims, secret []byte) string {
|
func generateToken(claims jwt.MapClaims, secret []byte) string {
|
||||||
|
|
@ -80,7 +81,7 @@ func TestAuth_ExpiredToken(t *testing.T) {
|
||||||
|
|
||||||
claims := validClaims()
|
claims := validClaims()
|
||||||
claims["exp"] = time.Now().Add(-time.Hour).Unix()
|
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 := httptest.NewRequest("GET", "/api/me", nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
@ -136,7 +137,7 @@ func TestAuth_ValidToken(t *testing.T) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
token := generateToken(validClaims(), jwtSecret)
|
token := generateToken(validClaims(), auth.JWTSecret())
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/me", nil)
|
req := httptest.NewRequest("GET", "/api/me", nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
@ -155,31 +156,22 @@ func TestAuth_ValidToken(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuth_MissingClaims(t *testing.T) {
|
func TestAuth_MissingClaims(t *testing.T) {
|
||||||
var gotUserID, gotEmail string
|
|
||||||
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
gotUserID = r.Header.Get("X-User-ID")
|
t.Fatal("next handler should not be called")
|
||||||
gotEmail = r.Header.Get("X-User-Email")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Token with no sub or email claims, only exp
|
// Token with no sub or email claims, only exp
|
||||||
claims := jwt.MapClaims{
|
claims := jwt.MapClaims{
|
||||||
"exp": time.Now().Add(time.Hour).Unix(),
|
"exp": time.Now().Add(time.Hour).Unix(),
|
||||||
}
|
}
|
||||||
token := generateToken(claims, jwtSecret)
|
token := generateToken(claims, auth.JWTSecret())
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/me", nil)
|
req := httptest.NewRequest("GET", "/api/me", nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(w, req)
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusUnauthorized {
|
||||||
t.Fatalf("expected 200, got %d", w.Code)
|
t.Fatalf("expected 401, 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
turbo.json
12
turbo.json
|
|
@ -1,6 +1,18 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://turbo.build/schema.json",
|
"$schema": "https://turbo.build/schema.json",
|
||||||
"globalDependencies": ["tsconfig.base.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": {
|
"tasks": {
|
||||||
"build": {
|
"build": {
|
||||||
"dependsOn": ["^build"],
|
"dependsOn": ["^build"],
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue