Skip to content
86 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 "/notifications",
23 // The old address, which leads to Notifications.
24 "/inbox",
25 "/inbox.json",
26 "/settings/menu.json",
27 // Who makes g1t and the promises it keeps.
28 "/policies",
29 "/security",
30 "/support",
31 "/pricing",
32]);
33
34type Someone =
35 | {
36 verified?: boolean;
37 kind?: string;
38 workspaces?: unknown[] | null;
39 grants?: unknown[] | null;
40 held?: unknown[] | null;
41 }
42 | null
43 | undefined;
44
45/** Whether `viewer` is a confirmed person who belongs to no workspace and has nothing else to use. */
46export function hasNoWorkspace(viewer: Someone): boolean {
47 if (!viewer?.verified) return false;
48 if (viewer.kind && viewer.kind !== "user") return false;
49 return (
50 (viewer.workspaces ?? []).length === 0 &&
51 // Someone a repository is shared with can use it without a workspace.
52 (viewer.grants ?? []).length === 0 &&
53 // Someone held out of their workspaces until they meet its policy is
54 // told so, and sent to turn on two-factor authentication, not to make one.
55 (viewer.held ?? []).length === 0
56 );
57}
58
59/**
60 * Where to send `viewer` instead of `page` (a page's path; a data request's
61 * page, from `pageOf`) + `search`: the page to make a workspace or answer
62 * an invitation, with where they were going as `next`. Null when they have
63 * a workspace or the page is theirs to open without one.
64 */
65export function workspaceGate(page: string, search: string, viewer: Someone): string | null {
66 if (!hasNoWorkspace(viewer)) return null;
67 const path = page.length > 1 ? page.replace(/\/+$/, "") : page;
68 if (
69 BEFORE_WORKSPACE.has(path) ||
70 path.startsWith("/settings/") ||
71 path.startsWith("/policies/") ||
72 path.startsWith("/-/") ||
73 path.startsWith("/u/") ||
74 // An invite to a workspace is how someone without one gets one, and an
75 // invitation to a repository is answered before anything else.
76 path.startsWith("/invite/") ||
77 /^\/[^/]+\/[^/]+\/invitations$/.test(path)
78 ) {
79 return null;
80 }
81 const params = new URLSearchParams(search);
82 params.delete("_routes");
83 const query = params.toString();
84 const next = path === "/" && !query ? "" : `?next=${encodeURIComponent(path + (query ? `?${query}` : ""))}`;
85 return `${NO_WORKSPACE_PATH}${next}`;
86}