@leisuresaas/react 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.
@@ -0,0 +1,75 @@
1
+ export const DEFAULT_HOSTED_UI_LOCALE = "en";
2
+
3
+ const BCP47_RE = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
4
+
5
+ export function isLocalePathSegment(raw?: string | null): boolean {
6
+ const v = (raw ?? "").trim();
7
+ return Boolean(v) && BCP47_RE.test(v) && v.length <= 32;
8
+ }
9
+
10
+ function canonicalizeBcp47(raw: string): string {
11
+ const parts = raw.trim().split("-").filter(Boolean);
12
+ if (parts.length === 0) {
13
+ return DEFAULT_HOSTED_UI_LOCALE;
14
+ }
15
+ const out = [parts[0]!.toLowerCase()];
16
+ for (const part of parts.slice(1)) {
17
+ if (part.length === 2 && /^[A-Za-z]{2}$/.test(part)) {
18
+ out.push(part.toUpperCase());
19
+ } else if (part.length === 4 && /^[A-Za-z]{4}$/.test(part)) {
20
+ out.push(part[0]!.toUpperCase() + part.slice(1).toLowerCase());
21
+ } else {
22
+ out.push(part.toLowerCase());
23
+ }
24
+ }
25
+ return out.join("-");
26
+ }
27
+
28
+ export function normalizeHostedUILocale(raw?: string | null): string {
29
+ const v = (raw ?? "").trim().replace(/_/g, "-");
30
+ if (!v || !isLocalePathSegment(v)) {
31
+ return DEFAULT_HOSTED_UI_LOCALE;
32
+ }
33
+ const lower = v.toLowerCase();
34
+ if (lower === "en" || lower.startsWith("en-")) {
35
+ return "en";
36
+ }
37
+ if (lower === "zh" || lower.startsWith("zh-")) {
38
+ return "zh-CN";
39
+ }
40
+ if (lower === "de" || lower.startsWith("de-")) {
41
+ return "de";
42
+ }
43
+ return canonicalizeBcp47(v);
44
+ }
45
+
46
+ export function isHostedUIPasswordResetURL(url: string): boolean {
47
+ try {
48
+ const path = new URL(url).pathname.replace(/^\//, "");
49
+ return /(^|\/)reset-password\/?$/.test(path);
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ /** Rewrite the Hosted UI path locale segment. */
56
+ export function withHostedUILocale(url: string, locale?: string | null): string {
57
+ const trimmed = url.trim();
58
+ if (locale == null || !String(locale).trim() || !trimmed || !/^https?:\/\//i.test(trimmed)) {
59
+ return trimmed;
60
+ }
61
+ const normalized = normalizeHostedUILocale(locale);
62
+ try {
63
+ const u = new URL(trimmed);
64
+ const segs = u.pathname.split("/").filter(Boolean);
65
+ if (segs.length > 0 && isLocalePathSegment(segs[0])) {
66
+ segs[0] = normalized;
67
+ } else {
68
+ segs.unshift(normalized);
69
+ }
70
+ u.pathname = `/${segs.join("/")}`;
71
+ return u.toString();
72
+ } catch {
73
+ return trimmed;
74
+ }
75
+ }
package/src/index.ts ADDED
@@ -0,0 +1,117 @@
1
+ export { LeisureSaasHttpError } from "./errors";
2
+ export { applyPublishableKeyHeaders, resolvePublishableKeyFromEnv } from "./publishable-key";
3
+ export { resolveGatewayUrlFromEnv } from "./gateway-url";
4
+ export { fetchPlatformApiBase, resolvePlatformApiBase } from "./platform-api-base";
5
+ export type { ResolvePlatformApiBaseOptions } from "./platform-api-base";
6
+
7
+ export {
8
+ bindPlatformAccount,
9
+ changeProductPassword,
10
+ deleteProductAccount,
11
+ loginProductAccount,
12
+ logoutProductAccount,
13
+ refreshProductSession,
14
+ registerProductAccount,
15
+ requestProductEmailVerification,
16
+ requestProductPasswordReset,
17
+ startPlatformAuthorize,
18
+ unbindPlatformAccount,
19
+ } from "./auth";
20
+ export type { ProductAuthTokens, PublicAuthContext } from "./auth";
21
+ export { ProductAuthProvider, useProductAuth } from "./auth-provider";
22
+
23
+ export {
24
+ accessNeedsRefresh,
25
+ beginPlatformLogin,
26
+ buildOAuthAuthorizeUrl,
27
+ completePlatformLogin,
28
+ createPkcePair,
29
+ exchangeAuthorizationCode,
30
+ exchangeMagicToken,
31
+ generateOAuthState,
32
+ magicTokenFromURL,
33
+ productTokensFromOAuth,
34
+ refreshOAuthTokens,
35
+ revokeOAuthToken,
36
+ } from "./oauth";
37
+ export type { OAuthTokenResponse, PkcePair } from "./oauth";
38
+ export { fetchUserInfo } from "./userinfo";
39
+ export type { UserInfo } from "./userinfo";
40
+ export {
41
+ DEFAULT_HOSTED_UI_LOCALE,
42
+ isHostedUIPasswordResetURL,
43
+ isLocalePathSegment,
44
+ normalizeHostedUILocale,
45
+ withHostedUILocale,
46
+ } from "./hosted-ui";
47
+
48
+ export {
49
+ Ad,
50
+ AdBanner,
51
+ AdFooter,
52
+ AdInline,
53
+ AdPreview,
54
+ AdSidebar,
55
+ AdsProvider,
56
+ useAdsContext,
57
+ useAdsFeed,
58
+ useAdRotation,
59
+ getPublicAdsFeed,
60
+ lineupIdFromSource,
61
+ recordPublicAdEvents,
62
+ AD_TYPES,
63
+ IMAGE_LAYOUTS,
64
+ TEXT_LAYOUTS,
65
+ defaultLayoutForType,
66
+ effectiveRotationMode,
67
+ layoutsForType,
68
+ normalizeAdType,
69
+ normalizeLayout,
70
+ normalizeRotationMode,
71
+ } from "./ads-ui";
72
+ export type {
73
+ AdEventInput,
74
+ AdFeedItem,
75
+ AdFeedRotation,
76
+ AdFeedSource,
77
+ AdLayout,
78
+ AdProps,
79
+ AdPreviewProps,
80
+ AdRotationMode,
81
+ AdType,
82
+ AdsFeedResponse,
83
+ AdsProviderProps,
84
+ ImageLayout,
85
+ InAppNavigate,
86
+ PublicAdsRequestContext,
87
+ TextLayout,
88
+ } from "./ads-ui";
89
+
90
+ export {
91
+ DRIVE_UPLOAD_MAX_AUTO_PUT_BYTES,
92
+ createFsContentURL,
93
+ createFsFolder,
94
+ createFsUploadSession,
95
+ deleteFsNode,
96
+ finishFsUploadSession,
97
+ getFsNode,
98
+ getFsStorageUsage,
99
+ listFsNodes,
100
+ moveFsNode,
101
+ putFsUploadURL,
102
+ renameFsNode,
103
+ uploadFsFile,
104
+ } from "./drive";
105
+ export type {
106
+ DriveRequestContext,
107
+ FsContentURLIntent,
108
+ FsContentURLResult,
109
+ FsNode,
110
+ FsStorageUsage,
111
+ FsUploadSessionResult,
112
+ UploadProgress,
113
+ UploadProgressHandler,
114
+ } from "./drive";
115
+
116
+ export { getPublicAppConfig } from "./app-config";
117
+ export type { AppConfigResponse, PublicAppConfigRequestContext } from "./app-config";
package/src/oauth.ts ADDED
@@ -0,0 +1,257 @@
1
+ import { startPlatformAuthorize, type PublicAuthContext, type ProductAuthTokens } from "./auth";
2
+
3
+ const REFRESH_SKEW_SEC = 60;
4
+
5
+ export function accessExpUnix(accessToken: string): number | undefined {
6
+ const parts = accessToken.split(".");
7
+ if (parts.length < 2) {
8
+ return undefined;
9
+ }
10
+ try {
11
+ const json = atob(parts[1].replace(/-/g, "+").replace(/_/g, "/"));
12
+ const payload = JSON.parse(json) as { exp?: number };
13
+ return typeof payload.exp === "number" ? payload.exp : undefined;
14
+ } catch {
15
+ return undefined;
16
+ }
17
+ }
18
+
19
+ export function accessNeedsRefresh(accessToken: string): boolean {
20
+ const exp = accessExpUnix(accessToken);
21
+ if (!exp) {
22
+ return false;
23
+ }
24
+ return exp - Math.floor(Date.now() / 1000) <= REFRESH_SKEW_SEC;
25
+ }
26
+
27
+ export type OAuthTokenResponse = {
28
+ accessToken: string;
29
+ refreshToken?: string;
30
+ expiresIn?: number;
31
+ refreshExpiresIn?: number;
32
+ };
33
+
34
+ export type PkcePair = { verifier: string; challenge: string };
35
+
36
+ function bytesToBase64Url(bytes: Uint8Array): string {
37
+ let binary = "";
38
+ for (const byte of bytes) {
39
+ binary += String.fromCharCode(byte);
40
+ }
41
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
42
+ }
43
+
44
+ export function generateOAuthState(): string {
45
+ const bytes = new Uint8Array(16);
46
+ crypto.getRandomValues(bytes);
47
+ return bytesToBase64Url(bytes);
48
+ }
49
+
50
+ export async function createPkcePair(): Promise<PkcePair> {
51
+ const bytes = new Uint8Array(32);
52
+ crypto.getRandomValues(bytes);
53
+ const verifier = bytesToBase64Url(bytes);
54
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
55
+ return { verifier, challenge: bytesToBase64Url(new Uint8Array(digest)) };
56
+ }
57
+
58
+ export function buildOAuthAuthorizeUrl(input: {
59
+ issuer: string;
60
+ clientId: string;
61
+ redirectUri: string;
62
+ codeChallenge: string;
63
+ state: string;
64
+ scopes?: string[];
65
+ locale?: string;
66
+ }): string {
67
+ const params = new URLSearchParams({
68
+ client_id: input.clientId,
69
+ redirect_uri: input.redirectUri,
70
+ response_type: "code",
71
+ code_challenge: input.codeChallenge,
72
+ code_challenge_method: "S256",
73
+ state: input.state,
74
+ });
75
+ if (input.scopes && input.scopes.length > 0) {
76
+ params.set("scope", input.scopes.join(" "));
77
+ }
78
+ if (input.locale?.trim()) {
79
+ params.set("ui_locales", input.locale.trim());
80
+ }
81
+ return `${input.issuer.replace(/\/$/, "")}/oauth/authorize?${params}`;
82
+ }
83
+
84
+ async function postOAuthToken(issuer: string, body: URLSearchParams, label: string): Promise<OAuthTokenResponse> {
85
+ const res = await fetch(`${issuer.replace(/\/$/, "")}/oauth/token`, {
86
+ method: "POST",
87
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
88
+ body,
89
+ });
90
+ if (!res.ok) {
91
+ const text = await res.text();
92
+ throw new Error(`${label} failed (${res.status}): ${text}`);
93
+ }
94
+ const data = (await res.json()) as {
95
+ access_token?: string;
96
+ refresh_token?: string;
97
+ expires_in?: number;
98
+ refresh_expires_in?: number;
99
+ };
100
+ const accessToken = data.access_token?.trim();
101
+ if (!accessToken) {
102
+ throw new Error(`${label}: missing access_token`);
103
+ }
104
+ return {
105
+ accessToken,
106
+ refreshToken: data.refresh_token?.trim() || undefined,
107
+ expiresIn: data.expires_in,
108
+ refreshExpiresIn: data.refresh_expires_in,
109
+ };
110
+ }
111
+
112
+ export async function refreshOAuthTokens(
113
+ issuer: string,
114
+ clientId: string,
115
+ refreshToken: string,
116
+ ): Promise<OAuthTokenResponse> {
117
+ const body = new URLSearchParams({
118
+ grant_type: "refresh_token",
119
+ refresh_token: refreshToken,
120
+ client_id: clientId,
121
+ });
122
+ return postOAuthToken(issuer, body, "token refresh");
123
+ }
124
+
125
+ export async function exchangeAuthorizationCode(
126
+ issuer: string,
127
+ clientId: string,
128
+ code: string,
129
+ redirectUri: string,
130
+ codeVerifier: string,
131
+ ): Promise<OAuthTokenResponse> {
132
+ const body = new URLSearchParams({
133
+ grant_type: "authorization_code",
134
+ code: code.trim(),
135
+ redirect_uri: redirectUri,
136
+ client_id: clientId,
137
+ code_verifier: codeVerifier,
138
+ });
139
+ return postOAuthToken(issuer, body, "authorization code exchange");
140
+ }
141
+
142
+ export async function revokeOAuthToken(input: {
143
+ issuer: string;
144
+ clientId: string;
145
+ token: string;
146
+ tokenTypeHint?: "refresh_token" | "access_token";
147
+ }): Promise<void> {
148
+ const token = input.token.trim();
149
+ if (!token) {
150
+ return;
151
+ }
152
+ const body = new URLSearchParams({
153
+ token,
154
+ client_id: input.clientId,
155
+ });
156
+ if (input.tokenTypeHint) {
157
+ body.set("token_type_hint", input.tokenTypeHint);
158
+ }
159
+ const res = await fetch(`${input.issuer.replace(/\/$/, "")}/oauth/revoke`, {
160
+ method: "POST",
161
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
162
+ body,
163
+ });
164
+ if (!res.ok) {
165
+ const text = await res.text();
166
+ throw new Error(`token revoke failed (${res.status}): ${text}`);
167
+ }
168
+ }
169
+
170
+ export async function exchangeMagicToken(
171
+ issuer: string,
172
+ clientId: string,
173
+ magicToken: string,
174
+ clientSecret?: string,
175
+ ): Promise<OAuthTokenResponse> {
176
+ const body = new URLSearchParams({
177
+ grant_type: "urn:leisuresaas:oauth:magic-token",
178
+ magic_token: magicToken.trim(),
179
+ client_id: clientId,
180
+ });
181
+ if (clientSecret?.trim()) {
182
+ body.set("client_secret", clientSecret.trim());
183
+ }
184
+ return postOAuthToken(issuer, body, "magic token exchange");
185
+ }
186
+
187
+ export function magicTokenFromURL(url: string): string | undefined {
188
+ try {
189
+ const u = new URL(url);
190
+ const fromQuery = u.searchParams.get("magic_token")?.trim();
191
+ if (fromQuery) {
192
+ return fromQuery;
193
+ }
194
+ if (u.hash) {
195
+ const hash = new URLSearchParams(u.hash.replace(/^#/, ""));
196
+ return hash.get("magic_token")?.trim() || undefined;
197
+ }
198
+ } catch {
199
+ return undefined;
200
+ }
201
+ return undefined;
202
+ }
203
+
204
+ function jwtClaims(accessToken: string): Record<string, unknown> {
205
+ const parts = accessToken.split(".");
206
+ if (parts.length < 2) {
207
+ return {};
208
+ }
209
+ try {
210
+ const json = atob(parts[1].replace(/-/g, "+").replace(/_/g, "/"));
211
+ return JSON.parse(json) as Record<string, unknown>;
212
+ } catch {
213
+ return {};
214
+ }
215
+ }
216
+
217
+ export function productTokensFromOAuth(tokens: OAuthTokenResponse): ProductAuthTokens {
218
+ const now = Math.floor(Date.now() / 1000);
219
+ const claims = jwtClaims(tokens.accessToken);
220
+ return {
221
+ accessToken: tokens.accessToken,
222
+ refreshToken: tokens.refreshToken ?? "",
223
+ accessExpiresAtUnix: tokens.expiresIn ? now + tokens.expiresIn : 0,
224
+ refreshExpiresAtUnix: tokens.refreshExpiresIn ? now + tokens.refreshExpiresIn : 0,
225
+ userId: typeof claims.sub === "string" ? claims.sub : "",
226
+ tenantId: typeof claims.tenant_id === "string" ? claims.tenant_id : "",
227
+ };
228
+ }
229
+
230
+ /** PKCE + publishable-key authorize URL. The product stores `verifier` and redirects the browser. */
231
+ export async function beginPlatformLogin(
232
+ ctx: PublicAuthContext,
233
+ input: { redirectUri: string; state?: string },
234
+ ): Promise<{ authorizationUrl: string; verifier: string; state: string }> {
235
+ const state = input.state?.trim() || generateOAuthState();
236
+ const pkce = await createPkcePair();
237
+ const authorizationUrl = await startPlatformAuthorize(ctx, {
238
+ redirectUri: input.redirectUri,
239
+ codeChallenge: pkce.challenge,
240
+ state,
241
+ });
242
+ return { authorizationUrl, verifier: pkce.verifier, state };
243
+ }
244
+
245
+ /** Exchange the code from the platform authorize redirect. */
246
+ export async function completePlatformLogin(input: {
247
+ authorizationUrl: string;
248
+ code: string;
249
+ redirectUri: string;
250
+ verifier: string;
251
+ }): Promise<ProductAuthTokens> {
252
+ const authorize = new URL(input.authorizationUrl);
253
+ const clientId = authorize.searchParams.get("client_id")?.trim() ?? "";
254
+ const issuer = `${authorize.protocol}//${authorize.host}`;
255
+ const tokens = await exchangeAuthorizationCode(issuer, clientId, input.code, input.redirectUri, input.verifier);
256
+ return productTokensFromOAuth(tokens);
257
+ }
@@ -0,0 +1,63 @@
1
+ import { LeisureSaasHttpError } from "./errors";
2
+ import { resolveGatewayUrlFromEnv } from "./gateway-url";
3
+
4
+ const DISCOVERY_PLATFORM_API_KEY = "leisuresaas_platform_api_base";
5
+
6
+ function trimSlash(url: string): string {
7
+ return url.replace(/\/$/, "");
8
+ }
9
+
10
+ /** Fetch platform API base from OIDC discovery (`leisuresaas_platform_api_base`). */
11
+ export async function fetchPlatformApiBase(issuer: string): Promise<string> {
12
+ const base = trimSlash(issuer.trim());
13
+ if (!base) {
14
+ throw new Error("issuer is required");
15
+ }
16
+ const res = await fetch(`${base}/.well-known/openid-configuration`, {
17
+ method: "GET",
18
+ headers: { Accept: "application/json" },
19
+ });
20
+ const text = await res.text();
21
+ if (!res.ok) {
22
+ throw new LeisureSaasHttpError(res.status, text.trim());
23
+ }
24
+ let doc: Record<string, unknown>;
25
+ try {
26
+ doc = JSON.parse(text) as Record<string, unknown>;
27
+ } catch {
28
+ throw new Error("openid-configuration: invalid JSON");
29
+ }
30
+ const raw = doc[DISCOVERY_PLATFORM_API_KEY];
31
+ const api = typeof raw === "string" ? trimSlash(raw.trim()) : "";
32
+ if (!api) {
33
+ throw new Error(`openid-configuration missing ${DISCOVERY_PLATFORM_API_KEY}`);
34
+ }
35
+ return api;
36
+ }
37
+
38
+ export type ResolvePlatformApiBaseOptions = {
39
+ gatewayUrl?: string;
40
+ issuer?: string;
41
+ env?: Record<string, string | undefined>;
42
+ };
43
+
44
+ /** Resolve platform API base: prop → NEXT_PUBLIC_GATEWAY_URL → discovery via issuer. */
45
+ export async function resolvePlatformApiBase(opts: ResolvePlatformApiBaseOptions = {}): Promise<string> {
46
+ const fromProp = opts.gatewayUrl?.trim();
47
+ if (fromProp) {
48
+ return trimSlash(fromProp);
49
+ }
50
+ const fromEnv = resolveGatewayUrlFromEnv(opts.env);
51
+ if (fromEnv) {
52
+ return trimSlash(fromEnv);
53
+ }
54
+ const issuer =
55
+ opts.issuer?.trim() ||
56
+ opts.env?.NEXT_PUBLIC_OAUTH_ISSUER?.trim() ||
57
+ (typeof process !== "undefined" ? process.env.NEXT_PUBLIC_OAUTH_ISSUER?.trim() : "") ||
58
+ "";
59
+ if (!issuer) {
60
+ return "";
61
+ }
62
+ return fetchPlatformApiBase(issuer);
63
+ }
@@ -0,0 +1,14 @@
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 NEXT_PUBLIC_PUBLISHABLE_KEY. */
12
+ export function resolvePublishableKeyFromEnv(env: Record<string, string | undefined> = process.env): string {
13
+ return env.NEXT_PUBLIC_PUBLISHABLE_KEY?.trim() || "";
14
+ }
@@ -0,0 +1,30 @@
1
+ export type UserInfo = {
2
+ sub: string;
3
+ email?: string;
4
+ email_verified?: boolean;
5
+ name?: string;
6
+ };
7
+
8
+ /** Fetch OIDC UserInfo from the platform issuer. */
9
+ export async function fetchUserInfo(issuer: string, accessToken: string): Promise<UserInfo> {
10
+ const base = issuer.replace(/\/$/, "");
11
+ const token = accessToken.trim();
12
+ if (!base) {
13
+ throw new Error("issuer is required");
14
+ }
15
+ if (!token) {
16
+ throw new Error("access token is required");
17
+ }
18
+ const res = await fetch(`${base}/oauth/userinfo`, {
19
+ headers: { Authorization: `Bearer ${token}` },
20
+ });
21
+ if (!res.ok) {
22
+ const body = await res.text();
23
+ throw new Error(`userinfo failed (${res.status}): ${body}`);
24
+ }
25
+ const data = (await res.json()) as UserInfo;
26
+ if (!data?.sub) {
27
+ throw new Error("userinfo missing sub");
28
+ }
29
+ return data;
30
+ }