@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
|
@@ -33,7 +33,7 @@ function decodeComponent(value: string): string {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* A query string as name to value: `"?
|
|
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> {
|
package/templates/src/router.tsx
CHANGED
|
@@ -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 `/
|
|
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: `"?
|
|
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 {
|
|
9
|
-
import {
|
|
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
|
|
30
|
+
// from your own screen, hand it the address and go here: `holdOtpAddress(email); go("/otp")`.
|
|
22
31
|
//
|
|
23
|
-
// **The
|
|
24
|
-
//
|
|
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
|
|
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
|
-
|
|
439
|
-
go(`${otpPath}${
|
|
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
|
+
}
|