g1t/apps/web/app/routes/workspace/layout.tsx

165 lines7,518 bytesCodeBlame
1import { Plus } from "lucide-react";
2import { Outlet, data, useLocation } from "react-router";
3
4import type { Route } from "./+types/layout";
5import { page } from "../../lib/meta";
6import { Avatar, ButtonLink, Pill } from "../../components/ui";
7import { WelcomeBanner } from "../../components/welcome";
8import { clearWelcome, welcomes } from "../../lib/invites";
9import { notFound } from "../../lib/not-found.server";
10import { redirectIfRenamed } from "../../lib/renamed.server";
11import { rememberWorkspace } from "../../lib/workspace-choice";
12import { identity } from "../../lib/services.server";
13import { getViewer, roleIn } from "../../lib/session.server";
14
15export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
16 return page(args, { title: `${loaderData?.workspace.name ?? params.owner} · g1t` });
17}
18
19export async function loader({ params, context, request }: Route.LoaderArgs) {
20 const workspace = await identity.getWorkspace(params.owner);
21 if (!workspace) {
22 // A workspace's old name, after a rename: its pages are at the new one.
23 await redirectIfRenamed(request, params.owner);
24 throw notFound("workspace");
25 }
26 const role = roleIn(getViewer(context), workspace.slug);
27 // Opening one of your workspaces makes it the one you are in.
28 if (!role) return { workspace, role, welcome: false };
29 const secure = new URL(request.url).protocol === "https:";
30 const headers = new Headers({ "Set-Cookie": rememberWorkspace(workspace.slug, secure) });
31 // Just joined with an invite: welcomed once (routes/invite.tsx).
32 const welcome = welcomes(request.headers.get("cookie"), workspace.slug);
33 if (welcome) headers.append("Set-Cookie", clearWelcome(secure));
34 return data({ workspace, role, welcome }, { headers });
35}
36
37/** A workspace's own pages, each with its title and what it is for. */
38const PAGES: Record<string, { title: string; about: string }> = {
39 settings: { title: "General", about: "The workspace's name, icon, address and description, and deleting it." },
40 people: {
41 title: "Members",
42 about: "Members create repositories and have the base permission on each one. Owners are Admins on every repository, and also manage members, tokens, billing and integrations.",
43 },
44 repositories: {
45 title: "Repositories",
46 about: "Every repository in the workspace: who can see it, whether it is archived, and the ones deleted recently.",
47 },
48 tokens: {
49 title: "Access tokens",
50 about: "Tokens that belong to the workspace, not a person: for CI, integrations and agents that work for the whole team.",
51 },
52 packages: {
53 title: "Packages",
54 about: "What the workspace publishes and installs, beside its code: container images, npm, Composer, Go, Cargo, Maven, NuGet and RubyGems, with the same members, roles and tokens.",
55 },
56 usage: { title: "Usage", about: "What the workspace's agents cost, run by run, by repository, pull request and model." },
57 billing: { title: "Billing and plans", about: "The g1t plan, the trial, your spend limit and caps, prepaying, and every charge." },
58 agents: {
59 title: "Agent fleet",
60 about: "Every agent at work across the workspace's projects: what each holds, what it is doing now, and what it has cost.",
61 },
62 memory: {
63 title: "Workspace memory",
64 about: "What holds across all of the workspace's projects, given to every agent in every one of them, beside each project's own memory.",
65 },
66 context: {
67 title: "Context",
68 about: "Everything the workspace builds and runs, and what it knows: a catalog built from its projects, memory with a review queue, one search over all of it, and a scorecard for each project.",
69 },
70 audit: {
71 title: "Audit log",
72 about: "Every action agents took with their run credentials, and every change people and tokens made: by whom, on whose behalf, to what, and whether it was allowed.",
73 },
74 guardrails: {
75 title: "Guardrails",
76 about: "What agents may reach, run, spend and take in every project's sandboxes. Each project can override these under its settings.",
77 },
78 webhooks: {
79 title: "Webhooks",
80 about: "Every repository's events, sent to your own addresses as they happen. A repository can also have its own, under its settings.",
81 },
82 secrets: {
83 title: "Secrets and variables",
84 about: "Shared with every repository, or the ones you link: read by workflows, deployments, or both. A repository's own row of the same key wins.",
85 },
86 runners: {
87 title: "Self-hosted runners",
88 about: "Your own machines, which run workflow jobs, and if you choose agents' work, for free. They connect out to g1t; nothing reaches in.",
89 },
90 security: {
91 title: "Security",
92 about: "Open alerts in every project: secrets found in pushes and history, and vulnerable dependencies. Each project's Security page has the details and the security update g1t opened for each.",
93 },
94 integrations: {
95 title: "Integrations",
96 about: "Model providers, alerts and trackers. Secrets are sealed when saved, and agents never see them.",
97 },
98};
99
100export default function WorkspaceLayout({ loaderData }: Route.ComponentProps) {
101 const { workspace, role, welcome } = loaderData;
102 // The sidebar finds the workspace's pages, for everyone, so its pages
103 // need a title, not the workspace's whole header again.
104 const parts = (useLocation().pathname.split("/-/")[1] ?? "").split("/").filter(Boolean);
105 const page = PAGES[parts[0] ?? ""];
106 // A page within one (a single package) has its own heading.
107 if (page && parts.length > 1) {
108 return (
109 <div className="mx-auto max-w-5xl px-4 py-10 sm:px-8">
110 <Outlet />
111 </div>
112 );
113 }
114 if (page) {
115 return (
116 <div className="mx-auto max-w-5xl px-4 py-10 sm:px-8">
117 <header className="mb-8 border-b border-line pb-6">
118 <h1 className="text-2xl font-semibold tracking-tight">{page.title}</h1>
119 <p className="mt-1.5 text-sm text-muted">{page.about}</p>
120 </header>
121 <Outlet />
122 </div>
123 );
124 }
125 return (
126 <>
127 {/* The workspace's own header band. */}
128 <div className="border-b border-line bg-surface/60">
129 <div className="mx-auto max-w-6xl px-4 pt-8 pb-8">
130 {welcome && (
131 <div className="mb-6">
132 <WelcomeBanner title={`You're in ${workspace.name}`}>
133 You joined as a {role}. Its projects, issues and agents are all here.
134 </WelcomeBanner>
135 </div>
136 )}
137 <div className="flex flex-wrap items-center gap-4">
138 <Avatar name={workspace.slug} image={workspace.avatar} size={52} square />
139 <div className="min-w-0 grow">
140 <div className="flex flex-wrap items-center gap-2.5">
141 <h1 className="truncate text-2xl font-semibold tracking-tight">
142 {workspace.name}
143 </h1>
144 {role && <Pill>{role}</Pill>}
145 </div>
146 <p className="font-mono text-sm text-muted">g1t.sh/{workspace.slug}</p>
147 </div>
148 {role && (
149 <ButtonLink to={`/new?workspace=${workspace.slug}`}>
150 <Plus size={15} />
151 New project
152 </ButtonLink>
153 )}
154 </div>
155 {workspace.description && (
156 <p className="mt-4 max-w-2xl text-sm text-muted">{workspace.description}</p>
157 )}
158 </div>
159 </div>
160 <div className="mx-auto max-w-6xl px-4 py-8">
161 <Outlet />
162 </div>
163 </>
164 );
165}