Skip to content
164 linesCodeBlameRaw
1import type { Membership, User } from "@g1t/contracts";
2import { useQueryClient } from "@tanstack/react-query";
3import * as SecureStore from "expo-secure-store";
4import { type ReactNode, createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
5
6import { ApiError, Client } from "./client";
7import { type Credentials, clearCredentials, loadCredentials, loadServer, saveCredentials, saveServer } from "./credentials";
8import { signInWithOAuth } from "./oauth";
9import { unregisterPush } from "./push";
10import type { Server } from "./server";
11
12/**
13 * Who is signed in, to which g1t, and in which workspace. Everything that
14 * talks to g1t reads its {@link Client} from here.
15 */
16
17/** `GET /user`: the person, their workspaces, and what the token may do. */
18export type Me = User & { token?: { token_id: string; scopes?: string[] } };
19
20type State =
21 | { status: "loading"; server: Server | null }
22 | { status: "signed-out"; server: Server }
23 | { status: "signed-in"; server: Server; client: Client; me: Me; workspace: Membership | null };
24
25type SignedInState = Extract<State, { status: "signed-in" }>;
26
27type SessionApi = {
28 state: State;
29 /**
30 * The last signed-in state, kept through signing out: the signed-in
31 * screens render once more as they leave, and read it then.
32 */
33 lastSignedIn: SignedInState | null;
34 /** Opens the site to approve the app, then signs in. */
35 signIn: () => Promise<void>;
36 /** Signs in with a personal access token (`g1t_…`). */
37 signInWithToken: (token: string) => Promise<void>;
38 signOut: () => Promise<void>;
39 /** Points the app at another g1t; signs out of the one before. */
40 changeServer: (server: Server) => Promise<void>;
41 /** Switches workspace by slug. */
42 selectWorkspace: (slug: string) => Promise<void>;
43};
44
45const WORKSPACE_KEY = "g1t.workspace";
46
47const SessionContext = createContext<SessionApi | null>(null);
48
49export function SessionProvider({ children }: { children: ReactNode }) {
50 const [state, setState] = useState<State>({ status: "loading", server: null });
51 const queries = useQueryClient();
52 const serverRef = useRef<Server | null>(null);
53 const lastSignedIn = useRef<SignedInState | null>(null);
54 if (state.status === "signed-in") lastSignedIn.current = state;
55
56 const signedOut = useCallback(
57 async (server: Server) => {
58 await clearCredentials();
59 queries.clear();
60 setState({ status: "signed-out", server });
61 },
62 [queries],
63 );
64
65 /** Makes a client for credentials, reads who they are, and signs in. */
66 const start = useCallback(
67 async (server: Server, credentials: Credentials) => {
68 const client = new Client({
69 server,
70 credentials,
71 onCredentials: (next) => {
72 if (next === null) void signedOut(server);
73 },
74 });
75 const me = await client.api<Me>("/user");
76 if ((me.kind ?? "user") !== "user") {
77 throw new ApiError("forbidden", "Sign in as yourself: a workspace's or an agent's token cannot use the app.");
78 }
79 const saved = await SecureStore.getItemAsync(WORKSPACE_KEY).catch(() => null);
80 const workspaces = me.workspaces ?? [];
81 const workspace = workspaces.find((one) => one.slug === saved) ?? workspaces[0] ?? null;
82 setState({ status: "signed-in", server, client, me, workspace });
83 },
84 [signedOut],
85 );
86
87 useEffect(() => {
88 void (async () => {
89 const server = await loadServer();
90 serverRef.current = server;
91 const credentials = await loadCredentials();
92 if (!credentials) return setState({ status: "signed-out", server });
93 try {
94 await start(server, credentials);
95 } catch (error) {
96 // Offline at launch keeps the credentials for next time; anything
97 // else (revoked, expired) signs out.
98 if (error instanceof ApiError && error.code === "offline") {
99 setState({ status: "signed-out", server });
100 return;
101 }
102 await signedOut(server);
103 }
104 })();
105 }, [start, signedOut]);
106
107 const api = useMemo<SessionApi>(() => {
108 const current = () => serverRef.current ?? state.server;
109 return {
110 state,
111 lastSignedIn: lastSignedIn.current,
112 signIn: async () => {
113 const server = current();
114 if (!server) return;
115 const credentials = await signInWithOAuth(server);
116 await saveCredentials(credentials);
117 await start(server, credentials);
118 },
119 signInWithToken: async (token) => {
120 const server = current();
121 if (!server) return;
122 const trimmed = token.trim();
123 if (!trimmed.startsWith("g1t_")) throw new ApiError("failed", "An access token starts with g1t_.");
124 const credentials: Credentials = { kind: "token", accessToken: trimmed };
125 await start(server, credentials);
126 await saveCredentials(credentials);
127 },
128 signOut: async () => {
129 const server = current();
130 // The phone stops getting notifications first, while the token still works.
131 if (state.status === "signed-in") await unregisterPush(state.client).catch(() => undefined);
132 if (server) await signedOut(server);
133 },
134 changeServer: async (server) => {
135 await saveServer(server);
136 serverRef.current = server;
137 await signedOut(server);
138 },
139 selectWorkspace: async (slug) => {
140 if (state.status !== "signed-in") return;
141 const workspace = state.me.workspaces?.find((one) => one.slug.toLowerCase() === slug.toLowerCase());
142 if (!workspace || workspace.slug === state.workspace?.slug) return;
143 await SecureStore.setItemAsync(WORKSPACE_KEY, workspace.slug);
144 setState({ ...state, workspace });
145 },
146 };
147 }, [state, start, signedOut]);
148
149 return <SessionContext.Provider value={api}>{children}</SessionContext.Provider>;
150}
151
152export function useSession(): SessionApi {
153 const session = useContext(SessionContext);
154 if (!session) throw new Error("useSession is used outside SessionProvider.");
155 return session;
156}
157
158/** The signed-in session, for screens that only show when signed in. */
159export function useSignedIn() {
160 const { state, lastSignedIn, ...rest } = useSession();
161 const signedIn = state.status === "signed-in" ? state : lastSignedIn;
162 if (!signedIn) throw new Error("This screen needs a signed-in session.");
163 return { ...signedIn, ...rest };
164}