@pithy-sh/ui-react-native 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pithy-sh/ui-react-native",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -33,7 +33,7 @@ function decodeComponent(value: string): string {
33
33
  }
34
34
 
35
35
  /**
36
- * A query string as name to value: `"?email=ada%40example.com"` gives `{ email: "ada@example.com" }`. The leading
36
+ * A query string as name to value: `"?tab=billing%20history"` gives `{ tab: "billing history" }`. The leading
37
37
  * `?` is optional. The first value of a repeated name wins, as `URLSearchParams.get` answers.
38
38
  */
39
39
  export function parseQuery(search: string): Record<string, string> {
@@ -38,7 +38,7 @@ import { useTheme } from "./theme";
38
38
 
39
39
  /** How the app navigates: the router a screen goes through, never `window`. */
40
40
  export interface RouterAdapter {
41
- /** Where the app is now: the path and its query, such as `/otp?email=ada%40example.com`. */
41
+ /** Where the app is now: the path and its query, such as `/invitations/tok_1?resume=%2Fhome`. */
42
42
  path(): string;
43
43
  /** Go to `to`, a path with an optional query. `replace` swaps the current entry rather than pushing one. */
44
44
  go(to: string, options: { replace: boolean }): void;
@@ -151,7 +151,7 @@ export function usePath(): string {
151
151
  return splitPath(useLocation()).pathname;
152
152
  }
153
153
 
154
- /** The current query string, verbatim: `"?email=ada"`, or `""`. Re-renders on every navigation. */
154
+ /** The current query string, verbatim: `"?tab=billing"`, or `""`. Re-renders on every navigation. */
155
155
  export function useSearch(): string {
156
156
  return splitPath(useLocation()).search;
157
157
  }
@@ -5,8 +5,17 @@ import { screenPath } from "@pithy-sh/ui-core/src/routes";
5
5
  import { type ReactNode, useCallback, useRef, useState } from "react";
6
6
  import { Pressable, Text, TextInput, View } from "react-native";
7
7
  import { authConfig } from "../../pithy-config";
8
- import { routeTable, useGo, useSearch, useSearchParam } from "../../router";
9
- import { adoptSignIn, afterSignIn, refusedAccountScreen } from "../../session";
8
+ import { parseQuery, queryString } from "../../platform";
9
+ import { Redirect, routeTable, useGo, usePath, useSearch, useSearchParam } from "../../router";
10
+ import {
11
+ adoptSignIn,
12
+ afterSignIn,
13
+ heldOtpAddress,
14
+ holdOtpAddress,
15
+ refusedAccountScreen,
16
+ releaseOtpAddress,
17
+ resumeQuery,
18
+ } from "../../session";
10
19
  import { type Theme, useTheme } from "../../theme";
11
20
  import { transport } from "../../transport";
12
21
  import { Turnstile, turnstilePending, turnstileRequest } from "../../turnstile";
@@ -18,10 +27,13 @@ export const path = "/otp";
18
27
  // **The sign-in screen sends people here on a phone with no universal link.** It offers one way in, and there that
19
28
  // is a code: the Worker hands a magic link only to an https link, since any app can claim a custom scheme. With
20
29
  // `universalLinkOrigin` set, the sign-in screen mails a link instead and nothing sends anyone here. To send a code
21
- // from your own screen, go here with `go(`/otp?email=${encodeURIComponent(email)}`)`.
30
+ // from your own screen, hand it the address and go here: `holdOtpAddress(email); go("/otp")`.
22
31
  //
23
- // **The email is a query parameter, not a path segment.** The address is a prefill, not the thing this screen
24
- // is, and `/otp` with no email is a valid screen that says "your inbox".
32
+ // **The address is never in the URL.** A path or a query string is written to every request log between the
33
+ // browser and the Worker, so `holdOtpAddress` keeps it in memory and in this tab's session storage. A reload keeps
34
+ // it. A new tab, a bookmark or a link somebody else opens has none, and goes back to sign-in, since a code cannot be
35
+ // checked without the address it was sent to. An older link carrying `?email=` is read once, held, and dropped from
36
+ // the address bar.
25
37
 
26
38
  /**
27
39
  * This screen's English, baked in: the only catalog that survives being copied into your repository.
@@ -75,7 +87,12 @@ export default function Otp(): ReactNode {
75
87
  const theme = useTheme();
76
88
  const go = useGo();
77
89
  const search = useSearch();
78
- const email = useSearchParam("email") ?? "";
90
+ const path = usePath();
91
+ const carried = useSearchParam("email");
92
+ const [email] = useState(() => {
93
+ if (carried) holdOtpAddress(carried);
94
+ return carried || heldOtpAddress() || "";
95
+ });
79
96
  const length = authConfig.otpLength;
80
97
  const [digits, setDigits] = useState<string[]>(() => Array.from({ length }, () => ""));
81
98
  const [captcha, setCaptcha] = useState<string | null>(null);
@@ -111,6 +128,7 @@ export default function Otp(): ReactNode {
111
128
  const held = result.ok && (await adoptSignIn(result.value));
112
129
  setBusy(false);
113
130
  if (held) {
131
+ releaseOtpAddress();
114
132
  // A sign-in a screen or an application's authorization sent somebody to resumes it, or goes home.
115
133
  const next = await afterSignIn(search);
116
134
  if (next !== null) go(next);
@@ -129,6 +147,15 @@ export default function Otp(): ReactNode {
129
147
  await sendOtp({ email, type: "sign-in" }, { ...transport.auth, gate: (body) => turnstileRequest(body, captcha) });
130
148
  }
131
149
 
150
+ if (carried !== null) {
151
+ const { email: _dropped, ...rest } = parseQuery(search);
152
+ return <Redirect to={`${path}${queryString(rest)}`} replace />;
153
+ }
154
+ if (!email) {
155
+ const resume = resumeQuery(search);
156
+ return <Redirect screen="sign-in" replace {...(resume ? { search: resume.slice(1) } : {})} />;
157
+ }
158
+
132
159
  return (
133
160
  <View
134
161
  style={{ flex: 1, padding: theme.spacing.lg, gap: theme.spacing.md, backgroundColor: theme.colors.background }}
@@ -27,6 +27,7 @@ import {
27
27
  afterSignIn,
28
28
  beginHandoff,
29
29
  completeHandoff,
30
+ holdOtpAddress,
30
31
  type IdTokenUser,
31
32
  leaveInPlace,
32
33
  refusedAccountScreen,
@@ -435,8 +436,8 @@ export function SignInScreen(props: SignInScreenProps): ReactNode {
435
436
  // Whatever the answer, the code screen: telling a registered address from another is an enumeration oracle.
436
437
  await sendOtp({ email, type: "sign-in" }, { ...transport.auth, gate: check.attach });
437
438
  setBusy(false);
438
- const resume = resumeQuery(search);
439
- go(`${otpPath}${queryString({ email })}${resume ? `&${resume.slice(1)}` : ""}`);
439
+ holdOtpAddress(email);
440
+ go(`${otpPath}${resumeQuery(search)}`);
440
441
  }
441
442
 
442
443
  /** Where a sign-in goes when it took: the screen or the request it should resume, or home. */
@@ -288,3 +288,62 @@ export async function afterSignIn(search: string): Promise<string | null> {
288
288
  if ((await resumeAfterSignIn(search)) && !onDevice()) return null;
289
289
  return "/";
290
290
  }
291
+
292
+ /** The key the code screen's address waits under in this tab's session storage. */
293
+ const OTP_ADDRESS_KEY = "pithy.auth.otp-address";
294
+
295
+ /** The address, held in memory too: a phone has no session storage, and a private window may refuse it. */
296
+ let otpAddress: string | null = null;
297
+
298
+ /** The slice of the Web Storage API the held address uses. */
299
+ interface TabStorage {
300
+ getItem(key: string): string | null;
301
+ setItem(key: string, value: string): void;
302
+ removeItem(key: string): void;
303
+ }
304
+
305
+ /** This tab's session storage, or `null` where there is none or it refuses to be read. */
306
+ function tabStorage(): TabStorage | null {
307
+ try {
308
+ return (globalThis as { sessionStorage?: TabStorage }).sessionStorage ?? null;
309
+ } catch {
310
+ return null;
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Hand the code screen the address a code was sent to, before going there.
316
+ *
317
+ * **Never in the URL.** A path or a query string is written to every request log between the browser and the
318
+ * Worker the moment a reload, a bookmark or a shared link sends it, so the address waits in memory and in this
319
+ * tab's session storage instead. A reload of the code screen keeps it; a new tab, a bookmark or a link somebody
320
+ * else opens does not, and the screen goes back to sign-in.
321
+ */
322
+ export function holdOtpAddress(email: string): void {
323
+ otpAddress = email;
324
+ try {
325
+ tabStorage()?.setItem(OTP_ADDRESS_KEY, email);
326
+ } catch {
327
+ // Storage refused: memory still holds it for this page.
328
+ }
329
+ }
330
+
331
+ /** The address the code screen was handed, or `null`. */
332
+ export function heldOtpAddress(): string | null {
333
+ if (otpAddress !== null) return otpAddress;
334
+ try {
335
+ return tabStorage()?.getItem(OTP_ADDRESS_KEY) ?? null;
336
+ } catch {
337
+ return null;
338
+ }
339
+ }
340
+
341
+ /** Forget the held address, once the code has done its work. */
342
+ export function releaseOtpAddress(): void {
343
+ otpAddress = null;
344
+ try {
345
+ tabStorage()?.removeItem(OTP_ADDRESS_KEY);
346
+ } catch {
347
+ // Nothing to forget.
348
+ }
349
+ }