Skip to content
153 linesCodeBlameRaw
1import { KeyRound, LogIn } from "lucide-react-native";
2import { useState } from "react";
3import { KeyboardAvoidingView, Platform, Pressable, View } from "react-native";
4
5import { G1tMark } from "@/components/mark";
6import { Button, Field, Screen, Text } from "@/components/ui";
7import { discover } from "@/lib/discover";
8import { SignInCancelled } from "@/lib/oauth";
9import { HOSTED, isHosted } from "@/lib/server";
10import { useSession } from "@/lib/session";
11import { space } from "@/theme";
12
13/**
14 * Signing in. On g1t.sh there is one thing to do: **Sign in with g1t**,
15 * which opens the site in the system's browser to approve the app
16 * (lib/oauth.ts). Everything else stays out of the way: a personal access
17 * token, and pointing the app at a g1t of your own, which a quiet line at
18 * the foot offers, or a `g1t://connect` link does for you (app/connect.tsx).
19 * Pointed at your own g1t, its address is shown, so you know where you sign in.
20 */
21export default function SignIn() {
22 const { state, signIn, signInWithToken, changeServer } = useSession();
23 const server = state.server ?? HOSTED;
24 const hosted = isHosted(server);
25 const host = new URL(server.site).host;
26 const [mode, setMode] = useState<"oauth" | "token" | "server">("oauth");
27 const [busy, setBusy] = useState(false);
28 const [error, setError] = useState<string | null>(null);
29 const [token, setToken] = useState("");
30 const [site, setSite] = useState(hosted ? "" : server.site);
31 const [api, setApi] = useState("");
32 const [apiShown, setApiShown] = useState(false);
33
34 const run = async (work: () => Promise<void>) => {
35 setBusy(true);
36 setError(null);
37 try {
38 await work();
39 } catch (failure) {
40 if (!(failure instanceof SignInCancelled)) setError(failure instanceof Error ? failure.message : "Sign-in failed.");
41 } finally {
42 setBusy(false);
43 }
44 };
45
46 return (
47 <Screen safe>
48 <KeyboardAvoidingView behavior={Platform.OS === "ios" ? "padding" : undefined} style={{ flex: 1 }}>
49 <View style={{ flex: 1, padding: space.xl, justifyContent: "center", gap: space.xl }}>
50 <View style={{ gap: space.md }}>
51 <G1tMark size={44} />
52 <Text size="title">{hosted ? "Sign in to g1t" : `Sign in to ${host}`}</Text>
53 <Text tone="muted">Chat with your team and its agents, and review and merge what they make.</Text>
54 </View>
55
56 {mode === "oauth" ? (
57 <View style={{ gap: space.md }}>
58 <Button title="Sign in with g1t" icon={LogIn} busy={busy} onPress={() => run(signIn)} />
59 <Text size="small" tone="faint">
60 {host} opens in your browser to approve the app. Your password never reaches it.
61 </Text>
62 <Button title="Use an access token" variant="secondary" icon={KeyRound} onPress={() => setMode("token")} />
63 </View>
64 ) : null}
65
66 {mode === "token" ? (
67 <View style={{ gap: space.md }}>
68 <Field
69 label="Access token"
70 placeholder="g1t_…"
71 value={token}
72 onChangeText={setToken}
73 secureTextEntry
74 hint={`Make one on ${host} in Settings → Access tokens, with Chat and Notifications, or “Use the website as you”.`}
75 />
76 <Button title="Sign in" busy={busy} disabled={!token.trim()} onPress={() => run(() => signInWithToken(token))} />
77 <Button title="Back" variant="secondary" onPress={() => setMode("oauth")} />
78 </View>
79 ) : null}
80
81 {mode === "server" ? (
82 <View style={{ gap: space.md }}>
83 <Field
84 label="Your g1t's address"
85 placeholder="g1t.example.com"
86 value={site}
87 onChangeText={setSite}
88 keyboardType="url"
89 autoFocus
90 hint="The address you open g1t at in a browser. The app finds the rest."
91 />
92 {apiShown ? (
93 <Field
94 label="Its API's address"
95 placeholder="api.g1t.example.com"
96 value={api}
97 onChangeText={setApi}
98 keyboardType="url"
99 hint="Only for a g1t too old to say where its API is."
100 />
101 ) : null}
102 <Button
103 title="Continue"
104 busy={busy}
105 disabled={!site.trim()}
106 onPress={() =>
107 run(async () => {
108 const found = await discover(site, apiShown ? api : undefined);
109 await changeServer(found.server);
110 setMode("oauth");
111 })
112 }
113 />
114 <Button title="Back" variant="secondary" onPress={() => setMode("oauth")} />
115 {!apiShown ? (
116 <Pressable onPress={() => setApiShown(true)} hitSlop={8}>
117 <Text size="small" tone="faint" style={{ textAlign: "center" }}>
118 Its API is at another address
119 </Text>
120 </Pressable>
121 ) : null}
122 </View>
123 ) : null}
124
125 {error ? <Text tone="danger">{error}</Text> : null}
126 </View>
127
128 {mode === "oauth" ? (
129 <View style={{ padding: space.xl, paddingTop: 0, alignItems: "center", gap: space.xs }}>
130 {hosted ? (
131 <Pressable onPress={() => setMode("server")} hitSlop={10}>
132 <Text size="small" tone="faint">
133 Using your own g1t? <Text size="small" tone="accent">Sign in there</Text>
134 </Text>
135 </Pressable>
136 ) : (
137 <Text size="small" tone="faint">
138 Signing in to {host} ·{" "}
139 <Text size="small" tone="accent" onPress={() => setMode("server")}>
140 Change
141 </Text>{" "}
142 ·{" "}
143 <Text size="small" tone="accent" onPress={() => void run(() => changeServer(HOSTED))}>
144 Use g1t.sh
145 </Text>
146 </Text>
147 )}
148 </View>
149 ) : null}
150 </KeyboardAvoidingView>
151 </Screen>
152 );
153}