feat(git): thin project onboarding, provider integration, and AgentOS repository access
Effect primitives: - git-provider: GitProvider, connection states, normalized errors, URL normalization, host compatibility, credential freshness window - git-provisioning: validated Puter commands, migration states, idempotency keys, safe replacement rules, owner-safe guards - git-webhook: supported events, signature verification, delivery states - host-repository: provider-neutral credential-safe clone via GIT_ASKPASS Normalized Convex schema: - gitProviderAccounts, refined gitConnections, gitProviderOrganizations, gitRepositories, gitMigrations, gitWebhookDeliveries - projects: gitRepositoryId + instructions fields - Schema fields optional for backward compatibility, with backfill cron Backend: - Connection health: verify action, hourly reconciliation (covers stale active + reauth-required + undefined-state legacy connections) - Puter provisioning: createPuterUser/Organization/Repository with owner binding, startGithubMigration (durable via scheduler), getMigration - Org ownership: explicit member add with admin role + verification - Webhook HTTP actions: HMAC verification, delivery persistence with idempotency, repository resolution, byte-length payload limit - Automatic Puter webhook creation after repo creation/migration with fail-loud state tracking - Repository sync after connection (Gitea + GitHub) - AgentOS execution resolves gitRepositoryId for real clone URL - Credential gating: state + freshness checks before execution and project creation - listForOrganization for cross-project Work filtering Frontend: - /projects onboarding page with GitHub OAuth (linkSocial) and Puter PAT - Zero-project redirect, repository selection, context editor - Provider-aware settings panel (no serverUrl for Puter) - Project selection via ?project= query param - GitHub scopes: repo + read:org Agent runtime: - Clones user repository with GIT_ASKPASS credential helper (no token in URL, args, or git config), provider-aware username - Removed fixed Zopu source path and .env copy
This commit is contained in:
465
apps/web/src/components/projects/projects-page.tsx
Normal file
465
apps/web/src/components/projects/projects-page.tsx
Normal file
@@ -0,0 +1,465 @@
|
||||
import { authClient } from "@code/auth/web";
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import { makeFunctionReference } from "convex/server";
|
||||
import {
|
||||
FileText,
|
||||
FolderGit2,
|
||||
GitBranch,
|
||||
LoaderCircle,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router";
|
||||
|
||||
const connectGithubRef = makeFunctionReference<
|
||||
"action",
|
||||
Record<string, never>,
|
||||
{ connectionId: Id<"gitConnections"> }
|
||||
>("gitConnections:connectGithub");
|
||||
const connectGiteaRef = makeFunctionReference<
|
||||
"action",
|
||||
{ token: string; username?: string },
|
||||
{ connectionId: Id<"gitConnections"> }
|
||||
>("gitConnections:connectGitea");
|
||||
const createProjectFromRepositoryRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ gitRepositoryId: Id<"gitRepositories">; name: string },
|
||||
{ projectId: Id<"projects"> }
|
||||
>("gitProvisioning:createProjectFromRepository");
|
||||
|
||||
const LoadingState = () => (
|
||||
<main className="grid min-h-svh place-items-center bg-[#f2f0e7]">
|
||||
<LoaderCircle className="size-5 animate-spin text-[#20201d]" />
|
||||
</main>
|
||||
);
|
||||
|
||||
const EmptyProjects = () => (
|
||||
<div className="mt-12 text-center">
|
||||
<span className="grid size-11 place-items-center bg-[#20201d] text-white">
|
||||
<FolderGit2 className="size-5" />
|
||||
</span>
|
||||
<h1 className="mt-6 text-2xl font-semibold text-[#20201d]">
|
||||
Connect your first project
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-[#68665e]">
|
||||
Choose a Git provider below to get started.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ProjectCard = ({
|
||||
instructions,
|
||||
name,
|
||||
projectId,
|
||||
sourceUrl,
|
||||
}: {
|
||||
instructions: string;
|
||||
name: string;
|
||||
projectId: string;
|
||||
sourceUrl: string;
|
||||
}) => (
|
||||
<Link
|
||||
className="block border border-[#d7d3c7] bg-[#fffefa] p-4 transition-colors hover:bg-[#f5f3eb]"
|
||||
to={`/?project=${projectId}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderGit2 className="size-4 text-[#747168]" />
|
||||
<h3 className="text-sm font-semibold text-[#20201d]">{name}</h3>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-[#747168]">{sourceUrl}</p>
|
||||
{instructions ? (
|
||||
<div className="mt-2 flex items-center gap-1 text-xs text-[#747168]">
|
||||
<FileText className="size-3" />
|
||||
<span className="truncate">Context configured</span>
|
||||
</div>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
|
||||
const ContextEditor = ({
|
||||
projectId,
|
||||
}: {
|
||||
readonly projectId: Id<"projects">;
|
||||
}) => {
|
||||
const instructions = useQuery(api.projects.getInstructions, {
|
||||
projectId,
|
||||
});
|
||||
const updateInstructions = useMutation(api.projects.updateInstructions);
|
||||
const [draft, setDraft] = useState<string>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const currentDraft = draft ?? instructions ?? "";
|
||||
const dirty = draft !== undefined && draft !== (instructions ?? "");
|
||||
|
||||
const save = async () => {
|
||||
if (!dirty || saving) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateInstructions({
|
||||
instructions: draft ?? "",
|
||||
projectId,
|
||||
});
|
||||
setDraft(undefined);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<label
|
||||
className="text-xs font-medium text-[#20201d]"
|
||||
htmlFor="context-textarea"
|
||||
>
|
||||
Context
|
||||
</label>
|
||||
<textarea
|
||||
className="mt-1 h-24 w-full resize-y border border-[#c9c5b9] bg-[#fffefa] p-2 text-xs outline-none focus:border-[#55564e]"
|
||||
id="context-textarea"
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
placeholder="Project-specific instructions for agents..."
|
||||
value={currentDraft}
|
||||
/>
|
||||
{dirty ? (
|
||||
<Button
|
||||
className="mt-1 h-8 text-xs"
|
||||
disabled={saving}
|
||||
onClick={save}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{saving ? <LoaderCircle className="size-3 animate-spin" /> : null}
|
||||
{saving ? "Saving" : "Save context"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PuterConnectForm = ({
|
||||
onConnected,
|
||||
}: {
|
||||
readonly onConnected: () => void;
|
||||
}) => {
|
||||
const connectGitea = useAction(connectGiteaRef);
|
||||
const [token, setToken] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!token.trim() || pending) {
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
await connectGitea({
|
||||
token: token.trim(),
|
||||
username: username.trim() || undefined,
|
||||
});
|
||||
setToken("");
|
||||
setUsername("");
|
||||
onConnected();
|
||||
} catch (caughtError) {
|
||||
setError(
|
||||
caughtError instanceof Error ? caughtError.message : String(caughtError)
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="mt-4 space-y-3" onSubmit={submit}>
|
||||
<p className="text-xs text-[#68665e]">
|
||||
Connect your Puter Git personal access token. The Puter Git instance is
|
||||
at <code className="text-[#20201d]">git.openputer.com</code>.
|
||||
</p>
|
||||
<input
|
||||
className="h-10 w-full border border-[#c9c5b9] bg-[#fffefa] px-3 text-sm outline-none focus:border-[#55564e]"
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="Puter username (optional)"
|
||||
value={username}
|
||||
/>
|
||||
<input
|
||||
className="h-10 w-full border border-[#c9c5b9] bg-[#fffefa] px-3 text-sm outline-none focus:border-[#55564e]"
|
||||
onChange={(event) => setToken(event.target.value)}
|
||||
placeholder="Personal access token"
|
||||
required
|
||||
type="password"
|
||||
value={token}
|
||||
/>
|
||||
{error ? <p className="text-xs text-red-700">{error}</p> : null}
|
||||
<Button className="h-10 w-full" disabled={pending} type="submit">
|
||||
{pending ? <LoaderCircle className="size-4 animate-spin" /> : null}
|
||||
{pending ? "Connecting" : "Connect Puter Git"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const RepositorySelector = ({
|
||||
onProjectCreated,
|
||||
}: {
|
||||
readonly onProjectCreated: (projectId: string) => void;
|
||||
}) => {
|
||||
const repositories = useQuery(
|
||||
makeFunctionReference<
|
||||
"query",
|
||||
Record<string, never>,
|
||||
readonly {
|
||||
cloneUrl: string;
|
||||
defaultBranch: string;
|
||||
fullName: string;
|
||||
id: string;
|
||||
name: string;
|
||||
owner: string;
|
||||
privacy: "public" | "private";
|
||||
provider: "github" | "gitea";
|
||||
webUrl: string;
|
||||
}[]
|
||||
>("gitProvisioning:listRepositories"),
|
||||
{}
|
||||
);
|
||||
const createProject = useMutation(createProjectFromRepositoryRef);
|
||||
const [pending, setPending] = useState<string>();
|
||||
|
||||
const create = async (repoId: string, name: string) => {
|
||||
setPending(repoId);
|
||||
try {
|
||||
const result = await createProject({
|
||||
gitRepositoryId: repoId as Id<"gitRepositories">,
|
||||
name,
|
||||
});
|
||||
onProjectCreated(String(result.projectId));
|
||||
} finally {
|
||||
setPending(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
if (repositories === undefined) {
|
||||
return <LoaderCircle className="size-4 animate-spin text-[#747168]" />;
|
||||
}
|
||||
if (repositories.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-[#747168]">
|
||||
No repositories found. Create one on your provider first, or import a
|
||||
public repository below.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-2">
|
||||
{repositories.map((repo) => (
|
||||
<button
|
||||
className="flex w-full items-center justify-between border border-[#d7d3c7] bg-[#fffefa] p-3 text-left transition-colors hover:bg-[#f5f3eb]"
|
||||
key={repo.id}
|
||||
onClick={() => create(repo.id, repo.name)}
|
||||
type="button"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[#20201d]">
|
||||
{repo.fullName}
|
||||
</p>
|
||||
<p className="text-xs text-[#747168]">
|
||||
{repo.provider} - {repo.defaultBranch}
|
||||
</p>
|
||||
</div>
|
||||
{pending === repo.id ? (
|
||||
<LoaderCircle className="size-4 animate-spin text-[#747168]" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const GitHubConnectButton = () => {
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const linkGithub = async () => {
|
||||
setPending(true);
|
||||
try {
|
||||
await authClient.linkSocial({
|
||||
callbackURL: `${window.location.origin}/projects?resume=github`,
|
||||
provider: "github",
|
||||
});
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className="flex w-full items-center gap-3 border border-[#d7d3c7] bg-[#fffefa] p-4 transition-colors hover:bg-[#f5f3eb]"
|
||||
onClick={linkGithub}
|
||||
type="button"
|
||||
>
|
||||
<GitBranch className="size-5 text-[#20201d]" />
|
||||
<div className="flex-1 text-left">
|
||||
<p className="text-sm font-semibold text-[#20201d]">GitHub</p>
|
||||
<p className="text-xs text-[#747168]">OAuth with private repo access</p>
|
||||
</div>
|
||||
{pending ? (
|
||||
<LoaderCircle className="size-4 animate-spin text-[#747168]" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const ProviderSection = () => {
|
||||
const [showPuterForm, setShowPuterForm] = useState(false);
|
||||
|
||||
return (
|
||||
<section className="mt-10">
|
||||
<h2 className="text-sm font-semibold text-[#20201d]">Git providers</h2>
|
||||
<p className="mt-1 text-xs text-[#68665e]">
|
||||
Connect a repository to create a Project.
|
||||
</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
<GitHubConnectButton />
|
||||
<button
|
||||
className="flex w-full items-center gap-3 border border-[#d7d3c7] bg-[#fffefa] p-4 transition-colors hover:bg-[#f5f3eb]"
|
||||
onClick={() => setShowPuterForm((v) => !v)}
|
||||
type="button"
|
||||
>
|
||||
<Server className="size-5 text-[#20201d]" />
|
||||
<div className="flex-1 text-left">
|
||||
<p className="text-sm font-semibold text-[#20201d]">Puter Git</p>
|
||||
<p className="text-xs text-[#747168]">
|
||||
Connect with a personal access token
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{showPuterForm ? (
|
||||
<PuterConnectForm onConnected={() => setShowPuterForm(false)} />
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const RepositorySection = ({
|
||||
onProjectCreated,
|
||||
}: {
|
||||
readonly onProjectCreated: (projectId: string) => void;
|
||||
}) => (
|
||||
<section className="mt-6">
|
||||
<h2 className="text-sm font-semibold text-[#20201d]">Your repositories</h2>
|
||||
<p className="mt-1 text-xs text-[#68665e]">
|
||||
Select a repository to create a Project.
|
||||
</p>
|
||||
<RepositorySelector onProjectCreated={onProjectCreated} />
|
||||
</section>
|
||||
);
|
||||
|
||||
export const ProjectsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const projects = useQuery(api.projects.list, {});
|
||||
const connectGithubAction = useAction(connectGithubRef);
|
||||
const [resumeError, setResumeError] = useState<string>();
|
||||
|
||||
// Handle GitHub OAuth callback resume
|
||||
useEffect(() => {
|
||||
const resume = searchParams.get("resume");
|
||||
if (resume !== "github") {
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
await connectGithubAction({});
|
||||
setSearchParams({}, { replace: true });
|
||||
} catch (caughtError) {
|
||||
setResumeError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: "GitHub connection failed"
|
||||
);
|
||||
}
|
||||
})();
|
||||
}, [searchParams, setSearchParams, connectGithubAction]);
|
||||
|
||||
if (projects === undefined) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
const hasProjects = projects.length > 0;
|
||||
const handleProjectCreated = (projectId: string) => {
|
||||
void navigate(`/?project=${projectId}`, { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-svh bg-[#f2f0e7] px-5 py-8 text-[#20201d]">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<header className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold">Projects</h1>
|
||||
{hasProjects ? (
|
||||
<Button
|
||||
className="h-9"
|
||||
onClick={() => navigate("/")}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Home
|
||||
</Button>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{resumeError ? (
|
||||
<p className="mt-4 border border-red-300 bg-red-50 p-3 text-xs text-red-700">
|
||||
{resumeError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{hasProjects ? null : <EmptyProjects />}
|
||||
|
||||
{hasProjects ? (
|
||||
<section className="mt-6 grid gap-3">
|
||||
{projects.map((project) => (
|
||||
<div key={project.id}>
|
||||
<ProjectCard
|
||||
instructions={
|
||||
project.contextDocuments.find(
|
||||
(doc) => doc.kind === "readme"
|
||||
)?.content ?? ""
|
||||
}
|
||||
name={project.name}
|
||||
projectId={project.id}
|
||||
sourceUrl={project.sources[0]?.url ?? ""}
|
||||
/>
|
||||
<ContextEditor
|
||||
projectId={project.id as unknown as Id<"projects">}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<RepositorySection onProjectCreated={handleProjectCreated} />
|
||||
<ProviderSection />
|
||||
|
||||
<section className="mt-8">
|
||||
<p className="text-xs text-[#68665e]">
|
||||
Or import a public repository:
|
||||
</p>
|
||||
<Link
|
||||
className="mt-2 inline-flex items-center gap-2 text-sm text-[#20201d] underline"
|
||||
to="/?import=public"
|
||||
>
|
||||
Import public Git URL
|
||||
</Link>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
@@ -11,7 +11,6 @@ export const ProjectSettingsPanel = ({
|
||||
readonly onClose: () => void;
|
||||
readonly workspace: WorkspaceState;
|
||||
}) => {
|
||||
const [serverUrl, setServerUrl] = useState("https://git.openputer.com");
|
||||
const [username, setUsername] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
const handleClearOperationError = () => workspace.clearOperationError();
|
||||
@@ -59,13 +58,7 @@ export const ProjectSettingsPanel = ({
|
||||
</div>
|
||||
<div className="mt-4 space-y-2 border-t border-[#e7e3d9] pt-4">
|
||||
<input
|
||||
aria-label="Gitea server URL"
|
||||
className="h-9 w-full border px-2 text-xs"
|
||||
onChange={(event) => setServerUrl(event.target.value)}
|
||||
value={serverUrl}
|
||||
/>
|
||||
<input
|
||||
aria-label="Gitea username"
|
||||
aria-label="Puter Git username"
|
||||
className="h-9 w-full border px-2 text-xs"
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="Username (optional)"
|
||||
@@ -85,7 +78,6 @@ export const ProjectSettingsPanel = ({
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void workspace.connectGitea({
|
||||
serverUrl,
|
||||
token,
|
||||
username: username || undefined,
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import { makeFunctionReference } from "convex/server";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
|
||||
import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent";
|
||||
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
|
||||
@@ -96,7 +97,7 @@ const projectGitConnectionRef = makeFunctionReference<
|
||||
>("gitConnectionData:getForProject");
|
||||
const connectGiteaRef = makeFunctionReference<
|
||||
"action",
|
||||
{ serverUrl: string; token: string; username?: string },
|
||||
{ token: string; username?: string },
|
||||
{ connectionId: Id<"gitConnections"> }
|
||||
>("gitConnections:connectGitea");
|
||||
const connectGithubRef = makeFunctionReference<
|
||||
@@ -118,6 +119,7 @@ export const useProjectWorkspace = (): WorkspaceState => {
|
||||
);
|
||||
const importPublicGit = useAction(api.projects.importPublicGit);
|
||||
const agent = useOrganizationChatAgent(organization);
|
||||
const [searchParams] = useSearchParams();
|
||||
const [selectedProjectId, setSelectedProjectId] =
|
||||
useState<Id<"projects"> | null>(null);
|
||||
const [repository, setRepository] = useState("");
|
||||
@@ -125,11 +127,17 @@ export const useProjectWorkspace = (): WorkspaceState => {
|
||||
const [error, setError] = useState<Error>();
|
||||
const [operationError, setOperationError] = useState<Error>();
|
||||
|
||||
const projectParam = searchParams.get("project");
|
||||
const paramProject = projectParam
|
||||
? (projectParam as unknown as Id<"projects">)
|
||||
: null;
|
||||
const selectedProjectStillExists = projects?.some(
|
||||
(project) => project.id === (selectedProjectId as unknown as string)
|
||||
(project) =>
|
||||
project.id === (selectedProjectId as unknown as string) ||
|
||||
project.id === (paramProject as unknown as string)
|
||||
);
|
||||
const activeProjectId = selectedProjectStillExists
|
||||
? selectedProjectId
|
||||
? (selectedProjectId ?? paramProject)
|
||||
: ((projects?.[0]?.id as unknown as Id<"projects"> | undefined) ?? null);
|
||||
const works = useQuery(
|
||||
workListRef,
|
||||
@@ -202,11 +210,7 @@ export const useProjectWorkspace = (): WorkspaceState => {
|
||||
});
|
||||
};
|
||||
|
||||
const connectGitea = (input: {
|
||||
serverUrl: string;
|
||||
token: string;
|
||||
username?: string;
|
||||
}) =>
|
||||
const connectGitea = (input: { token: string; username?: string }) =>
|
||||
runOperation(async () => {
|
||||
const result = await connectGiteaAction(input);
|
||||
await attachConnection(result.connectionId);
|
||||
@@ -228,8 +232,8 @@ export const useProjectWorkspace = (): WorkspaceState => {
|
||||
designVersion: number
|
||||
) => approveDesignMutation({ definitionVersion, designVersion, workId }),
|
||||
authorizeGithub: () =>
|
||||
authClient.signIn.social({
|
||||
callbackURL: window.location.href,
|
||||
authClient.linkSocial({
|
||||
callbackURL: `${window.location.origin}/projects?resume=github`,
|
||||
provider: "github",
|
||||
}),
|
||||
cancelExecution: (runId: Id<"workRuns">) =>
|
||||
|
||||
@@ -95,7 +95,6 @@ export interface WorkspaceState {
|
||||
readonly cancelSimulation: (runId: Id<"workRuns">) => Promise<unknown>;
|
||||
readonly clearOperationError: () => void;
|
||||
readonly connectGitea: (input: {
|
||||
readonly serverUrl: string;
|
||||
readonly token: string;
|
||||
readonly username?: string;
|
||||
}) => Promise<void>;
|
||||
|
||||
@@ -6,5 +6,8 @@ export default [
|
||||
route("login", "./routes/auth/login/page.tsx"),
|
||||
route("signup", "./routes/auth/signup/page.tsx"),
|
||||
]),
|
||||
layout("./routes/app/layout.tsx", [index("./routes/app/workspace/page.tsx")]),
|
||||
layout("./routes/app/layout.tsx", [
|
||||
index("./routes/app/workspace/page.tsx"),
|
||||
route("projects", "./routes/app/projects/page.tsx"),
|
||||
]),
|
||||
] satisfies RouteConfig;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Outlet } from "react-router";
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import { useQuery } from "convex/react";
|
||||
import { useEffect } from "react";
|
||||
import { Outlet, useNavigate } from "react-router";
|
||||
|
||||
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
|
||||
import { requireAuthToken } from "@/lib/auth.server";
|
||||
|
||||
import type { Route } from "./+types/layout";
|
||||
@@ -8,5 +12,24 @@ export const loader = ({ request }: Route.LoaderArgs) =>
|
||||
requireAuthToken(request);
|
||||
|
||||
export default function AppLayout() {
|
||||
const organization = usePersonalOrganization();
|
||||
const navigate = useNavigate();
|
||||
const projects = useQuery(
|
||||
api.projects.list,
|
||||
organization.organizationId ? {} : "skip"
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
projects !== undefined &&
|
||||
projects.length === 0 &&
|
||||
window.location.pathname === "/" &&
|
||||
!window.location.search.includes("resume=") &&
|
||||
!window.location.search.includes("project=")
|
||||
) {
|
||||
void navigate("/projects", { replace: true });
|
||||
}
|
||||
}, [projects, navigate]);
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
5
apps/web/src/routes/app/projects/page.tsx
Normal file
5
apps/web/src/routes/app/projects/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ProjectsPage } from "@/components/projects/projects-page";
|
||||
|
||||
export default function ProjectsRoute() {
|
||||
return <ProjectsPage />;
|
||||
}
|
||||
Reference in New Issue
Block a user