@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,106 @@
|
|
|
1
|
+
import { LeisureSaasHttpError } from "./errors";
|
|
2
|
+
import { mobilePlatform } from "./platform";
|
|
3
|
+
import { applyPublishableKeyHeaders } from "./publishable-key";
|
|
4
|
+
import type { MobilePlatform } from "./types";
|
|
5
|
+
|
|
6
|
+
export type PublicAppConfigRequestContext = {
|
|
7
|
+
gatewayUrl: string;
|
|
8
|
+
publishableKey: string;
|
|
9
|
+
platform?: MobilePlatform | "web";
|
|
10
|
+
bundleId?: string;
|
|
11
|
+
origin?: string;
|
|
12
|
+
locale?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type AppConfigResponse = {
|
|
16
|
+
platform: string;
|
|
17
|
+
policy: {
|
|
18
|
+
min_supported_version: string;
|
|
19
|
+
latest_version: string;
|
|
20
|
+
store_url: string;
|
|
21
|
+
release_notes?: string;
|
|
22
|
+
update_message?: string;
|
|
23
|
+
};
|
|
24
|
+
/** Product support / legal URLs (Admin → Branding → Links). Empty string when unset. */
|
|
25
|
+
links: {
|
|
26
|
+
support_url: string;
|
|
27
|
+
terms_url: string;
|
|
28
|
+
privacy_url: string;
|
|
29
|
+
};
|
|
30
|
+
/** Product authoring locales (BCP 47) from Admin product Settings. */
|
|
31
|
+
supported_locales: string[];
|
|
32
|
+
/** Product default locale; always a member of supported_locales. */
|
|
33
|
+
default_locale: string;
|
|
34
|
+
client?: {
|
|
35
|
+
app_version?: string;
|
|
36
|
+
app_build?: string;
|
|
37
|
+
};
|
|
38
|
+
update: {
|
|
39
|
+
required: boolean;
|
|
40
|
+
recommended: boolean;
|
|
41
|
+
reason: string;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function trimSlash(url: string): string {
|
|
46
|
+
return url.replace(/\/$/, "");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resolvePlatform(ctx: PublicAppConfigRequestContext): MobilePlatform | "web" {
|
|
50
|
+
return ctx.platform ?? mobilePlatform();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function getPublicAppConfig(
|
|
54
|
+
ctx: PublicAppConfigRequestContext,
|
|
55
|
+
input: { appVersion: string; appBuild?: string; fresh?: boolean },
|
|
56
|
+
): Promise<AppConfigResponse> {
|
|
57
|
+
const platform = resolvePlatform(ctx);
|
|
58
|
+
const headers: Record<string, string> = {
|
|
59
|
+
Accept: "application/json",
|
|
60
|
+
"X-Client-Platform": platform,
|
|
61
|
+
"X-App-Version": input.appVersion.trim(),
|
|
62
|
+
};
|
|
63
|
+
applyPublishableKeyHeaders(headers, ctx.publishableKey);
|
|
64
|
+
if (input.appBuild?.trim()) {
|
|
65
|
+
headers["X-App-Build"] = input.appBuild.trim();
|
|
66
|
+
}
|
|
67
|
+
if (ctx.locale?.trim()) {
|
|
68
|
+
headers["Accept-Language"] = ctx.locale.trim();
|
|
69
|
+
}
|
|
70
|
+
if (platform === "web") {
|
|
71
|
+
if (ctx.origin?.trim()) headers.Origin = ctx.origin.trim();
|
|
72
|
+
} else if (ctx.bundleId?.trim()) {
|
|
73
|
+
headers["X-Ads-Bundle-Id"] = ctx.bundleId.trim();
|
|
74
|
+
}
|
|
75
|
+
if (input.fresh) {
|
|
76
|
+
headers["Cache-Control"] = "no-cache";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Trailing slash: some gateways 301 …/app-config → …/app-config/ with a root-relative
|
|
80
|
+
// Location that drops the /gateway prefix and breaks fetch redirect (404).
|
|
81
|
+
let url = `${trimSlash(ctx.gatewayUrl)}/v1/public/app-config/`;
|
|
82
|
+
if (input.fresh) {
|
|
83
|
+
url += "?fresh=1";
|
|
84
|
+
}
|
|
85
|
+
const res = await fetch(url, { method: "GET", headers });
|
|
86
|
+
const text = await res.text();
|
|
87
|
+
if (!res.ok) {
|
|
88
|
+
throw new LeisureSaasHttpError(res.status, text.trim());
|
|
89
|
+
}
|
|
90
|
+
const body = JSON.parse(text) as AppConfigResponse;
|
|
91
|
+
const links = body.links ?? { support_url: "", terms_url: "", privacy_url: "" };
|
|
92
|
+
const supported = Array.isArray(body.supported_locales)
|
|
93
|
+
? body.supported_locales.map((x) => String(x ?? "").trim()).filter(Boolean)
|
|
94
|
+
: [];
|
|
95
|
+
const defaultLocale = String(body.default_locale ?? "").trim() || "en";
|
|
96
|
+
return {
|
|
97
|
+
...body,
|
|
98
|
+
links: {
|
|
99
|
+
support_url: String(links.support_url ?? "").trim(),
|
|
100
|
+
terms_url: String(links.terms_url ?? "").trim(),
|
|
101
|
+
privacy_url: String(links.privacy_url ?? "").trim(),
|
|
102
|
+
},
|
|
103
|
+
supported_locales: supported.length > 0 ? supported : ["en"],
|
|
104
|
+
default_locale: defaultLocale,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import * as AuthSession from "expo-auth-session";
|
|
2
|
+
import * as WebBrowser from "expo-web-browser";
|
|
3
|
+
|
|
4
|
+
import { exchangeAuthorizationCode } from "./auth-session";
|
|
5
|
+
import { LeisureSaasHttpError } from "./errors";
|
|
6
|
+
import { mobilePlatform } from "./platform";
|
|
7
|
+
import { applyPublishableKeyHeaders } from "./publishable-key";
|
|
8
|
+
import type { MobilePlatform } from "./types";
|
|
9
|
+
|
|
10
|
+
WebBrowser.maybeCompleteAuthSession();
|
|
11
|
+
|
|
12
|
+
export type PublicAuthContext = {
|
|
13
|
+
gatewayUrl: string;
|
|
14
|
+
publishableKey: string;
|
|
15
|
+
platform?: MobilePlatform | "web";
|
|
16
|
+
bundleId?: string;
|
|
17
|
+
origin?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type ProductAuthTokens = {
|
|
21
|
+
accessToken: string;
|
|
22
|
+
refreshToken: string;
|
|
23
|
+
accessExpiresAtUnix: number;
|
|
24
|
+
refreshExpiresAtUnix: number;
|
|
25
|
+
userId: string;
|
|
26
|
+
tenantId: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type TokenJSON = {
|
|
30
|
+
access_token?: string;
|
|
31
|
+
refresh_token?: string;
|
|
32
|
+
access_expires_at_unix?: number;
|
|
33
|
+
refresh_expires_at_unix?: number;
|
|
34
|
+
user_id?: string;
|
|
35
|
+
tenant_id?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function trimSlash(url: string): string {
|
|
39
|
+
return url.replace(/\/$/, "");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function resolvePlatform(ctx: PublicAuthContext): MobilePlatform | "web" {
|
|
43
|
+
return ctx.platform ?? mobilePlatform();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function authHeaders(ctx: PublicAuthContext, accessToken?: string): Record<string, string> {
|
|
47
|
+
const platform = resolvePlatform(ctx);
|
|
48
|
+
const headers: Record<string, string> = {
|
|
49
|
+
Accept: "application/json",
|
|
50
|
+
"Content-Type": "application/json",
|
|
51
|
+
"X-Client-Platform": platform,
|
|
52
|
+
};
|
|
53
|
+
applyPublishableKeyHeaders(headers, ctx.publishableKey);
|
|
54
|
+
if (platform === "web") {
|
|
55
|
+
if (ctx.origin?.trim()) headers.Origin = ctx.origin.trim();
|
|
56
|
+
} else if (ctx.bundleId?.trim()) {
|
|
57
|
+
headers["X-Ads-Bundle-Id"] = ctx.bundleId.trim();
|
|
58
|
+
}
|
|
59
|
+
if (accessToken?.trim()) {
|
|
60
|
+
headers.Authorization = `Bearer ${accessToken.trim()}`;
|
|
61
|
+
}
|
|
62
|
+
return headers;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function publicAuthRequest<T>(
|
|
66
|
+
ctx: PublicAuthContext,
|
|
67
|
+
path: string,
|
|
68
|
+
body?: unknown,
|
|
69
|
+
accessToken?: string,
|
|
70
|
+
): Promise<T> {
|
|
71
|
+
const url = `${trimSlash(ctx.gatewayUrl)}/v1/public/auth${path}`;
|
|
72
|
+
const res = await fetch(url, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: authHeaders(ctx, accessToken),
|
|
75
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
76
|
+
});
|
|
77
|
+
const text = await res.text();
|
|
78
|
+
if (!res.ok) {
|
|
79
|
+
throw new LeisureSaasHttpError(res.status, text.trim());
|
|
80
|
+
}
|
|
81
|
+
if (!text) {
|
|
82
|
+
return {} as T;
|
|
83
|
+
}
|
|
84
|
+
return JSON.parse(text) as T;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function tokensFromJSON(data: TokenJSON): ProductAuthTokens {
|
|
88
|
+
const accessToken = data.access_token?.trim() ?? "";
|
|
89
|
+
if (!accessToken) {
|
|
90
|
+
throw new LeisureSaasHttpError(502, "missing access_token");
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
accessToken,
|
|
94
|
+
refreshToken: data.refresh_token?.trim() ?? "",
|
|
95
|
+
accessExpiresAtUnix: data.access_expires_at_unix ?? 0,
|
|
96
|
+
refreshExpiresAtUnix: data.refresh_expires_at_unix ?? 0,
|
|
97
|
+
userId: data.user_id?.trim() ?? "",
|
|
98
|
+
tenantId: data.tenant_id?.trim() ?? "",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function registerProductAccount(
|
|
103
|
+
ctx: PublicAuthContext,
|
|
104
|
+
input: { email: string; password: string; name?: string; locale?: string },
|
|
105
|
+
): Promise<{ verificationRequired: boolean }> {
|
|
106
|
+
const resp = await publicAuthRequest<{ verification_required?: boolean }>(ctx, "/register", {
|
|
107
|
+
email: input.email, password: input.password, name: input.name ?? "", locale: input.locale ?? "",
|
|
108
|
+
});
|
|
109
|
+
return { verificationRequired: resp.verification_required !== false };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function loginProductAccount(
|
|
113
|
+
ctx: PublicAuthContext,
|
|
114
|
+
input: { email: string; password: string; locale?: string },
|
|
115
|
+
): Promise<ProductAuthTokens> {
|
|
116
|
+
const resp = await publicAuthRequest<TokenJSON>(ctx, "/login", {
|
|
117
|
+
email: input.email, password: input.password, locale: input.locale ?? "",
|
|
118
|
+
});
|
|
119
|
+
return tokensFromJSON(resp);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function refreshProductSession(ctx: PublicAuthContext, refreshToken: string): Promise<ProductAuthTokens> {
|
|
123
|
+
const resp = await publicAuthRequest<TokenJSON>(ctx, "/refresh", { refresh_token: refreshToken });
|
|
124
|
+
return tokensFromJSON(resp);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function requestProductPasswordReset(
|
|
128
|
+
ctx: PublicAuthContext,
|
|
129
|
+
input: { email: string; locale?: string },
|
|
130
|
+
): Promise<void> {
|
|
131
|
+
await publicAuthRequest(ctx, "/password-reset", { email: input.email, locale: input.locale ?? "" });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function requestProductEmailVerification(
|
|
135
|
+
ctx: PublicAuthContext,
|
|
136
|
+
input: { email: string; locale?: string },
|
|
137
|
+
): Promise<void> {
|
|
138
|
+
await publicAuthRequest(ctx, "/email-verification", { email: input.email, locale: input.locale ?? "" });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function changeProductPassword(
|
|
142
|
+
ctx: PublicAuthContext,
|
|
143
|
+
accessToken: string,
|
|
144
|
+
input: { currentPassword: string; newPassword: string },
|
|
145
|
+
): Promise<void> {
|
|
146
|
+
await publicAuthRequest(ctx, "/password", {
|
|
147
|
+
current_password: input.currentPassword, new_password: input.newPassword,
|
|
148
|
+
}, accessToken);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function logoutProductAccount(
|
|
152
|
+
ctx: PublicAuthContext,
|
|
153
|
+
accessToken: string,
|
|
154
|
+
refreshToken?: string,
|
|
155
|
+
): Promise<void> {
|
|
156
|
+
await publicAuthRequest(ctx, "/logout", { refresh_token: refreshToken ?? "" }, accessToken);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function deleteProductAccount(ctx: PublicAuthContext, accessToken: string): Promise<void> {
|
|
160
|
+
await publicAuthRequest(ctx, "/account/delete", {}, accessToken);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function startPlatformAuthorize(
|
|
164
|
+
ctx: PublicAuthContext,
|
|
165
|
+
input: { redirectUri: string; codeChallenge: string; state?: string },
|
|
166
|
+
): Promise<string> {
|
|
167
|
+
const resp = await publicAuthRequest<{ authorization_url?: string }>(ctx, "/authorize", {
|
|
168
|
+
redirect_uri: input.redirectUri,
|
|
169
|
+
code_challenge: input.codeChallenge,
|
|
170
|
+
state: input.state ?? "",
|
|
171
|
+
});
|
|
172
|
+
const url = resp.authorization_url?.trim() ?? "";
|
|
173
|
+
if (!url) {
|
|
174
|
+
throw new LeisureSaasHttpError(502, "missing authorization_url");
|
|
175
|
+
}
|
|
176
|
+
return url;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function bindPlatformAccount(
|
|
180
|
+
ctx: PublicAuthContext,
|
|
181
|
+
accessToken: string,
|
|
182
|
+
input: { code: string; codeVerifier: string; redirectUri: string },
|
|
183
|
+
): Promise<void> {
|
|
184
|
+
await publicAuthRequest(ctx, "/bind", {
|
|
185
|
+
code: input.code, code_verifier: input.codeVerifier, redirect_uri: input.redirectUri,
|
|
186
|
+
}, accessToken);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function unbindPlatformAccount(ctx: PublicAuthContext, accessToken: string): Promise<void> {
|
|
190
|
+
await publicAuthRequest(ctx, "/unbind", {}, accessToken);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Opens the platform hosted login and returns a product-pool token. */
|
|
194
|
+
export async function loginWithPlatformAccount(
|
|
195
|
+
ctx: PublicAuthContext,
|
|
196
|
+
input: { redirectUri: string; state?: string },
|
|
197
|
+
): Promise<ProductAuthTokens> {
|
|
198
|
+
const request = new AuthSession.AuthRequest({
|
|
199
|
+
clientId: "publishable",
|
|
200
|
+
redirectUri: input.redirectUri,
|
|
201
|
+
responseType: AuthSession.ResponseType.Code,
|
|
202
|
+
scopes: ["openid"],
|
|
203
|
+
usePKCE: true,
|
|
204
|
+
});
|
|
205
|
+
await request.makeAuthUrlAsync({ authorizationEndpoint: "https://leisuresaas.invalid/oauth/authorize" });
|
|
206
|
+
const challenge = request.codeChallenge?.trim() ?? "";
|
|
207
|
+
const verifier = request.codeVerifier?.trim() ?? "";
|
|
208
|
+
if (!challenge || !verifier) {
|
|
209
|
+
throw new LeisureSaasHttpError(500, "pkce challenge was not created");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const authorizationURL = await startPlatformAuthorize(ctx, {
|
|
213
|
+
redirectUri: input.redirectUri, codeChallenge: challenge, state: input.state,
|
|
214
|
+
});
|
|
215
|
+
const result = await WebBrowser.openAuthSessionAsync(authorizationURL, input.redirectUri);
|
|
216
|
+
if (result.type !== "success") {
|
|
217
|
+
throw new LeisureSaasHttpError(401, "platform login cancelled");
|
|
218
|
+
}
|
|
219
|
+
const redirected = new URL(result.url);
|
|
220
|
+
const code = redirected.searchParams.get("code")?.trim() ?? "";
|
|
221
|
+
if (!code) {
|
|
222
|
+
throw new LeisureSaasHttpError(401, "platform login returned no code");
|
|
223
|
+
}
|
|
224
|
+
const authorize = new URL(authorizationURL);
|
|
225
|
+
const clientId = authorize.searchParams.get("client_id")?.trim() ?? "";
|
|
226
|
+
const issuer = `${authorize.protocol}//${authorize.host}`;
|
|
227
|
+
const tokens = await exchangeAuthorizationCode(issuer, clientId, code, input.redirectUri, verifier);
|
|
228
|
+
const now = Math.floor(Date.now() / 1000);
|
|
229
|
+
const claims = jwtClaims(tokens.accessToken);
|
|
230
|
+
return {
|
|
231
|
+
accessToken: tokens.accessToken,
|
|
232
|
+
refreshToken: tokens.refreshToken ?? "",
|
|
233
|
+
accessExpiresAtUnix: tokens.expiresIn ? now + tokens.expiresIn : 0,
|
|
234
|
+
refreshExpiresAtUnix: tokens.refreshExpiresIn ? now + tokens.refreshExpiresIn : 0,
|
|
235
|
+
userId: typeof claims.sub === "string" ? claims.sub : "",
|
|
236
|
+
tenantId: typeof claims.tenant_id === "string" ? claims.tenant_id : "",
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function jwtClaims(accessToken: string): Record<string, unknown> {
|
|
241
|
+
const parts = accessToken.split(".");
|
|
242
|
+
if (parts.length < 2) {
|
|
243
|
+
return {};
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
const json = atob(parts[1].replace(/-/g, "+").replace(/_/g, "/"));
|
|
247
|
+
const payload = JSON.parse(json) as Record<string, unknown>;
|
|
248
|
+
return payload;
|
|
249
|
+
} catch {
|
|
250
|
+
return {};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Sets X-Publishable-Key for Public API calls. */
|
|
2
|
+
export function applyPublishableKeyHeaders(
|
|
3
|
+
headers: Record<string, string>,
|
|
4
|
+
publishableKey: string,
|
|
5
|
+
): void {
|
|
6
|
+
const key = publishableKey.trim();
|
|
7
|
+
if (!key) return;
|
|
8
|
+
headers["X-Publishable-Key"] = key;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Prefer EXPO_PUBLIC_PUBLISHABLE_KEY; legacy env names still accepted if they hold pk_live_. */
|
|
12
|
+
export function resolvePublishableKeyFromEnv(env: Record<string, string | undefined> = process.env): string {
|
|
13
|
+
return (
|
|
14
|
+
env.EXPO_PUBLIC_PUBLISHABLE_KEY?.trim() ||
|
|
15
|
+
env.EXPO_PUBLIC_ADS_PK?.trim() ||
|
|
16
|
+
env.EXPO_PUBLIC_APP_CFG_PK?.trim() ||
|
|
17
|
+
""
|
|
18
|
+
);
|
|
19
|
+
}
|
package/src/push.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { appBundleId } from "./ads/bundle-id";
|
|
2
|
+
import { mobilePlatform } from "./platform";
|
|
3
|
+
import type { EnablePushOptions, MobilePlatform, RegisterDeviceTokenInput } from "./types";
|
|
4
|
+
|
|
5
|
+
type ExpoNotificationsModule = {
|
|
6
|
+
requestPermissionsAsync: () => Promise<{ status: string }>;
|
|
7
|
+
getDevicePushTokenAsync: () => Promise<{ data: string; type?: string }>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function loadNotifications(): ExpoNotificationsModule {
|
|
11
|
+
try {
|
|
12
|
+
// Optional peer: apps must install expo-notifications.
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
14
|
+
return require("expo-notifications") as ExpoNotificationsModule;
|
|
15
|
+
} catch {
|
|
16
|
+
throw new Error(
|
|
17
|
+
"buildEnablePushRegistration requires peer dependency expo-notifications. Install it in your Expo app.",
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveAndroidPackage(opts?: EnablePushOptions): string | undefined {
|
|
23
|
+
if (opts?.androidPackage?.trim()) return opts.androidPackage.trim();
|
|
24
|
+
try {
|
|
25
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
26
|
+
const Application = require("expo-application") as { applicationId?: string | null };
|
|
27
|
+
if (Application.applicationId?.trim()) return Application.applicationId.trim();
|
|
28
|
+
} catch {
|
|
29
|
+
// fall through to appBundleId
|
|
30
|
+
}
|
|
31
|
+
return appBundleId() || undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function resolveBundleId(opts?: EnablePushOptions): string | undefined {
|
|
35
|
+
if (opts?.bundleId?.trim()) return opts.bundleId.trim();
|
|
36
|
+
return appBundleId() || undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolveEnvironment(opts?: EnablePushOptions): "development" | "production" {
|
|
40
|
+
if (opts?.environment === "development" || opts?.environment === "production") {
|
|
41
|
+
return opts.environment;
|
|
42
|
+
}
|
|
43
|
+
return typeof __DEV__ !== "undefined" && __DEV__ ? "development" : "production";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Read native FCM/APNs device token (not Expo Push Token). */
|
|
47
|
+
export async function fetchNativeDevicePushToken(): Promise<string> {
|
|
48
|
+
const Notifications = loadNotifications();
|
|
49
|
+
const device = await Notifications.getDevicePushTokenAsync();
|
|
50
|
+
const token = String(device.data ?? "").trim();
|
|
51
|
+
if (!token) {
|
|
52
|
+
throw new Error("empty device push token");
|
|
53
|
+
}
|
|
54
|
+
if (token.startsWith("ExponentPushToken[") || token.startsWith("ExpoPushToken[")) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
"ExponentPushToken is not supported; use a Dev Client / Store build with native FCM/APNs",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return token;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Request permission and build the body for product BFF
|
|
64
|
+
* `POST /v1/notifications/device-tokens` (path is product-defined).
|
|
65
|
+
* Does not call the network — App must POST via its own apiFetch.
|
|
66
|
+
*/
|
|
67
|
+
export async function buildEnablePushRegistration(
|
|
68
|
+
opts?: EnablePushOptions,
|
|
69
|
+
): Promise<RegisterDeviceTokenInput> {
|
|
70
|
+
const Notifications = loadNotifications();
|
|
71
|
+
const permission = await Notifications.requestPermissionsAsync();
|
|
72
|
+
if (permission.status !== "granted") {
|
|
73
|
+
throw new Error(`notification permission ${permission.status}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const token = await fetchNativeDevicePushToken();
|
|
77
|
+
const platform: MobilePlatform = opts?.platform ?? mobilePlatform();
|
|
78
|
+
const input: RegisterDeviceTokenInput = {
|
|
79
|
+
platform,
|
|
80
|
+
token,
|
|
81
|
+
environment: resolveEnvironment(opts),
|
|
82
|
+
};
|
|
83
|
+
if (platform === "android") {
|
|
84
|
+
input.androidPackage = resolveAndroidPackage(opts);
|
|
85
|
+
} else {
|
|
86
|
+
input.bundleId = resolveBundleId(opts);
|
|
87
|
+
}
|
|
88
|
+
return input;
|
|
89
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
export type MobilePlatform = "ios" | "android";
|
|
2
|
+
|
|
3
|
+
/** Shared shape for product BFF plan list responses (optional typing aid). */
|
|
4
|
+
export type Plan = {
|
|
5
|
+
plan_id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
code?: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
includes?: { text: string; href?: string; accent?: boolean }[];
|
|
10
|
+
plan_version_id?: string;
|
|
11
|
+
payment_channel?: string;
|
|
12
|
+
payment_mode?: string;
|
|
13
|
+
is_current?: boolean;
|
|
14
|
+
seat_limit?: number;
|
|
15
|
+
sort_order?: number;
|
|
16
|
+
recommended?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type SubscriptionStatus = {
|
|
20
|
+
id?: string;
|
|
21
|
+
status: string;
|
|
22
|
+
plan_id?: string;
|
|
23
|
+
plan_version_id?: string;
|
|
24
|
+
billing_source?: string;
|
|
25
|
+
current_period_end_unix?: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type Entitlement = {
|
|
29
|
+
id?: string;
|
|
30
|
+
tenant_id?: string;
|
|
31
|
+
product_id?: string;
|
|
32
|
+
plan_id?: string;
|
|
33
|
+
plan_version_id?: string;
|
|
34
|
+
status?: string;
|
|
35
|
+
permission_keys?: string[];
|
|
36
|
+
quotas?: { metric_key: string; limit: number; period: string }[];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Body for product BFF `POST …/notifications/device-tokens` (from buildEnablePushRegistration). */
|
|
40
|
+
export type RegisterDeviceTokenInput = {
|
|
41
|
+
platform: MobilePlatform;
|
|
42
|
+
token: string;
|
|
43
|
+
androidPackage?: string;
|
|
44
|
+
bundleId?: string;
|
|
45
|
+
environment?: "development" | "production";
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type EnablePushOptions = {
|
|
49
|
+
platform?: MobilePlatform;
|
|
50
|
+
androidPackage?: string;
|
|
51
|
+
bundleId?: string;
|
|
52
|
+
environment?: "development" | "production";
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type AuthConfig = {
|
|
56
|
+
issuer: string;
|
|
57
|
+
clientId: string;
|
|
58
|
+
redirectScheme: string;
|
|
59
|
+
redirectPath?: string;
|
|
60
|
+
scopes?: string[];
|
|
61
|
+
storageKey?: string;
|
|
62
|
+
refreshStorageKey?: string;
|
|
63
|
+
/**
|
|
64
|
+
* OAuth authorize `terminal` for Hosted UI layout (`mobile` = compact In-App Browser).
|
|
65
|
+
* Default: `mobile`.
|
|
66
|
+
*/
|
|
67
|
+
terminal?: "mobile" | "web";
|
|
68
|
+
/**
|
|
69
|
+
* Hosted UI UI language (BCP 47). Sent as OIDC `ui_locales` on authorize and used to
|
|
70
|
+
* rewrite Hosted UI HTTPS paths opened via deep link (e.g. `de` → `/de/…`).
|
|
71
|
+
*/
|
|
72
|
+
locale?: string;
|
|
73
|
+
/** When true (default), Universal Links to Hosted UI reset-password open In-App Browser. */
|
|
74
|
+
handlePasswordResetLinks?: boolean;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** @deprecated Use AuthConfig */
|
|
78
|
+
export type LeisureSaasAuthConfig = AuthConfig;
|
|
79
|
+
|
|
80
|
+
export type AdFeedItem = {
|
|
81
|
+
id: string;
|
|
82
|
+
scope?: string;
|
|
83
|
+
type: string;
|
|
84
|
+
placement?: string;
|
|
85
|
+
priority?: number;
|
|
86
|
+
title?: string;
|
|
87
|
+
body_text?: string;
|
|
88
|
+
image_url?: string;
|
|
89
|
+
click_url?: string;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export type AdFeedRotation = {
|
|
93
|
+
enabled: boolean;
|
|
94
|
+
mode: string;
|
|
95
|
+
interval_sec: number;
|
|
96
|
+
loop: boolean;
|
|
97
|
+
autoplay: boolean;
|
|
98
|
+
show_indicators: boolean;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export type AdFeedSource = {
|
|
102
|
+
kind: string;
|
|
103
|
+
lineup_id?: string;
|
|
104
|
+
scope: string;
|
|
105
|
+
overridden?: boolean;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export type AdsFeedResponse = {
|
|
109
|
+
placement: string;
|
|
110
|
+
surface_key?: string;
|
|
111
|
+
type?: string;
|
|
112
|
+
layout?: string;
|
|
113
|
+
source?: AdFeedSource;
|
|
114
|
+
rotation?: AdFeedRotation;
|
|
115
|
+
ads: AdFeedItem[];
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export type AdEventInput = {
|
|
119
|
+
adId: string;
|
|
120
|
+
eventType: "impression";
|
|
121
|
+
placementKey?: string;
|
|
122
|
+
lineupId?: string;
|
|
123
|
+
};
|
package/src/userinfo.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OIDC UserInfo (GET {issuer}/oauth/userinfo).
|
|
3
|
+
* Requires a user access token from the authorization_code flow — not client_credentials.
|
|
4
|
+
*/
|
|
5
|
+
export type UserInfo = {
|
|
6
|
+
sub: string;
|
|
7
|
+
email?: string;
|
|
8
|
+
email_verified?: boolean;
|
|
9
|
+
name?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** Fetch OIDC UserInfo from the platform issuer. */
|
|
13
|
+
export async function fetchUserInfo(issuer: string, accessToken: string): Promise<UserInfo> {
|
|
14
|
+
const base = issuer.replace(/\/$/, "");
|
|
15
|
+
const token = accessToken.trim();
|
|
16
|
+
if (!base) {
|
|
17
|
+
throw new Error("issuer is required");
|
|
18
|
+
}
|
|
19
|
+
if (!token) {
|
|
20
|
+
throw new Error("access token is required");
|
|
21
|
+
}
|
|
22
|
+
const res = await fetch(`${base}/oauth/userinfo`, {
|
|
23
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
24
|
+
});
|
|
25
|
+
if (!res.ok) {
|
|
26
|
+
const body = await res.text();
|
|
27
|
+
throw new Error(`userinfo failed (${res.status}): ${body}`);
|
|
28
|
+
}
|
|
29
|
+
const data = (await res.json()) as UserInfo;
|
|
30
|
+
if (!data?.sub) {
|
|
31
|
+
throw new Error("userinfo missing sub");
|
|
32
|
+
}
|
|
33
|
+
return data;
|
|
34
|
+
}
|