@pithy-sh/ui-react-native 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/dist/generatedConfig.d.ts +195 -0
- package/dist/generatedConfig.d.ts.map +1 -0
- package/dist/generatedConfig.js +323 -0
- package/dist/templates.d.ts +48 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +85 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +10 -0
- package/package.json +83 -4
- package/src/generatedConfig.ts +419 -0
- package/src/templates.ts +92 -0
- package/src/version.ts +13 -0
- package/templates/app/[...pithy].tsx +7 -0
- package/templates/app/_layout.tsx +31 -0
- package/templates/app/index.tsx +4 -0
- package/templates/app.config.ts +56 -0
- package/templates/metro-proxy.cjs +289 -0
- package/templates/metro.config.js +6 -0
- package/templates/src/payments-store.native.ts +20 -0
- package/templates/src/payments-store.ts +16 -0
- package/templates/src/payments.tsx +769 -0
- package/templates/src/pithy-config.generated.ts +99 -0
- package/templates/src/pithy-config.ts +169 -0
- package/templates/src/pithy-locale.tsx +90 -0
- package/templates/src/platform.ts +111 -0
- package/templates/src/route-modules.ts +76 -0
- package/templates/src/router.tsx +387 -0
- package/templates/src/routes/app/home.bare.tsx +75 -0
- package/templates/src/routes/app/home.tsx +55 -0
- package/templates/src/routes/pithy/account-deleted.tsx +69 -0
- package/templates/src/routes/pithy/account-suspended.tsx +70 -0
- package/templates/src/routes/pithy/agent-consent.tsx +260 -0
- package/templates/src/routes/pithy/callback.tsx +74 -0
- package/templates/src/routes/pithy/choose-organization.tsx +230 -0
- package/templates/src/routes/pithy/connected-applications.tsx +153 -0
- package/templates/src/routes/pithy/consent.tsx +453 -0
- package/templates/src/routes/pithy/invitation.tsx +239 -0
- package/templates/src/routes/pithy/organization-suspended.tsx +51 -0
- package/templates/src/routes/pithy/otp.tsx +196 -0
- package/templates/src/routes/pithy/paywall.tsx +253 -0
- package/templates/src/routes/pithy/pricing.tsx +317 -0
- package/templates/src/routes/pithy/sign-in.tsx +673 -0
- package/templates/src/routes/pithy/subscription.tsx +240 -0
- package/templates/src/session.tsx +290 -0
- package/templates/src/theme.ts +100 -0
- package/templates/src/transport.ts +290 -0
- package/templates/src/turnstile.tsx +117 -0
- package/templates/src/useAppFocus.ts +20 -0
- package/templates/src/useStoreTransactions.ts +16 -0
- package/templates/src/useTurnstileWeb.ts +104 -0
- package/templates/tsconfig.json +10 -0
- package/README.md +0 -3
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
|
|
2
|
+
import type { Translator } from "@pithy-sh/core/src/i18n/translator";
|
|
3
|
+
import { useTranslator } from "@pithy-sh/i18n/src/react/translator";
|
|
4
|
+
import {
|
|
5
|
+
PAYMENTS_HOSTED_RAILS,
|
|
6
|
+
type PaymentsClientOptions,
|
|
7
|
+
type PaymentsClientRail,
|
|
8
|
+
} from "@pithy-sh/payments/src/client/api";
|
|
9
|
+
import { useSubscription } from "@pithy-sh/payments/src/client/hooks";
|
|
10
|
+
import type { PaymentsRailChoice } from "@pithy-sh/payments/src/client/railChoice";
|
|
11
|
+
import { type ReactNode, useMemo, useRef } from "react";
|
|
12
|
+
import { Pressable, Text, View } from "react-native";
|
|
13
|
+
import { failureText, returningClient } from "../../payments";
|
|
14
|
+
import { paymentsConfig, platformRails } from "../../pithy-config";
|
|
15
|
+
import { devicePlatform } from "../../platform";
|
|
16
|
+
import { Link, useScreenPath } from "../../router";
|
|
17
|
+
import { type Theme, useTheme } from "../../theme";
|
|
18
|
+
import { useAppFocus } from "../../useAppFocus";
|
|
19
|
+
|
|
20
|
+
export const path = "/subscription";
|
|
21
|
+
|
|
22
|
+
// What the paywall and the pricing screen link to as "what do I already have?". Rename `path` above and both
|
|
23
|
+
// follow, because they read this claim rather than keeping a copy of the string.
|
|
24
|
+
export const role = "subscription";
|
|
25
|
+
|
|
26
|
+
// The entitlements shown are the caller's own; the server resolves them from the session. Under a project that
|
|
27
|
+
// bills organizations they are the organization's, resolved from that same session. Either way this screen names no
|
|
28
|
+
// holder and cannot, which is what stops it becoming a way to read somebody else's.
|
|
29
|
+
export const session = "required";
|
|
30
|
+
|
|
31
|
+
// Pithy's screen. Yours to override: put your own file at this path under src/routes/app/ and it wins.
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* This screen's English, baked in: the only catalog that survives being copied into your repository.
|
|
35
|
+
*
|
|
36
|
+
* The renewal date is not in here, and could not be: a date is `Intl`'s to render, in the reader's own locale,
|
|
37
|
+
* from the translator's `formattingLocale`. What the message carries is the sentence around it.
|
|
38
|
+
*/
|
|
39
|
+
const EN = {
|
|
40
|
+
"payments/subscription.subscribed": "You're subscribed.",
|
|
41
|
+
"payments/subscription.empty": "Nothing yet.",
|
|
42
|
+
"payments/subscription.unreadable": "We couldn't check.",
|
|
43
|
+
"payments/subscription.nothing_held": "You don't hold anything on this account.",
|
|
44
|
+
"payments/subscription.loading": "One moment.",
|
|
45
|
+
"payments/subscription.holding.ended": "Ended.",
|
|
46
|
+
"payments/subscription.holding.ended_not_renewing": "Ended, and not renewing.",
|
|
47
|
+
"payments/subscription.holding.kept": "Yours to keep.",
|
|
48
|
+
"payments/subscription.holding.renews": "Renews {date}.",
|
|
49
|
+
"payments/subscription.manage": "Manage billing",
|
|
50
|
+
"payments/subscription.apple": "Bought on the App Store",
|
|
51
|
+
"payments/subscription.google": "Bought on Google Play",
|
|
52
|
+
"payments/subscription.more": "See what else there is",
|
|
53
|
+
} satisfies MessageCatalog;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What to say about one entitlement.
|
|
57
|
+
*
|
|
58
|
+
* `granted` comes first: the server applies the expiry itself on every read, so a row can come back
|
|
59
|
+
* `granted: false` while its own `expiresAt` still says nothing is wrong. A refunded non-consumable is
|
|
60
|
+
* `{ granted: false, expiresAt: null }`, and reading only the date would print "Yours to keep." over a purchase the
|
|
61
|
+
* holder no longer has.
|
|
62
|
+
*/
|
|
63
|
+
function holding(t: Translator, entitlement: { granted: boolean; expiresAt: string | null }): string {
|
|
64
|
+
if (!entitlement.granted) {
|
|
65
|
+
return entitlement.expiresAt === null
|
|
66
|
+
? t.t("payments/subscription.holding.ended")
|
|
67
|
+
: t.t("payments/subscription.holding.ended_not_renewing");
|
|
68
|
+
}
|
|
69
|
+
if (entitlement.expiresAt === null) return t.t("payments/subscription.holding.kept");
|
|
70
|
+
// The app's locale, not the device's: a reader who chose Spanish inside the app reads a Spanish date.
|
|
71
|
+
return t.t("payments/subscription.holding.renews", { date: t.formatDate(new Date(entitlement.expiresAt)) });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One of this screen's buttons. The first is filled with the accent; the rest are outlined. */
|
|
75
|
+
function Action(props: {
|
|
76
|
+
theme: Theme;
|
|
77
|
+
label: string;
|
|
78
|
+
primary?: boolean;
|
|
79
|
+
disabled?: boolean;
|
|
80
|
+
onPress: () => void;
|
|
81
|
+
}): ReactNode {
|
|
82
|
+
const { theme } = props;
|
|
83
|
+
return (
|
|
84
|
+
<Pressable
|
|
85
|
+
role="button"
|
|
86
|
+
disabled={props.disabled}
|
|
87
|
+
aria-disabled={props.disabled}
|
|
88
|
+
onPress={props.onPress}
|
|
89
|
+
style={{
|
|
90
|
+
borderWidth: 1,
|
|
91
|
+
borderColor: props.primary ? theme.colors.accent : theme.colors.border,
|
|
92
|
+
backgroundColor: props.primary ? theme.colors.accent : "transparent",
|
|
93
|
+
borderRadius: theme.radius,
|
|
94
|
+
paddingVertical: theme.spacing.sm,
|
|
95
|
+
paddingHorizontal: theme.spacing.md,
|
|
96
|
+
alignSelf: "flex-start",
|
|
97
|
+
opacity: props.disabled ? 0.5 : 1,
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
<Text style={[theme.type.button, { color: props.primary ? theme.colors.onAccent : theme.colors.text }]}>
|
|
101
|
+
{props.label}
|
|
102
|
+
</Text>
|
|
103
|
+
</Pressable>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface SubscriptionScreenProps {
|
|
108
|
+
/**
|
|
109
|
+
* The translator this screen renders through. Absent, the screen reads the provider a `TranslatorProvider`
|
|
110
|
+
* mounted, and with no provider it reads {@link EN}.
|
|
111
|
+
*/
|
|
112
|
+
readonly t?: Translator;
|
|
113
|
+
/**
|
|
114
|
+
* Which rails the Worker sells through: `paymentsConfig.rails`. Keyed by the rail union rather than by `string`,
|
|
115
|
+
* so a rail added to the package whose projection nobody widened stops compiling here.
|
|
116
|
+
*/
|
|
117
|
+
readonly rails: Readonly<Record<PaymentsClientRail, boolean>>;
|
|
118
|
+
/**
|
|
119
|
+
* The rail this platform sells through, from `pithy-config.generated.ts`. `none` is a build that sells nothing:
|
|
120
|
+
* it shows what the account holds and offers no way to buy or manage a purchase.
|
|
121
|
+
*/
|
|
122
|
+
readonly choice: PaymentsRailChoice;
|
|
123
|
+
/**
|
|
124
|
+
* Where the payments routes are, and the fetch to reach them with. Absent, the app's own, with the system
|
|
125
|
+
* browser's close reported back so the screen reads the subscription again.
|
|
126
|
+
*/
|
|
127
|
+
readonly client?: PaymentsClientOptions;
|
|
128
|
+
/** Where "see what else there is" points: the path the paywall screen declares, read through its role. */
|
|
129
|
+
readonly paywallPath: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The subscription screen, taking its projection rather than reading it, so a test mounts it against any set of
|
|
134
|
+
* rails and looks for the buttons a reader would.
|
|
135
|
+
*
|
|
136
|
+
* Managing a subscription belongs to whoever sold it. A hosted rail's billing portal is a page the Worker mints,
|
|
137
|
+
* which opens in the system browser on a phone; Apple's and Google's are pages in their own stores. Coming back to
|
|
138
|
+
* the app reads the subscription again, so a cancellation shows the moment the reader returns.
|
|
139
|
+
*/
|
|
140
|
+
export function SubscriptionScreen({
|
|
141
|
+
rails,
|
|
142
|
+
choice,
|
|
143
|
+
client,
|
|
144
|
+
paywallPath,
|
|
145
|
+
t: given,
|
|
146
|
+
}: SubscriptionScreenProps): ReactNode {
|
|
147
|
+
const refreshAfter = useRef<() => void>(() => undefined);
|
|
148
|
+
const returning = useMemo(() => returningClient(() => refreshAfter.current()), []);
|
|
149
|
+
const { entitlements, subscribed, loading, refresh, manage, manageStore, managing, failure, readFailure } =
|
|
150
|
+
useSubscription(client ?? returning);
|
|
151
|
+
refreshAfter.current = refresh;
|
|
152
|
+
useAppFocus(refresh);
|
|
153
|
+
// Called unconditionally, and chosen from afterwards: `given ?? useTranslator(EN)` would skip the hook whenever
|
|
154
|
+
// the prop is passed, which is a hook count that changes between renders.
|
|
155
|
+
const baked = useTranslator(EN);
|
|
156
|
+
const t = given ?? baked;
|
|
157
|
+
const theme = useTheme();
|
|
158
|
+
const muted = [theme.type.body, { color: theme.colors.muted }];
|
|
159
|
+
|
|
160
|
+
if (loading) {
|
|
161
|
+
return (
|
|
162
|
+
<View style={{ flex: 1, padding: theme.spacing.lg, backgroundColor: theme.colors.background }}>
|
|
163
|
+
<Text style={muted}>{t.t("payments/subscription.loading")}</Text>
|
|
164
|
+
</View>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const sells = choice !== "none";
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<View
|
|
172
|
+
style={{ flex: 1, padding: theme.spacing.lg, gap: theme.spacing.md, backgroundColor: theme.colors.background }}
|
|
173
|
+
>
|
|
174
|
+
{/* A read that failed is not an account that holds nothing. "Nothing yet." over an unreachable Worker tells a
|
|
175
|
+
paying subscriber they have no subscription, one tap from the paywall that would sell them a second. */}
|
|
176
|
+
<Text role="heading" style={[theme.type.title, { color: theme.colors.text }]}>
|
|
177
|
+
{readFailure
|
|
178
|
+
? t.t("payments/subscription.unreadable")
|
|
179
|
+
: subscribed
|
|
180
|
+
? t.t("payments/subscription.subscribed")
|
|
181
|
+
: t.t("payments/subscription.empty")}
|
|
182
|
+
</Text>
|
|
183
|
+
|
|
184
|
+
{readFailure ? (
|
|
185
|
+
<Text style={muted}>{failureText(t, readFailure)}</Text>
|
|
186
|
+
) : entitlements.length === 0 ? (
|
|
187
|
+
<Text style={muted}>{t.t("payments/subscription.nothing_held")}</Text>
|
|
188
|
+
) : (
|
|
189
|
+
<View style={{ gap: theme.spacing.sm }}>
|
|
190
|
+
{entitlements.map((entitlement) => (
|
|
191
|
+
<Text key={entitlement.key} style={[theme.type.body, { color: theme.colors.text }]}>
|
|
192
|
+
<Text style={{ fontWeight: "700" }}>{entitlement.key}</Text>{" "}
|
|
193
|
+
<Text style={{ color: theme.colors.muted }}>{holding(t, entitlement)}</Text>
|
|
194
|
+
</Text>
|
|
195
|
+
))}
|
|
196
|
+
</View>
|
|
197
|
+
)}
|
|
198
|
+
|
|
199
|
+
{failure && <Text style={muted}>{failureText(t, failure)}</Text>}
|
|
200
|
+
|
|
201
|
+
{sells && (
|
|
202
|
+
<View style={{ gap: theme.spacing.sm }}>
|
|
203
|
+
{/* Every hosted rail mints a portal, and the Worker picks whichever one this account bought on. The list
|
|
204
|
+
is the package's, so a rail added to Pithy reaches this screen without an edit. */}
|
|
205
|
+
{PAYMENTS_HOSTED_RAILS.some((rail) => rails[rail]) && (
|
|
206
|
+
<Action
|
|
207
|
+
theme={theme}
|
|
208
|
+
primary
|
|
209
|
+
label={t.t("payments/subscription.manage")}
|
|
210
|
+
disabled={managing}
|
|
211
|
+
onPress={() => void manage()}
|
|
212
|
+
/>
|
|
213
|
+
)}
|
|
214
|
+
{/* Named one at a time: each store has its own sentence, and its own page. */}
|
|
215
|
+
{rails.apple && (
|
|
216
|
+
<Action theme={theme} label={t.t("payments/subscription.apple")} onPress={() => manageStore("apple")} />
|
|
217
|
+
)}
|
|
218
|
+
{rails.google && (
|
|
219
|
+
<Action theme={theme} label={t.t("payments/subscription.google")} onPress={() => manageStore("google")} />
|
|
220
|
+
)}
|
|
221
|
+
<Link to={paywallPath} style={{ color: theme.colors.muted }}>
|
|
222
|
+
{t.t("payments/subscription.more")}
|
|
223
|
+
</Link>
|
|
224
|
+
</View>
|
|
225
|
+
)}
|
|
226
|
+
</View>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export default function Subscription(): ReactNode {
|
|
231
|
+
// The paywall names itself; this reads the path it declared rather than keeping a copy.
|
|
232
|
+
const paywallPath = useScreenPath("paywall");
|
|
233
|
+
return (
|
|
234
|
+
<SubscriptionScreen
|
|
235
|
+
rails={paymentsConfig.rails}
|
|
236
|
+
choice={platformRails[devicePlatform()]}
|
|
237
|
+
paywallPath={paywallPath}
|
|
238
|
+
/>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import type { AuthSession, AuthUser } from "@pithy-sh/auth/src/client/api";
|
|
2
|
+
import {
|
|
3
|
+
AUTH_UNREACHABLE,
|
|
4
|
+
AUTH_UNREADABLE,
|
|
5
|
+
callAuth,
|
|
6
|
+
signOut as endSession,
|
|
7
|
+
getSession as readSession,
|
|
8
|
+
} from "@pithy-sh/auth/src/client/api";
|
|
9
|
+
import { createHandoffVerifier, exchangeHandoff, handoffCallbackURL } from "@pithy-sh/auth/src/client/handoff";
|
|
10
|
+
import { accountRefusal } from "@pithy-sh/auth/src/client/lifecycle";
|
|
11
|
+
import { useRead } from "@pithy-sh/ui-core/src/read";
|
|
12
|
+
import { matchPath, type ScreenRole, screenPath } from "@pithy-sh/ui-core/src/routes";
|
|
13
|
+
import { CryptoDigestAlgorithm, digest, getRandomBytes } from "expo-crypto";
|
|
14
|
+
import { useCallback } from "react";
|
|
15
|
+
import { Linking } from "react-native";
|
|
16
|
+
import { authConfig, origin, universalLinkOrigin } from "./pithy-config";
|
|
17
|
+
import { appLink, onDevice, openExternal, parseQuery, queryString } from "./platform";
|
|
18
|
+
import { routeTable, useGo } from "./router";
|
|
19
|
+
import { secureStore, transport } from "./transport";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Session state, read from the Worker.
|
|
23
|
+
*
|
|
24
|
+
* The transport is `./transport`'s: a bearer credential in the secure store on a phone, the page's httpOnly cookie
|
|
25
|
+
* on the web. The requests themselves live in `@pithy-sh/auth/src/client/api` and `.../bearer`, which upgrade with
|
|
26
|
+
* a minor release; this file decides what the app does with their answers.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** The signed-in user, as the session route returns them. */
|
|
30
|
+
export type SessionUser = AuthUser;
|
|
31
|
+
|
|
32
|
+
/** A live session, or `null` when nobody is signed in. */
|
|
33
|
+
export type Session = AuthSession;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The current session, or `null`. On a phone the stored credential is restored first, and reading the session is
|
|
37
|
+
* what tells the bearer session when its credential expires, so it rotates in time.
|
|
38
|
+
*
|
|
39
|
+
* Never throws. A Worker that could not be reached reads as signed out here.
|
|
40
|
+
*/
|
|
41
|
+
export async function getSession(): Promise<Session> {
|
|
42
|
+
if (!authConfig.enabled) return null;
|
|
43
|
+
await transport.session();
|
|
44
|
+
const result = await readSession(transport.auth);
|
|
45
|
+
return result.ok ? result.value : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Hold the session a sign-in just answered with. On a phone that is the raw `token` in the answer's body, kept in
|
|
50
|
+
* the secure store before this resolves; on the web the Worker already set the cookie. `false` when a phone got an
|
|
51
|
+
* answer with no token in it, which is a sign-in that did not take.
|
|
52
|
+
*/
|
|
53
|
+
export async function adoptSignIn(answer: unknown): Promise<boolean> {
|
|
54
|
+
if (!transport.bearer) return true;
|
|
55
|
+
const token = typeof answer === "object" && answer !== null ? (answer as { token?: unknown }).token : undefined;
|
|
56
|
+
if (typeof token !== "string" || token === "") return false;
|
|
57
|
+
await transport.adopt(token);
|
|
58
|
+
await readSession(transport.auth);
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** How a sign-in on a phone ended: the session is held, or the code the Worker refused it with. */
|
|
63
|
+
export type SignInOutcome = { ok: true } | { ok: false; code: string };
|
|
64
|
+
|
|
65
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
66
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Who the provider says signed in, beside the token. Apple puts no name in its identity token and hands the name
|
|
71
|
+
* and address to the app on the first sign-in only, so the Worker learns them from this or never.
|
|
72
|
+
*/
|
|
73
|
+
export interface IdTokenUser {
|
|
74
|
+
readonly name?: { readonly firstName?: string; readonly lastName?: string };
|
|
75
|
+
readonly email?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Sign in with an id token a provider's own SDK handed the app: Apple's identity token, or Google's id token
|
|
80
|
+
* for the web client id. The Worker checks the token's audience and answers with the raw session token, which
|
|
81
|
+
* is adopted before this resolves. A phone only: the web signs in through the redirect.
|
|
82
|
+
*/
|
|
83
|
+
export async function signInWithIdToken(
|
|
84
|
+
provider: "apple" | "google",
|
|
85
|
+
idToken: { token: string; nonce?: string; user?: IdTokenUser },
|
|
86
|
+
): Promise<SignInOutcome> {
|
|
87
|
+
const result = await callAuth(
|
|
88
|
+
"/sign-in/social",
|
|
89
|
+
{ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ provider, idToken }) },
|
|
90
|
+
transport.auth,
|
|
91
|
+
isRecord,
|
|
92
|
+
);
|
|
93
|
+
if (!result.ok) return { ok: false, code: result.failure.code };
|
|
94
|
+
return (await adoptSignIn(result.value)) ? { ok: true } : { ok: false, code: AUTH_UNREADABLE.code };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Where the verifier of a redirect sign-in waits while the system browser has it. The secure store rather than
|
|
99
|
+
* memory, because a magic link is opened from the mail app, and the app may be gone by the time it returns.
|
|
100
|
+
*/
|
|
101
|
+
export const HANDOFF_VERIFIER_KEY = "pithy.auth.handoff";
|
|
102
|
+
|
|
103
|
+
/** The code {@link completeHandoff} answers when no sign-in from this device is waiting for a code. */
|
|
104
|
+
export const NO_HANDOFF = "client/no_handoff";
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The link a sign-in in the system browser returns to `path` on: the universal link when `universalLinkOrigin` is
|
|
108
|
+
* set, which only this app can claim, and the app's own scheme otherwise. The Worker's `trustedOrigins` names it.
|
|
109
|
+
*/
|
|
110
|
+
export function returnLink(path: string): string {
|
|
111
|
+
return universalLinkOrigin === null ? appLink(path) : `${universalLinkOrigin}${path}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Start a redirect sign-in on a phone: make a verifier, keep it, and answer the `callbackURL` that returns to
|
|
116
|
+
* `path` in this app carrying the verifier's challenge. The Worker sends the browser back there with a one-time
|
|
117
|
+
* code instead of a cookie the app could never read. One sign-in waits at a time; starting another replaces it.
|
|
118
|
+
*/
|
|
119
|
+
export async function beginHandoff(path: string): Promise<string> {
|
|
120
|
+
const { verifier, challenge } = await createHandoffVerifier({
|
|
121
|
+
randomBytes: getRandomBytes,
|
|
122
|
+
// A copy over a fresh `ArrayBuffer`: `BufferSource` takes no view over a `SharedArrayBuffer`.
|
|
123
|
+
sha256: (data) => digest(CryptoDigestAlgorithm.SHA256, new Uint8Array(data)),
|
|
124
|
+
});
|
|
125
|
+
await secureStore.setItem(HANDOFF_VERIFIER_KEY, verifier);
|
|
126
|
+
return handoffCallbackURL(returnLink(path), challenge);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Forget the waiting verifier: the sign-in it belonged to was abandoned. */
|
|
130
|
+
export async function abandonHandoff(): Promise<void> {
|
|
131
|
+
try {
|
|
132
|
+
await secureStore.deleteItem(HANDOFF_VERIFIER_KEY);
|
|
133
|
+
} catch {
|
|
134
|
+
// The next sign-in replaces it, and the Worker spends the code it stood for within a minute anyway.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function trade(code: string): Promise<SignInOutcome> {
|
|
139
|
+
let verifier: string | null;
|
|
140
|
+
try {
|
|
141
|
+
verifier = await secureStore.getItem(HANDOFF_VERIFIER_KEY);
|
|
142
|
+
} catch {
|
|
143
|
+
verifier = null;
|
|
144
|
+
}
|
|
145
|
+
if (verifier === null || verifier === "") return { ok: false, code: NO_HANDOFF };
|
|
146
|
+
const traded = await exchangeHandoff({ code, verifier }, transport.auth);
|
|
147
|
+
// A refused code may be a stray link's, so the verifier waits on for the code its own sign-in returns with.
|
|
148
|
+
if (!traded.ok) return { ok: false, code: traded.failure.code };
|
|
149
|
+
await abandonHandoff();
|
|
150
|
+
return (await adoptSignIn(traded.value)) ? { ok: true } : { ok: false, code: AUTH_UNREADABLE.code };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const trades = new Map<string, Promise<SignInOutcome>>();
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Trade the one-time code a redirect sign-in returned with for the session, and adopt it. The sign-in screen
|
|
157
|
+
* reads the code off the browser's answer and the callback screen reads it off the deep link, and on Android
|
|
158
|
+
* both see the same return: they share one trade per code, because the Worker honors a code once. A trade that
|
|
159
|
+
* never reached the Worker left the code unspent, so it is forgotten and the next call tries again.
|
|
160
|
+
*/
|
|
161
|
+
export function completeHandoff(code: string): Promise<SignInOutcome> {
|
|
162
|
+
const running = trades.get(code);
|
|
163
|
+
if (running) return running;
|
|
164
|
+
const started = trade(code).then(
|
|
165
|
+
(outcome) => {
|
|
166
|
+
if (!outcome.ok && outcome.code === AUTH_UNREACHABLE.code) trades.delete(code);
|
|
167
|
+
return outcome;
|
|
168
|
+
},
|
|
169
|
+
(error: unknown) => {
|
|
170
|
+
trades.delete(code);
|
|
171
|
+
throw error;
|
|
172
|
+
},
|
|
173
|
+
);
|
|
174
|
+
trades.set(code, started);
|
|
175
|
+
return started;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The screen for an account that may not act, from the code its refusal carried, or `null` for any other code.
|
|
180
|
+
* The callback and the sign-in screen both route through it, by role.
|
|
181
|
+
*/
|
|
182
|
+
export function refusedAccountScreen(code: string | null | undefined): ScreenRole | null {
|
|
183
|
+
const refused = accountRefusal(code);
|
|
184
|
+
if (refused === "suspended") return "account-suspended";
|
|
185
|
+
if (refused === "deleted") return "account-deleted";
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** End the session: the Worker's first, then the credential this device holds. */
|
|
190
|
+
export async function signOut(): Promise<void> {
|
|
191
|
+
await endSession(transport.auth);
|
|
192
|
+
await transport.forget();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** {@link signOut}, then the sign-in screen. */
|
|
196
|
+
export function useSignOut(): () => Promise<void> {
|
|
197
|
+
const go = useGo();
|
|
198
|
+
return useCallback(async () => {
|
|
199
|
+
await signOut();
|
|
200
|
+
go(screenPath(await routeTable(), "sign-in"));
|
|
201
|
+
}, [go]);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The session as component state, plus a `refresh` for after a sign-in completes. */
|
|
205
|
+
export function useSession(): { session: Session; loading: boolean; refresh: () => void } {
|
|
206
|
+
const read = useRead("session", getSession);
|
|
207
|
+
return { session: read.value ?? null, loading: read.loading, refresh: read.refresh };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The query parameter a sign-in carries the request it should resume in. */
|
|
211
|
+
export const RESUME_PARAM = "resume";
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* `path` when it is a path on the Worker's own origin, or `null`. A rooted path with no second slash or backslash
|
|
215
|
+
* after the first, and no control character: a URL parser drops tab, CR and LF before it reads, so `"/\t/evil.test"`
|
|
216
|
+
* is a protocol-relative URL to it.
|
|
217
|
+
*/
|
|
218
|
+
function ownPath(path: string): string | null {
|
|
219
|
+
if (!path.startsWith("/")) return null;
|
|
220
|
+
if (path[1] === "/" || path[1] === "\\") return null;
|
|
221
|
+
for (const char of path) {
|
|
222
|
+
const code = char.charCodeAt(0);
|
|
223
|
+
if (code < 0x20 || code === 0x7f || char === "\\") return null;
|
|
224
|
+
}
|
|
225
|
+
return path;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Where this screen's sign-in should resume, read off its query, or `null`. */
|
|
229
|
+
export function resumeTarget(search: string): string | null {
|
|
230
|
+
const path = parseQuery(search)[RESUME_PARAM];
|
|
231
|
+
return path === undefined ? null : ownPath(path);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** `?resume=…` to carry a sign-in's resume target onward, or the empty string. */
|
|
235
|
+
export function resumeQuery(search: string): string {
|
|
236
|
+
const target = resumeTarget(search);
|
|
237
|
+
return target === null ? "" : queryString({ [RESUME_PARAM]: target });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Leave for a page in this tab, on the web. React Native Web's own `Linking.openURL` takes the target as a second
|
|
242
|
+
* argument that React Native's types leave out; `expo-linking` drops it, and a link opened without it is a new
|
|
243
|
+
* tab, which a popup blocker stops once an await has passed.
|
|
244
|
+
*/
|
|
245
|
+
const pageLinking: { openURL(url: string, target?: string): Promise<unknown> } = Linking;
|
|
246
|
+
|
|
247
|
+
/** Leave the app for `url` in this tab, on the web: a provider's authorization page, which returns with a cookie. */
|
|
248
|
+
export async function leaveInPlace(url: string): Promise<void> {
|
|
249
|
+
await pageLinking.openURL(url, "_self");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Whether `target` is one of this app's own screens, such as an invitation, rather than a page the Worker serves. */
|
|
253
|
+
async function isScreen(target: string): Promise<boolean> {
|
|
254
|
+
const at = target.indexOf("?");
|
|
255
|
+
return matchPath((await routeTable()).patterns, at === -1 ? target : target.slice(0, at)) !== null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* After a sign-in: go where the query says to resume. `true` when the app is going there.
|
|
260
|
+
*
|
|
261
|
+
* One of this app's own screens, such as the invitation that sent somebody to sign in, is gone to in the app
|
|
262
|
+
* through `go`: pass the screen's `useGo()`. Anything else is the Worker's own page, such as an authorization
|
|
263
|
+
* request: on the web it loads in place, and on a phone it opens in the system browser.
|
|
264
|
+
*/
|
|
265
|
+
export async function resumeAfterSignIn(search: string, go?: (to: string) => void): Promise<boolean> {
|
|
266
|
+
const target = resumeTarget(search);
|
|
267
|
+
if (target === null) return false;
|
|
268
|
+
if (go && (await isScreen(target))) {
|
|
269
|
+
go(target);
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
// The system browser holds no session of the app's: the Worker's page signs the person in there itself.
|
|
273
|
+
if (onDevice()) return openExternal(`${origin}${target}`);
|
|
274
|
+
await pageLinking.openURL(target, "_self");
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Where a sign-in that took goes next, or `null` when the page is leaving for the Worker's own page in place.
|
|
280
|
+
*
|
|
281
|
+
* One of this app's own screens, such as the invitation that sent somebody to sign in, is that screen, on every
|
|
282
|
+
* platform. The Worker's own page, such as an authorization request, loads in place on the web and opens in the
|
|
283
|
+
* system browser on a phone, where the app goes home beneath it. With nothing to resume, home.
|
|
284
|
+
*/
|
|
285
|
+
export async function afterSignIn(search: string): Promise<string | null> {
|
|
286
|
+
const target = resumeTarget(search);
|
|
287
|
+
if (target !== null && (await isScreen(target))) return target;
|
|
288
|
+
if ((await resumeAfterSignIn(search)) && !onDevice()) return null;
|
|
289
|
+
return "/";
|
|
290
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { type TextStyle, useColorScheme } from "react-native";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The look every Pithy screen draws with: colors, spacing and type, light and dark. The palette is the one the
|
|
5
|
+
* web screens use, so an app on both reads as one product.
|
|
6
|
+
*
|
|
7
|
+
* Edit the values here and every screen follows. A screen never writes a color or a size of its own; it asks
|
|
8
|
+
* {@link useTheme} for the palette the device is in and reads a token, so a dark device gets the dark palette
|
|
9
|
+
* without a screen branching on it.
|
|
10
|
+
*/
|
|
11
|
+
export interface Theme {
|
|
12
|
+
/** Light or dark: which palette this is. */
|
|
13
|
+
readonly scheme: "light" | "dark";
|
|
14
|
+
/** Colors, by the job they do. */
|
|
15
|
+
readonly colors: {
|
|
16
|
+
/** Behind everything. */
|
|
17
|
+
readonly background: string;
|
|
18
|
+
/** A raised surface: a card, an input. */
|
|
19
|
+
readonly surface: string;
|
|
20
|
+
/** Body text. */
|
|
21
|
+
readonly text: string;
|
|
22
|
+
/** Secondary text: a hint, a caption. */
|
|
23
|
+
readonly muted: string;
|
|
24
|
+
/** Borders and dividers. */
|
|
25
|
+
readonly border: string;
|
|
26
|
+
/** The one action a screen wants taken. */
|
|
27
|
+
readonly accent: string;
|
|
28
|
+
/** Text drawn on {@link Theme.colors.accent}. */
|
|
29
|
+
readonly onAccent: string;
|
|
30
|
+
/** A failure the reader has to act on. */
|
|
31
|
+
readonly danger: string;
|
|
32
|
+
};
|
|
33
|
+
/** Spacing, in density-independent pixels. */
|
|
34
|
+
readonly spacing: {
|
|
35
|
+
readonly xs: number;
|
|
36
|
+
readonly sm: number;
|
|
37
|
+
readonly md: number;
|
|
38
|
+
readonly lg: number;
|
|
39
|
+
readonly xl: number;
|
|
40
|
+
};
|
|
41
|
+
/** Corner radius for surfaces and buttons. */
|
|
42
|
+
readonly radius: number;
|
|
43
|
+
/** Text styles, by role. Colors are left to the screen, so one style serves both palettes. */
|
|
44
|
+
readonly type: {
|
|
45
|
+
readonly title: TextStyle;
|
|
46
|
+
readonly body: TextStyle;
|
|
47
|
+
readonly caption: TextStyle;
|
|
48
|
+
readonly button: TextStyle;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const spacing = { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 } as const;
|
|
53
|
+
|
|
54
|
+
const type = {
|
|
55
|
+
title: { fontSize: 28, lineHeight: 34, fontWeight: "700" },
|
|
56
|
+
body: { fontSize: 16, lineHeight: 24, fontWeight: "400" },
|
|
57
|
+
caption: { fontSize: 13, lineHeight: 18, fontWeight: "400" },
|
|
58
|
+
button: { fontSize: 16, lineHeight: 20, fontWeight: "600" },
|
|
59
|
+
} as const satisfies Theme["type"];
|
|
60
|
+
|
|
61
|
+
/** The light palette. */
|
|
62
|
+
export const light: Theme = {
|
|
63
|
+
scheme: "light",
|
|
64
|
+
colors: {
|
|
65
|
+
background: "#fafaf6",
|
|
66
|
+
surface: "#ffffff",
|
|
67
|
+
text: "#111111",
|
|
68
|
+
muted: "#5f5d57",
|
|
69
|
+
border: "#e5e3db",
|
|
70
|
+
accent: "#d4a017",
|
|
71
|
+
onAccent: "#111111",
|
|
72
|
+
danger: "#b3261e",
|
|
73
|
+
},
|
|
74
|
+
spacing,
|
|
75
|
+
radius: 10,
|
|
76
|
+
type,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** The dark palette. */
|
|
80
|
+
export const dark: Theme = {
|
|
81
|
+
scheme: "dark",
|
|
82
|
+
colors: {
|
|
83
|
+
background: "#111111",
|
|
84
|
+
surface: "#1a1a19",
|
|
85
|
+
text: "#fafaf6",
|
|
86
|
+
muted: "#9a988f",
|
|
87
|
+
border: "#2e2e2b",
|
|
88
|
+
accent: "#d4a017",
|
|
89
|
+
onAccent: "#111111",
|
|
90
|
+
danger: "#f2b8b5",
|
|
91
|
+
},
|
|
92
|
+
spacing,
|
|
93
|
+
radius: 10,
|
|
94
|
+
type,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** The palette for the scheme the device is in now. Re-renders when the device switches. */
|
|
98
|
+
export function useTheme(): Theme {
|
|
99
|
+
return useColorScheme() === "dark" ? dark : light;
|
|
100
|
+
}
|