Skip to content
161 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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