@leisuresaas/react-native 0.6.2
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/README.md +90 -0
- package/package.json +49 -0
- package/src/ads/ad-slot.tsx +131 -0
- package/src/ads/ad.tsx +91 -0
- package/src/ads/bundle-id.ts +9 -0
- package/src/ads/constants.ts +92 -0
- package/src/ads/context.tsx +76 -0
- package/src/ads/creative.tsx +105 -0
- package/src/ads/default-theme.ts +284 -0
- package/src/ads/hooks.ts +156 -0
- package/src/ads/index.ts +32 -0
- package/src/ads/layouts/image.tsx +101 -0
- package/src/ads/layouts/index.tsx +28 -0
- package/src/ads/layouts/shared.tsx +80 -0
- package/src/ads/layouts/text.tsx +77 -0
- package/src/ads/lineup-id.ts +6 -0
- package/src/ads/open-click.ts +61 -0
- package/src/ads/rotation/indicators-context.tsx +31 -0
- package/src/ads/rotation/indicators.tsx +22 -0
- package/src/ads/rotation/shell.tsx +98 -0
- package/src/ads/session-id.ts +18 -0
- package/src/ads/theme.ts +105 -0
- package/src/ads-ui.tsx +40 -0
- package/src/ads.ts +16 -0
- package/src/app-update/context.tsx +380 -0
- package/src/auth-errors.ts +50 -0
- package/src/auth-session.ts +129 -0
- package/src/auth-storage.ts +62 -0
- package/src/auth.tsx +249 -0
- package/src/dev.ts +16 -0
- package/src/drive/types.ts +62 -0
- package/src/drive.ts +355 -0
- package/src/errors.ts +11 -0
- package/src/gateway-url.ts +7 -0
- package/src/hosted-ui-link.ts +265 -0
- package/src/index.ts +161 -0
- package/src/platform-api-base.ts +72 -0
- package/src/platform.ts +16 -0
- package/src/public-ads.ts +105 -0
- package/src/public-app-config.ts +106 -0
- package/src/public-auth.ts +252 -0
- package/src/publishable-key.ts +19 -0
- package/src/push.ts +89 -0
- package/src/types.ts +123 -0
- package/src/userinfo.ts +34 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
type ReactNode,
|
|
12
|
+
} from "react";
|
|
13
|
+
import { Alert, AppState, Linking, Platform, Pressable, Text, View } from "react-native";
|
|
14
|
+
import * as SecureStore from "expo-secure-store";
|
|
15
|
+
import Constants from "expo-constants";
|
|
16
|
+
|
|
17
|
+
import { appBundleId } from "../ads/bundle-id";
|
|
18
|
+
import { resolveGatewayUrlFromEnv } from "../gateway-url";
|
|
19
|
+
import { mobilePlatform } from "../platform";
|
|
20
|
+
import { getPublicAppConfig, type AppConfigResponse } from "../public-app-config";
|
|
21
|
+
import { resolvePublishableKeyFromEnv } from "../publishable-key";
|
|
22
|
+
|
|
23
|
+
const DISMISS_KEY_PREFIX = "leisuresaas_app_update_dismissed_";
|
|
24
|
+
const THROTTLE_KEY = "leisuresaas_app_update_last_check_at";
|
|
25
|
+
const DEFAULT_FOREGROUND_THROTTLE_MS = 4 * 60 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
export type AppUpdateStatus = "idle" | "checking" | "up_to_date" | "recommended" | "required" | "error";
|
|
28
|
+
|
|
29
|
+
export type AppUpdateInfo = AppConfigResponse;
|
|
30
|
+
|
|
31
|
+
/** Override default Alert / Settings Card copy (server `update_message` still preferred for body). */
|
|
32
|
+
export type AppUpdateLabels = {
|
|
33
|
+
requiredTitle?: string;
|
|
34
|
+
recommendedTitle?: string;
|
|
35
|
+
updateNow?: string;
|
|
36
|
+
later?: string;
|
|
37
|
+
update?: string;
|
|
38
|
+
settingsTitle?: string;
|
|
39
|
+
checkForUpdates?: string;
|
|
40
|
+
checking?: string;
|
|
41
|
+
openStore?: string;
|
|
42
|
+
upToDate?: string;
|
|
43
|
+
checkFailed?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Headless Settings-page state: product owns all UI; SDK owns check / store actions. */
|
|
47
|
+
export type AppVersionSettingsState = {
|
|
48
|
+
status: AppUpdateStatus;
|
|
49
|
+
displayVersion: string;
|
|
50
|
+
message: string | null;
|
|
51
|
+
busy: boolean;
|
|
52
|
+
canOpenStore: boolean;
|
|
53
|
+
checkNow: () => Promise<void>;
|
|
54
|
+
openStore: () => Promise<void>;
|
|
55
|
+
labels: Required<AppUpdateLabels>;
|
|
56
|
+
info: AppUpdateInfo | null;
|
|
57
|
+
error: string | null;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type AppVersionSettingsCardProps = {
|
|
61
|
+
/** Custom Settings UI; omit to use the unstyled reference layout. */
|
|
62
|
+
children?: (state: AppVersionSettingsState) => ReactNode;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const defaultLabels: Required<AppUpdateLabels> = {
|
|
66
|
+
requiredTitle: "Update required",
|
|
67
|
+
recommendedTitle: "Update available",
|
|
68
|
+
updateNow: "Update now",
|
|
69
|
+
later: "Later",
|
|
70
|
+
update: "Update",
|
|
71
|
+
settingsTitle: "App version",
|
|
72
|
+
checkForUpdates: "Check for updates",
|
|
73
|
+
checking: "Checking…",
|
|
74
|
+
openStore: "Open store",
|
|
75
|
+
upToDate: "You're up to date.",
|
|
76
|
+
checkFailed: "Check failed. Try again.",
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type AppUpdateProviderProps = {
|
|
80
|
+
publishableKey?: string;
|
|
81
|
+
/** Platform API root for Public App Config. Defaults to EXPO_PUBLIC_GATEWAY_URL (never OAuth issuer). */
|
|
82
|
+
gatewayUrl?: string;
|
|
83
|
+
locale?: string;
|
|
84
|
+
labels?: AppUpdateLabels;
|
|
85
|
+
checkOnMount?: boolean;
|
|
86
|
+
checkOnForeground?: boolean;
|
|
87
|
+
foregroundThrottleMs?: number;
|
|
88
|
+
skipInDev?: boolean;
|
|
89
|
+
skipOnWeb?: boolean;
|
|
90
|
+
onUpdateRequired?: (info: AppUpdateInfo) => void;
|
|
91
|
+
onUpdateRecommended?: (info: AppUpdateInfo) => void;
|
|
92
|
+
children: ReactNode;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
type AppUpdateContextValue = {
|
|
96
|
+
status: AppUpdateStatus;
|
|
97
|
+
info: AppUpdateInfo | null;
|
|
98
|
+
error: string | null;
|
|
99
|
+
displayVersion: string;
|
|
100
|
+
labels: Required<AppUpdateLabels>;
|
|
101
|
+
checkForUpdate: (opts?: { fresh?: boolean; quiet?: boolean }) => Promise<AppUpdateInfo | null>;
|
|
102
|
+
openStore: () => Promise<void>;
|
|
103
|
+
dismiss: () => Promise<void>;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const AppUpdateContext = createContext<AppUpdateContextValue | null>(null);
|
|
107
|
+
|
|
108
|
+
function nativeAppVersion(): string {
|
|
109
|
+
return Constants.nativeApplicationVersion?.trim() || Constants.expoConfig?.version?.trim() || "0.0.0";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function nativeAppBuild(): string {
|
|
113
|
+
return Constants.nativeBuildVersion?.trim() || "";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function displayVersionString(): string {
|
|
117
|
+
const v = nativeAppVersion();
|
|
118
|
+
const b = nativeAppBuild();
|
|
119
|
+
return b ? `${v} (${b})` : v;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function mergeLabels(partial?: AppUpdateLabels): Required<AppUpdateLabels> {
|
|
123
|
+
return { ...defaultLabels, ...partial };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function AppUpdateProvider({
|
|
127
|
+
publishableKey,
|
|
128
|
+
gatewayUrl,
|
|
129
|
+
locale,
|
|
130
|
+
labels: labelsProp,
|
|
131
|
+
checkOnMount = true,
|
|
132
|
+
checkOnForeground = true,
|
|
133
|
+
foregroundThrottleMs = DEFAULT_FOREGROUND_THROTTLE_MS,
|
|
134
|
+
skipInDev = true,
|
|
135
|
+
skipOnWeb = true,
|
|
136
|
+
onUpdateRequired,
|
|
137
|
+
onUpdateRecommended,
|
|
138
|
+
children,
|
|
139
|
+
}: AppUpdateProviderProps) {
|
|
140
|
+
const [status, setStatus] = useState<AppUpdateStatus>("idle");
|
|
141
|
+
const [info, setInfo] = useState<AppUpdateInfo | null>(null);
|
|
142
|
+
const [error, setError] = useState<string | null>(null);
|
|
143
|
+
const lastCheckAt = useRef(0);
|
|
144
|
+
const labels = useMemo(() => mergeLabels(labelsProp), [labelsProp]);
|
|
145
|
+
const callbacksRef = useRef({ onUpdateRequired, onUpdateRecommended });
|
|
146
|
+
callbacksRef.current = { onUpdateRequired, onUpdateRecommended };
|
|
147
|
+
const labelsRef = useRef(labels);
|
|
148
|
+
labelsRef.current = labels;
|
|
149
|
+
const displayVersion = useMemo(() => displayVersionString(), []);
|
|
150
|
+
|
|
151
|
+
const skipped =
|
|
152
|
+
(skipInDev && typeof __DEV__ !== "undefined" && __DEV__) || (skipOnWeb && Platform.OS === "web");
|
|
153
|
+
|
|
154
|
+
const resolvedPublishableKey = publishableKey?.trim() || resolvePublishableKeyFromEnv();
|
|
155
|
+
const resolvedGatewayUrl = gatewayUrl?.trim() || resolveGatewayUrlFromEnv();
|
|
156
|
+
|
|
157
|
+
const dismissForVersion = useCallback(async (latest: string) => {
|
|
158
|
+
const v = latest.trim();
|
|
159
|
+
if (!v) return;
|
|
160
|
+
await SecureStore.setItemAsync(DISMISS_KEY_PREFIX + v, "1");
|
|
161
|
+
}, []);
|
|
162
|
+
|
|
163
|
+
const showDefaultAlert = useCallback(
|
|
164
|
+
(next: AppConfigResponse) => {
|
|
165
|
+
const L = labelsRef.current;
|
|
166
|
+
const body =
|
|
167
|
+
next.policy.update_message ||
|
|
168
|
+
next.policy.release_notes ||
|
|
169
|
+
(next.update.required ? "Please update to continue using the app." : "A new version is available.");
|
|
170
|
+
|
|
171
|
+
if (next.update.required) {
|
|
172
|
+
if (callbacksRef.current.onUpdateRequired) {
|
|
173
|
+
callbacksRef.current.onUpdateRequired(next);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
Alert.alert(
|
|
177
|
+
L.requiredTitle,
|
|
178
|
+
body,
|
|
179
|
+
[{ text: L.updateNow, onPress: () => void Linking.openURL(next.policy.store_url) }],
|
|
180
|
+
{ cancelable: false },
|
|
181
|
+
);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (next.update.recommended) {
|
|
185
|
+
if (callbacksRef.current.onUpdateRecommended) {
|
|
186
|
+
callbacksRef.current.onUpdateRecommended(next);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
Alert.alert(L.recommendedTitle, body, [
|
|
190
|
+
{
|
|
191
|
+
text: L.later,
|
|
192
|
+
style: "cancel",
|
|
193
|
+
onPress: () => {
|
|
194
|
+
void dismissForVersion(next.policy.latest_version);
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
{ text: L.update, onPress: () => void Linking.openURL(next.policy.store_url) },
|
|
198
|
+
]);
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
[dismissForVersion],
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const checkForUpdate = useCallback(
|
|
205
|
+
async (opts?: { fresh?: boolean; quiet?: boolean }) => {
|
|
206
|
+
const key = resolvedPublishableKey;
|
|
207
|
+
const gw = resolvedGatewayUrl;
|
|
208
|
+
if (!key || !gw || skipped) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
setStatus("checking");
|
|
212
|
+
setError(null);
|
|
213
|
+
try {
|
|
214
|
+
const next = await getPublicAppConfig(
|
|
215
|
+
{
|
|
216
|
+
gatewayUrl: gw,
|
|
217
|
+
publishableKey: key,
|
|
218
|
+
platform: mobilePlatform(),
|
|
219
|
+
bundleId: appBundleId(),
|
|
220
|
+
locale,
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
appVersion: nativeAppVersion(),
|
|
224
|
+
appBuild: nativeAppBuild(),
|
|
225
|
+
fresh: opts?.fresh,
|
|
226
|
+
},
|
|
227
|
+
);
|
|
228
|
+
setInfo(next);
|
|
229
|
+
lastCheckAt.current = Date.now();
|
|
230
|
+
void SecureStore.setItemAsync(THROTTLE_KEY, String(lastCheckAt.current));
|
|
231
|
+
|
|
232
|
+
if (next.update.required) {
|
|
233
|
+
setStatus("required");
|
|
234
|
+
if (!opts?.quiet) showDefaultAlert(next);
|
|
235
|
+
} else if (next.update.recommended) {
|
|
236
|
+
setStatus("recommended");
|
|
237
|
+
const dismissed = await SecureStore.getItemAsync(DISMISS_KEY_PREFIX + next.policy.latest_version);
|
|
238
|
+
// Auto-prompt skips dismissed; manual fresh check always shows the dialog.
|
|
239
|
+
const shouldPrompt = opts?.fresh || !dismissed;
|
|
240
|
+
if (shouldPrompt && !opts?.quiet) showDefaultAlert(next);
|
|
241
|
+
} else {
|
|
242
|
+
setStatus("up_to_date");
|
|
243
|
+
}
|
|
244
|
+
return next;
|
|
245
|
+
} catch (err) {
|
|
246
|
+
setStatus("error");
|
|
247
|
+
setError(err instanceof Error ? err.message : "update check failed");
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
[resolvedPublishableKey, resolvedGatewayUrl, locale, skipped, showDefaultAlert],
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const openStore = useCallback(async () => {
|
|
255
|
+
const url = info?.policy.store_url?.trim();
|
|
256
|
+
if (url) await Linking.openURL(url);
|
|
257
|
+
}, [info]);
|
|
258
|
+
|
|
259
|
+
const dismiss = useCallback(async () => {
|
|
260
|
+
const latest = info?.policy.latest_version;
|
|
261
|
+
if (latest) await dismissForVersion(latest);
|
|
262
|
+
}, [info, dismissForVersion]);
|
|
263
|
+
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
if (checkOnMount) void checkForUpdate();
|
|
266
|
+
}, [checkOnMount, checkForUpdate]);
|
|
267
|
+
|
|
268
|
+
useEffect(() => {
|
|
269
|
+
if (!checkOnForeground || skipped) return;
|
|
270
|
+
let cancelled = false;
|
|
271
|
+
void (async () => {
|
|
272
|
+
const raw = await SecureStore.getItemAsync(THROTTLE_KEY);
|
|
273
|
+
const n = Number(raw ?? 0);
|
|
274
|
+
if (!cancelled && Number.isFinite(n) && n > 0) lastCheckAt.current = n;
|
|
275
|
+
})();
|
|
276
|
+
const sub = AppState.addEventListener("change", (state) => {
|
|
277
|
+
if (state !== "active") return;
|
|
278
|
+
if (Date.now() - lastCheckAt.current < foregroundThrottleMs) return;
|
|
279
|
+
void checkForUpdate();
|
|
280
|
+
});
|
|
281
|
+
return () => {
|
|
282
|
+
cancelled = true;
|
|
283
|
+
sub.remove();
|
|
284
|
+
};
|
|
285
|
+
}, [checkOnForeground, checkForUpdate, foregroundThrottleMs, skipped]);
|
|
286
|
+
|
|
287
|
+
const value = useMemo(
|
|
288
|
+
(): AppUpdateContextValue => ({
|
|
289
|
+
status, info, error, displayVersion, labels, checkForUpdate, openStore, dismiss,
|
|
290
|
+
}),
|
|
291
|
+
[status, info, error, displayVersion, labels, checkForUpdate, openStore, dismiss],
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
return <AppUpdateContext.Provider value={value}>{children}</AppUpdateContext.Provider>;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function useAppUpdate(): AppUpdateContextValue {
|
|
298
|
+
const ctx = useContext(AppUpdateContext);
|
|
299
|
+
if (!ctx) throw new Error("useAppUpdate must be used within AppUpdateProvider");
|
|
300
|
+
return ctx;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Settings-page headless API: version display + manual check + open store.
|
|
305
|
+
* Prefer this over `AppVersionSettingsCard` when matching product branding.
|
|
306
|
+
*/
|
|
307
|
+
export function useAppVersionSettings(): AppVersionSettingsState {
|
|
308
|
+
const { status, displayVersion, checkForUpdate, openStore, info, error, labels } = useAppUpdate();
|
|
309
|
+
const [busy, setBusy] = useState(false);
|
|
310
|
+
const [message, setMessage] = useState<string | null>(null);
|
|
311
|
+
|
|
312
|
+
const checkNow = useCallback(async () => {
|
|
313
|
+
setBusy(true);
|
|
314
|
+
setMessage(null);
|
|
315
|
+
try {
|
|
316
|
+
const next = await checkForUpdate({ fresh: true });
|
|
317
|
+
if (!next) {
|
|
318
|
+
setMessage(labels.checkFailed);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (next.update.required || next.update.recommended) {
|
|
322
|
+
setMessage(next.policy.update_message || next.policy.release_notes || labels.recommendedTitle);
|
|
323
|
+
} else {
|
|
324
|
+
setMessage(labels.upToDate);
|
|
325
|
+
}
|
|
326
|
+
} finally {
|
|
327
|
+
setBusy(false);
|
|
328
|
+
}
|
|
329
|
+
}, [checkForUpdate, labels.checkFailed, labels.recommendedTitle, labels.upToDate]);
|
|
330
|
+
|
|
331
|
+
const canOpenStore = Boolean(info?.update.required || info?.update.recommended);
|
|
332
|
+
|
|
333
|
+
return useMemo(
|
|
334
|
+
(): AppVersionSettingsState => ({
|
|
335
|
+
status,
|
|
336
|
+
displayVersion,
|
|
337
|
+
message,
|
|
338
|
+
busy,
|
|
339
|
+
canOpenStore,
|
|
340
|
+
checkNow,
|
|
341
|
+
openStore,
|
|
342
|
+
labels,
|
|
343
|
+
info,
|
|
344
|
+
error,
|
|
345
|
+
}),
|
|
346
|
+
[status, displayVersion, message, busy, canOpenStore, checkNow, openStore, labels, info, error],
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Optional reference Settings UI (unstyled). Prefer `useAppVersionSettings` + product layout.
|
|
352
|
+
* Pass `children` as a render prop for full visual control without rewriting check logic.
|
|
353
|
+
*/
|
|
354
|
+
export function AppVersionSettingsCard({ children }: AppVersionSettingsCardProps) {
|
|
355
|
+
const state = useAppVersionSettings();
|
|
356
|
+
if (children) return <>{children(state)}</>;
|
|
357
|
+
|
|
358
|
+
const { status, displayVersion, message, busy, canOpenStore, checkNow, openStore, labels } = state;
|
|
359
|
+
return (
|
|
360
|
+
<View style={{ gap: 8, paddingVertical: 8 }}>
|
|
361
|
+
<Text style={{ fontSize: 16, fontWeight: "600" }}>{labels.settingsTitle}</Text>
|
|
362
|
+
<Text style={{ opacity: 0.7 }}>{displayVersion}</Text>
|
|
363
|
+
{message ? <Text>{message}</Text> : null}
|
|
364
|
+
<View style={{ flexDirection: "row", gap: 12 }}>
|
|
365
|
+
<Pressable
|
|
366
|
+
onPress={() => void checkNow()}
|
|
367
|
+
disabled={busy || status === "checking"}
|
|
368
|
+
style={{ paddingVertical: 8, paddingHorizontal: 12, opacity: busy ? 0.5 : 1 }}
|
|
369
|
+
>
|
|
370
|
+
<Text>{busy || status === "checking" ? labels.checking : labels.checkForUpdates}</Text>
|
|
371
|
+
</Pressable>
|
|
372
|
+
{canOpenStore ? (
|
|
373
|
+
<Pressable onPress={() => void openStore()} style={{ paddingVertical: 8, paddingHorizontal: 12 }}>
|
|
374
|
+
<Text>{labels.openStore}</Text>
|
|
375
|
+
</Pressable>
|
|
376
|
+
) : null}
|
|
377
|
+
</View>
|
|
378
|
+
</View>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export type AuthLoginErrorCode = "cancelled" | "no_code" | "exchange_failed" | "not_ready";
|
|
2
|
+
|
|
3
|
+
const STABLE_MESSAGES: Record<AuthLoginErrorCode, string> = {
|
|
4
|
+
cancelled: "Cancelled",
|
|
5
|
+
no_code: "Did not redirect",
|
|
6
|
+
exchange_failed: "Login failed",
|
|
7
|
+
not_ready: "Login failed",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function causeSummary(cause: unknown): string {
|
|
11
|
+
if (cause == null) {
|
|
12
|
+
return "";
|
|
13
|
+
}
|
|
14
|
+
if (cause instanceof Error && cause.message.trim()) {
|
|
15
|
+
return cause.message.trim();
|
|
16
|
+
}
|
|
17
|
+
if (typeof cause === "string" && cause.trim()) {
|
|
18
|
+
return cause.trim();
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
return String(cause);
|
|
22
|
+
} catch {
|
|
23
|
+
return "";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Stable OAuth login failure for product UI; use `message` only outside __DEV__. */
|
|
28
|
+
export class AuthLoginError extends Error {
|
|
29
|
+
readonly code: AuthLoginErrorCode;
|
|
30
|
+
readonly cause?: unknown;
|
|
31
|
+
|
|
32
|
+
constructor(code: AuthLoginErrorCode, cause?: unknown) {
|
|
33
|
+
const stable = STABLE_MESSAGES[code];
|
|
34
|
+
const detail = typeof __DEV__ !== "undefined" && __DEV__ ? causeSummary(cause) : "";
|
|
35
|
+
super(detail ? `${stable}: ${detail}` : stable);
|
|
36
|
+
this.name = "AuthLoginError";
|
|
37
|
+
this.code = code;
|
|
38
|
+
if (cause !== undefined) {
|
|
39
|
+
this.cause = cause;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function toAuthLoginError(code: AuthLoginErrorCode, cause?: unknown): AuthLoginError {
|
|
45
|
+
const err = new AuthLoginError(code, cause);
|
|
46
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
47
|
+
console.warn(`[AuthProvider] login ${code}`, cause ?? err.message);
|
|
48
|
+
}
|
|
49
|
+
return err;
|
|
50
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
const REFRESH_SKEW_SEC = 60;
|
|
2
|
+
|
|
3
|
+
export function accessExpUnix(accessToken: string): number | undefined {
|
|
4
|
+
const parts = accessToken.split(".");
|
|
5
|
+
if (parts.length < 2) {
|
|
6
|
+
return undefined;
|
|
7
|
+
}
|
|
8
|
+
try {
|
|
9
|
+
const json = atob(parts[1].replace(/-/g, "+").replace(/_/g, "/"));
|
|
10
|
+
const payload = JSON.parse(json) as { exp?: number };
|
|
11
|
+
return typeof payload.exp === "number" ? payload.exp : undefined;
|
|
12
|
+
} catch {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function accessNeedsRefresh(accessToken: string): boolean {
|
|
18
|
+
const exp = accessExpUnix(accessToken);
|
|
19
|
+
if (!exp) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
return exp - Math.floor(Date.now() / 1000) <= REFRESH_SKEW_SEC;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type OAuthTokenResponse = {
|
|
26
|
+
accessToken: string;
|
|
27
|
+
refreshToken?: string;
|
|
28
|
+
expiresIn?: number;
|
|
29
|
+
refreshExpiresIn?: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
async function postOAuthToken(
|
|
33
|
+
issuer: string,
|
|
34
|
+
body: URLSearchParams,
|
|
35
|
+
label: string,
|
|
36
|
+
): Promise<OAuthTokenResponse> {
|
|
37
|
+
const res = await fetch(`${issuer.replace(/\/$/, "")}/oauth/token`, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
40
|
+
body,
|
|
41
|
+
});
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
const text = await res.text();
|
|
44
|
+
throw new Error(`${label} failed (${res.status}): ${text}`);
|
|
45
|
+
}
|
|
46
|
+
const data = (await res.json()) as {
|
|
47
|
+
access_token?: string;
|
|
48
|
+
refresh_token?: string;
|
|
49
|
+
expires_in?: number;
|
|
50
|
+
refresh_expires_in?: number;
|
|
51
|
+
};
|
|
52
|
+
const accessToken = data.access_token?.trim();
|
|
53
|
+
if (!accessToken) {
|
|
54
|
+
throw new Error(`${label}: missing access_token`);
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
accessToken,
|
|
58
|
+
refreshToken: data.refresh_token?.trim() || undefined,
|
|
59
|
+
expiresIn: data.expires_in,
|
|
60
|
+
refreshExpiresIn: data.refresh_expires_in,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function refreshOAuthTokens(
|
|
65
|
+
issuer: string,
|
|
66
|
+
clientId: string,
|
|
67
|
+
refreshToken: string,
|
|
68
|
+
): Promise<OAuthTokenResponse> {
|
|
69
|
+
const body = new URLSearchParams({
|
|
70
|
+
grant_type: "refresh_token",
|
|
71
|
+
refresh_token: refreshToken,
|
|
72
|
+
client_id: clientId,
|
|
73
|
+
});
|
|
74
|
+
return postOAuthToken(issuer, body, "token refresh");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Exchange an authorization code (PKCE) at the platform token endpoint. */
|
|
78
|
+
export async function exchangeAuthorizationCode(
|
|
79
|
+
issuer: string,
|
|
80
|
+
clientId: string,
|
|
81
|
+
code: string,
|
|
82
|
+
redirectUri: string,
|
|
83
|
+
codeVerifier: string,
|
|
84
|
+
): Promise<OAuthTokenResponse> {
|
|
85
|
+
const body = new URLSearchParams({
|
|
86
|
+
grant_type: "authorization_code",
|
|
87
|
+
code: code.trim(),
|
|
88
|
+
redirect_uri: redirectUri,
|
|
89
|
+
client_id: clientId,
|
|
90
|
+
code_verifier: codeVerifier,
|
|
91
|
+
});
|
|
92
|
+
return postOAuthToken(issuer, body, "authorization code exchange");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Exchange a one-time magic_token from household Magic redeem (grant magic-token). */
|
|
96
|
+
export async function exchangeMagicToken(
|
|
97
|
+
issuer: string,
|
|
98
|
+
clientId: string,
|
|
99
|
+
magicToken: string,
|
|
100
|
+
clientSecret?: string,
|
|
101
|
+
): Promise<OAuthTokenResponse> {
|
|
102
|
+
const body = new URLSearchParams({
|
|
103
|
+
grant_type: "urn:leisuresaas:oauth:magic-token",
|
|
104
|
+
magic_token: magicToken.trim(),
|
|
105
|
+
client_id: clientId,
|
|
106
|
+
});
|
|
107
|
+
if (clientSecret?.trim()) {
|
|
108
|
+
body.set("client_secret", clientSecret.trim());
|
|
109
|
+
}
|
|
110
|
+
return postOAuthToken(issuer, body, "magic token exchange");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Read magic_token from an App Link / redirect URL after Magic confirm. */
|
|
114
|
+
export function magicTokenFromURL(url: string): string | undefined {
|
|
115
|
+
try {
|
|
116
|
+
const u = new URL(url);
|
|
117
|
+
const fromQuery = u.searchParams.get("magic_token")?.trim();
|
|
118
|
+
if (fromQuery) {
|
|
119
|
+
return fromQuery;
|
|
120
|
+
}
|
|
121
|
+
if (u.hash) {
|
|
122
|
+
const hash = new URLSearchParams(u.hash.replace(/^#/, ""));
|
|
123
|
+
return hash.get("magic_token")?.trim() || undefined;
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import * as SecureStore from "expo-secure-store";
|
|
2
|
+
import { Platform } from "react-native";
|
|
3
|
+
|
|
4
|
+
const WEB_PROD_MSG =
|
|
5
|
+
"OAuth token persistence is not supported on web production builds; use the native app.";
|
|
6
|
+
|
|
7
|
+
/** Metro / Expo web development (`npm start` in browser). */
|
|
8
|
+
export function isWebDev(): boolean {
|
|
9
|
+
return Platform.OS === "web" && typeof __DEV__ !== "undefined" && __DEV__;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Web production / release build — no durable OAuth tokens. */
|
|
13
|
+
export function isWebProduction(): boolean {
|
|
14
|
+
return Platform.OS === "web" && !isWebDev();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Throws when Web production would persist OAuth credentials. */
|
|
18
|
+
export function assertAuthStorageAllowed(): void {
|
|
19
|
+
if (isWebProduction()) {
|
|
20
|
+
throw new Error(WEB_PROD_MSG);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function authGetItem(key: string): Promise<string | null> {
|
|
25
|
+
if (Platform.OS !== "web") {
|
|
26
|
+
return SecureStore.getItemAsync(key);
|
|
27
|
+
}
|
|
28
|
+
if (isWebDev()) {
|
|
29
|
+
try {
|
|
30
|
+
return globalThis.localStorage?.getItem(key) ?? null;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function authSetItem(key: string, value: string): Promise<void> {
|
|
39
|
+
if (Platform.OS !== "web") {
|
|
40
|
+
await SecureStore.setItemAsync(key, value);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (isWebDev()) {
|
|
44
|
+
globalThis.localStorage?.setItem(key, value);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
throw new Error(WEB_PROD_MSG);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function authDeleteItem(key: string): Promise<void> {
|
|
51
|
+
if (Platform.OS !== "web") {
|
|
52
|
+
await SecureStore.deleteItemAsync(key);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (isWebDev()) {
|
|
56
|
+
try {
|
|
57
|
+
globalThis.localStorage?.removeItem(key);
|
|
58
|
+
} catch {
|
|
59
|
+
/* ignore */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|