Skip to content
84 linesCodeBlameRaw
1/**
2 * The workspace gate: everything on g1t lives in a workspace, so a confirmed
3 * person with none is sent to make one or answer an invitation to one
4 * (`/workspaces/new`, "Create your workspace or ask to join one"), from
5 * every page but the few that needs, and is returned to where they were
6 * going afterwards. Mission control is never shown without a workspace.
7 * No Workers or React imports, so it can be tested under Node.
8 */
9
10/** Where someone without a workspace makes one, or answers an invitation. */
11export const NO_WORKSPACE_PATH = "/workspaces/new";
12
13/** Pages a signed-in person can use before they have a workspace. */
14const BEFORE_WORKSPACE = new Set([
15 NO_WORKSPACE_PATH,
16 "/invitations",
17 "/settings",
18 "/verify",
19 "/logout",
20 "/auth/github",
21 "/auth/github/callback",
22 "/inbox",
23 "/inbox.json",
24 "/settings/menu.json",
25 // Who makes g1t and the promises it keeps.
26 "/policies",
27 "/security",
28 "/support",
29 "/pricing",
30]);
31
32type Someone =
33 | {
34 verified?: boolean;
35 kind?: string;
36 workspaces?: unknown[] | null;
37 grants?: unknown[] | null;
38 held?: unknown[] | null;
39 }
40 | null
41 | undefined;
42
43/** Whether `viewer` is a confirmed person who belongs to no workspace and has nothing else to use. */
44export function hasNoWorkspace(viewer: Someone): boolean {
45 if (!viewer?.verified) return false;
46 if (viewer.kind && viewer.kind !== "user") return false;
47 return (
48 (viewer.workspaces ?? []).length === 0 &&
49 // Someone a repository is shared with can use it without a workspace.
50 (viewer.grants ?? []).length === 0 &&
51 // Someone held out of their workspaces until they meet its policy is
52 // told so, and sent to turn on two-factor authentication, not to make one.
53 (viewer.held ?? []).length === 0
54 );
55}
56
57/**
58 * Where to send `viewer` instead of `page` (a page's path; a data request's
59 * page, from `pageOf`) + `search`: the page to make a workspace or answer
60 * an invitation, with where they were going as `next`. Null when they have
61 * a workspace or the page is theirs to open without one.
62 */
63export function workspaceGate(page: string, search: string, viewer: Someone): string | null {
64 if (!hasNoWorkspace(viewer)) return null;
65 const path = page.length > 1 ? page.replace(/\/+$/, "") : page;
66 if (
67 BEFORE_WORKSPACE.has(path) ||
68 path.startsWith("/settings/") ||
69 path.startsWith("/policies/") ||
70 path.startsWith("/-/") ||
71 path.startsWith("/u/") ||
72 // An invite to a workspace is how someone without one gets one, and an
73 // invitation to a repository is answered before anything else.
74 path.startsWith("/invite/") ||
75 /^\/[^/]+\/[^/]+\/invitations$/.test(path)
76 ) {
77 return null;
78 }
79 const params = new URLSearchParams(search);
80 params.delete("_routes");
81 const query = params.toString();
82 const next = path === "/" && !query ? "" : `?next=${encodeURIComponent(path + (query ? `?${query}` : ""))}`;
83 return `${NO_WORKSPACE_PATH}${next}`;
84}