fix: resolve merge conflicts with main, preserve PAT functionality
- Resolve conflicts in CLAUDE.md, client.ts, settings/page.tsx - Migrate PAT types and API methods to @/shared/types + @/shared/api architecture - Restore simplified login flow (login page, auth store, tests) - Fix issue detail comment submit test (use fireEvent + useRef for mock) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
f70b34a50f
45 changed files with 2044 additions and 261 deletions
|
|
@ -21,6 +21,10 @@ MULTICA_CODEX_MODEL=
|
||||||
MULTICA_CODEX_WORKDIR=
|
MULTICA_CODEX_WORKDIR=
|
||||||
MULTICA_CODEX_TIMEOUT=20m
|
MULTICA_CODEX_TIMEOUT=20m
|
||||||
|
|
||||||
|
# Email (Resend)
|
||||||
|
RESEND_API_KEY=
|
||||||
|
RESEND_FROM_EMAIL=noreply@multica.ai
|
||||||
|
|
||||||
# Google OAuth
|
# Google OAuth
|
||||||
GOOGLE_CLIENT_ID=
|
GOOGLE_CLIENT_ID=
|
||||||
GOOGLE_CLIENT_SECRET=
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,6 +2,7 @@ node_modules
|
||||||
dist
|
dist
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.envrc
|
||||||
|
|
||||||
# build outputs
|
# build outputs
|
||||||
.next
|
.next
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ apps/web/
|
||||||
| `features/issues/` | Issue state, components, config | `useIssueStore`, icons, pickers, status/priority config |
|
| `features/issues/` | Issue state, components, config | `useIssueStore`, icons, pickers, status/priority config |
|
||||||
| `features/inbox/` | Inbox notification state | `useInboxStore` |
|
| `features/inbox/` | Inbox notification state | `useInboxStore` |
|
||||||
| `features/realtime/` | WebSocket connection + sync | `WSProvider`, `useWSEvent`, `useRealtimeSync` |
|
| `features/realtime/` | WebSocket connection + sync | `WSProvider`, `useWSEvent`, `useRealtimeSync` |
|
||||||
|
| `features/modals/` | Modal registry and state | Modal store and components |
|
||||||
|
| `features/skills/` | Skill management | Skill components |
|
||||||
|
|
||||||
**`shared/`** — Code used across multiple features:
|
**`shared/`** — Code used across multiple features:
|
||||||
- `shared/api/` — `ApiClient` (REST) and `WSClient` (WebSocket) for backend communication, plus the `api` singleton.
|
- `shared/api/` — `ApiClient` (REST) and `WSClient` (WebSocket) for backend communication, plus the `api` singleton.
|
||||||
|
|
@ -91,6 +93,7 @@ Browser ← WSClient (shared/api) ← WebSocket ← Hub.Broadcast() ← Handlers
|
||||||
- **Agent SDK** (`pkg/agent/`): Unified `Backend` interface for executing prompts via Claude Code or Codex. Each backend spawns its CLI and streams results via `Session.Messages` + `Session.Result` channels.
|
- **Agent SDK** (`pkg/agent/`): Unified `Backend` interface for executing prompts via Claude Code or Codex. Each backend spawns its CLI and streams results via `Session.Messages` + `Session.Result` channels.
|
||||||
- **Daemon** (`internal/daemon/`): Local agent runtime — auto-detects available CLIs (claude, codex), registers runtimes, polls for tasks, routes by provider.
|
- **Daemon** (`internal/daemon/`): Local agent runtime — auto-detects available CLIs (claude, codex), registers runtimes, polls for tasks, routes by provider.
|
||||||
- **CLI** (`internal/cli/`): Shared helpers for the `multica` CLI — API client, config management, output formatting.
|
- **CLI** (`internal/cli/`): Shared helpers for the `multica` CLI — API client, config management, output formatting.
|
||||||
|
- **Events** (`internal/events/`): Internal event bus for decoupled communication between handlers and services.
|
||||||
- **Database**: sqlc generates Go code from SQL in `pkg/db/queries/` → `pkg/db/generated/`. Migrations in `migrations/`.
|
- **Database**: sqlc generates Go code from SQL in `pkg/db/queries/` → `pkg/db/generated/`. Migrations in `migrations/`.
|
||||||
- **Routes** (`cmd/server/router.go`): Public routes (auth, health, ws) + protected routes (require JWT) + daemon routes (unauthenticated, separate auth model).
|
- **Routes** (`cmd/server/router.go`): Public routes (auth, health, ws) + protected routes (require JWT) + daemon routes (unauthenticated, separate auth model).
|
||||||
|
|
||||||
|
|
@ -142,7 +145,8 @@ make db-down # Stop shared PostgreSQL
|
||||||
|
|
||||||
### Worktree Support
|
### Worktree Support
|
||||||
|
|
||||||
For isolated feature testing with a separate database:
|
All checkouts share one PostgreSQL container. Isolation is at the database level — each worktree gets its own DB name and unique ports via `.env.worktree`. Main checkouts use `.env`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make worktree-env # Generate .env.worktree with unique DB/ports
|
make worktree-env # Generate .env.worktree with unique DB/ports
|
||||||
make setup-worktree # Setup using .env.worktree
|
make setup-worktree # Setup using .env.worktree
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { Suspense, useState } from "react";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import { useAuthStore } from "@/features/auth";
|
import { useAuthStore } from "@/features/auth";
|
||||||
import { useWorkspaceStore } from "@/features/workspace";
|
import { useWorkspaceStore } from "@/features/workspace";
|
||||||
import { useNavigationStore } from "@/features/navigation";
|
|
||||||
import { api } from "@/shared/api";
|
import { api } from "@/shared/api";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -41,9 +40,8 @@ function LoginPageContent() {
|
||||||
await login(email, name || undefined);
|
await login(email, name || undefined);
|
||||||
const wsList = await api.listWorkspaces();
|
const wsList = await api.listWorkspaces();
|
||||||
await hydrateWorkspace(wsList);
|
await hydrateWorkspace(wsList);
|
||||||
const fallback = useNavigationStore.getState().lastPath;
|
router.push(searchParams.get("next") || "/issues");
|
||||||
router.push(searchParams.get("next") || fallback);
|
} catch {
|
||||||
} catch (err) {
|
|
||||||
setError("Login failed. Make sure the server is running.");
|
setError("Login failed. Make sure the server is running.");
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Suspense, forwardRef, useState, useImperativeHandle } from "react";
|
import { Suspense, forwardRef, useRef, useState, useImperativeHandle } from "react";
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, waitFor, act } from "@testing-library/react";
|
import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import type { Issue, Comment } from "@/shared/types";
|
import type { Issue, Comment } from "@/shared/types";
|
||||||
|
|
||||||
|
|
@ -74,16 +74,18 @@ vi.mock("@/components/ui/calendar", () => ({
|
||||||
// Mock RichTextEditor (Tiptap needs real DOM)
|
// Mock RichTextEditor (Tiptap needs real DOM)
|
||||||
vi.mock("@/components/common/rich-text-editor", () => ({
|
vi.mock("@/components/common/rich-text-editor", () => ({
|
||||||
RichTextEditor: forwardRef(({ defaultValue, onUpdate, placeholder, onSubmit }: any, ref: any) => {
|
RichTextEditor: forwardRef(({ defaultValue, onUpdate, placeholder, onSubmit }: any, ref: any) => {
|
||||||
|
const valueRef = useRef(defaultValue || "");
|
||||||
const [value, setValue] = useState(defaultValue || "");
|
const [value, setValue] = useState(defaultValue || "");
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
getMarkdown: () => value,
|
getMarkdown: () => valueRef.current,
|
||||||
clearContent: () => setValue(""),
|
clearContent: () => { valueRef.current = ""; setValue(""); },
|
||||||
focus: () => {},
|
focus: () => {},
|
||||||
}));
|
}));
|
||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
valueRef.current = e.target.value;
|
||||||
setValue(e.target.value);
|
setValue(e.target.value);
|
||||||
onUpdate?.(e.target.value);
|
onUpdate?.(e.target.value);
|
||||||
}}
|
}}
|
||||||
|
|
@ -268,11 +270,20 @@ describe("IssueDetailPage", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const commentInput = screen.getByPlaceholderText("Leave a comment...");
|
const commentInput = screen.getByPlaceholderText("Leave a comment...");
|
||||||
await user.type(commentInput, "New test comment");
|
|
||||||
|
|
||||||
// Find the Send button (sibling of the editor wrapper)
|
// Use fireEvent to update the textarea value and trigger onUpdate
|
||||||
const commentSection = commentInput.closest(".flex.items-start")!;
|
await act(async () => {
|
||||||
const submitBtn = commentSection.querySelector("button")!;
|
fireEvent.change(commentInput, { target: { value: "New test comment" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for button to be enabled after commentEmpty state update
|
||||||
|
const allButtons = screen.getAllByRole("button");
|
||||||
|
const submitBtn = allButtons.find(
|
||||||
|
(btn) => btn.querySelector(".lucide-arrow-up") !== null,
|
||||||
|
)!;
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(submitBtn).not.toBeDisabled();
|
||||||
|
});
|
||||||
await user.click(submitBtn);
|
await user.click(submitBtn);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { Settings, Users, Building2, Save, Crown, Shield, User, Plus, Trash2, LogOut } from "lucide-react";
|
import { Settings, Users, Building2, Save, Crown, Shield, User, Plus, Trash2, LogOut, Key, Copy, Check } from "lucide-react";
|
||||||
import type { MemberWithUser, MemberRole } from "@/shared/types";
|
import type { MemberWithUser, MemberRole, PersonalAccessToken } from "@/shared/types";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -24,6 +24,14 @@ import {
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useAuthStore } from "@/features/auth";
|
import { useAuthStore } from "@/features/auth";
|
||||||
import { useWorkspaceStore } from "@/features/workspace";
|
import { useWorkspaceStore } from "@/features/workspace";
|
||||||
|
|
@ -120,6 +128,61 @@ export default function SettingsPage() {
|
||||||
const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url ?? "");
|
const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url ?? "");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [profileSaving, setProfileSaving] = useState(false);
|
const [profileSaving, setProfileSaving] = useState(false);
|
||||||
|
const [tokens, setTokens] = useState<PersonalAccessToken[]>([]);
|
||||||
|
const [tokenName, setTokenName] = useState("");
|
||||||
|
const [tokenExpiry, setTokenExpiry] = useState("90");
|
||||||
|
const [tokenCreating, setTokenCreating] = useState(false);
|
||||||
|
const [newToken, setNewToken] = useState<string | null>(null);
|
||||||
|
const [tokenCopied, setTokenCopied] = useState(false);
|
||||||
|
const [tokenRevoking, setTokenRevoking] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadTokens = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const list = await api.listPersonalAccessTokens();
|
||||||
|
setTokens(list);
|
||||||
|
} catch {
|
||||||
|
// ignore — tokens section simply stays empty
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { loadTokens(); }, [loadTokens]);
|
||||||
|
|
||||||
|
const handleCreateToken = async () => {
|
||||||
|
setTokenCreating(true);
|
||||||
|
try {
|
||||||
|
const expiresInDays = tokenExpiry === "never" ? undefined : Number(tokenExpiry);
|
||||||
|
const result = await api.createPersonalAccessToken({ name: tokenName, expires_in_days: expiresInDays });
|
||||||
|
setNewToken(result.token);
|
||||||
|
setTokenName("");
|
||||||
|
setTokenExpiry("90");
|
||||||
|
await loadTokens();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Failed to create token");
|
||||||
|
} finally {
|
||||||
|
setTokenCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevokeToken = async (id: string) => {
|
||||||
|
setTokenRevoking(id);
|
||||||
|
try {
|
||||||
|
await api.revokePersonalAccessToken(id);
|
||||||
|
await loadTokens();
|
||||||
|
toast.success("Token revoked");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Failed to revoke token");
|
||||||
|
} finally {
|
||||||
|
setTokenRevoking(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyToken = async () => {
|
||||||
|
if (!newToken) return;
|
||||||
|
await navigator.clipboard.writeText(newToken);
|
||||||
|
setTokenCopied(true);
|
||||||
|
setTimeout(() => setTokenCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
const [inviteEmail, setInviteEmail] = useState("");
|
const [inviteEmail, setInviteEmail] = useState("");
|
||||||
const [inviteRole, setInviteRole] = useState<MemberRole>("member");
|
const [inviteRole, setInviteRole] = useState<MemberRole>("member");
|
||||||
const [inviteLoading, setInviteLoading] = useState(false);
|
const [inviteLoading, setInviteLoading] = useState(false);
|
||||||
|
|
@ -325,6 +388,87 @@ export default function SettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* API Tokens */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Key className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-sm font-semibold">API Tokens</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border p-4 space-y-3">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Personal access tokens allow the CLI and external integrations to authenticate with your account.
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-[1fr_120px_auto]">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={tokenName}
|
||||||
|
onChange={(e) => setTokenName(e.target.value)}
|
||||||
|
placeholder="Token name (e.g. My CLI)"
|
||||||
|
/>
|
||||||
|
<Select value={tokenExpiry} onValueChange={(v) => { if (v) setTokenExpiry(v); }}>
|
||||||
|
<SelectTrigger size="sm"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="30">30 days</SelectItem>
|
||||||
|
<SelectItem value="90">90 days</SelectItem>
|
||||||
|
<SelectItem value="365">1 year</SelectItem>
|
||||||
|
<SelectItem value="never">No expiry</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button onClick={handleCreateToken} disabled={tokenCreating || !tokenName.trim()}>
|
||||||
|
{tokenCreating ? "Creating..." : "Create"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tokens.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tokens.map((t) => (
|
||||||
|
<div key={t.id} className="flex items-center gap-3 rounded-lg border px-4 py-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium truncate">{t.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t.token_prefix}... · Created {new Date(t.created_at).toLocaleDateString()} · {t.last_used_at ? `Last used ${new Date(t.last_used_at).toLocaleDateString()}` : "Never used"}
|
||||||
|
{t.expires_at && ` · Expires ${new Date(t.expires_at).toLocaleDateString()}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => handleRevokeToken(t.id)}
|
||||||
|
disabled={tokenRevoking === t.id}
|
||||||
|
aria-label={`Revoke ${t.name}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Dialog open={!!newToken} onOpenChange={(v) => { if (!v) { setNewToken(null); setTokenCopied(false); } }}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Token created</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Copy your personal access token now. You won't be able to see it again.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 rounded-md border bg-muted/50 px-3 py-2 text-sm break-all select-all">
|
||||||
|
{newToken}
|
||||||
|
</code>
|
||||||
|
<Button variant="outline" size="icon" onClick={handleCopyToken}>
|
||||||
|
{tokenCopied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => { setNewToken(null); setTokenCopied(false); }}>Done</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* 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">
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ import type {
|
||||||
CreateSkillRequest,
|
CreateSkillRequest,
|
||||||
UpdateSkillRequest,
|
UpdateSkillRequest,
|
||||||
SetAgentSkillsRequest,
|
SetAgentSkillsRequest,
|
||||||
|
PersonalAccessToken,
|
||||||
|
CreatePersonalAccessTokenRequest,
|
||||||
|
CreatePersonalAccessTokenResponse,
|
||||||
} from "@/shared/types";
|
} from "@/shared/types";
|
||||||
import { type Logger, noopLogger } from "@/shared/logger";
|
import { type Logger, noopLogger } from "@/shared/logger";
|
||||||
|
|
||||||
|
|
@ -372,4 +375,20 @@ export class ApiClient {
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Personal Access Tokens
|
||||||
|
async listPersonalAccessTokens(): Promise<PersonalAccessToken[]> {
|
||||||
|
return this.fetch("/api/tokens");
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPersonalAccessToken(data: CreatePersonalAccessTokenRequest): Promise<CreatePersonalAccessTokenResponse> {
|
||||||
|
return this.fetch("/api/tokens", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokePersonalAccessToken(id: string): Promise<void> {
|
||||||
|
await this.fetch(`/api/tokens/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,4 +19,5 @@ if (typeof window !== "undefined") {
|
||||||
if (wsId) {
|
if (wsId) {
|
||||||
api.setWorkspaceId(wsId);
|
api.setWorkspaceId(wsId);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,25 @@ export interface UpdateMemberRequest {
|
||||||
role: MemberRole;
|
role: MemberRole;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Personal Access Tokens
|
||||||
|
export interface PersonalAccessToken {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
token_prefix: string;
|
||||||
|
expires_at: string | null;
|
||||||
|
last_used_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePersonalAccessTokenRequest {
|
||||||
|
name: string;
|
||||||
|
expires_in_days?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePersonalAccessTokenResponse extends PersonalAccessToken {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
export interface PaginationParams {
|
export interface PaginationParams {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
|
|
||||||
|
// jsdom doesn't provide ResizeObserver; stub it so components that rely on it
|
||||||
|
// (e.g. input-otp) can render in tests.
|
||||||
|
if (typeof globalThis.ResizeObserver === "undefined") {
|
||||||
|
globalThis.ResizeObserver = class ResizeObserver {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
} as unknown as typeof ResizeObserver;
|
||||||
|
}
|
||||||
|
|
||||||
// jsdom 29 / Node.js 22+ may not provide a proper Web Storage API.
|
// jsdom 29 / Node.js 22+ may not provide a proper Web Storage API.
|
||||||
// Create a proper localStorage mock if methods are missing.
|
// Create a proper localStorage mock if methods are missing.
|
||||||
if (
|
if (
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,10 @@
|
||||||
* Uses raw fetch so E2E tests have zero build-time coupling to the web app.
|
* Uses raw fetch so E2E tests have zero build-time coupling to the web app.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import pg from "pg";
|
||||||
|
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? `http://localhost:${process.env.PORT ?? "8080"}`;
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? `http://localhost:${process.env.PORT ?? "8080"}`;
|
||||||
|
const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://multica:multica@localhost:5432/multica?sslmode=disable";
|
||||||
|
|
||||||
interface TestWorkspace {
|
interface TestWorkspace {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -18,14 +21,53 @@ export class TestApiClient {
|
||||||
private createdIssueIds: string[] = [];
|
private createdIssueIds: string[] = [];
|
||||||
|
|
||||||
async login(email: string, name: string) {
|
async login(email: string, name: string) {
|
||||||
const res = await fetch(`${API_BASE}/auth/login`, {
|
// Step 1: Send verification code
|
||||||
|
const sendRes = await fetch(`${API_BASE}/auth/send-code`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ email, name }),
|
body: JSON.stringify({ email }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
if (!sendRes.ok) {
|
||||||
this.token = data.token;
|
// Rate limited — code already sent recently, read it from DB
|
||||||
return data;
|
if (sendRes.status !== 429) {
|
||||||
|
throw new Error(`send-code failed: ${sendRes.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Read code from database
|
||||||
|
const client = new pg.Client(DATABASE_URL);
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
const result = await client.query(
|
||||||
|
"SELECT code FROM verification_code WHERE email = $1 AND used = FALSE AND expires_at > now() ORDER BY created_at DESC LIMIT 1",
|
||||||
|
[email]
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error(`No verification code found for ${email}`);
|
||||||
|
}
|
||||||
|
const code = result.rows[0].code;
|
||||||
|
|
||||||
|
// Step 3: Verify code to get JWT
|
||||||
|
const verifyRes = await fetch(`${API_BASE}/auth/verify-code`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, code }),
|
||||||
|
});
|
||||||
|
const data = await verifyRes.json();
|
||||||
|
this.token = data.token;
|
||||||
|
|
||||||
|
// Update user name if needed
|
||||||
|
if (name && data.user?.name !== name) {
|
||||||
|
await this.authedFetch("/api/me", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getWorkspaces(): Promise<TestWorkspace[]> {
|
async getWorkspaces(): Promise<TestWorkspace[]> {
|
||||||
|
|
@ -91,6 +133,10 @@ export class TestApiClient {
|
||||||
this.createdIssueIds = [];
|
this.createdIssueIds = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getToken() {
|
||||||
|
return this.token;
|
||||||
|
}
|
||||||
|
|
||||||
private async authedFetch(path: string, init?: RequestInit) {
|
private async authedFetch(path: string, init?: RequestInit) {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,20 @@ const DEFAULT_E2E_WORKSPACE = "e2e-workspace";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log in as the default E2E user and ensure the workspace exists first.
|
* Log in as the default E2E user and ensure the workspace exists first.
|
||||||
|
* Authenticates via API (send-code → DB read → verify-code), then injects
|
||||||
|
* the token into localStorage so the browser session is authenticated.
|
||||||
*/
|
*/
|
||||||
export async function loginAsDefault(page: Page) {
|
export async function loginAsDefault(page: Page) {
|
||||||
const api = new TestApiClient();
|
const api = new TestApiClient();
|
||||||
await api.login(DEFAULT_E2E_EMAIL, DEFAULT_E2E_NAME);
|
await api.login(DEFAULT_E2E_EMAIL, DEFAULT_E2E_NAME);
|
||||||
await api.ensureWorkspace("E2E Workspace", DEFAULT_E2E_WORKSPACE);
|
await api.ensureWorkspace("E2E Workspace", DEFAULT_E2E_WORKSPACE);
|
||||||
|
|
||||||
|
const token = api.getToken();
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.fill('input[placeholder="Name"]', DEFAULT_E2E_NAME);
|
await page.evaluate((t) => {
|
||||||
await page.fill('input[placeholder="Email"]', DEFAULT_E2E_EMAIL);
|
localStorage.setItem("multica_token", t);
|
||||||
await page.click('button[type="submit"]');
|
}, token);
|
||||||
|
await page.goto("/issues");
|
||||||
await page.waitForURL("**/issues", { timeout: 10000 });
|
await page.waitForURL("**/issues", { timeout: 10000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
|
"pg": "^8.20.0",
|
||||||
"turbo": "^2.5.0",
|
"turbo": "^2.5.0",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
122
pnpm-lock.yaml
generated
122
pnpm-lock.yaml
generated
|
|
@ -54,6 +54,12 @@ importers:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 25.5.0
|
version: 25.5.0
|
||||||
|
'@types/pg':
|
||||||
|
specifier: ^8.20.0
|
||||||
|
version: 8.20.0
|
||||||
|
pg:
|
||||||
|
specifier: ^8.20.0
|
||||||
|
version: 8.20.0
|
||||||
turbo:
|
turbo:
|
||||||
specifier: ^2.5.0
|
specifier: ^2.5.0
|
||||||
version: 2.8.20
|
version: 2.8.20
|
||||||
|
|
@ -1624,6 +1630,9 @@ packages:
|
||||||
'@types/node@25.5.0':
|
'@types/node@25.5.0':
|
||||||
resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==}
|
resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==}
|
||||||
|
|
||||||
|
'@types/pg@8.20.0':
|
||||||
|
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
|
||||||
|
|
||||||
'@types/react-dom@19.2.3':
|
'@types/react-dom@19.2.3':
|
||||||
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -3068,6 +3077,40 @@ packages:
|
||||||
pathe@2.0.3:
|
pathe@2.0.3:
|
||||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||||
|
|
||||||
|
pg-cloudflare@1.3.0:
|
||||||
|
resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
|
||||||
|
|
||||||
|
pg-connection-string@2.12.0:
|
||||||
|
resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==}
|
||||||
|
|
||||||
|
pg-int8@1.0.1:
|
||||||
|
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
|
||||||
|
engines: {node: '>=4.0.0'}
|
||||||
|
|
||||||
|
pg-pool@3.13.0:
|
||||||
|
resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==}
|
||||||
|
peerDependencies:
|
||||||
|
pg: '>=8.0'
|
||||||
|
|
||||||
|
pg-protocol@1.13.0:
|
||||||
|
resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==}
|
||||||
|
|
||||||
|
pg-types@2.2.0:
|
||||||
|
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
pg@8.20.0:
|
||||||
|
resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==}
|
||||||
|
engines: {node: '>= 16.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
pg-native: '>=3.0.1'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
pg-native:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
pgpass@1.0.5:
|
||||||
|
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
|
||||||
|
|
||||||
picocolors@1.1.1:
|
picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
|
|
@ -3105,6 +3148,22 @@ packages:
|
||||||
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
|
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
postgres-array@2.0.0:
|
||||||
|
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
postgres-bytea@1.0.1:
|
||||||
|
resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
postgres-date@1.0.7:
|
||||||
|
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
postgres-interval@1.2.0:
|
||||||
|
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
powershell-utils@0.1.0:
|
powershell-utils@0.1.0:
|
||||||
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
|
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
@ -3469,6 +3528,10 @@ packages:
|
||||||
space-separated-tokens@2.0.2:
|
space-separated-tokens@2.0.2:
|
||||||
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
|
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
|
||||||
|
|
||||||
|
split2@4.2.0:
|
||||||
|
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||||
|
engines: {node: '>= 10.x'}
|
||||||
|
|
||||||
stackback@0.0.2:
|
stackback@0.0.2:
|
||||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||||
|
|
||||||
|
|
@ -3890,6 +3953,10 @@ packages:
|
||||||
xmlchars@2.2.0:
|
xmlchars@2.2.0:
|
||||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||||
|
|
||||||
|
xtend@4.0.2:
|
||||||
|
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||||
|
engines: {node: '>=0.4'}
|
||||||
|
|
||||||
y18n@5.0.8:
|
y18n@5.0.8:
|
||||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -5196,6 +5263,12 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.18.2
|
undici-types: 7.18.2
|
||||||
|
|
||||||
|
'@types/pg@8.20.0':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 25.5.0
|
||||||
|
pg-protocol: 1.13.0
|
||||||
|
pg-types: 2.2.0
|
||||||
|
|
||||||
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
|
|
@ -6777,6 +6850,41 @@ snapshots:
|
||||||
|
|
||||||
pathe@2.0.3: {}
|
pathe@2.0.3: {}
|
||||||
|
|
||||||
|
pg-cloudflare@1.3.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
pg-connection-string@2.12.0: {}
|
||||||
|
|
||||||
|
pg-int8@1.0.1: {}
|
||||||
|
|
||||||
|
pg-pool@3.13.0(pg@8.20.0):
|
||||||
|
dependencies:
|
||||||
|
pg: 8.20.0
|
||||||
|
|
||||||
|
pg-protocol@1.13.0: {}
|
||||||
|
|
||||||
|
pg-types@2.2.0:
|
||||||
|
dependencies:
|
||||||
|
pg-int8: 1.0.1
|
||||||
|
postgres-array: 2.0.0
|
||||||
|
postgres-bytea: 1.0.1
|
||||||
|
postgres-date: 1.0.7
|
||||||
|
postgres-interval: 1.2.0
|
||||||
|
|
||||||
|
pg@8.20.0:
|
||||||
|
dependencies:
|
||||||
|
pg-connection-string: 2.12.0
|
||||||
|
pg-pool: 3.13.0(pg@8.20.0)
|
||||||
|
pg-protocol: 1.13.0
|
||||||
|
pg-types: 2.2.0
|
||||||
|
pgpass: 1.0.5
|
||||||
|
optionalDependencies:
|
||||||
|
pg-cloudflare: 1.3.0
|
||||||
|
|
||||||
|
pgpass@1.0.5:
|
||||||
|
dependencies:
|
||||||
|
split2: 4.2.0
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
picomatch@2.3.1: {}
|
picomatch@2.3.1: {}
|
||||||
|
|
@ -6810,6 +6918,16 @@ snapshots:
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
postgres-array@2.0.0: {}
|
||||||
|
|
||||||
|
postgres-bytea@1.0.1: {}
|
||||||
|
|
||||||
|
postgres-date@1.0.7: {}
|
||||||
|
|
||||||
|
postgres-interval@1.2.0:
|
||||||
|
dependencies:
|
||||||
|
xtend: 4.0.2
|
||||||
|
|
||||||
powershell-utils@0.1.0: {}
|
powershell-utils@0.1.0: {}
|
||||||
|
|
||||||
pretty-format@27.5.1:
|
pretty-format@27.5.1:
|
||||||
|
|
@ -7356,6 +7474,8 @@ snapshots:
|
||||||
|
|
||||||
space-separated-tokens@2.0.2: {}
|
space-separated-tokens@2.0.2: {}
|
||||||
|
|
||||||
|
split2@4.2.0: {}
|
||||||
|
|
||||||
stackback@0.0.2: {}
|
stackback@0.0.2: {}
|
||||||
|
|
||||||
statuses@2.0.2: {}
|
statuses@2.0.2: {}
|
||||||
|
|
@ -7732,6 +7852,8 @@ snapshots:
|
||||||
|
|
||||||
xmlchars@2.2.0: {}
|
xmlchars@2.2.0: {}
|
||||||
|
|
||||||
|
xtend@4.0.2: {}
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
|
|
|
||||||
|
|
@ -57,12 +57,13 @@ func init() {
|
||||||
func newAPIClient(cmd *cobra.Command) (*cli.APIClient, error) {
|
func newAPIClient(cmd *cobra.Command) (*cli.APIClient, error) {
|
||||||
serverURL := resolveServerURL(cmd)
|
serverURL := resolveServerURL(cmd)
|
||||||
workspaceID := resolveWorkspaceID(cmd)
|
workspaceID := resolveWorkspaceID(cmd)
|
||||||
|
token := resolveToken()
|
||||||
|
|
||||||
if serverURL == "" {
|
if serverURL == "" {
|
||||||
return nil, fmt.Errorf("server URL not set: use --server-url flag, MULTICA_SERVER_URL env, or 'multica config set server_url <url>'")
|
return nil, fmt.Errorf("server URL not set: use --server-url flag, MULTICA_SERVER_URL env, or 'multica config set server_url <url>'")
|
||||||
}
|
}
|
||||||
|
|
||||||
return cli.NewAPIClient(serverURL, workspaceID), nil
|
return cli.NewAPIClient(serverURL, workspaceID, token), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveServerURL(cmd *cobra.Command) string {
|
func resolveServerURL(cmd *cobra.Command) string {
|
||||||
|
|
|
||||||
338
server/cmd/multica/cmd_auth.go
Normal file
338
server/cmd/multica/cmd_auth.go
Normal file
|
|
@ -0,0 +1,338 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/multica-ai/multica/server/internal/cli"
|
||||||
|
)
|
||||||
|
|
||||||
|
var authCmd = &cobra.Command{
|
||||||
|
Use: "auth",
|
||||||
|
Short: "Manage authentication",
|
||||||
|
}
|
||||||
|
|
||||||
|
var authLoginCmd = &cobra.Command{
|
||||||
|
Use: "login",
|
||||||
|
Short: "Authenticate with Multica",
|
||||||
|
RunE: runAuthLogin,
|
||||||
|
}
|
||||||
|
|
||||||
|
var authStatusCmd = &cobra.Command{
|
||||||
|
Use: "status",
|
||||||
|
Short: "Show current authentication status",
|
||||||
|
RunE: runAuthStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
var authLogoutCmd = &cobra.Command{
|
||||||
|
Use: "logout",
|
||||||
|
Short: "Remove stored authentication token",
|
||||||
|
RunE: runAuthLogout,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
authLoginCmd.Flags().Bool("token", false, "Authenticate by pasting a personal access token")
|
||||||
|
authCmd.AddCommand(authLoginCmd)
|
||||||
|
authCmd.AddCommand(authStatusCmd)
|
||||||
|
authCmd.AddCommand(authLogoutCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveToken() string {
|
||||||
|
if v := strings.TrimSpace(os.Getenv("MULTICA_TOKEN")); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
cfg, _ := cli.LoadCLIConfig()
|
||||||
|
return cfg.Token
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveAppURL() string {
|
||||||
|
if val := strings.TrimSpace(os.Getenv("MULTICA_APP_URL")); val != "" {
|
||||||
|
return strings.TrimRight(val, "/")
|
||||||
|
}
|
||||||
|
return "http://localhost:3000"
|
||||||
|
}
|
||||||
|
|
||||||
|
func openBrowser(url string) error {
|
||||||
|
var cmd string
|
||||||
|
var args []string
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
cmd = "open"
|
||||||
|
args = []string{url}
|
||||||
|
case "linux":
|
||||||
|
cmd = "xdg-open"
|
||||||
|
args = []string{url}
|
||||||
|
case "windows":
|
||||||
|
cmd = "cmd"
|
||||||
|
args = []string{"/c", "start", url}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
|
||||||
|
}
|
||||||
|
return exec.Command(cmd, args...).Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAuthLogin(cmd *cobra.Command, _ []string) error {
|
||||||
|
useToken, _ := cmd.Flags().GetBool("token")
|
||||||
|
if useToken {
|
||||||
|
return runAuthLoginToken(cmd)
|
||||||
|
}
|
||||||
|
return runAuthLoginBrowser(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAuthLoginBrowser(cmd *cobra.Command) error {
|
||||||
|
serverURL := resolveServerURL(cmd)
|
||||||
|
appURL := resolveAppURL()
|
||||||
|
|
||||||
|
// Start a local HTTP server on a random port to receive the callback.
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to start local server: %w", err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
port := listener.Addr().(*net.TCPAddr).Port
|
||||||
|
callbackURL := fmt.Sprintf("http://localhost:%d/callback", port)
|
||||||
|
|
||||||
|
// Generate a random state parameter for CSRF protection.
|
||||||
|
stateBytes := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(stateBytes); err != nil {
|
||||||
|
return fmt.Errorf("failed to generate state: %w", err)
|
||||||
|
}
|
||||||
|
state := hex.EncodeToString(stateBytes)
|
||||||
|
|
||||||
|
loginURL := fmt.Sprintf("%s/login?cli_callback=%s&cli_state=%s", appURL, url.QueryEscape(callbackURL), url.QueryEscape(state))
|
||||||
|
|
||||||
|
// Channel to receive the JWT from the browser callback.
|
||||||
|
jwtCh := make(chan string, 1)
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := r.URL.Query().Get("token")
|
||||||
|
if token == "" {
|
||||||
|
http.Error(w, "missing token", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
returnedState := r.URL.Query().Get("state")
|
||||||
|
if returnedState != state {
|
||||||
|
http.Error(w, "invalid state parameter", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
w.Write([]byte(callbackSuccessHTML))
|
||||||
|
jwtCh <- token
|
||||||
|
})
|
||||||
|
|
||||||
|
srv := &http.Server{Handler: mux}
|
||||||
|
go func() {
|
||||||
|
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||||
|
errCh <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Open the browser.
|
||||||
|
fmt.Fprintln(os.Stderr, "Opening browser to authenticate...")
|
||||||
|
if err := openBrowser(loginURL); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Could not open browser automatically.\n")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "If the browser didn't open, visit:\n %s\n\nWaiting for authentication...\n", loginURL)
|
||||||
|
|
||||||
|
// Wait for the JWT from the callback (timeout 5 minutes).
|
||||||
|
var jwtToken string
|
||||||
|
select {
|
||||||
|
case jwtToken = <-jwtCh:
|
||||||
|
case err := <-errCh:
|
||||||
|
return fmt.Errorf("local server error: %w", err)
|
||||||
|
case <-time.After(5 * time.Minute):
|
||||||
|
return fmt.Errorf("timed out waiting for authentication")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the JWT to create a PAT via the existing API.
|
||||||
|
client := cli.NewAPIClient(serverURL, "", jwtToken)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
hostname, _ := os.Hostname()
|
||||||
|
if hostname == "" {
|
||||||
|
hostname = "unknown"
|
||||||
|
}
|
||||||
|
patName := fmt.Sprintf("CLI (%s)", hostname)
|
||||||
|
expiresInDays := 90
|
||||||
|
|
||||||
|
var patResp struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
err = client.PostJSON(ctx, "/api/tokens", map[string]any{
|
||||||
|
"name": patName,
|
||||||
|
"expires_in_days": expiresInDays,
|
||||||
|
}, &patResp)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create access token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the PAT works.
|
||||||
|
patClient := cli.NewAPIClient(serverURL, "", patResp.Token)
|
||||||
|
var me struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
if err := patClient.GetJSON(ctx, "/api/me", &me); err != nil {
|
||||||
|
return fmt.Errorf("token verification failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to config.
|
||||||
|
cfg, _ := cli.LoadCLIConfig()
|
||||||
|
cfg.Token = patResp.Token
|
||||||
|
if cfg.ServerURL == "" {
|
||||||
|
cfg.ServerURL = serverURL
|
||||||
|
}
|
||||||
|
if err := cli.SaveCLIConfig(cfg); err != nil {
|
||||||
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "Authenticated as %s (%s)\nToken saved to config.\n", me.Name, me.Email)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAuthLoginToken(cmd *cobra.Command) error {
|
||||||
|
fmt.Print("Enter your personal access token: ")
|
||||||
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
|
if !scanner.Scan() {
|
||||||
|
return fmt.Errorf("no input")
|
||||||
|
}
|
||||||
|
token := strings.TrimSpace(scanner.Text())
|
||||||
|
if token == "" {
|
||||||
|
return fmt.Errorf("token is required")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(token, "mul_") {
|
||||||
|
return fmt.Errorf("invalid token format: must start with mul_")
|
||||||
|
}
|
||||||
|
|
||||||
|
serverURL := resolveServerURL(cmd)
|
||||||
|
client := cli.NewAPIClient(serverURL, "", token)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var me struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
if err := client.GetJSON(ctx, "/api/me", &me); err != nil {
|
||||||
|
return fmt.Errorf("invalid token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, _ := cli.LoadCLIConfig()
|
||||||
|
cfg.Token = token
|
||||||
|
if cfg.ServerURL == "" {
|
||||||
|
cfg.ServerURL = serverURL
|
||||||
|
}
|
||||||
|
if err := cli.SaveCLIConfig(cfg); err != nil {
|
||||||
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "Authenticated as %s (%s)\nToken saved to config.\n", me.Name, me.Email)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAuthStatus(cmd *cobra.Command, _ []string) error {
|
||||||
|
token := resolveToken()
|
||||||
|
serverURL := resolveServerURL(cmd)
|
||||||
|
|
||||||
|
if token == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "Not authenticated. Run 'multica auth login' to authenticate.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
client := cli.NewAPIClient(serverURL, "", token)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var me struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
if err := client.GetJSON(ctx, "/api/me", &me); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Token is invalid or expired: %v\nRun 'multica auth login' to re-authenticate.\n", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := token
|
||||||
|
if len(prefix) > 12 {
|
||||||
|
prefix = prefix[:12] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "Server: %s\nUser: %s (%s)\nToken: %s\n", serverURL, me.Name, me.Email, prefix)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const callbackSuccessHTML = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Multica — Authenticated</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root { --bg: #0b0b0f; --card-bg: #16161d; --border: rgba(255,255,255,0.10); --fg: #f5f5f5; --fg2: #a1a1aa; --accent: #22c55e; --accent-bg: rgba(34,197,94,0.12); }
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root { --bg: #f8f8fa; --card-bg: #ffffff; --border: rgba(0,0,0,0.08); --fg: #0f0f12; --fg2: #71717a; --accent: #16a34a; --accent-bg: rgba(22,163,74,0.08); }
|
||||||
|
}
|
||||||
|
body { font-family: -apple-system, "Segoe UI", Helvetica, Arial, sans-serif; background: var(--bg); color: var(--fg); display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
||||||
|
.card { width: 100%; max-width: 380px; border: 1px solid var(--border); border-radius: 12px; background: var(--card-bg); padding: 40px 32px; text-align: center; }
|
||||||
|
.icon-wrap { width: 48px; height: 48px; margin: 0 auto 24px; background: var(--accent-bg); border-radius: 50%; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.icon-wrap svg { width: 24px; height: 24px; color: var(--accent); }
|
||||||
|
.brand { display: flex; align-items: center; justify-content: center; gap: 6px; margin-bottom: 8px; }
|
||||||
|
.asterisk { display: inline-block; width: 14px; height: 14px; background: var(--fg); clip-path: polygon(45% 62.1%,45% 100%,55% 100%,55% 62.1%,81.8% 88.9%,88.9% 81.8%,62.1% 55%,100% 55%,100% 45%,62.1% 45%,88.9% 18.2%,81.8% 11.1%,55% 37.9%,55% 0%,45% 0%,45% 37.9%,18.2% 11.1%,11.1% 18.2%,37.9% 45%,0% 45%,0% 55%,37.9% 55%,11.1% 81.8%,18.2% 88.9%); }
|
||||||
|
h1 { font-size: 20px; font-weight: 600; margin-bottom: 8px; }
|
||||||
|
p { font-size: 14px; color: var(--fg2); line-height: 1.5; }
|
||||||
|
.hint { margin-top: 24px; font-size: 13px; color: var(--fg2); opacity: 0.7; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="icon-wrap">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="brand"><span class="asterisk"></span></div>
|
||||||
|
<h1>Authentication successful</h1>
|
||||||
|
<p>You can close this tab and return to the terminal.</p>
|
||||||
|
<p class="hint">Your CLI session is now authenticated.</p>
|
||||||
|
</div>
|
||||||
|
<script>setTimeout(function(){window.close()},3000)</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
func runAuthLogout(_ *cobra.Command, _ []string) error {
|
||||||
|
cfg, _ := cli.LoadCLIConfig()
|
||||||
|
if cfg.Token == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "Not authenticated.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Token = ""
|
||||||
|
if err := cli.SaveCLIConfig(cfg); err != nil {
|
||||||
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(os.Stderr, "Token removed. You are now logged out.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,7 @@ func init() {
|
||||||
rootCmd.PersistentFlags().String("server-url", "", "Multica server URL (env: MULTICA_SERVER_URL)")
|
rootCmd.PersistentFlags().String("server-url", "", "Multica server URL (env: MULTICA_SERVER_URL)")
|
||||||
rootCmd.PersistentFlags().String("workspace-id", "", "Workspace ID (env: MULTICA_WORKSPACE_ID)")
|
rootCmd.PersistentFlags().String("workspace-id", "", "Workspace ID (env: MULTICA_WORKSPACE_ID)")
|
||||||
|
|
||||||
|
rootCmd.AddCommand(authCmd)
|
||||||
rootCmd.AddCommand(daemonCmd)
|
rootCmd.AddCommand(daemonCmd)
|
||||||
rootCmd.AddCommand(agentCmd)
|
rootCmd.AddCommand(agentCmd)
|
||||||
rootCmd.AddCommand(runtimeCmd)
|
rootCmd.AddCommand(runtimeCmd)
|
||||||
|
|
|
||||||
|
|
@ -71,28 +71,14 @@ func TestMain(m *testing.M) {
|
||||||
router := NewRouter(pool, hub, bus)
|
router := NewRouter(pool, hub, bus)
|
||||||
testServer = httptest.NewServer(router)
|
testServer = httptest.NewServer(router)
|
||||||
|
|
||||||
// Login to get a real JWT token
|
// Generate a JWT token directly for the test user
|
||||||
loginBody, _ := json.Marshal(map[string]string{
|
testToken, err = generateTestJWT(testUserID, integrationTestEmail, integrationTestName)
|
||||||
"email": integrationTestEmail,
|
|
||||||
"name": integrationTestName,
|
|
||||||
})
|
|
||||||
resp, err := http.Post(testServer.URL+"/auth/login", "application/json", bytes.NewReader(loginBody))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Skipping: login failed: %v\n", err)
|
fmt.Printf("Failed to generate test JWT: %v\n", err)
|
||||||
testServer.Close()
|
testServer.Close()
|
||||||
pool.Close()
|
pool.Close()
|
||||||
os.Exit(0)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var loginResp struct {
|
|
||||||
Token string `json:"token"`
|
|
||||||
User struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
} `json:"user"`
|
|
||||||
}
|
|
||||||
json.NewDecoder(resp.Body).Decode(&loginResp)
|
|
||||||
testToken = loginResp.Token
|
|
||||||
|
|
||||||
code := m.Run()
|
code := m.Run()
|
||||||
|
|
||||||
|
|
@ -202,6 +188,17 @@ func readJSON(t *testing.T, resp *http.Response, v any) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func generateTestJWT(userID, email, name string) (string, error) {
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||||
|
"sub": userID,
|
||||||
|
"email": email,
|
||||||
|
"name": name,
|
||||||
|
"exp": time.Now().Add(72 * time.Hour).Unix(),
|
||||||
|
"iat": time.Now().Unix(),
|
||||||
|
})
|
||||||
|
return token.SignedString(jwtSecret)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Health ----
|
// ---- Health ----
|
||||||
|
|
||||||
func TestHealth(t *testing.T) {
|
func TestHealth(t *testing.T) {
|
||||||
|
|
@ -224,27 +221,65 @@ func TestHealth(t *testing.T) {
|
||||||
|
|
||||||
// ---- Auth ----
|
// ---- Auth ----
|
||||||
|
|
||||||
func TestLoginAndGetMe(t *testing.T) {
|
func TestSendCodeAndVerify(t *testing.T) {
|
||||||
// Login
|
const email = "integration-sendcode@multica.ai"
|
||||||
body, _ := json.Marshal(map[string]string{
|
ctx := context.Background()
|
||||||
"email": "integration-test@multica.ai",
|
|
||||||
"name": "Integration Tester",
|
t.Cleanup(func() {
|
||||||
|
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
|
||||||
|
var userID string
|
||||||
|
err := testPool.QueryRow(ctx, `SELECT id FROM "user" WHERE email = $1`, email).Scan(&userID)
|
||||||
|
if err == nil {
|
||||||
|
rows, queryErr := testPool.Query(ctx, `
|
||||||
|
SELECT w.id FROM workspace w JOIN member m ON m.workspace_id = w.id WHERE m.user_id = $1
|
||||||
|
`, userID)
|
||||||
|
if queryErr == nil {
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var wsID string
|
||||||
|
if rows.Scan(&wsID) == nil {
|
||||||
|
testPool.Exec(ctx, `DELETE FROM workspace WHERE id = $1`, wsID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
|
||||||
})
|
})
|
||||||
resp, err := http.Post(testServer.URL+"/auth/login", "application/json", bytes.NewReader(body))
|
|
||||||
|
// Step 1: Send code
|
||||||
|
body, _ := json.Marshal(map[string]string{"email": email})
|
||||||
|
resp, err := http.Post(testServer.URL+"/auth/send-code", "application/json", bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("login failed: %v", err)
|
t.Fatalf("send-code failed: %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("send-code: expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
// Read code from DB
|
||||||
|
var code string
|
||||||
|
err = testPool.QueryRow(ctx, `SELECT code FROM verification_code WHERE email = $1 ORDER BY created_at DESC LIMIT 1`, email).Scan(&code)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read code from DB: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 2: Verify code
|
||||||
|
body, _ = json.Marshal(map[string]string{"email": email, "code": code})
|
||||||
|
resp, err = http.Post(testServer.URL+"/auth/verify-code", "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verify-code failed: %v", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
t.Fatalf("verify-code: expected 200, got %d: %s", resp.StatusCode, respBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
var loginResp struct {
|
var loginResp struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
User struct {
|
User struct {
|
||||||
ID string `json:"id"`
|
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Name string `json:"name"`
|
|
||||||
} `json:"user"`
|
} `json:"user"`
|
||||||
}
|
}
|
||||||
readJSON(t, resp, &loginResp)
|
readJSON(t, resp, &loginResp)
|
||||||
|
|
@ -252,83 +287,81 @@ func TestLoginAndGetMe(t *testing.T) {
|
||||||
if loginResp.Token == "" {
|
if loginResp.Token == "" {
|
||||||
t.Fatal("expected non-empty token")
|
t.Fatal("expected non-empty token")
|
||||||
}
|
}
|
||||||
if loginResp.User.Email != "integration-test@multica.ai" {
|
if loginResp.User.Email != email {
|
||||||
t.Fatalf("expected email 'integration-test@multica.ai', got '%s'", loginResp.User.Email)
|
t.Fatalf("expected email '%s', got '%s'", email, loginResp.User.Email)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use token to call /api/me
|
// Verify the token works with /api/me
|
||||||
req, _ := http.NewRequest("GET", testServer.URL+"/api/me", nil)
|
req, _ := http.NewRequest("GET", testServer.URL+"/api/me", nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+loginResp.Token)
|
req.Header.Set("Authorization", "Bearer "+loginResp.Token)
|
||||||
meResp, err := http.DefaultClient.Do(req)
|
meResp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("getMe failed: %v", err)
|
t.Fatalf("getMe failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if meResp.StatusCode != 200 {
|
if meResp.StatusCode != 200 {
|
||||||
t.Fatalf("expected 200, got %d", meResp.StatusCode)
|
t.Fatalf("getMe: expected 200, got %d", meResp.StatusCode)
|
||||||
}
|
|
||||||
|
|
||||||
var me struct {
|
|
||||||
Email string `json:"email"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
readJSON(t, meResp, &me)
|
|
||||||
if me.Email != "integration-test@multica.ai" {
|
|
||||||
t.Fatalf("expected email 'integration-test@multica.ai', got '%s'", me.Email)
|
|
||||||
}
|
}
|
||||||
|
meResp.Body.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginCreatesWorkspaceForNewUser(t *testing.T) {
|
func TestVerifyCodeCreatesWorkspaceForNewUser(t *testing.T) {
|
||||||
const email = "new-integration-login@multica.ai"
|
const email = "new-integration-verify@multica.ai"
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
|
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
|
||||||
var userID string
|
var userID string
|
||||||
err := testPool.QueryRow(ctx, `SELECT id FROM "user" WHERE email = $1`, email).Scan(&userID)
|
err := testPool.QueryRow(ctx, `SELECT id FROM "user" WHERE email = $1`, email).Scan(&userID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
rows, queryErr := testPool.Query(ctx, `
|
rows, queryErr := testPool.Query(ctx, `
|
||||||
SELECT w.id
|
SELECT w.id FROM workspace w JOIN member m ON m.workspace_id = w.id WHERE m.user_id = $1
|
||||||
FROM workspace w
|
|
||||||
JOIN member m ON m.workspace_id = w.id
|
|
||||||
WHERE m.user_id = $1
|
|
||||||
`, userID)
|
`, userID)
|
||||||
if queryErr == nil {
|
if queryErr == nil {
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var workspaceID string
|
var wsID string
|
||||||
if scanErr := rows.Scan(&workspaceID); scanErr == nil {
|
if rows.Scan(&wsID) == nil {
|
||||||
_, _ = testPool.Exec(ctx, `DELETE FROM workspace WHERE id = $1`, workspaceID)
|
testPool.Exec(ctx, `DELETE FROM workspace WHERE id = $1`, wsID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_, _ = testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
|
testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
|
||||||
})
|
})
|
||||||
|
|
||||||
_, _ = testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
|
testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]string{
|
// Send code
|
||||||
"email": email,
|
body, _ := json.Marshal(map[string]string{"email": email})
|
||||||
"name": "Jiayuan",
|
resp, err := http.Post(testServer.URL+"/auth/send-code", "application/json", bytes.NewReader(body))
|
||||||
})
|
|
||||||
resp, err := http.Post(testServer.URL+"/auth/login", "application/json", bytes.NewReader(body))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("login failed: %v", err)
|
t.Fatalf("send-code failed: %v", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
|
// Read code from DB
|
||||||
|
var code string
|
||||||
|
err = testPool.QueryRow(ctx, `SELECT code FROM verification_code WHERE email = $1 ORDER BY created_at DESC LIMIT 1`, email).Scan(&code)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read code from DB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify code
|
||||||
|
body, _ = json.Marshal(map[string]string{"email": email, "code": code})
|
||||||
|
resp, err = http.Post(testServer.URL+"/auth/verify-code", "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verify-code failed: %v", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
t.Fatalf("verify-code: expected 200, got %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
var loginResp struct {
|
var loginResp struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
}
|
}
|
||||||
readJSON(t, resp, &loginResp)
|
readJSON(t, resp, &loginResp)
|
||||||
if loginResp.Token == "" {
|
|
||||||
t.Fatal("expected non-empty token")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Check workspace was created
|
||||||
req, _ := http.NewRequest("GET", testServer.URL+"/api/workspaces", nil)
|
req, _ := http.NewRequest("GET", testServer.URL+"/api/workspaces", nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+loginResp.Token)
|
req.Header.Set("Authorization", "Bearer "+loginResp.Token)
|
||||||
workspacesResp, err := http.DefaultClient.Do(req)
|
workspacesResp, err := http.DefaultClient.Do(req)
|
||||||
|
|
@ -350,11 +383,8 @@ func TestLoginCreatesWorkspaceForNewUser(t *testing.T) {
|
||||||
if len(workspaces) != 1 {
|
if len(workspaces) != 1 {
|
||||||
t.Fatalf("expected 1 workspace, got %d", len(workspaces))
|
t.Fatalf("expected 1 workspace, got %d", len(workspaces))
|
||||||
}
|
}
|
||||||
if workspaces[0].Name != "Jiayuan's Workspace" {
|
if !strings.Contains(workspaces[0].Name, "Workspace") {
|
||||||
t.Fatalf("expected default workspace name %q, got %q", "Jiayuan's Workspace", workspaces[0].Name)
|
t.Fatalf("expected workspace name containing 'Workspace', got %q", workspaces[0].Name)
|
||||||
}
|
|
||||||
if workspaces[0].Slug == "" {
|
|
||||||
t.Fatal("expected non-empty workspace slug")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import (
|
||||||
"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"
|
||||||
"github.com/multica-ai/multica/server/internal/realtime"
|
"github.com/multica-ai/multica/server/internal/realtime"
|
||||||
|
"github.com/multica-ai/multica/server/internal/service"
|
||||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -45,7 +46,8 @@ func allowedOrigins() []string {
|
||||||
// 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(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Router {
|
func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Router {
|
||||||
queries := db.New(pool)
|
queries := db.New(pool)
|
||||||
h := handler.New(queries, pool, hub, bus)
|
emailSvc := service.NewEmailService()
|
||||||
|
h := handler.New(queries, pool, hub, bus, emailSvc)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
|
@ -74,7 +76,8 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
||||||
})
|
})
|
||||||
|
|
||||||
// Auth (public)
|
// Auth (public)
|
||||||
r.Post("/auth/login", h.Login)
|
r.Post("/auth/send-code", h.SendCode)
|
||||||
|
r.Post("/auth/verify-code", h.VerifyCode)
|
||||||
|
|
||||||
// Daemon API routes (no user auth; daemon auth deferred to later)
|
// Daemon API routes (no user auth; daemon auth deferred to later)
|
||||||
r.Route("/api/daemon", func(r chi.Router) {
|
r.Route("/api/daemon", func(r chi.Router) {
|
||||||
|
|
@ -96,7 +99,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
||||||
|
|
||||||
// Protected API routes
|
// Protected API routes
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(middleware.Auth)
|
r.Use(middleware.Auth(queries))
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
r.Get("/api/me", h.GetMe)
|
r.Get("/api/me", h.GetMe)
|
||||||
|
|
@ -155,6 +158,13 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
||||||
|
|
||||||
r.Post("/api/daemon/pairing-sessions/{token}/approve", h.ApproveDaemonPairingSession)
|
r.Post("/api/daemon/pairing-sessions/{token}/approve", h.ApproveDaemonPairingSession)
|
||||||
|
|
||||||
|
// Personal Access Tokens
|
||||||
|
r.Route("/api/tokens", func(r chi.Router) {
|
||||||
|
r.Get("/", h.ListPersonalAccessTokens)
|
||||||
|
r.Post("/", h.CreatePersonalAccessToken)
|
||||||
|
r.Delete("/{id}", h.RevokePersonalAccessToken)
|
||||||
|
})
|
||||||
|
|
||||||
// Inbox
|
// Inbox
|
||||||
r.Route("/api/inbox", func(r chi.Router) {
|
r.Route("/api/inbox", func(r chi.Router) {
|
||||||
r.Get("/", h.ListInbox)
|
r.Get("/", h.ListInbox)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ require (
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/jackc/pgx/v5 v5.8.0
|
github.com/jackc/pgx/v5 v5.8.0
|
||||||
|
github.com/resend/resend-go/v2 v2.28.0
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
|
||||||
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/resend/resend-go/v2 v2.28.0 h1:ttM1/VZR4fApBv3xI1TneSKi1pbfFsVrq7fXFlHKtj4=
|
||||||
|
github.com/resend/resend-go/v2 v2.28.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
@ -23,3 +27,18 @@ func JWTSecret() []byte {
|
||||||
|
|
||||||
return jwtSecret
|
return jwtSecret
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GeneratePATToken creates a new personal access token: "mul_" + 40 random hex chars.
|
||||||
|
func GeneratePATToken() (string, error) {
|
||||||
|
b := make([]byte, 20) // 20 bytes = 40 hex chars
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", fmt.Errorf("generate PAT token: %w", err)
|
||||||
|
}
|
||||||
|
return "mul_" + hex.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashToken returns the hex-encoded SHA-256 hash of a token string.
|
||||||
|
func HashToken(token string) string {
|
||||||
|
h := sha256.Sum256([]byte(token))
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,27 +20,36 @@ import (
|
||||||
type APIClient struct {
|
type APIClient struct {
|
||||||
BaseURL string
|
BaseURL string
|
||||||
WorkspaceID string
|
WorkspaceID string
|
||||||
|
Token string
|
||||||
HTTPClient *http.Client
|
HTTPClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAPIClient creates a new API client for ctrl commands.
|
// NewAPIClient creates a new API client for ctrl commands.
|
||||||
func NewAPIClient(baseURL, workspaceID string) *APIClient {
|
func NewAPIClient(baseURL, workspaceID, token string) *APIClient {
|
||||||
return &APIClient{
|
return &APIClient{
|
||||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||||
WorkspaceID: workspaceID,
|
WorkspaceID: workspaceID,
|
||||||
|
Token: token,
|
||||||
HTTPClient: &http.Client{Timeout: 15 * time.Second},
|
HTTPClient: &http.Client{Timeout: 15 * time.Second},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *APIClient) setHeaders(req *http.Request) {
|
||||||
|
if c.Token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||||
|
}
|
||||||
|
if c.WorkspaceID != "" {
|
||||||
|
req.Header.Set("X-Workspace-ID", c.WorkspaceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetJSON performs a GET request and decodes the JSON response.
|
// GetJSON performs a GET request and decodes the JSON response.
|
||||||
func (c *APIClient) GetJSON(ctx context.Context, path string, out any) error {
|
func (c *APIClient) GetJSON(ctx context.Context, path string, out any) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if c.WorkspaceID != "" {
|
c.setHeaders(req)
|
||||||
req.Header.Set("X-Workspace-ID", c.WorkspaceID)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.HTTPClient.Do(req)
|
resp, err := c.HTTPClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -64,9 +73,7 @@ func (c *APIClient) DeleteJSON(ctx context.Context, path string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if c.WorkspaceID != "" {
|
c.setHeaders(req)
|
||||||
req.Header.Set("X-Workspace-ID", c.WorkspaceID)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.HTTPClient.Do(req)
|
resp, err := c.HTTPClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -81,6 +88,36 @@ func (c *APIClient) DeleteJSON(ctx context.Context, path string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PostJSON performs a POST request with a JSON body.
|
||||||
|
func (c *APIClient) PostJSON(ctx context.Context, path string, body any, out any) error {
|
||||||
|
data, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+path, bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
c.setHeaders(req)
|
||||||
|
|
||||||
|
resp, err := c.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
respData, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
return fmt.Errorf("POST %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(respData)))
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.NewDecoder(resp.Body).Decode(out)
|
||||||
|
}
|
||||||
|
|
||||||
// PutJSON performs a PUT request with a JSON body.
|
// PutJSON performs a PUT request with a JSON body.
|
||||||
func (c *APIClient) PutJSON(ctx context.Context, path string, body any, out any) error {
|
func (c *APIClient) PutJSON(ctx context.Context, path string, body any, out any) error {
|
||||||
data, err := json.Marshal(body)
|
data, err := json.Marshal(body)
|
||||||
|
|
@ -93,9 +130,7 @@ func (c *APIClient) PutJSON(ctx context.Context, path string, body any, out any)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
if c.WorkspaceID != "" {
|
c.setHeaders(req)
|
||||||
req.Header.Set("X-Workspace-ID", c.WorkspaceID)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.HTTPClient.Do(req)
|
resp, err := c.HTTPClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
104
server/internal/cli/client_test.go
Normal file
104
server/internal/cli/client_test.go
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPostJSON(t *testing.T) {
|
||||||
|
type reqBody struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Age int `json:"age"`
|
||||||
|
}
|
||||||
|
type respBody struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("expected POST, got %s", r.Method)
|
||||||
|
}
|
||||||
|
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
||||||
|
t.Errorf("expected Content-Type application/json, got %s", ct)
|
||||||
|
}
|
||||||
|
if auth := r.Header.Get("Authorization"); auth != "Bearer test-token" {
|
||||||
|
t.Errorf("expected Authorization Bearer test-token, got %s", auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body reqBody
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatalf("failed to decode request body: %v", err)
|
||||||
|
}
|
||||||
|
if body.Name != "alice" || body.Age != 30 {
|
||||||
|
t.Errorf("unexpected body: %+v", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(respBody{ID: "123"})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := NewAPIClient(srv.URL, "", "test-token")
|
||||||
|
var out respBody
|
||||||
|
err := client.PostJSON(context.Background(), "/test", reqBody{Name: "alice", Age: 30}, &out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if out.ID != "123" {
|
||||||
|
t.Errorf("expected ID 123, got %s", out.ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("error status", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
io.WriteString(w, "bad request")
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := NewAPIClient(srv.URL, "", "test-token")
|
||||||
|
err := client.PostJSON(context.Background(), "/test", reqBody{Name: "bob"}, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if got := err.Error(); got != "POST /test returned 400: bad request" {
|
||||||
|
t.Errorf("unexpected error message: %s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nil output", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := NewAPIClient(srv.URL, "", "test-token")
|
||||||
|
err := client.PostJSON(context.Background(), "/test", reqBody{Name: "charlie"}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("workspace header", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if ws := r.Header.Get("X-Workspace-ID"); ws != "ws-abc" {
|
||||||
|
t.Errorf("expected X-Workspace-ID ws-abc, got %s", ws)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(respBody{ID: "456"})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := NewAPIClient(srv.URL, "ws-abc", "test-token")
|
||||||
|
var out respBody
|
||||||
|
err := client.PostJSON(context.Background(), "/test", reqBody{}, &out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ const defaultCLIConfigPath = ".multica/config.json"
|
||||||
type CLIConfig struct {
|
type CLIConfig struct {
|
||||||
ServerURL string `json:"server_url,omitempty"`
|
ServerURL string `json:"server_url,omitempty"`
|
||||||
WorkspaceID string `json:"workspace_id,omitempty"`
|
WorkspaceID string `json:"workspace_id,omitempty"`
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CLIConfigPath returns the default path for the CLI config file.
|
// CLIConfigPath returns the default path for the CLI config file.
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,14 @@ package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/binary"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -35,16 +40,20 @@ func userToResponse(u db.User) UserResponse {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoginRequest struct {
|
|
||||||
Email string `json:"email"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type LoginResponse struct {
|
type LoginResponse struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
User UserResponse `json:"user"`
|
User UserResponse `json:"user"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SendCodeRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VerifyCodeRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
func defaultWorkspaceName(user db.User) string {
|
func defaultWorkspaceName(user db.User) string {
|
||||||
name := strings.TrimSpace(user.Name)
|
name := strings.TrimSpace(user.Name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
|
|
@ -150,63 +159,16 @@ func (h *Handler) ensureUserWorkspace(ctx context.Context, user db.User) error {
|
||||||
return tx.Commit(ctx)
|
return tx.Commit(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
func generateCode() (string, error) {
|
||||||
var req LoginRequest
|
var buf [4]byte
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if _, err := rand.Read(buf[:]); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
return "", err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
n := binary.BigEndian.Uint32(buf[:]) % 1000000
|
||||||
|
return fmt.Sprintf("%06d", n), nil
|
||||||
|
}
|
||||||
|
|
||||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
func (h *Handler) issueJWT(user db.User) (string, error) {
|
||||||
req.Name = strings.TrimSpace(req.Name)
|
|
||||||
|
|
||||||
if req.Email == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "email is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to find existing user
|
|
||||||
user, err := h.Queries.GetUserByEmail(r.Context(), req.Email)
|
|
||||||
if err != nil {
|
|
||||||
if !isNotFound(err) {
|
|
||||||
slog.Warn("login failed", append(logger.RequestAttrs(r), "error", err, "email", req.Email)...)
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to load user")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new user
|
|
||||||
name := req.Name
|
|
||||||
if name == "" {
|
|
||||||
name = req.Email
|
|
||||||
}
|
|
||||||
user, err = h.Queries.CreateUser(r.Context(), db.CreateUserParams{
|
|
||||||
Name: name,
|
|
||||||
Email: req.Email,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("login failed", append(logger.RequestAttrs(r), "error", err, "email", req.Email)...)
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to create user: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Info("new user created", append(logger.RequestAttrs(r), "user_id", uuidToString(user.ID), "email", user.Email)...)
|
|
||||||
} 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.ensureUserWorkspace(r.Context(), user); err != nil {
|
|
||||||
slog.Warn("login failed", append(logger.RequestAttrs(r), "error", err, "email", req.Email)...)
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to provision workspace")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate JWT
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||||
"sub": uuidToString(user.ID),
|
"sub": uuidToString(user.ID),
|
||||||
"email": user.Email,
|
"email": user.Email,
|
||||||
|
|
@ -214,8 +176,122 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
"exp": time.Now().Add(72 * time.Hour).Unix(),
|
"exp": time.Now().Add(72 * time.Hour).Unix(),
|
||||||
"iat": time.Now().Unix(),
|
"iat": time.Now().Unix(),
|
||||||
})
|
})
|
||||||
|
return token.SignedString(auth.JWTSecret())
|
||||||
|
}
|
||||||
|
|
||||||
tokenString, err := token.SignedString(auth.JWTSecret())
|
func (h *Handler) findOrCreateUser(ctx context.Context, email string) (db.User, error) {
|
||||||
|
user, err := h.Queries.GetUserByEmail(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
if !isNotFound(err) {
|
||||||
|
return db.User{}, err
|
||||||
|
}
|
||||||
|
name := email
|
||||||
|
if at := strings.Index(email, "@"); at > 0 {
|
||||||
|
name = email[:at]
|
||||||
|
}
|
||||||
|
user, err = h.Queries.CreateUser(ctx, db.CreateUserParams{
|
||||||
|
Name: name,
|
||||||
|
Email: email,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return db.User{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req SendCodeRequest
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit: max 1 code per 10 seconds per email
|
||||||
|
latest, err := h.Queries.GetLatestCodeByEmail(r.Context(), email)
|
||||||
|
if err == nil && time.Since(latest.CreatedAt.Time) < 10*time.Second {
|
||||||
|
writeError(w, http.StatusTooManyRequests, "please wait before requesting another code")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := generateCode()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to generate code")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = h.Queries.CreateVerificationCode(r.Context(), db.CreateVerificationCodeParams{
|
||||||
|
Email: email,
|
||||||
|
Code: code,
|
||||||
|
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(10 * time.Minute), Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to store verification code")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.EmailService.SendVerificationCode(email, code); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to send verification code")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort cleanup of expired codes
|
||||||
|
_ = h.Queries.DeleteExpiredVerificationCodes(r.Context())
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"message": "Verification code sent"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) VerifyCode(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req VerifyCodeRequest
|
||||||
|
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))
|
||||||
|
code := strings.TrimSpace(req.Code)
|
||||||
|
|
||||||
|
if email == "" || code == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "email and code are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dbCode, err := h.Queries.GetLatestVerificationCode(r.Context(), email)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid or expired code")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isMasterCode := code == "888888" && os.Getenv("APP_ENV") != "production"
|
||||||
|
if !isMasterCode && subtle.ConstantTimeCompare([]byte(code), []byte(dbCode.Code)) != 1 {
|
||||||
|
_ = h.Queries.IncrementVerificationCodeAttempts(r.Context(), dbCode.ID)
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid or expired code")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.Queries.MarkVerificationCodeUsed(r.Context(), dbCode.ID); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to verify code")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.findOrCreateUser(r.Context(), email)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.ensureUserWorkspace(r.Context(), user); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to provision workspace")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenString, err := h.issueJWT(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("login failed", append(logger.RequestAttrs(r), "error", err, "email", req.Email)...)
|
slog.Warn("login failed", append(logger.RequestAttrs(r), "error", err, "email", req.Email)...)
|
||||||
writeError(w, http.StatusInternalServerError, "failed to generate token")
|
writeError(w, http.StatusInternalServerError, "failed to generate token")
|
||||||
|
|
|
||||||
|
|
@ -27,27 +27,29 @@ type dbExecutor interface {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
Queries *db.Queries
|
Queries *db.Queries
|
||||||
DB dbExecutor
|
DB dbExecutor
|
||||||
TxStarter txStarter
|
TxStarter txStarter
|
||||||
Hub *realtime.Hub
|
Hub *realtime.Hub
|
||||||
Bus *events.Bus
|
Bus *events.Bus
|
||||||
TaskService *service.TaskService
|
TaskService *service.TaskService
|
||||||
|
EmailService *service.EmailService
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus) *Handler {
|
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService) *Handler {
|
||||||
var executor dbExecutor
|
var executor dbExecutor
|
||||||
if candidate, ok := txStarter.(dbExecutor); ok {
|
if candidate, ok := txStarter.(dbExecutor); ok {
|
||||||
executor = candidate
|
executor = candidate
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Handler{
|
return &Handler{
|
||||||
Queries: queries,
|
Queries: queries,
|
||||||
DB: executor,
|
DB: executor,
|
||||||
TxStarter: txStarter,
|
TxStarter: txStarter,
|
||||||
Hub: hub,
|
Hub: hub,
|
||||||
Bus: bus,
|
Bus: bus,
|
||||||
TaskService: service.NewTaskService(queries, hub, bus),
|
TaskService: service.NewTaskService(queries, hub, bus),
|
||||||
|
EmailService: emailService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/multica-ai/multica/server/internal/events"
|
"github.com/multica-ai/multica/server/internal/events"
|
||||||
"github.com/multica-ai/multica/server/internal/realtime"
|
"github.com/multica-ai/multica/server/internal/realtime"
|
||||||
|
"github.com/multica-ai/multica/server/internal/service"
|
||||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -51,7 +52,8 @@ func TestMain(m *testing.M) {
|
||||||
hub := realtime.NewHub()
|
hub := realtime.NewHub()
|
||||||
go hub.Run()
|
go hub.Run()
|
||||||
bus := events.New()
|
bus := events.New()
|
||||||
testHandler = New(queries, pool, hub, bus)
|
emailSvc := service.NewEmailService()
|
||||||
|
testHandler = New(queries, pool, hub, bus, emailSvc)
|
||||||
testPool = pool
|
testPool = pool
|
||||||
|
|
||||||
testUserID, testWorkspaceID, err = setupHandlerTestFixture(ctx, pool)
|
testUserID, testWorkspaceID, err = setupHandlerTestFixture(ctx, pool)
|
||||||
|
|
@ -360,33 +362,65 @@ func TestWorkspaceCRUD(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthLogin(t *testing.T) {
|
func TestSendCode(t *testing.T) {
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
body := map[string]string{"email": "test-handler@multica.ai", "name": "Test User"}
|
body := map[string]string{"email": "sendcode-test@multica.ai"}
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
json.NewEncoder(&buf).Encode(body)
|
json.NewEncoder(&buf).Encode(body)
|
||||||
req := httptest.NewRequest("POST", "/auth/login", &buf)
|
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
testHandler.Login(w, req)
|
testHandler.SendCode(w, req)
|
||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
t.Fatalf("Login: expected 200, got %d: %s", w.Code, w.Body.String())
|
t.Fatalf("SendCode: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp LoginResponse
|
var resp map[string]string
|
||||||
json.NewDecoder(w.Body).Decode(&resp)
|
json.NewDecoder(w.Body).Decode(&resp)
|
||||||
if resp.Token == "" {
|
if resp["message"] == "" {
|
||||||
t.Fatal("Login: expected non-empty token")
|
t.Fatal("SendCode: expected non-empty message")
|
||||||
}
|
}
|
||||||
if resp.User.Email != "test-handler@multica.ai" {
|
|
||||||
t.Fatalf("Login: expected email 'test-handler@multica.ai', got '%s'", resp.User.Email)
|
t.Cleanup(func() {
|
||||||
|
testPool.Exec(context.Background(), `DELETE FROM verification_code WHERE email = $1`, "sendcode-test@multica.ai")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendCodeRateLimit(t *testing.T) {
|
||||||
|
const email = "ratelimit-test@multica.ai"
|
||||||
|
t.Cleanup(func() {
|
||||||
|
testPool.Exec(context.Background(), `DELETE FROM verification_code WHERE email = $1`, email)
|
||||||
|
})
|
||||||
|
|
||||||
|
// First request should succeed
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
body := map[string]string{"email": email}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
json.NewEncoder(&buf).Encode(body)
|
||||||
|
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
testHandler.SendCode(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("SendCode (first): expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second request within 60s should be rate limited
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
buf.Reset()
|
||||||
|
json.NewEncoder(&buf).Encode(body)
|
||||||
|
req = httptest.NewRequest("POST", "/auth/send-code", &buf)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
testHandler.SendCode(w, req)
|
||||||
|
if w.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("SendCode (second): expected 429, got %d: %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthLoginCreatesWorkspaceForNewUser(t *testing.T) {
|
func TestVerifyCode(t *testing.T) {
|
||||||
const email = "new-handler-login@multica.ai"
|
const email = "verify-test@multica.ai"
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
|
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
|
||||||
user, err := testHandler.Queries.GetUserByEmail(ctx, email)
|
user, err := testHandler.Queries.GetUserByEmail(ctx, email)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
workspaces, listErr := testHandler.Queries.ListWorkspaces(ctx, user.ID)
|
workspaces, listErr := testHandler.Queries.ListWorkspaces(ctx, user.ID)
|
||||||
|
|
@ -396,21 +430,166 @@ func TestAuthLoginCreatesWorkspaceForNewUser(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_, _ = testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
|
testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
|
||||||
})
|
})
|
||||||
|
|
||||||
_, _ = testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
|
// Send code first
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
body := map[string]string{"email": email, "name": "Workspace Owner"}
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
json.NewEncoder(&buf).Encode(body)
|
json.NewEncoder(&buf).Encode(map[string]string{"email": email})
|
||||||
req := httptest.NewRequest("POST", "/auth/login", &buf)
|
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
testHandler.SendCode(w, req)
|
||||||
testHandler.Login(w, req)
|
|
||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
t.Fatalf("Login: expected 200, got %d: %s", w.Code, w.Body.String())
|
t.Fatalf("SendCode: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read code from DB
|
||||||
|
dbCode, err := testHandler.Queries.GetLatestVerificationCode(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestVerificationCode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify with correct code
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
buf.Reset()
|
||||||
|
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": dbCode.Code})
|
||||||
|
req = httptest.NewRequest("POST", "/auth/verify-code", &buf)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
testHandler.VerifyCode(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("VerifyCode: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp LoginResponse
|
||||||
|
json.NewDecoder(w.Body).Decode(&resp)
|
||||||
|
if resp.Token == "" {
|
||||||
|
t.Fatal("VerifyCode: expected non-empty token")
|
||||||
|
}
|
||||||
|
if resp.User.Email != email {
|
||||||
|
t.Fatalf("VerifyCode: expected email '%s', got '%s'", email, resp.User.Email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyCodeWrongCode(t *testing.T) {
|
||||||
|
const email = "wrong-code-test@multica.ai"
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Send code
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
json.NewEncoder(&buf).Encode(map[string]string{"email": email})
|
||||||
|
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
testHandler.SendCode(w, req)
|
||||||
|
|
||||||
|
// Verify with wrong code
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
buf.Reset()
|
||||||
|
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": "000000"})
|
||||||
|
req = httptest.NewRequest("POST", "/auth/verify-code", &buf)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
testHandler.VerifyCode(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("VerifyCode (wrong code): expected 400, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyCodeBruteForceProtection(t *testing.T) {
|
||||||
|
const email = "bruteforce-test@multica.ai"
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Send code
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
json.NewEncoder(&buf).Encode(map[string]string{"email": email})
|
||||||
|
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
testHandler.SendCode(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("SendCode: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read actual code so we can try it after lockout
|
||||||
|
dbCode, err := testHandler.Queries.GetLatestVerificationCode(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestVerificationCode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exhaust all 5 attempts with wrong codes
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
buf.Reset()
|
||||||
|
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": "000000"})
|
||||||
|
req = httptest.NewRequest("POST", "/auth/verify-code", &buf)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
testHandler.VerifyCode(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("attempt %d: expected 400, got %d", i+1, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now even the correct code should be rejected (code is locked out)
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
buf.Reset()
|
||||||
|
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": dbCode.Code})
|
||||||
|
req = httptest.NewRequest("POST", "/auth/verify-code", &buf)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
testHandler.VerifyCode(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("after lockout: expected 400, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyCodeCreatesWorkspace(t *testing.T) {
|
||||||
|
const email = "workspace-verify-test@multica.ai"
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
|
||||||
|
user, err := testHandler.Queries.GetUserByEmail(ctx, email)
|
||||||
|
if err == nil {
|
||||||
|
workspaces, listErr := testHandler.Queries.ListWorkspaces(ctx, user.ID)
|
||||||
|
if listErr == nil {
|
||||||
|
for _, workspace := range workspaces {
|
||||||
|
_ = testHandler.Queries.DeleteWorkspace(ctx, workspace.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Send code
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
json.NewEncoder(&buf).Encode(map[string]string{"email": email})
|
||||||
|
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
testHandler.SendCode(w, req)
|
||||||
|
|
||||||
|
// Read code from DB
|
||||||
|
dbCode, err := testHandler.Queries.GetLatestVerificationCode(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestVerificationCode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
buf.Reset()
|
||||||
|
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": dbCode.Code})
|
||||||
|
req = httptest.NewRequest("POST", "/auth/verify-code", &buf)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
testHandler.VerifyCode(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("VerifyCode: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := testHandler.Queries.GetUserByEmail(ctx, email)
|
user, err := testHandler.Queries.GetUserByEmail(ctx, email)
|
||||||
|
|
@ -428,9 +607,6 @@ func TestAuthLoginCreatesWorkspaceForNewUser(t *testing.T) {
|
||||||
if !strings.Contains(workspaces[0].Name, "Workspace") {
|
if !strings.Contains(workspaces[0].Name, "Workspace") {
|
||||||
t.Fatalf("expected auto-created workspace name, got %q", workspaces[0].Name)
|
t.Fatalf("expected auto-created workspace name, got %q", workspaces[0].Name)
|
||||||
}
|
}
|
||||||
if workspaces[0].Slug == "" {
|
|
||||||
t.Fatal("expected auto-created workspace slug")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDaemonRegisterMissingWorkspaceReturns404(t *testing.T) {
|
func TestDaemonRegisterMissingWorkspaceReturns404(t *testing.T) {
|
||||||
|
|
|
||||||
132
server/internal/handler/personal_access_token.go
Normal file
132
server/internal/handler/personal_access_token.go
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/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"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PersonalAccessTokenResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Prefix string `json:"token_prefix"`
|
||||||
|
ExpiresAt *string `json:"expires_at"`
|
||||||
|
LastUsedAt *string `json:"last_used_at"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreatePATResponse struct {
|
||||||
|
PersonalAccessTokenResponse
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func patToResponse(pat db.PersonalAccessToken) PersonalAccessTokenResponse {
|
||||||
|
return PersonalAccessTokenResponse{
|
||||||
|
ID: uuidToString(pat.ID),
|
||||||
|
Name: pat.Name,
|
||||||
|
Prefix: pat.TokenPrefix,
|
||||||
|
ExpiresAt: timestampToPtr(pat.ExpiresAt),
|
||||||
|
LastUsedAt: timestampToPtr(pat.LastUsedAt),
|
||||||
|
CreatedAt: timestampToString(pat.CreatedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreatePATRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ExpiresInDays *int `json:"expires_in_days"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreatePersonalAccessToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := requireUserID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req CreatePATRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Name == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rawToken, err := auth.GeneratePATToken()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to generate token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var expiresAt pgtype.Timestamptz
|
||||||
|
if req.ExpiresInDays != nil && *req.ExpiresInDays > 0 {
|
||||||
|
expiresAt = pgtype.Timestamptz{
|
||||||
|
Time: time.Now().Add(time.Duration(*req.ExpiresInDays) * 24 * time.Hour),
|
||||||
|
Valid: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := rawToken
|
||||||
|
if len(prefix) > 12 {
|
||||||
|
prefix = prefix[:12]
|
||||||
|
}
|
||||||
|
|
||||||
|
pat, err := h.Queries.CreatePersonalAccessToken(r.Context(), db.CreatePersonalAccessTokenParams{
|
||||||
|
UserID: parseUUID(userID),
|
||||||
|
Name: req.Name,
|
||||||
|
TokenHash: auth.HashToken(rawToken),
|
||||||
|
TokenPrefix: prefix,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, CreatePATResponse{
|
||||||
|
PersonalAccessTokenResponse: patToResponse(pat),
|
||||||
|
Token: rawToken,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListPersonalAccessTokens(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := requireUserID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pats, err := h.Queries.ListPersonalAccessTokensByUser(r.Context(), parseUUID(userID))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to list tokens")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := make([]PersonalAccessTokenResponse, len(pats))
|
||||||
|
for i, pat := range pats {
|
||||||
|
resp[i] = patToResponse(pat)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RevokePersonalAccessToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := requireUserID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
if err := h.Queries.RevokePersonalAccessToken(r.Context(), db.RevokePersonalAccessTokenParams{
|
||||||
|
ID: parseUUID(id),
|
||||||
|
UserID: parseUUID(userID),
|
||||||
|
}); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to revoke token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
@ -1,62 +1,94 @@
|
||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"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"
|
"github.com/multica-ai/multica/server/internal/auth"
|
||||||
|
"github.com/multica-ai/multica/server/internal/util"
|
||||||
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Auth middleware validates JWT tokens from the Authorization header.
|
func uuidToString(u pgtype.UUID) string { return util.UUIDToString(u) }
|
||||||
|
|
||||||
|
// Auth middleware validates JWT tokens or Personal Access 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(queries *db.Queries) func(http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return func(next http.Handler) http.Handler {
|
||||||
authHeader := r.Header.Get("Authorization")
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if authHeader == "" {
|
authHeader := r.Header.Get("Authorization")
|
||||||
slog.Debug("auth: missing authorization header", "path", r.URL.Path)
|
if authHeader == "" {
|
||||||
http.Error(w, `{"error":"missing authorization header"}`, http.StatusUnauthorized)
|
slog.Debug("auth: missing authorization header", "path", r.URL.Path)
|
||||||
return
|
http.Error(w, `{"error":"missing authorization header"}`, http.StatusUnauthorized)
|
||||||
}
|
return
|
||||||
|
|
||||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
|
||||||
if tokenString == authHeader {
|
|
||||||
slog.Debug("auth: invalid format", "path", r.URL.Path)
|
|
||||||
http.Error(w, `{"error":"invalid authorization format"}`, http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
|
|
||||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
||||||
return nil, jwt.ErrSignatureInvalid
|
|
||||||
}
|
}
|
||||||
return auth.JWTSecret(), nil
|
|
||||||
|
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
if tokenString == authHeader {
|
||||||
|
slog.Debug("auth: invalid format", "path", r.URL.Path)
|
||||||
|
http.Error(w, `{"error":"invalid authorization format"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// PAT: tokens starting with "mul_"
|
||||||
|
if strings.HasPrefix(tokenString, "mul_") {
|
||||||
|
if queries == nil {
|
||||||
|
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash := auth.HashToken(tokenString)
|
||||||
|
pat, err := queries.GetPersonalAccessTokenByHash(r.Context(), hash)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("auth: invalid PAT", "path", r.URL.Path, "error", err)
|
||||||
|
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Header.Set("X-User-ID", uuidToString(pat.UserID))
|
||||||
|
|
||||||
|
// Best-effort: update last_used_at
|
||||||
|
go queries.UpdatePersonalAccessTokenLastUsed(context.Background(), pat.ID)
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT
|
||||||
|
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, jwt.ErrSignatureInvalid
|
||||||
|
}
|
||||||
|
return auth.JWTSecret(), nil
|
||||||
|
})
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
slog.Warn("auth: invalid token", "path", r.URL.Path, "error", err)
|
||||||
|
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
slog.Warn("auth: invalid claims", "path", r.URL.Path)
|
||||||
|
http.Error(w, `{"error":"invalid claims"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sub, ok := claims["sub"].(string)
|
||||||
|
if !ok || strings.TrimSpace(sub) == "" {
|
||||||
|
slog.Warn("auth: invalid claims", "path", r.URL.Path)
|
||||||
|
http.Error(w, `{"error":"invalid claims"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Header.Set("X-User-ID", sub)
|
||||||
|
if email, ok := claims["email"].(string); ok {
|
||||||
|
r.Header.Set("X-User-Email", email)
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
if err != nil || !token.Valid {
|
}
|
||||||
slog.Warn("auth: invalid token", "path", r.URL.Path, "error", err)
|
|
||||||
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
claims, ok := token.Claims.(jwt.MapClaims)
|
|
||||||
if !ok {
|
|
||||||
slog.Warn("auth: invalid claims", "path", r.URL.Path)
|
|
||||||
http.Error(w, `{"error":"invalid claims"}`, http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sub, ok := claims["sub"].(string)
|
|
||||||
if !ok || strings.TrimSpace(sub) == "" {
|
|
||||||
slog.Warn("auth: invalid claims", "path", r.URL.Path)
|
|
||||||
http.Error(w, `{"error":"invalid claims"}`, http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
r.Header.Set("X-User-ID", sub)
|
|
||||||
if email, ok := claims["email"].(string); ok {
|
|
||||||
r.Header.Set("X-User-Email", email)
|
|
||||||
}
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,13 @@ func validClaims() jwt.MapClaims {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// authMiddleware returns the Auth middleware with nil queries (JWT-only tests).
|
||||||
|
func authMiddleware(next http.Handler) http.Handler {
|
||||||
|
return Auth(nil)(next)
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuth_MissingHeader(t *testing.T) {
|
func TestAuth_MissingHeader(t *testing.T) {
|
||||||
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
t.Fatal("next handler should not be called")
|
t.Fatal("next handler should not be called")
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
@ -42,7 +47,7 @@ func TestAuth_MissingHeader(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuth_NoBearerPrefix(t *testing.T) {
|
func TestAuth_NoBearerPrefix(t *testing.T) {
|
||||||
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
t.Fatal("next handler should not be called")
|
t.Fatal("next handler should not be called")
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
@ -60,7 +65,7 @@ func TestAuth_NoBearerPrefix(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuth_InvalidToken(t *testing.T) {
|
func TestAuth_InvalidToken(t *testing.T) {
|
||||||
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
t.Fatal("next handler should not be called")
|
t.Fatal("next handler should not be called")
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
@ -75,7 +80,7 @@ func TestAuth_InvalidToken(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuth_ExpiredToken(t *testing.T) {
|
func TestAuth_ExpiredToken(t *testing.T) {
|
||||||
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
t.Fatal("next handler should not be called")
|
t.Fatal("next handler should not be called")
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
@ -94,7 +99,7 @@ func TestAuth_ExpiredToken(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuth_WrongSecret(t *testing.T) {
|
func TestAuth_WrongSecret(t *testing.T) {
|
||||||
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
t.Fatal("next handler should not be called")
|
t.Fatal("next handler should not be called")
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
@ -111,7 +116,7 @@ func TestAuth_WrongSecret(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuth_WrongSigningMethod(t *testing.T) {
|
func TestAuth_WrongSigningMethod(t *testing.T) {
|
||||||
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
t.Fatal("next handler should not be called")
|
t.Fatal("next handler should not be called")
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
@ -131,7 +136,7 @@ func TestAuth_WrongSigningMethod(t *testing.T) {
|
||||||
|
|
||||||
func TestAuth_ValidToken(t *testing.T) {
|
func TestAuth_ValidToken(t *testing.T) {
|
||||||
var gotUserID, gotEmail string
|
var gotUserID, gotEmail string
|
||||||
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
gotUserID = r.Header.Get("X-User-ID")
|
gotUserID = r.Header.Get("X-User-ID")
|
||||||
gotEmail = r.Header.Get("X-User-Email")
|
gotEmail = r.Header.Get("X-User-Email")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
@ -156,7 +161,7 @@ func TestAuth_ValidToken(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuth_MissingClaims(t *testing.T) {
|
func TestAuth_MissingClaims(t *testing.T) {
|
||||||
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
t.Fatal("next handler should not be called")
|
t.Fatal("next handler should not be called")
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
@ -175,3 +180,18 @@ func TestAuth_MissingClaims(t *testing.T) {
|
||||||
t.Fatalf("expected 401, got %d", w.Code)
|
t.Fatalf("expected 401, got %d", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuth_InvalidPAT(t *testing.T) {
|
||||||
|
handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatal("next handler should not be called")
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/me", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer mul_invalid_token_here")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected 401, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
54
server/internal/service/email.go
Normal file
54
server/internal/service/email.go
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/resend/resend-go/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EmailService struct {
|
||||||
|
client *resend.Client
|
||||||
|
fromEmail string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEmailService() *EmailService {
|
||||||
|
apiKey := os.Getenv("RESEND_API_KEY")
|
||||||
|
from := os.Getenv("RESEND_FROM_EMAIL")
|
||||||
|
if from == "" {
|
||||||
|
from = "noreply@multica.ai"
|
||||||
|
}
|
||||||
|
|
||||||
|
var client *resend.Client
|
||||||
|
if apiKey != "" {
|
||||||
|
client = resend.NewClient(apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &EmailService{
|
||||||
|
client: client,
|
||||||
|
fromEmail: from,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EmailService) SendVerificationCode(to, code string) error {
|
||||||
|
if s.client == nil {
|
||||||
|
fmt.Printf("[DEV] Verification code for %s: %s\n", to, code)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
params := &resend.SendEmailRequest{
|
||||||
|
From: s.fromEmail,
|
||||||
|
To: []string{to},
|
||||||
|
Subject: "Your Multica verification code",
|
||||||
|
Html: fmt.Sprintf(
|
||||||
|
`<div style="font-family: sans-serif; max-width: 400px; margin: 0 auto;">
|
||||||
|
<h2>Your verification code</h2>
|
||||||
|
<p style="font-size: 32px; font-weight: bold; letter-spacing: 8px; margin: 24px 0;">%s</p>
|
||||||
|
<p>This code expires in 10 minutes.</p>
|
||||||
|
<p style="color: #666; font-size: 14px;">If you didn't request this code, you can safely ignore this email.</p>
|
||||||
|
</div>`, code),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.client.Emails.Send(params)
|
||||||
|
return err
|
||||||
|
}
|
||||||
1
server/migrations/009_verification_code.down.sql
Normal file
1
server/migrations/009_verification_code.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS verification_code;
|
||||||
10
server/migrations/009_verification_code.up.sql
Normal file
10
server/migrations/009_verification_code.up.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
CREATE TABLE verification_code (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
used BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_verification_code_email ON verification_code(email, used, expires_at);
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE verification_code DROP COLUMN attempts;
|
||||||
1
server/migrations/010_verification_code_attempts.up.sql
Normal file
1
server/migrations/010_verification_code_attempts.up.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE verification_code ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0;
|
||||||
1
server/migrations/011_personal_access_tokens.down.sql
Normal file
1
server/migrations/011_personal_access_tokens.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS personal_access_token;
|
||||||
14
server/migrations/011_personal_access_tokens.up.sql
Normal file
14
server/migrations/011_personal_access_tokens.up.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
CREATE TABLE personal_access_token (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
token_prefix TEXT NOT NULL,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
revoked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_pat_user ON personal_access_token(user_id, revoked);
|
||||||
|
CREATE UNIQUE INDEX idx_pat_token_hash ON personal_access_token(token_hash);
|
||||||
|
|
@ -179,6 +179,18 @@ type Member struct {
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PersonalAccessToken struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
UserID pgtype.UUID `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
TokenHash string `json:"token_hash"`
|
||||||
|
TokenPrefix string `json:"token_prefix"`
|
||||||
|
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||||
|
LastUsedAt pgtype.Timestamptz `json:"last_used_at"`
|
||||||
|
Revoked bool `json:"revoked"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type Skill struct {
|
type Skill struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
|
@ -209,6 +221,16 @@ type User struct {
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VerificationCode struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||||
|
Used bool `json:"used"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
Attempts int32 `json:"attempts"`
|
||||||
|
}
|
||||||
|
|
||||||
type Workspace struct {
|
type Workspace struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|
|
||||||
137
server/pkg/db/generated/personal_access_token.sql.go
Normal file
137
server/pkg/db/generated/personal_access_token.sql.go
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.30.0
|
||||||
|
// source: personal_access_token.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createPersonalAccessToken = `-- name: CreatePersonalAccessToken :one
|
||||||
|
INSERT INTO personal_access_token (user_id, name, token_hash, token_prefix, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id, user_id, name, token_hash, token_prefix, expires_at, last_used_at, revoked, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreatePersonalAccessTokenParams struct {
|
||||||
|
UserID pgtype.UUID `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
TokenHash string `json:"token_hash"`
|
||||||
|
TokenPrefix string `json:"token_prefix"`
|
||||||
|
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreatePersonalAccessToken(ctx context.Context, arg CreatePersonalAccessTokenParams) (PersonalAccessToken, error) {
|
||||||
|
row := q.db.QueryRow(ctx, createPersonalAccessToken,
|
||||||
|
arg.UserID,
|
||||||
|
arg.Name,
|
||||||
|
arg.TokenHash,
|
||||||
|
arg.TokenPrefix,
|
||||||
|
arg.ExpiresAt,
|
||||||
|
)
|
||||||
|
var i PersonalAccessToken
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.Name,
|
||||||
|
&i.TokenHash,
|
||||||
|
&i.TokenPrefix,
|
||||||
|
&i.ExpiresAt,
|
||||||
|
&i.LastUsedAt,
|
||||||
|
&i.Revoked,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPersonalAccessTokenByHash = `-- name: GetPersonalAccessTokenByHash :one
|
||||||
|
SELECT id, user_id, name, token_hash, token_prefix, expires_at, last_used_at, revoked, created_at FROM personal_access_token
|
||||||
|
WHERE token_hash = $1
|
||||||
|
AND revoked = FALSE
|
||||||
|
AND (expires_at IS NULL OR expires_at > now())
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetPersonalAccessTokenByHash(ctx context.Context, tokenHash string) (PersonalAccessToken, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getPersonalAccessTokenByHash, tokenHash)
|
||||||
|
var i PersonalAccessToken
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.Name,
|
||||||
|
&i.TokenHash,
|
||||||
|
&i.TokenPrefix,
|
||||||
|
&i.ExpiresAt,
|
||||||
|
&i.LastUsedAt,
|
||||||
|
&i.Revoked,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listPersonalAccessTokensByUser = `-- name: ListPersonalAccessTokensByUser :many
|
||||||
|
SELECT id, user_id, name, token_hash, token_prefix, expires_at, last_used_at, revoked, created_at FROM personal_access_token
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND revoked = FALSE
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListPersonalAccessTokensByUser(ctx context.Context, userID pgtype.UUID) ([]PersonalAccessToken, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listPersonalAccessTokensByUser, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []PersonalAccessToken{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i PersonalAccessToken
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.Name,
|
||||||
|
&i.TokenHash,
|
||||||
|
&i.TokenPrefix,
|
||||||
|
&i.ExpiresAt,
|
||||||
|
&i.LastUsedAt,
|
||||||
|
&i.Revoked,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const revokePersonalAccessToken = `-- name: RevokePersonalAccessToken :exec
|
||||||
|
UPDATE personal_access_token
|
||||||
|
SET revoked = TRUE
|
||||||
|
WHERE id = $1 AND user_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type RevokePersonalAccessTokenParams struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
UserID pgtype.UUID `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) RevokePersonalAccessToken(ctx context.Context, arg RevokePersonalAccessTokenParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, revokePersonalAccessToken, arg.ID, arg.UserID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePersonalAccessTokenLastUsed = `-- name: UpdatePersonalAccessTokenLastUsed :exec
|
||||||
|
UPDATE personal_access_token
|
||||||
|
SET last_used_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) UpdatePersonalAccessTokenLastUsed(ctx context.Context, id pgtype.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, updatePersonalAccessTokenLastUsed, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
118
server/pkg/db/generated/verification_code.sql.go
Normal file
118
server/pkg/db/generated/verification_code.sql.go
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.30.0
|
||||||
|
// source: verification_code.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createVerificationCode = `-- name: CreateVerificationCode :one
|
||||||
|
INSERT INTO verification_code (email, code, expires_at)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, email, code, expires_at, used, created_at, attempts
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateVerificationCodeParams struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateVerificationCode(ctx context.Context, arg CreateVerificationCodeParams) (VerificationCode, error) {
|
||||||
|
row := q.db.QueryRow(ctx, createVerificationCode, arg.Email, arg.Code, arg.ExpiresAt)
|
||||||
|
var i VerificationCode
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Email,
|
||||||
|
&i.Code,
|
||||||
|
&i.ExpiresAt,
|
||||||
|
&i.Used,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.Attempts,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteExpiredVerificationCodes = `-- name: DeleteExpiredVerificationCodes :exec
|
||||||
|
DELETE FROM verification_code
|
||||||
|
WHERE expires_at < now() - interval '1 hour'
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteExpiredVerificationCodes(ctx context.Context) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteExpiredVerificationCodes)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLatestCodeByEmail = `-- name: GetLatestCodeByEmail :one
|
||||||
|
SELECT id, email, code, expires_at, used, created_at, attempts FROM verification_code
|
||||||
|
WHERE email = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetLatestCodeByEmail(ctx context.Context, email string) (VerificationCode, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getLatestCodeByEmail, email)
|
||||||
|
var i VerificationCode
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Email,
|
||||||
|
&i.Code,
|
||||||
|
&i.ExpiresAt,
|
||||||
|
&i.Used,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.Attempts,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLatestVerificationCode = `-- name: GetLatestVerificationCode :one
|
||||||
|
SELECT id, email, code, expires_at, used, created_at, attempts FROM verification_code
|
||||||
|
WHERE email = $1
|
||||||
|
AND used = FALSE
|
||||||
|
AND expires_at > now()
|
||||||
|
AND attempts < 5
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetLatestVerificationCode(ctx context.Context, email string) (VerificationCode, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getLatestVerificationCode, email)
|
||||||
|
var i VerificationCode
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Email,
|
||||||
|
&i.Code,
|
||||||
|
&i.ExpiresAt,
|
||||||
|
&i.Used,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.Attempts,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const incrementVerificationCodeAttempts = `-- name: IncrementVerificationCodeAttempts :exec
|
||||||
|
UPDATE verification_code
|
||||||
|
SET attempts = attempts + 1
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) IncrementVerificationCodeAttempts(ctx context.Context, id pgtype.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, incrementVerificationCodeAttempts, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const markVerificationCodeUsed = `-- name: MarkVerificationCodeUsed :exec
|
||||||
|
UPDATE verification_code
|
||||||
|
SET used = TRUE
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) MarkVerificationCodeUsed(ctx context.Context, id pgtype.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, markVerificationCodeUsed, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
26
server/pkg/db/queries/personal_access_token.sql
Normal file
26
server/pkg/db/queries/personal_access_token.sql
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
-- name: CreatePersonalAccessToken :one
|
||||||
|
INSERT INTO personal_access_token (user_id, name, token_hash, token_prefix, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetPersonalAccessTokenByHash :one
|
||||||
|
SELECT * FROM personal_access_token
|
||||||
|
WHERE token_hash = $1
|
||||||
|
AND revoked = FALSE
|
||||||
|
AND (expires_at IS NULL OR expires_at > now());
|
||||||
|
|
||||||
|
-- name: ListPersonalAccessTokensByUser :many
|
||||||
|
SELECT * FROM personal_access_token
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND revoked = FALSE
|
||||||
|
ORDER BY created_at DESC;
|
||||||
|
|
||||||
|
-- name: RevokePersonalAccessToken :exec
|
||||||
|
UPDATE personal_access_token
|
||||||
|
SET revoked = TRUE
|
||||||
|
WHERE id = $1 AND user_id = $2;
|
||||||
|
|
||||||
|
-- name: UpdatePersonalAccessTokenLastUsed :exec
|
||||||
|
UPDATE personal_access_token
|
||||||
|
SET last_used_at = now()
|
||||||
|
WHERE id = $1;
|
||||||
33
server/pkg/db/queries/verification_code.sql
Normal file
33
server/pkg/db/queries/verification_code.sql
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
-- name: CreateVerificationCode :one
|
||||||
|
INSERT INTO verification_code (email, code, expires_at)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetLatestVerificationCode :one
|
||||||
|
SELECT * FROM verification_code
|
||||||
|
WHERE email = $1
|
||||||
|
AND used = FALSE
|
||||||
|
AND expires_at > now()
|
||||||
|
AND attempts < 5
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- name: MarkVerificationCodeUsed :exec
|
||||||
|
UPDATE verification_code
|
||||||
|
SET used = TRUE
|
||||||
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: IncrementVerificationCodeAttempts :exec
|
||||||
|
UPDATE verification_code
|
||||||
|
SET attempts = attempts + 1
|
||||||
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: GetLatestCodeByEmail :one
|
||||||
|
SELECT * FROM verification_code
|
||||||
|
WHERE email = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- name: DeleteExpiredVerificationCodes :exec
|
||||||
|
DELETE FROM verification_code
|
||||||
|
WHERE expires_at < now() - interval '1 hour';
|
||||||
Loading…
Add table
Add a link
Reference in a new issue