Auth flow refactor, project requests, and routing updates
- Add server-side auth token loader (auth.server.ts) with SSR cookie forwarding - Add project-requests client for explicit/signal/existing issue dispatch - Refactor auth layout and app layout for SSR auth gating - Update auth-client and auth-provider for convex token flow - Extend project-issue primitives with request result schema - Wire projectIssues backend mutation for request handling - Update slice-one page and project-workspace hook - Adjust routes (remove unused), Caddy config, and env templates
This commit is contained in:
@@ -6,8 +6,8 @@ SITE_URL=http://localhost:5173
|
|||||||
NATIVE_APP_URL=code://
|
NATIVE_APP_URL=code://
|
||||||
|
|
||||||
# Browser and native public endpoints
|
# Browser and native public endpoints
|
||||||
|
VITE_AUTH_URL=http://localhost:5173
|
||||||
VITE_CONVEX_URL=https://example.convex.cloud
|
VITE_CONVEX_URL=https://example.convex.cloud
|
||||||
VITE_CONVEX_SITE_URL=https://example.convex.site
|
|
||||||
# For phone testing, replace localhost with this machine's Tailscale IPv4 address.
|
# For phone testing, replace localhost with this machine's Tailscale IPv4 address.
|
||||||
VITE_FLUE_URL=http://localhost:3583
|
VITE_FLUE_URL=http://localhost:3583
|
||||||
EXPO_PUBLIC_CONVEX_URL=https://example.convex.cloud
|
EXPO_PUBLIC_CONVEX_URL=https://example.convex.cloud
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Config } from "@react-router/dev/config";
|
import type { Config } from "@react-router/dev/config";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
// Desktop addons package static web assets; SSR output cannot be bundled
|
|
||||||
ssr: false,
|
|
||||||
appDirectory: "src",
|
appDirectory: "src",
|
||||||
|
|
||||||
|
ssr: true,
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|||||||
@@ -90,6 +90,30 @@ const WorkCard = ({ onSourceSelect, work }: WorkCardProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ConversationLoading = () => (
|
||||||
|
<output
|
||||||
|
aria-label="Loading conversation"
|
||||||
|
className="mx-auto flex min-h-[55vh] w-full max-w-md flex-col justify-center gap-4"
|
||||||
|
>
|
||||||
|
<span className="h-16 w-4/5 animate-pulse rounded-sm bg-[#e3e0d5]" />
|
||||||
|
<span className="ml-auto h-12 w-3/5 animate-pulse rounded-sm bg-[#dedbd0]" />
|
||||||
|
<span className="h-24 w-full animate-pulse rounded-sm bg-[#e3e0d5]" />
|
||||||
|
<span className="sr-only">Loading conversation…</span>
|
||||||
|
</output>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ConversationEmptyState = () => (
|
||||||
|
<div className="grid min-h-[55vh] place-items-center text-center">
|
||||||
|
<div>
|
||||||
|
<MessageSquareText className="mx-auto size-7 text-[#8a887f]" />
|
||||||
|
<h1 className="mt-4 text-xl font-semibold">What should move forward?</h1>
|
||||||
|
<p className="mx-auto mt-2 max-w-sm text-sm leading-6 text-[#69675f]">
|
||||||
|
Describe an outcome or problem. Casual conversation stays conversation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
export const SliceOnePage = () => {
|
export const SliceOnePage = () => {
|
||||||
const slice = useSliceOne();
|
const slice = useSliceOne();
|
||||||
const viewportStyle = useVisualViewportStyle();
|
const viewportStyle = useVisualViewportStyle();
|
||||||
@@ -233,19 +257,11 @@ export const SliceOnePage = () => {
|
|||||||
</header>
|
</header>
|
||||||
<Conversation className="min-h-0 flex-1">
|
<Conversation className="min-h-0 flex-1">
|
||||||
<ConversationContent className="mx-auto min-h-full w-full max-w-2xl gap-4 px-4 py-5 sm:px-6">
|
<ConversationContent className="mx-auto min-h-full w-full max-w-2xl gap-4 px-4 py-5 sm:px-6">
|
||||||
|
{!slice.agent.historyReady && timeline.length === 0 ? (
|
||||||
|
<ConversationLoading />
|
||||||
|
) : null}
|
||||||
{slice.agent.historyReady && timeline.length === 0 ? (
|
{slice.agent.historyReady && timeline.length === 0 ? (
|
||||||
<div className="grid min-h-[55vh] place-items-center text-center">
|
<ConversationEmptyState />
|
||||||
<div>
|
|
||||||
<MessageSquareText className="mx-auto size-7 text-[#8a887f]" />
|
|
||||||
<h1 className="mt-4 text-xl font-semibold">
|
|
||||||
What should move forward?
|
|
||||||
</h1>
|
|
||||||
<p className="mx-auto mt-2 max-w-sm text-sm leading-6 text-[#69675f]">
|
|
||||||
Describe an outcome or problem. Casual conversation stays
|
|
||||||
conversation.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
{timeline.map((item) => {
|
{timeline.map((item) => {
|
||||||
if (item.kind === "work") {
|
if (item.kind === "work") {
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
|
import { useConvexAccessToken } from "@code/auth/web";
|
||||||
import { api } from "@code/backend/convex/_generated/api";
|
import { api } from "@code/backend/convex/_generated/api";
|
||||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||||
import { useFlueClient } from "@flue/react";
|
import { useAction, useQuery } from "convex/react";
|
||||||
import { useAction, useMutation, useQuery } from "convex/react";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildProjectLoopView,
|
buildProjectLoopView,
|
||||||
summarizeProjectIssues,
|
summarizeProjectIssues,
|
||||||
} from "@/lib/projects/project-evidence";
|
} from "@/lib/projects/project-evidence";
|
||||||
|
import { requestProjectIssue } from "@/lib/projects/project-requests";
|
||||||
|
|
||||||
const errorMessage = (error: unknown) =>
|
const errorMessage = (error: unknown) =>
|
||||||
error instanceof Error ? error.message : String(error);
|
error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
export const useProjectWorkspace = () => {
|
export const useProjectWorkspace = () => {
|
||||||
const flueClient = useFlueClient();
|
const resolveAccessToken = useConvexAccessToken();
|
||||||
const projects = useQuery(api.projects.list);
|
const projects = useQuery(api.projects.list);
|
||||||
const [selectedProjectId, setSelectedProjectId] =
|
const [selectedProjectId, setSelectedProjectId] =
|
||||||
useState<Id<"projects"> | null>(null);
|
useState<Id<"projects"> | null>(null);
|
||||||
@@ -25,10 +26,7 @@ export const useProjectWorkspace = () => {
|
|||||||
const [pendingAction, setPendingAction] = useState<string | null>(null);
|
const [pendingAction, setPendingAction] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const importPublicGit = useAction(api.projects.importPublicGit);
|
const importPublicGit = useAction(api.projects.importPublicGit);
|
||||||
const createIssue = useMutation(api.projectIssues.create);
|
|
||||||
const createIssueFromSignal = useMutation(api.projectIssues.createFromSignal);
|
|
||||||
const beginIssue = useMutation(api.projectIssues.begin);
|
|
||||||
const markDispatchFailed = useMutation(api.projectIssues.markDispatchFailed);
|
|
||||||
const activeProjectId =
|
const activeProjectId =
|
||||||
selectedProjectId ??
|
selectedProjectId ??
|
||||||
(projects?.[0]?.id as unknown as Id<"projects">) ??
|
(projects?.[0]?.id as unknown as Id<"projects">) ??
|
||||||
@@ -92,12 +90,20 @@ export const useProjectWorkspace = () => {
|
|||||||
setPendingAction("issue");
|
setPendingAction("issue");
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const issueId = await createIssue({
|
const accessToken = await resolveAccessToken();
|
||||||
body: nextBody,
|
if (!accessToken) {
|
||||||
projectId: activeProjectId,
|
throw new Error("Authentication required");
|
||||||
title: nextTitle,
|
}
|
||||||
|
const outcome = await requestProjectIssue({
|
||||||
|
accessToken,
|
||||||
|
request: {
|
||||||
|
body: nextBody,
|
||||||
|
kind: "explicit",
|
||||||
|
projectId: activeProjectId,
|
||||||
|
title: nextTitle,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
setSelectedIssueId(issueId);
|
setSelectedIssueId(outcome.issueId as Id<"projectIssues">);
|
||||||
setIssueTitle("");
|
setIssueTitle("");
|
||||||
setIssueBody("");
|
setIssueBody("");
|
||||||
} catch (caughtError) {
|
} catch (caughtError) {
|
||||||
@@ -111,8 +117,15 @@ export const useProjectWorkspace = () => {
|
|||||||
setPendingAction(`signal:${signalId}`);
|
setPendingAction(`signal:${signalId}`);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const outcome = await createIssueFromSignal({ signalId });
|
const accessToken = await resolveAccessToken();
|
||||||
setSelectedIssueId(outcome.issueId);
|
if (!accessToken) {
|
||||||
|
throw new Error("Authentication required");
|
||||||
|
}
|
||||||
|
const outcome = await requestProjectIssue({
|
||||||
|
accessToken,
|
||||||
|
request: { kind: "signal", signalId },
|
||||||
|
});
|
||||||
|
setSelectedIssueId(outcome.issueId as Id<"projectIssues">);
|
||||||
} catch (caughtError) {
|
} catch (caughtError) {
|
||||||
setError(errorMessage(caughtError));
|
setError(errorMessage(caughtError));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -120,23 +133,21 @@ export const useProjectWorkspace = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const startIssue = async (
|
const startIssue = async (issueId: Id<"projectIssues">) => {
|
||||||
issueId: Id<"projectIssues">,
|
|
||||||
issueNumber: number,
|
|
||||||
title: string
|
|
||||||
) => {
|
|
||||||
const actionKey = `issue:${issueId}`;
|
const actionKey = `issue:${issueId}`;
|
||||||
setPendingAction(actionKey);
|
setPendingAction(actionKey);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await beginIssue({ issueId });
|
const accessToken = await resolveAccessToken();
|
||||||
await flueClient.agents.send("project-manager", String(issueId), {
|
if (!accessToken) {
|
||||||
message: `Start project issue ${issueNumber}: ${title}. Read the bound context and complete the workflow.`,
|
throw new Error("Authentication required");
|
||||||
|
}
|
||||||
|
await requestProjectIssue({
|
||||||
|
accessToken,
|
||||||
|
request: { issueId, kind: "existing" },
|
||||||
});
|
});
|
||||||
} catch (caughtError) {
|
} catch (caughtError) {
|
||||||
const message = errorMessage(caughtError);
|
setError(errorMessage(caughtError));
|
||||||
await markDispatchFailed({ error: message, issueId });
|
|
||||||
setError(message);
|
|
||||||
} finally {
|
} finally {
|
||||||
setPendingAction(null);
|
setPendingAction(null);
|
||||||
}
|
}
|
||||||
@@ -148,7 +159,7 @@ export const useProjectWorkspace = () => {
|
|||||||
setError("That work unit is no longer available");
|
setError("That work unit is no longer available");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await startIssue(issue._id, issue.number, issue.title);
|
await startIssue(issue._id);
|
||||||
};
|
};
|
||||||
const selectedProject =
|
const selectedProject =
|
||||||
projects?.find(
|
projects?.find(
|
||||||
|
|||||||
@@ -75,6 +75,19 @@ html.slice-one-viewport-lock body {
|
|||||||
min-height: 1.75rem;
|
min-height: 1.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.route-progress-bar {
|
||||||
|
animation: route-progress 900ms ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes route-progress {
|
||||||
|
from {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(400%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes response-dot {
|
@keyframes response-dot {
|
||||||
0%,
|
0%,
|
||||||
60%,
|
60%,
|
||||||
|
|||||||
64
apps/web/src/lib/auth.server.ts
Normal file
64
apps/web/src/lib/auth.server.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { env } from "@code/env/web";
|
||||||
|
import { redirect } from "react-router";
|
||||||
|
|
||||||
|
interface AuthLoaderData {
|
||||||
|
readonly token: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenUrl = new URL("/api/auth/convex/token", env.VITE_AUTH_URL);
|
||||||
|
|
||||||
|
export const loadAuthToken = async (
|
||||||
|
request: Request
|
||||||
|
): Promise<AuthLoaderData> => {
|
||||||
|
const cookie = request.headers.get("cookie");
|
||||||
|
if (!cookie) {
|
||||||
|
return { token: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = new Headers({ cookie });
|
||||||
|
headers.set("host", tokenUrl.host);
|
||||||
|
const response = await fetch(tokenUrl, { headers });
|
||||||
|
if (response.status === 401) {
|
||||||
|
return { token: null };
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Response("Authentication service unavailable", { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: unknown = await response.json();
|
||||||
|
if (
|
||||||
|
typeof payload !== "object" ||
|
||||||
|
payload === null ||
|
||||||
|
!("token" in payload) ||
|
||||||
|
typeof payload.token !== "string"
|
||||||
|
) {
|
||||||
|
throw new Response("Authentication service returned an invalid response", {
|
||||||
|
status: 502,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { token: payload.token };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requireAuthToken = async (
|
||||||
|
request: Request
|
||||||
|
): Promise<AuthLoaderData> => {
|
||||||
|
const auth = await loadAuthToken(request);
|
||||||
|
if (!auth.token) {
|
||||||
|
const requestUrl = new URL(request.url);
|
||||||
|
const returnTo = `${requestUrl.pathname}${requestUrl.search}`;
|
||||||
|
throw redirect(`/login?returnTo=${encodeURIComponent(returnTo)}`);
|
||||||
|
}
|
||||||
|
return auth;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const redirectAuthenticated = async (
|
||||||
|
request: Request
|
||||||
|
): Promise<AuthLoaderData> => {
|
||||||
|
const auth = await loadAuthToken(request);
|
||||||
|
if (auth.token) {
|
||||||
|
const requestUrl = new URL(request.url);
|
||||||
|
const returnTo = requestUrl.searchParams.get("returnTo");
|
||||||
|
throw redirect(returnTo?.startsWith("/") ? returnTo : "/");
|
||||||
|
}
|
||||||
|
return auth;
|
||||||
|
};
|
||||||
62
apps/web/src/lib/projects/project-requests.ts
Normal file
62
apps/web/src/lib/projects/project-requests.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||||
|
import { env } from "@code/env/web";
|
||||||
|
import { ProjectIssueRequestResult } from '@code/primitives/project-issue';
|
||||||
|
import type { ProjectIssueRequestResult as ProjectIssueRequestResultValue } from '@code/primitives/project-issue';
|
||||||
|
import { Schema } from "effect";
|
||||||
|
|
||||||
|
const projectRequestsUrl = new URL(
|
||||||
|
"project-requests",
|
||||||
|
`${env.VITE_FLUE_URL.replace(/\/+$/u, "")}/`
|
||||||
|
);
|
||||||
|
|
||||||
|
interface RequestProjectIssueOptions {
|
||||||
|
readonly accessToken: string;
|
||||||
|
readonly request:
|
||||||
|
| {
|
||||||
|
readonly issueId: Id<"projectIssues">;
|
||||||
|
readonly kind: "existing";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly body: string;
|
||||||
|
readonly kind: "explicit";
|
||||||
|
readonly projectId: Id<"projects">;
|
||||||
|
readonly title: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly kind: "signal";
|
||||||
|
readonly signalId: Id<"signals">;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseError = async (response: Response): Promise<Error> => {
|
||||||
|
const payload: unknown = await response.json().catch(() => null);
|
||||||
|
if (
|
||||||
|
typeof payload === "object" &&
|
||||||
|
payload !== null &&
|
||||||
|
"error" in payload &&
|
||||||
|
typeof payload.error === "string"
|
||||||
|
) {
|
||||||
|
return new Error(payload.error);
|
||||||
|
}
|
||||||
|
return new Error(`Project request failed (${response.status})`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requestProjectIssue = async ({
|
||||||
|
accessToken,
|
||||||
|
request,
|
||||||
|
}: RequestProjectIssueOptions): Promise<ProjectIssueRequestResultValue> => {
|
||||||
|
const response = await fetch(projectRequestsUrl, {
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${accessToken}`,
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await responseError(response);
|
||||||
|
}
|
||||||
|
return Schema.decodeUnknownSync(ProjectIssueRequestResult)(
|
||||||
|
await response.json()
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,9 +2,10 @@ import { useConvexAccessToken, WebAuthProvider } from "@code/auth/web";
|
|||||||
import { env } from "@code/env/web";
|
import { env } from "@code/env/web";
|
||||||
import { Toaster } from "@code/ui/components/sonner";
|
import { Toaster } from "@code/ui/components/sonner";
|
||||||
import { FlueProvider } from "@flue/react";
|
import { FlueProvider } from "@flue/react";
|
||||||
|
import { createFlueClient } from "@flue/sdk";
|
||||||
|
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import { createFlueClient } from "@flue/sdk";
|
import { LoaderCircle } from "lucide-react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
isRouteErrorResponse,
|
isRouteErrorResponse,
|
||||||
@@ -13,12 +14,16 @@ import {
|
|||||||
Outlet,
|
Outlet,
|
||||||
Scripts,
|
Scripts,
|
||||||
ScrollRestoration,
|
ScrollRestoration,
|
||||||
|
useNavigation,
|
||||||
} from "react-router";
|
} from "react-router";
|
||||||
|
|
||||||
import type { Route } from "./+types/root";
|
import type { Route } from "./+types/root";
|
||||||
import { ThemeProvider } from "./components/theme-provider";
|
import { ThemeProvider } from "./components/theme-provider";
|
||||||
|
import { loadAuthToken } from "./lib/auth.server";
|
||||||
import { createFlueFetch } from "./lib/flue-transport";
|
import { createFlueFetch } from "./lib/flue-transport";
|
||||||
|
|
||||||
|
export const loader = ({ request }: Route.LoaderArgs) => loadAuthToken(request);
|
||||||
|
|
||||||
export const links: Route.LinksFunction = () => [
|
export const links: Route.LinksFunction = () => [
|
||||||
{ href: "https://fonts.googleapis.com", rel: "preconnect" },
|
{ href: "https://fonts.googleapis.com", rel: "preconnect" },
|
||||||
{
|
{
|
||||||
@@ -79,8 +84,31 @@ export const Layout = ({ children }: { children: React.ReactNode }) => (
|
|||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
||||||
const App = () => (
|
const RouteProgress = () => {
|
||||||
<WebAuthProvider>
|
const navigation = useNavigation();
|
||||||
|
if (!navigation.location) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<output
|
||||||
|
aria-label="Loading page"
|
||||||
|
className="fixed inset-x-0 top-0 z-50 h-0.5 overflow-hidden bg-[#d7d3c7]"
|
||||||
|
>
|
||||||
|
<div className="route-progress-bar h-full w-1/3 bg-[#7f9130]" />
|
||||||
|
</output>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HydrateFallback = () => (
|
||||||
|
<main className="grid min-h-svh place-items-center bg-[#f2f0e7] text-[#69675f]">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<LoaderCircle className="size-4 animate-spin" /> Loading Zopu…
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
|
||||||
|
const App = ({ loaderData }: Route.ComponentProps) => (
|
||||||
|
<WebAuthProvider initialToken={loaderData.token}>
|
||||||
<AuthenticatedFlueProvider>
|
<AuthenticatedFlueProvider>
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
attribute="class"
|
attribute="class"
|
||||||
@@ -89,6 +117,7 @@ const App = () => (
|
|||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
storageKey="vite-ui-theme"
|
storageKey="vite-ui-theme"
|
||||||
>
|
>
|
||||||
|
<RouteProgress />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<Toaster richColors />
|
<Toaster richColors />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { index, layout, route } from "@react-router/dev/routes";
|
|||||||
import type { RouteConfig } from "@react-router/dev/routes";
|
import type { RouteConfig } from "@react-router/dev/routes";
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
// Standalone chat page — no auth, mobile-first, talks to the zopu server.
|
|
||||||
route("chat", "./routes/standalone/chat/page.tsx"),
|
|
||||||
layout("./routes/auth/layout.tsx", [
|
layout("./routes/auth/layout.tsx", [
|
||||||
route("login", "./routes/auth/login/page.tsx"),
|
route("login", "./routes/auth/login/page.tsx"),
|
||||||
route("signup", "./routes/auth/signup/page.tsx"),
|
route("signup", "./routes/auth/signup/page.tsx"),
|
||||||
|
|||||||
@@ -1,29 +1,12 @@
|
|||||||
import { useWebAuth } from "@code/auth/web";
|
import { Outlet } from "react-router";
|
||||||
import { Navigate, Outlet, useLocation } from "react-router";
|
|
||||||
|
import { requireAuthToken } from "@/lib/auth.server";
|
||||||
|
|
||||||
|
import type { Route } from "./+types/layout";
|
||||||
|
|
||||||
|
export const loader = ({ request }: Route.LoaderArgs) =>
|
||||||
|
requireAuthToken(request);
|
||||||
|
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
const auth = useWebAuth();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
if (auth.status === "loading") {
|
|
||||||
return (
|
|
||||||
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
|
|
||||||
Loading…
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (auth.status === "inconsistent") {
|
|
||||||
return (
|
|
||||||
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
|
|
||||||
Your session could not be verified
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (auth.status === "unauthenticated") {
|
|
||||||
return <Navigate replace state={{ from: location.pathname }} to="/login" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,13 @@
|
|||||||
import { useWebAuth } from "@code/auth/web";
|
import { Outlet } from "react-router";
|
||||||
import { Navigate, Outlet } from "react-router";
|
|
||||||
|
import { redirectAuthenticated } from "@/lib/auth.server";
|
||||||
|
|
||||||
|
import type { Route } from "./+types/layout";
|
||||||
|
|
||||||
|
export const loader = ({ request }: Route.LoaderArgs) =>
|
||||||
|
redirectAuthenticated(request);
|
||||||
|
|
||||||
export default function AuthLayout() {
|
export default function AuthLayout() {
|
||||||
const auth = useWebAuth();
|
|
||||||
|
|
||||||
if (auth.status === "loading") {
|
|
||||||
return (
|
|
||||||
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
|
|
||||||
Loading…
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (auth.status === "authenticated") {
|
|
||||||
return <Navigate replace to="/" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="grid min-h-svh place-items-center bg-muted/30 p-6 md:p-10">
|
<main className="grid min-h-svh place-items-center bg-muted/30 p-6 md:p-10">
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-sm">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { LoginForm } from "@code/auth/web";
|
import { LoginForm } from "@code/auth/web";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate, useSearchParams } from "react-router";
|
||||||
|
|
||||||
import type { Route } from "./+types/page";
|
import type { Route } from "./+types/page";
|
||||||
|
|
||||||
@@ -10,6 +10,14 @@ export const meta = (_args: Route.MetaArgs) => [
|
|||||||
|
|
||||||
export default function LoginRoute() {
|
export default function LoginRoute() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const returnTo = searchParams.get("returnTo");
|
||||||
|
|
||||||
return <LoginForm onSuccess={() => navigate("/", { replace: true })} />;
|
return (
|
||||||
|
<LoginForm
|
||||||
|
onSuccess={() =>
|
||||||
|
navigate(returnTo?.startsWith("/") ? returnTo : "/", { replace: true })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,11 @@
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
CONVEX_URL=https://your-deployment.convex.cloud
|
CONVEX_URL=https://your-deployment.convex.cloud
|
||||||
CONVEX_SITE_URL=https://your-deployment.convex.site
|
CONVEX_SITE_URL=https://your-deployment.convex.site
|
||||||
SITE_URL=http://localhost:5173
|
SITE_URL=http://localhost:13100
|
||||||
|
VITE_AUTH_URL=http://localhost:13100
|
||||||
VITE_CONVEX_URL=https://your-deployment.convex.cloud
|
VITE_CONVEX_URL=https://your-deployment.convex.cloud
|
||||||
VITE_CONVEX_SITE_URL=https://your-deployment.convex.site
|
|
||||||
VITE_FLUE_URL=http://localhost:3583
|
VITE_FLUE_URL=http://localhost:3583
|
||||||
|
VITE_ZOPU_SERVER_URL=http://localhost:3590
|
||||||
|
|
||||||
# Self-hosted Convex origins used by convex/docker-compose.yml
|
# Self-hosted Convex origins used by convex/docker-compose.yml
|
||||||
CONVEX_CLOUD_ORIGIN=https://your-deployment.convex.cloud
|
CONVEX_CLOUD_ORIGIN=https://your-deployment.convex.cloud
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
# Caddyfile — Public reverse proxy for the Zopu web + API.
|
# Caddyfile — Public reverse proxy for the Zopu web + API.
|
||||||
#
|
#
|
||||||
# The web frontend (port 5173) is served at the root, and the Flue agent/API
|
# The web frontend (port 5173) is served at the root, Better Auth is proxied
|
||||||
# (port 3585) is mounted under /api so the browser talks same-origin.
|
# first-party under /api/auth, and Flue is mounted under /api/flue.
|
||||||
|
|
||||||
zopu.cheaptricks.puter.wtf {
|
zopu.cheaptricks.puter.wtf {
|
||||||
bind 135.181.82.179 2a01:4f9:c013:4a64::1
|
bind 135.181.82.179 2a01:4f9:c013:4a64::1
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
||||||
handle_path /api/* {
|
handle /api/auth/* {
|
||||||
|
reverse_proxy https://befitting-dalmatian-161.convex.site {
|
||||||
|
header_up Host befitting-dalmatian-161.convex.site
|
||||||
|
header_up X-Forwarded-Host {host}
|
||||||
|
header_up X-Forwarded-Proto {scheme}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_path /api/flue/* {
|
||||||
reverse_proxy 127.0.0.1:3585
|
reverse_proxy 127.0.0.1:3585
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { parseAgentEnv } from "@code/env/agent";
|
import { parseAgentEnv } from "@code/env/agent";
|
||||||
import { defineAgent } from "@flue/runtime";
|
import { defineAgent } from "@flue/runtime";
|
||||||
import type { AgentRouteHandler } from "@flue/runtime";
|
|
||||||
|
|
||||||
import { createFinalizeGiteaLifecycle } from "../actions/finalize-gitea-lifecycle";
|
import { createFinalizeGiteaLifecycle } from "../actions/finalize-gitea-lifecycle";
|
||||||
import { agentOs } from "../sandboxes/agent-os";
|
import { agentOs } from "../sandboxes/agent-os";
|
||||||
@@ -9,8 +8,6 @@ import { createProjectTools } from "../tools/project";
|
|||||||
export const description =
|
export const description =
|
||||||
"Works one repository issue inside an isolated AgentOS workspace.";
|
"Works one repository issue inside an isolated AgentOS workspace.";
|
||||||
|
|
||||||
export const route: AgentRouteHandler = (_context, next) => next();
|
|
||||||
|
|
||||||
export default defineAgent(({ env, id }) => {
|
export default defineAgent(({ env, id }) => {
|
||||||
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
|
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ const createIssueFromSignal = makeFunctionReference<
|
|||||||
{ readonly signalId: string },
|
{ readonly signalId: string },
|
||||||
{ readonly issueId: string; readonly projectId: string }
|
{ readonly issueId: string; readonly projectId: string }
|
||||||
>("projectIssues:createFromSignal");
|
>("projectIssues:createFromSignal");
|
||||||
|
const getIssue = makeFunctionReference<
|
||||||
|
"query",
|
||||||
|
{ readonly issueId: string },
|
||||||
|
{ readonly issueId: string; readonly projectId: string } | null
|
||||||
|
>("projectIssues:getForDispatch");
|
||||||
|
|
||||||
const beginIssue = makeFunctionReference<
|
const beginIssue = makeFunctionReference<
|
||||||
"mutation",
|
"mutation",
|
||||||
@@ -75,6 +80,14 @@ const createIssueForRequest = async (
|
|||||||
client: ConvexHttpClient,
|
client: ConvexHttpClient,
|
||||||
request: Awaited<ReturnType<typeof decodeRequest>>
|
request: Awaited<ReturnType<typeof decodeRequest>>
|
||||||
): Promise<{ readonly issueId: string; readonly projectId: string }> => {
|
): Promise<{ readonly issueId: string; readonly projectId: string }> => {
|
||||||
|
if (request.kind === "existing") {
|
||||||
|
const issue = await client.query(getIssue, { issueId: request.issueId });
|
||||||
|
if (!issue) {
|
||||||
|
throw new Error("Project issue not found");
|
||||||
|
}
|
||||||
|
return issue;
|
||||||
|
}
|
||||||
|
|
||||||
if (request.kind === "signal") {
|
if (request.kind === "signal") {
|
||||||
return client.mutation(createIssueFromSignal, {
|
return client.mutation(createIssueFromSignal, {
|
||||||
signalId: request.signalId,
|
signalId: request.signalId,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { env } from "@code/env/web";
|
import { env } from "@code/env/web";
|
||||||
import { convexClient, crossDomainClient } from "@convex-dev/better-auth/client/plugins";
|
import { convexClient } from "@convex-dev/better-auth/client/plugins";
|
||||||
import { createAuthClient } from "better-auth/react";
|
import { createAuthClient } from "better-auth/react";
|
||||||
|
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
baseURL: env.VITE_CONVEX_SITE_URL,
|
baseURL: env.VITE_AUTH_URL,
|
||||||
plugins: [convexClient(), crossDomainClient()],
|
plugins: [convexClient()],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,8 +7,18 @@ import { authClient } from "./auth-client";
|
|||||||
|
|
||||||
const convex = new ConvexReactClient(env.VITE_CONVEX_URL, { expectAuth: true });
|
const convex = new ConvexReactClient(env.VITE_CONVEX_URL, { expectAuth: true });
|
||||||
|
|
||||||
export const WebAuthProvider = ({ children }: { children: ReactNode }) => (
|
export const WebAuthProvider = ({
|
||||||
<ConvexBetterAuthProvider authClient={authClient} client={convex}>
|
children,
|
||||||
|
initialToken,
|
||||||
|
}: {
|
||||||
|
readonly children: ReactNode;
|
||||||
|
readonly initialToken?: string | null;
|
||||||
|
}) => (
|
||||||
|
<ConvexBetterAuthProvider
|
||||||
|
authClient={authClient}
|
||||||
|
client={convex}
|
||||||
|
initialToken={initialToken}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</ConvexBetterAuthProvider>
|
</ConvexBetterAuthProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { expo } from "@better-auth/expo";
|
|||||||
import { env } from "@code/env/convex";
|
import { env } from "@code/env/convex";
|
||||||
import { createClient } from "@convex-dev/better-auth";
|
import { createClient } from "@convex-dev/better-auth";
|
||||||
import type { GenericCtx } from "@convex-dev/better-auth";
|
import type { GenericCtx } from "@convex-dev/better-auth";
|
||||||
import { convex, crossDomain } from "@convex-dev/better-auth/plugins";
|
import { convex } from "@convex-dev/better-auth/plugins";
|
||||||
import { betterAuth } from "better-auth/minimal";
|
import { betterAuth } from "better-auth/minimal";
|
||||||
|
|
||||||
import { components } from "./_generated/api";
|
import { components } from "./_generated/api";
|
||||||
@@ -25,7 +25,6 @@ const createAuth = (ctx: GenericCtx<DataModel>) =>
|
|||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
expo(),
|
expo(),
|
||||||
crossDomain({ siteUrl }),
|
|
||||||
convex({
|
convex({
|
||||||
authConfig,
|
authConfig,
|
||||||
jwksRotateOnTokenGenerationError: true,
|
jwksRotateOnTokenGenerationError: true,
|
||||||
|
|||||||
@@ -118,6 +118,18 @@ export const createFromSignal = mutation({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getForDispatch = query({
|
||||||
|
args: { issueId: v.id("projectIssues") },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||||
|
if (!issue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await requireProjectMember(ctx, issue.projectId);
|
||||||
|
return { issueId: issue._id, projectId: issue.projectId };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const begin = mutation({
|
export const begin = mutation({
|
||||||
args: { issueId: v.id("projectIssues") },
|
args: { issueId: v.id("projectIssues") },
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
|
|||||||
2
packages/env/src/vite-env.d.ts
vendored
2
packages/env/src/vite-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly [key: string]: string | number | boolean | undefined;
|
readonly [key: string]: string | number | boolean | undefined;
|
||||||
readonly VITE_CONVEX_SITE_URL: string;
|
readonly VITE_AUTH_URL: string;
|
||||||
readonly VITE_FLUE_URL: string;
|
readonly VITE_FLUE_URL: string;
|
||||||
readonly VITE_CONVEX_URL: string;
|
readonly VITE_CONVEX_URL: string;
|
||||||
}
|
}
|
||||||
|
|||||||
3
packages/env/src/web.ts
vendored
3
packages/env/src/web.ts
vendored
@@ -8,10 +8,9 @@ const convexUrlSchema = (exampleHost: string) =>
|
|||||||
|
|
||||||
export const env = createEnv({
|
export const env = createEnv({
|
||||||
client: {
|
client: {
|
||||||
VITE_CONVEX_SITE_URL: convexUrlSchema("example.convex.site"),
|
VITE_AUTH_URL: z.url(),
|
||||||
VITE_CONVEX_URL: convexUrlSchema("example.convex.cloud"),
|
VITE_CONVEX_URL: convexUrlSchema("example.convex.cloud"),
|
||||||
VITE_FLUE_URL: z.url(),
|
VITE_FLUE_URL: z.url(),
|
||||||
VITE_ZOPU_SERVER_URL: z.url(),
|
|
||||||
},
|
},
|
||||||
clientPrefix: "VITE_",
|
clientPrefix: "VITE_",
|
||||||
emptyStringAsUndefined: true,
|
emptyStringAsUndefined: true,
|
||||||
|
|||||||
@@ -29,6 +29,17 @@ describe("project issue primitives", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts an existing issue request without creating a duplicate", () => {
|
||||||
|
const request = Effect.runSync(
|
||||||
|
decodeProjectIssueRequest({
|
||||||
|
issueId: "issue-1",
|
||||||
|
kind: "existing",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(request).toEqual({ issueId: "issue-1", kind: "existing" });
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects invalid issue drafts before persistence", () => {
|
it("rejects invalid issue drafts before persistence", () => {
|
||||||
const error = Effect.runSync(
|
const error = Effect.runSync(
|
||||||
Effect.flip(
|
Effect.flip(
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ export const ProjectIssueDraft = Schema.Struct({
|
|||||||
});
|
});
|
||||||
export type ProjectIssueDraft = typeof ProjectIssueDraft.Type;
|
export type ProjectIssueDraft = typeof ProjectIssueDraft.Type;
|
||||||
|
|
||||||
|
export const ExistingProjectIssueRequest = Schema.Struct({
|
||||||
|
issueId: ProjectIssueId,
|
||||||
|
kind: Schema.Literal("existing"),
|
||||||
|
});
|
||||||
|
|
||||||
export const ExplicitProjectIssueRequest = Schema.Struct({
|
export const ExplicitProjectIssueRequest = Schema.Struct({
|
||||||
body: Schema.String,
|
body: Schema.String,
|
||||||
kind: Schema.Literal("explicit"),
|
kind: Schema.Literal("explicit"),
|
||||||
@@ -48,6 +53,7 @@ export const SignalProjectIssueRequest = Schema.Struct({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const ProjectIssueRequest = Schema.Union([
|
export const ProjectIssueRequest = Schema.Union([
|
||||||
|
ExistingProjectIssueRequest,
|
||||||
ExplicitProjectIssueRequest,
|
ExplicitProjectIssueRequest,
|
||||||
SignalProjectIssueRequest,
|
SignalProjectIssueRequest,
|
||||||
]);
|
]);
|
||||||
@@ -186,7 +192,7 @@ export const decodeProjectIssueRequest = (
|
|||||||
() =>
|
() =>
|
||||||
new ProjectIssueRequestError({
|
new ProjectIssueRequestError({
|
||||||
message:
|
message:
|
||||||
"Use a signal request or an explicit request with projectId, title, and body",
|
"Use an existing issue, signal request, or explicit request with projectId, title, and body",
|
||||||
reason: "InvalidInput",
|
reason: "InvalidInput",
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user