@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,153 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ConnectedApplication,
|
|
3
|
+
listConnectedApplications,
|
|
4
|
+
revokeConnectedApplication,
|
|
5
|
+
} from "@pithy-sh/auth/src/client/mcp";
|
|
6
|
+
import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
|
|
7
|
+
import { useTranslator } from "@pithy-sh/i18n/src/react/translator";
|
|
8
|
+
import { useRead } from "@pithy-sh/ui-core/src/read";
|
|
9
|
+
import { type ReactNode, useState } from "react";
|
|
10
|
+
import { Pressable, Text, View } from "react-native";
|
|
11
|
+
import { type Theme, useTheme } from "../../theme";
|
|
12
|
+
import { transport } from "../../transport";
|
|
13
|
+
|
|
14
|
+
export const path = "/connected-applications";
|
|
15
|
+
|
|
16
|
+
// Pithy's screen. Yours to override: put your own file at this path under src/routes/app/ and it wins.
|
|
17
|
+
//
|
|
18
|
+
// **Revoking is the only narrowing a person has.** Consent is one decision with nothing to deselect, so "I do not
|
|
19
|
+
// want it doing that" has exactly one answer, and it must be reachable without asking an administrator. Link to
|
|
20
|
+
// this screen from your account settings. A revoked application's next call is refused.
|
|
21
|
+
//
|
|
22
|
+
// Each application is shown by the name it gave itself — unverified, as on the consent screen — and by where its
|
|
23
|
+
// access goes, which is what identifies it.
|
|
24
|
+
|
|
25
|
+
/** Where the MCP capability mounts. Change this if `mcp({ path })` does. */
|
|
26
|
+
const MCP_PATH = "/mcp";
|
|
27
|
+
|
|
28
|
+
/** This screen's English, baked in — the only catalog that survives being copied into your repository. */
|
|
29
|
+
const EN = {
|
|
30
|
+
"auth/connected.title": "Applications acting as you",
|
|
31
|
+
"auth/connected.empty": "None. Nothing is acting as you.",
|
|
32
|
+
"auth/connected.unnamed": "An application with no name",
|
|
33
|
+
"auth/connected.approved": "Approved {date}. Access goes to {redirect}.",
|
|
34
|
+
"auth/connected.revoke": "Revoke",
|
|
35
|
+
"auth/connected.failed": "We couldn't load your applications.",
|
|
36
|
+
"auth/connected.retry": "Try again",
|
|
37
|
+
} satisfies MessageCatalog;
|
|
38
|
+
|
|
39
|
+
/** The MCP routes, over this app's transport. */
|
|
40
|
+
function mcpOptions() {
|
|
41
|
+
return { ...transport.auth, basePath: MCP_PATH };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function Screen(props: { theme: Theme; children?: ReactNode }): ReactNode {
|
|
45
|
+
const { theme } = props;
|
|
46
|
+
return (
|
|
47
|
+
<View
|
|
48
|
+
role="main"
|
|
49
|
+
style={{ flex: 1, padding: theme.spacing.lg, gap: theme.spacing.md, backgroundColor: theme.colors.background }}
|
|
50
|
+
>
|
|
51
|
+
{props.children}
|
|
52
|
+
</View>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function Button(props: { theme: Theme; label: string; disabled?: boolean; onPress: () => void }): ReactNode {
|
|
57
|
+
const { theme } = props;
|
|
58
|
+
const disabled = props.disabled ?? false;
|
|
59
|
+
return (
|
|
60
|
+
<Pressable
|
|
61
|
+
role="button"
|
|
62
|
+
disabled={disabled}
|
|
63
|
+
aria-disabled={disabled}
|
|
64
|
+
onPress={props.onPress}
|
|
65
|
+
style={{
|
|
66
|
+
borderWidth: 1,
|
|
67
|
+
borderColor: theme.colors.border,
|
|
68
|
+
borderRadius: theme.radius,
|
|
69
|
+
paddingVertical: theme.spacing.sm,
|
|
70
|
+
paddingHorizontal: theme.spacing.md,
|
|
71
|
+
alignSelf: "flex-start",
|
|
72
|
+
opacity: disabled ? 0.5 : 1,
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
<Text style={[theme.type.button, { color: theme.colors.text }]}>{props.label}</Text>
|
|
76
|
+
</Pressable>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export default function ConnectedApplications(): ReactNode {
|
|
81
|
+
const t = useTranslator(EN);
|
|
82
|
+
const theme = useTheme();
|
|
83
|
+
const listed = useRead("connected-applications", () => listConnectedApplications(mcpOptions()));
|
|
84
|
+
const [revokeFailed, setRevokeFailed] = useState(false);
|
|
85
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
86
|
+
const applications: readonly ConnectedApplication[] | null = listed.value?.ok ? listed.value.value : null;
|
|
87
|
+
|
|
88
|
+
async function revoke(clientId: string): Promise<void> {
|
|
89
|
+
setBusy(clientId);
|
|
90
|
+
const result = await revokeConnectedApplication(clientId, mcpOptions());
|
|
91
|
+
setBusy(null);
|
|
92
|
+
if (!result.ok) return setRevokeFailed(true);
|
|
93
|
+
listed.refresh();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function retry(): void {
|
|
97
|
+
setRevokeFailed(false);
|
|
98
|
+
listed.refresh();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (revokeFailed || (listed.value?.ok === false && !listed.loading)) {
|
|
102
|
+
return (
|
|
103
|
+
<Screen theme={theme}>
|
|
104
|
+
<Text style={[theme.type.body, { color: theme.colors.text }]}>{t.t("auth/connected.failed")}</Text>
|
|
105
|
+
<Button theme={theme} label={t.t("auth/connected.retry")} onPress={retry} />
|
|
106
|
+
</Screen>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
if (applications === null) return <Screen theme={theme} />;
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<Screen theme={theme}>
|
|
113
|
+
<Text role="heading" style={[theme.type.title, { color: theme.colors.text }]}>
|
|
114
|
+
{t.t("auth/connected.title")}
|
|
115
|
+
</Text>
|
|
116
|
+
{applications.length === 0 ? (
|
|
117
|
+
<Text style={[theme.type.body, { color: theme.colors.muted }]}>{t.t("auth/connected.empty")}</Text>
|
|
118
|
+
) : null}
|
|
119
|
+
<View role="list" style={{ gap: theme.spacing.md }}>
|
|
120
|
+
{applications.map((application) => (
|
|
121
|
+
<View
|
|
122
|
+
role="listitem"
|
|
123
|
+
key={application.clientId}
|
|
124
|
+
style={{
|
|
125
|
+
gap: theme.spacing.xs,
|
|
126
|
+
padding: theme.spacing.md,
|
|
127
|
+
borderWidth: 1,
|
|
128
|
+
borderColor: theme.colors.border,
|
|
129
|
+
borderRadius: theme.radius,
|
|
130
|
+
backgroundColor: theme.colors.surface,
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
<Text style={[theme.type.body, { color: theme.colors.text }]}>
|
|
134
|
+
{application.reportedName ?? t.t("auth/connected.unnamed")}
|
|
135
|
+
</Text>
|
|
136
|
+
<Text style={[theme.type.caption, { color: theme.colors.muted }]}>
|
|
137
|
+
{t.t("auth/connected.approved", {
|
|
138
|
+
date: t.formatDate(new Date(application.approvedAt)),
|
|
139
|
+
redirect: application.redirectUris.join(", "),
|
|
140
|
+
})}
|
|
141
|
+
</Text>
|
|
142
|
+
<Button
|
|
143
|
+
theme={theme}
|
|
144
|
+
label={t.t("auth/connected.revoke")}
|
|
145
|
+
disabled={busy === application.clientId}
|
|
146
|
+
onPress={() => void revoke(application.clientId)}
|
|
147
|
+
/>
|
|
148
|
+
</View>
|
|
149
|
+
))}
|
|
150
|
+
</View>
|
|
151
|
+
</Screen>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import { type ConsentClient, type ConsentClientOptions, consentClient } from "@pithy-sh/consent/src/client/api";
|
|
2
|
+
import type { MyConsentResponse } from "@pithy-sh/consent/src/http/responses";
|
|
3
|
+
import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
|
|
4
|
+
import type { Translator } from "@pithy-sh/core/src/i18n/translator";
|
|
5
|
+
import { useTranslator } from "@pithy-sh/i18n/src/react/translator";
|
|
6
|
+
import { useRead } from "@pithy-sh/ui-core/src/read";
|
|
7
|
+
import { type ReactNode, useRef, useState } from "react";
|
|
8
|
+
import { type GestureResponderEvent, Pressable, Text, TextInput, View } from "react-native";
|
|
9
|
+
import { consentConfig } from "../../pithy-config";
|
|
10
|
+
import { type Theme, useTheme } from "../../theme";
|
|
11
|
+
import { transport } from "../../transport";
|
|
12
|
+
|
|
13
|
+
export const path = "/consent";
|
|
14
|
+
|
|
15
|
+
// What a "your privacy choices" link points at, wherever it sits — by this role, so renaming the path moves it.
|
|
16
|
+
export const role = "consent";
|
|
17
|
+
|
|
18
|
+
// No `session` export: a visitor who has not signed in reads and changes their choices here too. On the web that is
|
|
19
|
+
// against the device id their first choice set in a cookie; a phone sends no cookie, so there a visitor's choices
|
|
20
|
+
// last only as long as the Worker can tell them apart. Signed in, they are their account's.
|
|
21
|
+
|
|
22
|
+
// Pithy's screen. Yours to override: put your own file at this path under src/routes/app/ and it wins.
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* This screen's English, baked in — the only catalog that survives being copied into your repository.
|
|
26
|
+
*
|
|
27
|
+
* **Withdrawing is never harder than granting.** Every purpose a press grants, a press withdraws, on the same row.
|
|
28
|
+
* A purpose that needs a signature is signed where its document is shown — this screen cannot render your notice.
|
|
29
|
+
* When the purpose asks for a written withdrawal, it is withdrawn at the grade it was signed at, because the server
|
|
30
|
+
* refuses anything less: here by typing a name for a typed grant, here by drawing for a drawn one, and for a grant
|
|
31
|
+
* signed through a provider, where it was signed. A click grant is always withdrawn by a press.
|
|
32
|
+
*/
|
|
33
|
+
const EN = {
|
|
34
|
+
"consent/settings.title": "Your privacy choices.",
|
|
35
|
+
"consent/settings.loading": "One moment.",
|
|
36
|
+
"consent/settings.unreadable": "We couldn't load your choices.",
|
|
37
|
+
"consent/settings.none": "Nothing here asks for your consent.",
|
|
38
|
+
"consent/settings.on": "On.",
|
|
39
|
+
"consent/settings.off": "Off.",
|
|
40
|
+
"consent/settings.renew": "The notice changed. Agree again to keep this on.",
|
|
41
|
+
"consent/settings.expired": "This has expired.",
|
|
42
|
+
"consent/settings.agree": "Agree",
|
|
43
|
+
"consent/settings.withdraw": "Withdraw",
|
|
44
|
+
"consent/settings.signed_elsewhere": "This needs your signature on the document it covers.",
|
|
45
|
+
"consent/settings.type_name": "Type your full name to withdraw in writing",
|
|
46
|
+
"consent/settings.draw_name": "Sign below to withdraw in writing",
|
|
47
|
+
"consent/settings.clear": "Clear",
|
|
48
|
+
"consent/settings.signed_with_provider":
|
|
49
|
+
"You signed this through an e-signature service. Withdraw it where you signed it.",
|
|
50
|
+
"consent/settings.saved": "Saved.",
|
|
51
|
+
} satisfies MessageCatalog;
|
|
52
|
+
|
|
53
|
+
/** One row of the screen: a purpose, and where the reader stands on it. */
|
|
54
|
+
type Row = MyConsentResponse["purposes"][number];
|
|
55
|
+
|
|
56
|
+
/** What to say about where the reader stands. */
|
|
57
|
+
function standingText(t: Translator, row: Row): string {
|
|
58
|
+
if (row.granted) return t.t("consent/settings.on");
|
|
59
|
+
if (row.standing === "stale_notice" || row.standing === "below_minimum") return t.t("consent/settings.renew");
|
|
60
|
+
if (row.standing === "expired" || row.standing === "visitor_expired") return t.t("consent/settings.expired");
|
|
61
|
+
return t.t("consent/settings.off");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A refusal in the reader's language: the code's own sentence where a catalog has one, and the server's English
|
|
66
|
+
* where none does — the order `docs/I18N.md` gives, and the one `failureText` in `src/payments.tsx` keeps.
|
|
67
|
+
*/
|
|
68
|
+
function refusalText(t: Translator, refusal: { code: string; message: string }): string {
|
|
69
|
+
const { code, message: english } = refusal;
|
|
70
|
+
return t.maybe(code) ?? english;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The latest grant behind a purpose, for a written withdrawal's grade. */
|
|
74
|
+
function grantMethod(me: MyConsentResponse, purpose: string): string | null {
|
|
75
|
+
const grants = me.records.filter((record) => record.purpose === purpose && record.action === "grant");
|
|
76
|
+
return grants.at(-1)?.method ?? null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** How a granted purpose is withdrawn: a press, a typed name, a drawn signature, or where a provider took it. */
|
|
80
|
+
type Withdrawal = "click" | "typed" | "drawn" | "provider";
|
|
81
|
+
|
|
82
|
+
/** The withdrawal a row asks for: a press, unless the purpose requires one in writing at the grant's grade. */
|
|
83
|
+
function withdrawalFor(me: MyConsentResponse, row: Row): Withdrawal {
|
|
84
|
+
if (!row.purpose.writtenWithdrawal) return "click";
|
|
85
|
+
const method = grantMethod(me, row.purpose.id);
|
|
86
|
+
return method === "typed" || method === "drawn" || method === "provider" ? method : "click";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The consent routes, under the base path the Worker composed them at, over this app's transport: the Worker's
|
|
91
|
+
* origin and the bearer session's fetch on a phone.
|
|
92
|
+
*/
|
|
93
|
+
function consentTransport(): ConsentClientOptions {
|
|
94
|
+
const basePath = consentConfig.basePath;
|
|
95
|
+
const { origin, fetch } = transport.capability;
|
|
96
|
+
if (origin === undefined || fetch === undefined) return { basePath };
|
|
97
|
+
return { basePath, fetch: (path, init) => fetch(`${origin}${path}`, init) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── the drawn signature ──────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/** A point on the pad, in the pad's own coordinates. */
|
|
103
|
+
interface Point {
|
|
104
|
+
readonly x: number;
|
|
105
|
+
readonly y: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The pad's size: the drawing's coordinates, and the SVG's. */
|
|
109
|
+
const PAD = { width: 320, height: 96 } as const;
|
|
110
|
+
|
|
111
|
+
/** One coordinate, kept on the pad and to a tenth of a point. */
|
|
112
|
+
function coordinate(value: number, limit: number): number {
|
|
113
|
+
const finite = Number.isFinite(value) ? value : 0;
|
|
114
|
+
return Math.round(Math.min(Math.max(finite, 0), limit) * 10) / 10;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The strokes as an SVG document: one path, a line through each stroke's points. */
|
|
118
|
+
function signatureSvg(strokes: readonly (readonly Point[])[]): string {
|
|
119
|
+
const d = strokes
|
|
120
|
+
.filter((stroke) => stroke.length > 0)
|
|
121
|
+
.map((stroke) => {
|
|
122
|
+
const [first, ...rest] = stroke;
|
|
123
|
+
if (!first) return "";
|
|
124
|
+
const lines = rest.length === 0 ? ["l0 0"] : rest.map((point) => `L${point.x} ${point.y}`);
|
|
125
|
+
return [`M${first.x} ${first.y}`, ...lines].join(" ");
|
|
126
|
+
})
|
|
127
|
+
.join(" ");
|
|
128
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${PAD.width}" height="${PAD.height}" viewBox="0 0 ${PAD.width} ${PAD.height}"><path d="${d}" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
132
|
+
|
|
133
|
+
/** Standard base64 of ASCII text. The SVG above is ASCII by construction, so no character needs encoding first. */
|
|
134
|
+
function base64Ascii(text: string): string {
|
|
135
|
+
let out = "";
|
|
136
|
+
for (let at = 0; at < text.length; at += 3) {
|
|
137
|
+
const a = text.charCodeAt(at);
|
|
138
|
+
const b = at + 1 < text.length ? text.charCodeAt(at + 1) : 0;
|
|
139
|
+
const c = at + 2 < text.length ? text.charCodeAt(at + 2) : 0;
|
|
140
|
+
const triple = (a << 16) | (b << 8) | c;
|
|
141
|
+
out += BASE64.charAt((triple >> 18) & 63) + BASE64.charAt((triple >> 12) & 63);
|
|
142
|
+
out += at + 1 < text.length ? BASE64.charAt((triple >> 6) & 63) : "=";
|
|
143
|
+
out += at + 2 < text.length ? BASE64.charAt(triple & 63) : "=";
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A segment of a stroke, drawn as a thin rotated bar between two points. */
|
|
149
|
+
function Segment(props: { from: Point; to: Point; color: string }): ReactNode {
|
|
150
|
+
const { from, to } = props;
|
|
151
|
+
const dx = to.x - from.x;
|
|
152
|
+
const dy = to.y - from.y;
|
|
153
|
+
const length = Math.max(Math.hypot(dx, dy), 2);
|
|
154
|
+
return (
|
|
155
|
+
<View
|
|
156
|
+
pointerEvents="none"
|
|
157
|
+
style={{
|
|
158
|
+
position: "absolute",
|
|
159
|
+
left: (from.x + to.x) / 2 - length / 2,
|
|
160
|
+
top: (from.y + to.y) / 2 - 1,
|
|
161
|
+
width: length,
|
|
162
|
+
height: 2,
|
|
163
|
+
borderRadius: 1,
|
|
164
|
+
backgroundColor: props.color,
|
|
165
|
+
transform: [{ rotate: `${Math.atan2(dy, dx)}rad` }],
|
|
166
|
+
}}
|
|
167
|
+
/>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A drawn signature: a finger or a pointer draws on the pad, and lifting it hands back the drawing as a base64 SVG.
|
|
173
|
+
* Clear starts again. The pad is the whole of it — no library, nothing sent anywhere but the withdrawal it signs.
|
|
174
|
+
*/
|
|
175
|
+
function SignaturePad({
|
|
176
|
+
label,
|
|
177
|
+
clearLabel,
|
|
178
|
+
onChange,
|
|
179
|
+
}: {
|
|
180
|
+
label: string;
|
|
181
|
+
clearLabel: string;
|
|
182
|
+
onChange: (image: string | null) => void;
|
|
183
|
+
}): ReactNode {
|
|
184
|
+
const theme = useTheme();
|
|
185
|
+
const strokes = useRef<Point[][]>([]);
|
|
186
|
+
const [, setDrawn] = useState(0);
|
|
187
|
+
const redraw = () => setDrawn((count) => count + 1);
|
|
188
|
+
const point = (event: GestureResponderEvent): Point => ({
|
|
189
|
+
x: coordinate(event.nativeEvent.locationX, PAD.width),
|
|
190
|
+
y: coordinate(event.nativeEvent.locationY, PAD.height),
|
|
191
|
+
});
|
|
192
|
+
const down = (event: GestureResponderEvent) => {
|
|
193
|
+
strokes.current = [...strokes.current, [point(event)]];
|
|
194
|
+
redraw();
|
|
195
|
+
};
|
|
196
|
+
const move = (event: GestureResponderEvent) => {
|
|
197
|
+
const stroke = strokes.current.at(-1);
|
|
198
|
+
if (!stroke) return;
|
|
199
|
+
stroke.push(point(event));
|
|
200
|
+
redraw();
|
|
201
|
+
};
|
|
202
|
+
const up = () => {
|
|
203
|
+
const drawn = strokes.current.some((stroke) => stroke.length > 0);
|
|
204
|
+
onChange(drawn ? base64Ascii(signatureSvg(strokes.current)) : null);
|
|
205
|
+
};
|
|
206
|
+
const clear = () => {
|
|
207
|
+
strokes.current = [];
|
|
208
|
+
redraw();
|
|
209
|
+
onChange(null);
|
|
210
|
+
};
|
|
211
|
+
return (
|
|
212
|
+
<View style={{ gap: theme.spacing.sm }}>
|
|
213
|
+
<Text style={[theme.type.body, { color: theme.colors.text }]}>{label}</Text>
|
|
214
|
+
<View
|
|
215
|
+
role="img"
|
|
216
|
+
aria-label={label}
|
|
217
|
+
style={{
|
|
218
|
+
width: PAD.width,
|
|
219
|
+
maxWidth: "100%",
|
|
220
|
+
height: PAD.height,
|
|
221
|
+
borderWidth: 1,
|
|
222
|
+
borderColor: theme.colors.text,
|
|
223
|
+
borderRadius: theme.radius,
|
|
224
|
+
backgroundColor: theme.colors.surface,
|
|
225
|
+
overflow: "hidden",
|
|
226
|
+
}}
|
|
227
|
+
onStartShouldSetResponder={() => true}
|
|
228
|
+
onMoveShouldSetResponder={() => true}
|
|
229
|
+
onResponderTerminationRequest={() => false}
|
|
230
|
+
onResponderGrant={down}
|
|
231
|
+
onResponderMove={move}
|
|
232
|
+
onResponderRelease={up}
|
|
233
|
+
onResponderTerminate={up}
|
|
234
|
+
>
|
|
235
|
+
{strokes.current.flatMap((stroke, index) =>
|
|
236
|
+
stroke.map((at, step) => (
|
|
237
|
+
<Segment
|
|
238
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: a stroke's points never reorder; the pad only appends.
|
|
239
|
+
key={`${index}:${step}`}
|
|
240
|
+
from={stroke[step - 1] ?? at}
|
|
241
|
+
to={at}
|
|
242
|
+
color={theme.colors.text}
|
|
243
|
+
/>
|
|
244
|
+
)),
|
|
245
|
+
)}
|
|
246
|
+
</View>
|
|
247
|
+
<Button theme={theme} label={clearLabel} onPress={clear} />
|
|
248
|
+
</View>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── the screen ───────────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
function Button(props: {
|
|
255
|
+
theme: Theme;
|
|
256
|
+
label: string;
|
|
257
|
+
primary?: boolean;
|
|
258
|
+
disabled?: boolean;
|
|
259
|
+
onPress: () => void;
|
|
260
|
+
}): ReactNode {
|
|
261
|
+
const { theme } = props;
|
|
262
|
+
const disabled = props.disabled ?? false;
|
|
263
|
+
return (
|
|
264
|
+
<Pressable
|
|
265
|
+
role="button"
|
|
266
|
+
disabled={disabled}
|
|
267
|
+
aria-disabled={disabled}
|
|
268
|
+
onPress={props.onPress}
|
|
269
|
+
style={{
|
|
270
|
+
borderWidth: 1,
|
|
271
|
+
borderColor: props.primary ? theme.colors.accent : theme.colors.border,
|
|
272
|
+
backgroundColor: props.primary ? theme.colors.accent : "transparent",
|
|
273
|
+
borderRadius: theme.radius,
|
|
274
|
+
paddingVertical: theme.spacing.sm,
|
|
275
|
+
paddingHorizontal: theme.spacing.md,
|
|
276
|
+
alignSelf: "flex-start",
|
|
277
|
+
opacity: disabled ? 0.5 : 1,
|
|
278
|
+
}}
|
|
279
|
+
>
|
|
280
|
+
<Text style={[theme.type.button, { color: props.primary ? theme.colors.onAccent : theme.colors.text }]}>
|
|
281
|
+
{props.label}
|
|
282
|
+
</Text>
|
|
283
|
+
</Pressable>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export interface ConsentScreenProps {
|
|
288
|
+
/** The translator this screen renders through. Absent, the provider's, and with none, {@link EN}. */
|
|
289
|
+
readonly t?: Translator;
|
|
290
|
+
/** Where the consent routes are, and the fetch to reach them with. Injected so a test never reaches a Worker. */
|
|
291
|
+
readonly client?: ConsentClientOptions;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** A refusal carried as the read's failure, so the last answer survives it. */
|
|
295
|
+
class Refused extends Error {
|
|
296
|
+
constructor(readonly refusal: { code: string; message: string }) {
|
|
297
|
+
super(refusal.code);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* The consent settings screen: every purpose and where the reader stands. A click-level purpose is granted here; a
|
|
303
|
+
* grant is withdrawn here the way it was given, except one signed with a provider, which is withdrawn there.
|
|
304
|
+
*/
|
|
305
|
+
export function ConsentScreen({ client: options, t: given }: ConsentScreenProps): ReactNode {
|
|
306
|
+
const baked = useTranslator(EN);
|
|
307
|
+
const t = given ?? baked;
|
|
308
|
+
const theme = useTheme();
|
|
309
|
+
const [client] = useState<ConsentClient>(() => consentClient(options));
|
|
310
|
+
const read = useRead("consent", async (): Promise<MyConsentResponse> => {
|
|
311
|
+
const result = await client.me();
|
|
312
|
+
if (result.ok) return result.value;
|
|
313
|
+
throw new Refused(result);
|
|
314
|
+
});
|
|
315
|
+
const me = read.value ?? null;
|
|
316
|
+
const [refusal, setRefusal] = useState<{ code: string; message: string } | null>(null);
|
|
317
|
+
// `client.me()` answers a refusal rather than throwing, so a refusal is the only failure the read carries.
|
|
318
|
+
const failure = refusal ?? (read.failure instanceof Refused ? read.failure.refusal : null);
|
|
319
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
320
|
+
const [names, setNames] = useState<Record<string, string>>({});
|
|
321
|
+
const [drawings, setDrawings] = useState<Record<string, string | null>>({});
|
|
322
|
+
const [saved, setSaved] = useState(false);
|
|
323
|
+
|
|
324
|
+
const choose = async (row: Row, grant: boolean) => {
|
|
325
|
+
setBusy(row.purpose.id);
|
|
326
|
+
setSaved(false);
|
|
327
|
+
const withdrawal = me ? withdrawalFor(me, row) : "click";
|
|
328
|
+
const typedName = names[row.purpose.id]?.trim();
|
|
329
|
+
const drawn = drawings[row.purpose.id];
|
|
330
|
+
const evidence =
|
|
331
|
+
withdrawal === "typed" && typedName
|
|
332
|
+
? { method: "typed" as const, typedName }
|
|
333
|
+
: withdrawal === "drawn" && drawn
|
|
334
|
+
? { method: "drawn" as const, drawn: { image: drawn, type: "image/svg+xml" as const } }
|
|
335
|
+
: { method: "click" as const };
|
|
336
|
+
const result = grant
|
|
337
|
+
? await client.grant({
|
|
338
|
+
purpose: row.purpose.id,
|
|
339
|
+
noticeVersion: row.purpose.noticeVersion,
|
|
340
|
+
method: "click",
|
|
341
|
+
surface: "settings",
|
|
342
|
+
})
|
|
343
|
+
: await client.withdraw({
|
|
344
|
+
purpose: row.purpose.id,
|
|
345
|
+
method: evidence.method,
|
|
346
|
+
surface: "settings",
|
|
347
|
+
...("typedName" in evidence ? { typedName: evidence.typedName } : {}),
|
|
348
|
+
...("drawn" in evidence ? { drawn: evidence.drawn } : {}),
|
|
349
|
+
});
|
|
350
|
+
setBusy(null);
|
|
351
|
+
if (result.ok) {
|
|
352
|
+
setSaved(true);
|
|
353
|
+
setRefusal(null);
|
|
354
|
+
read.refresh();
|
|
355
|
+
} else {
|
|
356
|
+
setRefusal(result);
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const body = [theme.type.body, { color: theme.colors.text }];
|
|
361
|
+
const muted = [theme.type.body, { color: theme.colors.muted }];
|
|
362
|
+
|
|
363
|
+
if (me === null && failure === null) return <Text style={muted}>{t.t("consent/settings.loading")}</Text>;
|
|
364
|
+
|
|
365
|
+
return (
|
|
366
|
+
<View
|
|
367
|
+
role="main"
|
|
368
|
+
style={{ flex: 1, padding: theme.spacing.lg, gap: theme.spacing.md, backgroundColor: theme.colors.background }}
|
|
369
|
+
>
|
|
370
|
+
<Text role="heading" style={[theme.type.title, { color: theme.colors.text }]}>
|
|
371
|
+
{me === null ? t.t("consent/settings.unreadable") : t.t("consent/settings.title")}
|
|
372
|
+
</Text>
|
|
373
|
+
{failure && <Text style={muted}>{refusalText(t, failure)}</Text>}
|
|
374
|
+
{me !== null && me.purposes.length === 0 && <Text style={muted}>{t.t("consent/settings.none")}</Text>}
|
|
375
|
+
{me?.purposes.map((row) => {
|
|
376
|
+
const clickable = row.purpose.minimumMethod === "click";
|
|
377
|
+
const withdrawal = withdrawalFor(me, row);
|
|
378
|
+
const ready =
|
|
379
|
+
withdrawal === "typed"
|
|
380
|
+
? Boolean(names[row.purpose.id]?.trim())
|
|
381
|
+
: withdrawal === "drawn"
|
|
382
|
+
? Boolean(drawings[row.purpose.id])
|
|
383
|
+
: true;
|
|
384
|
+
return (
|
|
385
|
+
<View key={row.purpose.id} testID={`consent-${row.purpose.id}`} style={{ gap: theme.spacing.sm }}>
|
|
386
|
+
<Text style={body}>
|
|
387
|
+
<Text style={{ fontWeight: "700" }}>{row.purpose.summary}</Text>{" "}
|
|
388
|
+
<Text style={{ color: theme.colors.muted }}>{standingText(t, row)}</Text>
|
|
389
|
+
</Text>
|
|
390
|
+
{row.granted && withdrawal === "provider" ? (
|
|
391
|
+
<Text style={muted}>{t.t("consent/settings.signed_with_provider")}</Text>
|
|
392
|
+
) : row.granted ? (
|
|
393
|
+
<>
|
|
394
|
+
{withdrawal === "typed" && (
|
|
395
|
+
<View style={{ gap: theme.spacing.xs }}>
|
|
396
|
+
<Text style={body}>{t.t("consent/settings.type_name")}</Text>
|
|
397
|
+
<TextInput
|
|
398
|
+
aria-label={t.t("consent/settings.type_name")}
|
|
399
|
+
autoComplete="name"
|
|
400
|
+
textContentType="name"
|
|
401
|
+
value={names[row.purpose.id] ?? ""}
|
|
402
|
+
onChangeText={(text) => setNames({ ...names, [row.purpose.id]: text })}
|
|
403
|
+
style={[
|
|
404
|
+
theme.type.body,
|
|
405
|
+
{
|
|
406
|
+
color: theme.colors.text,
|
|
407
|
+
backgroundColor: theme.colors.surface,
|
|
408
|
+
borderWidth: 1,
|
|
409
|
+
borderColor: theme.colors.border,
|
|
410
|
+
borderRadius: theme.radius,
|
|
411
|
+
paddingVertical: theme.spacing.sm,
|
|
412
|
+
paddingHorizontal: theme.spacing.md,
|
|
413
|
+
},
|
|
414
|
+
]}
|
|
415
|
+
/>
|
|
416
|
+
</View>
|
|
417
|
+
)}
|
|
418
|
+
{withdrawal === "drawn" && (
|
|
419
|
+
<SignaturePad
|
|
420
|
+
label={t.t("consent/settings.draw_name")}
|
|
421
|
+
clearLabel={t.t("consent/settings.clear")}
|
|
422
|
+
onChange={(image) => setDrawings((current) => ({ ...current, [row.purpose.id]: image }))}
|
|
423
|
+
/>
|
|
424
|
+
)}
|
|
425
|
+
<Button
|
|
426
|
+
theme={theme}
|
|
427
|
+
label={t.t("consent/settings.withdraw")}
|
|
428
|
+
disabled={busy !== null || !ready}
|
|
429
|
+
onPress={() => void choose(row, false)}
|
|
430
|
+
/>
|
|
431
|
+
</>
|
|
432
|
+
) : clickable ? (
|
|
433
|
+
<Button
|
|
434
|
+
theme={theme}
|
|
435
|
+
primary
|
|
436
|
+
label={t.t("consent/settings.agree")}
|
|
437
|
+
disabled={busy !== null}
|
|
438
|
+
onPress={() => void choose(row, true)}
|
|
439
|
+
/>
|
|
440
|
+
) : (
|
|
441
|
+
<Text style={muted}>{t.t("consent/settings.signed_elsewhere")}</Text>
|
|
442
|
+
)}
|
|
443
|
+
</View>
|
|
444
|
+
);
|
|
445
|
+
})}
|
|
446
|
+
{saved && <Text style={muted}>{t.t("consent/settings.saved")}</Text>}
|
|
447
|
+
</View>
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export default function Consent(): ReactNode {
|
|
452
|
+
return <ConsentScreen client={consentTransport()} />;
|
|
453
|
+
}
|