@pisti-paypiller/react 0.0.0-stage → 0.7.1
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 +159 -2
- package/dist/api-keys.d.ts +8 -0
- package/dist/api-keys.d.ts.map +1 -0
- package/dist/api-keys.js +54 -0
- package/dist/api-keys.js.map +1 -0
- package/dist/async-state.d.ts +14 -0
- package/dist/async-state.d.ts.map +1 -0
- package/dist/async-state.js +81 -0
- package/dist/async-state.js.map +1 -0
- package/dist/auth.d.ts +23 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +297 -0
- package/dist/auth.js.map +1 -0
- package/dist/client.d.ts +152 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +633 -0
- package/dist/client.js.map +1 -0
- package/dist/context.d.ts +94 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +145 -0
- package/dist/context.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/organizations.d.ts +16 -0
- package/dist/organizations.d.ts.map +1 -0
- package/dist/organizations.js +169 -0
- package/dist/organizations.js.map +1 -0
- package/dist/resources.d.ts +204 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/resources.js +129 -0
- package/dist/resources.js.map +1 -0
- package/dist/reverification.d.ts +5 -0
- package/dist/reverification.d.ts.map +1 -0
- package/dist/reverification.js +54 -0
- package/dist/reverification.js.map +1 -0
- package/dist/types.d.ts +118 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/ui.d.ts +72 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +125 -0
- package/dist/ui.js.map +1 -0
- package/dist/user-profile.d.ts +6 -0
- package/dist/user-profile.d.ts.map +1 -0
- package/dist/user-profile.js +219 -0
- package/dist/user-profile.js.map +1 -0
- package/package.json +48 -4
- package/styles.css +517 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
import { AccountResource, OrganizationResource, } from "./resources.js";
|
|
2
|
+
export class PistiClientError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
code;
|
|
5
|
+
constructor(message, status = 0, code) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.name = "PistiClientError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const INITIAL = Object.freeze({
|
|
13
|
+
status: "loading",
|
|
14
|
+
isLoaded: false,
|
|
15
|
+
isSignedIn: false,
|
|
16
|
+
user: null,
|
|
17
|
+
sessionId: null,
|
|
18
|
+
sessionClaims: null,
|
|
19
|
+
error: null,
|
|
20
|
+
});
|
|
21
|
+
const SIGNED_OUT = Object.freeze({
|
|
22
|
+
status: "signedOut",
|
|
23
|
+
isLoaded: true,
|
|
24
|
+
isSignedIn: false,
|
|
25
|
+
user: null,
|
|
26
|
+
sessionId: null,
|
|
27
|
+
sessionClaims: null,
|
|
28
|
+
error: null,
|
|
29
|
+
});
|
|
30
|
+
/** Reads `VITE_<name>` or `<name>` from Vite's import.meta.env, then process.env. */
|
|
31
|
+
function envValue(name) {
|
|
32
|
+
try {
|
|
33
|
+
const env = import.meta.env;
|
|
34
|
+
const value = env?.[`VITE_${name}`] ?? env?.[name];
|
|
35
|
+
if (value)
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
/* Not a Vite build. */
|
|
40
|
+
}
|
|
41
|
+
const processEnv = globalThis.process?.env;
|
|
42
|
+
return processEnv?.[`VITE_${name}`] ?? processEnv?.[name];
|
|
43
|
+
}
|
|
44
|
+
function withEnvironmentDefaults(options) {
|
|
45
|
+
return {
|
|
46
|
+
...options,
|
|
47
|
+
signInUrl: options.signInUrl ?? envValue("PISTI_SIGN_IN_URL"),
|
|
48
|
+
signUpUrl: options.signUpUrl ?? envValue("PISTI_SIGN_UP_URL"),
|
|
49
|
+
signInFallbackRedirectUrl: options.signInFallbackRedirectUrl ?? envValue("PISTI_SIGN_IN_FALLBACK_REDIRECT_URL"),
|
|
50
|
+
signUpFallbackRedirectUrl: options.signUpFallbackRedirectUrl ?? envValue("PISTI_SIGN_UP_FALLBACK_REDIRECT_URL"),
|
|
51
|
+
signInForceRedirectUrl: options.signInForceRedirectUrl ?? envValue("PISTI_SIGN_IN_FORCE_REDIRECT_URL"),
|
|
52
|
+
signUpForceRedirectUrl: options.signUpForceRedirectUrl ?? envValue("PISTI_SIGN_UP_FORCE_REDIRECT_URL"),
|
|
53
|
+
afterSignOutUrl: options.afterSignOutUrl ?? envValue("PISTI_AFTER_SIGN_OUT_URL"),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** Parses `pk_test_…`/`pk_live_…`: base64url of the Frontend API URL followed by `$`. */
|
|
57
|
+
export function parsePublishableKey(key) {
|
|
58
|
+
if (!key)
|
|
59
|
+
throw new Error("Missing Pisti publishable key. Set VITE_PISTI_PUBLISHABLE_KEY in your .env file (copy it from your application's Home page in the Pisti console), or pass publishableKey to <PistiProvider>.");
|
|
60
|
+
const match = /^pk_(test|live)_([A-Za-z0-9_-]+)$/.exec(key.trim());
|
|
61
|
+
if (!match)
|
|
62
|
+
throw new Error("That isn't a Pisti publishable key. It should start with pk_test_ or pk_live_.");
|
|
63
|
+
const base64 = match[2].replace(/-/g, "+").replace(/_/g, "/");
|
|
64
|
+
const decoded = atob(base64 + "=".repeat((4 - (base64.length % 4)) % 4));
|
|
65
|
+
if (!decoded.endsWith("$"))
|
|
66
|
+
throw new Error("The publishable key is malformed.");
|
|
67
|
+
const frontendApi = new URL(decoded.slice(0, -1));
|
|
68
|
+
const frontendApiId = frontendApi.pathname.split("/").filter(Boolean).at(-1) ?? "";
|
|
69
|
+
if (!frontendApi.pathname.endsWith(`/v1/fapi/${frontendApiId}`))
|
|
70
|
+
throw new Error("The publishable key is malformed.");
|
|
71
|
+
const development = match[1] === "test";
|
|
72
|
+
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(frontendApi.hostname);
|
|
73
|
+
if (frontendApi.protocol !== "https:" && !(development && loopback))
|
|
74
|
+
throw new Error("Pisti requires HTTPS outside local development.");
|
|
75
|
+
const issuer = new URL(frontendApi.pathname.slice(0, -`v1/fapi/${frontendApiId}`.length), frontendApi.origin);
|
|
76
|
+
return { frontendApi, frontendApiId, issuer, development };
|
|
77
|
+
}
|
|
78
|
+
function decodeClaims(token) {
|
|
79
|
+
try {
|
|
80
|
+
const payload = token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
81
|
+
return JSON.parse(decodeURIComponent(Array.from(atob(payload + "=".repeat((4 - (payload.length % 4)) % 4)), (character) => "%" + character.charCodeAt(0).toString(16).padStart(2, "0")).join("")));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return {};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Talks to the environment's Frontend API from the browser: sign-in and sign-up
|
|
89
|
+
* steps, the signed-in session, and short-lived session tokens. Keeps the
|
|
90
|
+
* `__session` cookie fresh so same-site backends can read it.
|
|
91
|
+
*/
|
|
92
|
+
export class PistiClient {
|
|
93
|
+
publishableKey;
|
|
94
|
+
frontendApi;
|
|
95
|
+
issuer;
|
|
96
|
+
development;
|
|
97
|
+
account = new AccountResource((method, path, body) => this.withReverification(() => this.resource(method, path, body)));
|
|
98
|
+
organizations = new OrganizationResource((method, path, body) => this.withReverification(() => this.resource(method, path, body)), () => {
|
|
99
|
+
const identity = this.snapshot.sessionId;
|
|
100
|
+
return () => this.requireIdentity(identity);
|
|
101
|
+
});
|
|
102
|
+
snapshot = INITIAL;
|
|
103
|
+
environmentConfig = null;
|
|
104
|
+
environmentRequest;
|
|
105
|
+
listeners = new Set();
|
|
106
|
+
token;
|
|
107
|
+
renewing;
|
|
108
|
+
loading;
|
|
109
|
+
timer;
|
|
110
|
+
channel;
|
|
111
|
+
storageKey;
|
|
112
|
+
frontendApiId;
|
|
113
|
+
navigation;
|
|
114
|
+
modal = null;
|
|
115
|
+
modalOptions = {};
|
|
116
|
+
modalListeners = new Set();
|
|
117
|
+
reverificationListeners = new Set();
|
|
118
|
+
reverification;
|
|
119
|
+
constructor(options = {}) {
|
|
120
|
+
const publishableKey = options.publishableKey ?? envValue("PISTI_PUBLISHABLE_KEY");
|
|
121
|
+
const key = parsePublishableKey(publishableKey);
|
|
122
|
+
this.publishableKey = publishableKey;
|
|
123
|
+
this.frontendApi = key.frontendApi;
|
|
124
|
+
this.frontendApiId = key.frontendApiId;
|
|
125
|
+
this.issuer = key.issuer;
|
|
126
|
+
this.development = key.development;
|
|
127
|
+
this.storageKey = `pisti_client:${key.frontendApiId}`;
|
|
128
|
+
const { publishableKey: _key, ...navigation } = options;
|
|
129
|
+
this.navigation = withEnvironmentDefaults(navigation);
|
|
130
|
+
}
|
|
131
|
+
/** Updates redirect URLs and router functions without recreating the client. */
|
|
132
|
+
configure(navigation) {
|
|
133
|
+
this.navigation = withEnvironmentDefaults(navigation);
|
|
134
|
+
}
|
|
135
|
+
subscribe = (listener) => {
|
|
136
|
+
this.listeners.add(listener);
|
|
137
|
+
return () => {
|
|
138
|
+
this.listeners.delete(listener);
|
|
139
|
+
};
|
|
140
|
+
};
|
|
141
|
+
getSnapshot = () => this.snapshot;
|
|
142
|
+
getServerSnapshot = () => INITIAL;
|
|
143
|
+
get environment() {
|
|
144
|
+
return this.environmentConfig;
|
|
145
|
+
}
|
|
146
|
+
start() {
|
|
147
|
+
if (typeof window === "undefined")
|
|
148
|
+
return () => { };
|
|
149
|
+
void this.load();
|
|
150
|
+
void this.loadEnvironment().catch(() => { });
|
|
151
|
+
this.timer = setInterval(() => {
|
|
152
|
+
if (this.snapshot.isSignedIn && document.visibilityState === "visible")
|
|
153
|
+
void this.getToken().catch(() => { });
|
|
154
|
+
}, 15_000);
|
|
155
|
+
const focus = () => {
|
|
156
|
+
if (document.visibilityState === "visible")
|
|
157
|
+
void this.load();
|
|
158
|
+
};
|
|
159
|
+
document.addEventListener("visibilitychange", focus);
|
|
160
|
+
if (typeof BroadcastChannel !== "undefined") {
|
|
161
|
+
this.channel = new BroadcastChannel(`pisti:${this.frontendApiId}`);
|
|
162
|
+
this.channel.onmessage = () => void this.load();
|
|
163
|
+
}
|
|
164
|
+
return () => {
|
|
165
|
+
clearInterval(this.timer);
|
|
166
|
+
document.removeEventListener("visibilitychange", focus);
|
|
167
|
+
this.channel?.close();
|
|
168
|
+
this.channel = undefined;
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/** Re-reads the browser's session from Pisti. */
|
|
172
|
+
load = () => (this.loading ??= (async () => {
|
|
173
|
+
try {
|
|
174
|
+
const state = await this.fapi("GET", "client");
|
|
175
|
+
await this.apply(state);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
this.setSnapshot({
|
|
179
|
+
...SIGNED_OUT,
|
|
180
|
+
status: "error",
|
|
181
|
+
error: error instanceof Error ? error : new Error("Pisti is unavailable."),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
this.loading = undefined;
|
|
186
|
+
}
|
|
187
|
+
})());
|
|
188
|
+
loadEnvironment = () => this.environmentConfig
|
|
189
|
+
? Promise.resolve(this.environmentConfig)
|
|
190
|
+
: (this.environmentRequest ??= this.fapi("GET", "environment").then((value) => {
|
|
191
|
+
this.environmentConfig = value;
|
|
192
|
+
for (const listener of this.listeners)
|
|
193
|
+
listener();
|
|
194
|
+
return value;
|
|
195
|
+
}, (error) => {
|
|
196
|
+
this.environmentRequest = undefined;
|
|
197
|
+
throw error;
|
|
198
|
+
}));
|
|
199
|
+
signIn = {
|
|
200
|
+
password: (identifier, password) => this.complete(this.fapi("POST", "sign_in/password", { identifier, password })),
|
|
201
|
+
sendEmailCode: (identifier) => this.fapi("POST", "sign_in/email_code", { identifier }),
|
|
202
|
+
verifyEmailCode: (requestId, code) => this.complete(this.fapi("POST", "sign_in/email_code/verify", { requestId, code })),
|
|
203
|
+
secondFactorDetails: (attemptId) => this.fapi("POST", "sign_in/second_factor/details", { attemptId }),
|
|
204
|
+
completeSecondFactor: (attemptId, code, emailCode) => this.complete(this.fapi("POST", "sign_in/second_factor", { attemptId, code, ...(emailCode ? { emailCode } : {}) })),
|
|
205
|
+
};
|
|
206
|
+
signUp = {
|
|
207
|
+
sendEmailCode: (email) => this.fapi("POST", "sign_up", { identifier: email }),
|
|
208
|
+
verify: (input) => this.complete(this.fapi("POST", "sign_up/verify", input)),
|
|
209
|
+
};
|
|
210
|
+
passwordReset = {
|
|
211
|
+
sendEmailCode: (identifier) => this.fapi("POST", "password_reset", { identifier }),
|
|
212
|
+
complete: (input) => this.fapi("POST", "password_reset/complete", input),
|
|
213
|
+
};
|
|
214
|
+
/** Restores a self-deactivated account during its 30-day grace period. The user then signs in again. */
|
|
215
|
+
reactivation = {
|
|
216
|
+
sendEmailCode: (identifier) => this.fapi("POST", "reactivation", { identifier }),
|
|
217
|
+
complete: (input) => this.fapi("POST", "reactivation/complete", input),
|
|
218
|
+
};
|
|
219
|
+
signOut = async () => {
|
|
220
|
+
await this.apply(await this.fapi("POST", "sign_out"));
|
|
221
|
+
this.channel?.postMessage("changed");
|
|
222
|
+
};
|
|
223
|
+
setActiveOrganization = async (organizationId) => {
|
|
224
|
+
const sessionId = this.requireSession();
|
|
225
|
+
await this.apply(await this.fapi("POST", `sessions/${sessionId}/touch`, { organizationId }));
|
|
226
|
+
await this.getToken({ skipCache: true });
|
|
227
|
+
this.channel?.postMessage("changed");
|
|
228
|
+
};
|
|
229
|
+
/** A fresh session token for your backend, or null when signed out. Tokens last about a minute. */
|
|
230
|
+
getToken = (options = {}) => {
|
|
231
|
+
const sessionId = this.snapshot.sessionId;
|
|
232
|
+
if (!sessionId)
|
|
233
|
+
return Promise.resolve(null);
|
|
234
|
+
if (!options.skipCache &&
|
|
235
|
+
this.token?.sessionId === sessionId &&
|
|
236
|
+
this.token.expiresAt - Date.now() > 20_000)
|
|
237
|
+
return Promise.resolve(this.token.value);
|
|
238
|
+
return (this.renewing ??= (async () => {
|
|
239
|
+
try {
|
|
240
|
+
const result = await this.fapi("POST", `sessions/${sessionId}/tokens`);
|
|
241
|
+
if (this.snapshot.sessionId !== sessionId)
|
|
242
|
+
return null;
|
|
243
|
+
this.token = { value: result.jwt, expiresAt: Date.parse(result.expiresAt), sessionId };
|
|
244
|
+
this.writeCookie(result.jwt);
|
|
245
|
+
this.applyClaims(decodeClaims(result.jwt));
|
|
246
|
+
return result.jwt;
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
if (error instanceof PistiClientError && error.status === 401)
|
|
250
|
+
await this.load();
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
this.renewing = undefined;
|
|
255
|
+
}
|
|
256
|
+
})());
|
|
257
|
+
};
|
|
258
|
+
has = (check) => {
|
|
259
|
+
const user = this.snapshot.user;
|
|
260
|
+
return Boolean(user &&
|
|
261
|
+
(check.permission || check.role) &&
|
|
262
|
+
(!check.permission || user.organizationPermissions.includes(check.permission)) &&
|
|
263
|
+
(!check.role || user.organizationRole === check.role));
|
|
264
|
+
};
|
|
265
|
+
/** `fetch` with the session token attached as a bearer credential. */
|
|
266
|
+
authenticatedFetch = async (input, init = {}) => {
|
|
267
|
+
const token = await this.getToken();
|
|
268
|
+
const headers = new Headers(init.headers);
|
|
269
|
+
if (token)
|
|
270
|
+
headers.set("Authorization", "Bearer " + token);
|
|
271
|
+
const response = await fetch(input, { ...init, headers });
|
|
272
|
+
if (response.status !== 401 || !token)
|
|
273
|
+
return response;
|
|
274
|
+
const renewed = await this.getToken({ skipCache: true });
|
|
275
|
+
if (!renewed)
|
|
276
|
+
return response;
|
|
277
|
+
headers.set("Authorization", "Bearer " + renewed);
|
|
278
|
+
return fetch(input, { ...init, headers });
|
|
279
|
+
};
|
|
280
|
+
get signInUrl() {
|
|
281
|
+
return this.navigation.signInUrl ?? this.environmentConfig?.paths.signInUrl ?? null;
|
|
282
|
+
}
|
|
283
|
+
get signUpUrl() {
|
|
284
|
+
return this.navigation.signUpUrl ?? this.environmentConfig?.paths.signUpUrl ?? null;
|
|
285
|
+
}
|
|
286
|
+
/** Goes to your sign-in page (remembering the current page), or opens sign-in in a modal when you have none. */
|
|
287
|
+
redirectToSignIn = () => {
|
|
288
|
+
const target = this.signInUrl;
|
|
289
|
+
if (target)
|
|
290
|
+
this.navigate(this.withReturn(target));
|
|
291
|
+
else
|
|
292
|
+
this.openSignIn();
|
|
293
|
+
};
|
|
294
|
+
redirectToSignUp = () => {
|
|
295
|
+
const target = this.signUpUrl;
|
|
296
|
+
if (target)
|
|
297
|
+
this.navigate(this.withReturn(target));
|
|
298
|
+
else
|
|
299
|
+
this.openSignUp();
|
|
300
|
+
};
|
|
301
|
+
/** Force redirect, then `redirect_url`, then the fallback, then the console setting, then "/". */
|
|
302
|
+
redirectAfterSignIn = (options = {}) => this.navigate(options.forceRedirectUrl ??
|
|
303
|
+
this.navigation.signInForceRedirectUrl ??
|
|
304
|
+
this.returnUrl() ??
|
|
305
|
+
options.fallbackRedirectUrl ??
|
|
306
|
+
this.navigation.signInFallbackRedirectUrl ??
|
|
307
|
+
this.environmentConfig?.paths.afterSignInUrl ??
|
|
308
|
+
"/", true);
|
|
309
|
+
redirectAfterSignUp = (options = {}) => this.navigate(options.forceRedirectUrl ??
|
|
310
|
+
this.navigation.signUpForceRedirectUrl ??
|
|
311
|
+
this.returnUrl() ??
|
|
312
|
+
options.fallbackRedirectUrl ??
|
|
313
|
+
this.navigation.signUpFallbackRedirectUrl ??
|
|
314
|
+
this.environmentConfig?.paths.afterSignUpUrl ??
|
|
315
|
+
this.navigation.signInFallbackRedirectUrl ??
|
|
316
|
+
this.environmentConfig?.paths.afterSignInUrl ??
|
|
317
|
+
"/", true);
|
|
318
|
+
redirectAfterSignOut = () => this.navigate(this.navigation.afterSignOutUrl ?? this.environmentConfig?.paths.afterSignOutUrl ?? "/");
|
|
319
|
+
/** Navigates with your router when you passed routerPush, otherwise with a normal page load. */
|
|
320
|
+
navigate = (to, replace = false) => {
|
|
321
|
+
if (typeof window === "undefined")
|
|
322
|
+
return;
|
|
323
|
+
const url = new URL(to, window.location.href);
|
|
324
|
+
const router = replace ? (this.navigation.routerReplace ?? this.navigation.routerPush) : this.navigation.routerPush;
|
|
325
|
+
if (url.origin === window.location.origin && router)
|
|
326
|
+
void router(url.pathname + url.search + url.hash);
|
|
327
|
+
else if (replace)
|
|
328
|
+
window.location.replace(url.href);
|
|
329
|
+
else
|
|
330
|
+
window.location.assign(url.href);
|
|
331
|
+
};
|
|
332
|
+
// ----- Modals -----
|
|
333
|
+
subscribeModal = (listener) => {
|
|
334
|
+
this.modalListeners.add(listener);
|
|
335
|
+
return () => {
|
|
336
|
+
this.modalListeners.delete(listener);
|
|
337
|
+
};
|
|
338
|
+
};
|
|
339
|
+
getModal = () => this.modal;
|
|
340
|
+
getServerModal = () => null;
|
|
341
|
+
/** Redirect options the open modal was given, for example by a sign-in button. */
|
|
342
|
+
getModalOptions = () => this.modalOptions;
|
|
343
|
+
openSignIn = (options = {}) => this.setModal("sign-in", options);
|
|
344
|
+
openSignUp = (options = {}) => this.setModal("sign-up", options);
|
|
345
|
+
openUserProfile = () => this.setModal("user-profile");
|
|
346
|
+
closeModal = () => this.setModal(null);
|
|
347
|
+
// ----- Reverification (sensitive account changes) -----
|
|
348
|
+
subscribeReverification = (listener) => {
|
|
349
|
+
this.reverificationListeners.add(listener);
|
|
350
|
+
return () => {
|
|
351
|
+
this.reverificationListeners.delete(listener);
|
|
352
|
+
if (this.reverificationListeners.size === 0)
|
|
353
|
+
this.cancelReverification();
|
|
354
|
+
};
|
|
355
|
+
};
|
|
356
|
+
getReverificationSnapshot = () => this.reverification !== undefined;
|
|
357
|
+
getServerReverificationSnapshot = () => false;
|
|
358
|
+
requestReverification = () => {
|
|
359
|
+
if (!this.snapshot.isSignedIn)
|
|
360
|
+
return Promise.reject(new PistiClientError("Sign in before verifying your identity.", 401));
|
|
361
|
+
if (this.reverification)
|
|
362
|
+
return this.reverification.promise;
|
|
363
|
+
if (this.reverificationListeners.size === 0)
|
|
364
|
+
return Promise.reject(new PistiClientError("Confirm your identity to continue.", 403, "reauthentication_required"));
|
|
365
|
+
let resolve;
|
|
366
|
+
let reject;
|
|
367
|
+
const promise = new Promise((complete, fail) => {
|
|
368
|
+
resolve = complete;
|
|
369
|
+
reject = fail;
|
|
370
|
+
});
|
|
371
|
+
this.reverification = { promise, resolve, reject };
|
|
372
|
+
for (const listener of this.reverificationListeners)
|
|
373
|
+
listener();
|
|
374
|
+
return promise;
|
|
375
|
+
};
|
|
376
|
+
completeReverification = async (proof) => {
|
|
377
|
+
const pending = this.reverification;
|
|
378
|
+
if (!pending)
|
|
379
|
+
throw new PistiClientError("Start identity verification again.", 400);
|
|
380
|
+
await this.account.reauthenticate(proof);
|
|
381
|
+
if (this.reverification !== pending)
|
|
382
|
+
return;
|
|
383
|
+
this.reverification = undefined;
|
|
384
|
+
pending.resolve();
|
|
385
|
+
for (const listener of this.reverificationListeners)
|
|
386
|
+
listener();
|
|
387
|
+
};
|
|
388
|
+
cancelReverification = () => {
|
|
389
|
+
const pending = this.reverification;
|
|
390
|
+
if (!pending)
|
|
391
|
+
return;
|
|
392
|
+
this.reverification = undefined;
|
|
393
|
+
pending.reject(new PistiClientError("Identity verification was canceled.", 403, "reauthentication_cancelled"));
|
|
394
|
+
for (const listener of this.reverificationListeners)
|
|
395
|
+
listener();
|
|
396
|
+
};
|
|
397
|
+
/** Signs out so the user can sign in again, for example to prove who they are. */
|
|
398
|
+
reauthenticate = async () => {
|
|
399
|
+
this.cancelReverification();
|
|
400
|
+
await this.signOut();
|
|
401
|
+
this.redirectToSignIn();
|
|
402
|
+
};
|
|
403
|
+
withReverification = async (operation) => {
|
|
404
|
+
const identity = this.snapshot.sessionId;
|
|
405
|
+
try {
|
|
406
|
+
return await operation();
|
|
407
|
+
}
|
|
408
|
+
catch (error) {
|
|
409
|
+
if (!(error instanceof PistiClientError) || error.code !== "reauthentication_required")
|
|
410
|
+
throw error;
|
|
411
|
+
this.requireIdentity(identity);
|
|
412
|
+
await this.requestReverification();
|
|
413
|
+
this.requireIdentity(identity);
|
|
414
|
+
return operation();
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
// ----- Internals -----
|
|
418
|
+
async complete(request) {
|
|
419
|
+
const outcome = await request;
|
|
420
|
+
if (outcome.status !== "complete")
|
|
421
|
+
return outcome;
|
|
422
|
+
await this.apply(outcome.client, outcome.sessionId);
|
|
423
|
+
await this.getToken({ skipCache: true }).catch(() => { });
|
|
424
|
+
this.channel?.postMessage("changed");
|
|
425
|
+
return { status: "complete", recoveryCodes: outcome.recoveryCodes ?? null };
|
|
426
|
+
}
|
|
427
|
+
async apply(state, preferred) {
|
|
428
|
+
if (state.clientToken && this.development) {
|
|
429
|
+
try {
|
|
430
|
+
localStorage.setItem(this.storageKey, state.clientToken);
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
/* The HttpOnly cookie still works when storage is unavailable. */
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
const session = state.sessions.find((item) => item.id === preferred) ??
|
|
437
|
+
state.sessions.find((item) => item.id === state.lastActiveSessionId) ??
|
|
438
|
+
state.sessions[0];
|
|
439
|
+
if (!session) {
|
|
440
|
+
this.token = undefined;
|
|
441
|
+
this.writeCookie(null);
|
|
442
|
+
this.setSnapshot(SIGNED_OUT);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
const previous = this.snapshot.user?.id === session.user.id ? this.snapshot.user : null;
|
|
446
|
+
const fullName = `${session.user.firstName} ${session.user.lastName}`.trim();
|
|
447
|
+
const sameOrganization = previous?.organizationId === session.organizationId;
|
|
448
|
+
this.setSnapshot({
|
|
449
|
+
status: "signedIn",
|
|
450
|
+
isLoaded: true,
|
|
451
|
+
isSignedIn: true,
|
|
452
|
+
sessionId: session.id,
|
|
453
|
+
sessionClaims: this.snapshot.sessionId === session.id ? this.snapshot.sessionClaims : null,
|
|
454
|
+
error: null,
|
|
455
|
+
user: Object.freeze({
|
|
456
|
+
id: session.user.id,
|
|
457
|
+
issuer: this.issuer.href,
|
|
458
|
+
fullName: fullName || null,
|
|
459
|
+
firstName: session.user.firstName,
|
|
460
|
+
lastName: session.user.lastName,
|
|
461
|
+
primaryEmailAddress: session.user.email ? { emailAddress: session.user.email } : null,
|
|
462
|
+
imageUrl: session.user.imageUrl,
|
|
463
|
+
hasImage: Boolean(session.user.imageUrl),
|
|
464
|
+
authenticatedAt: previous?.authenticatedAt ?? null,
|
|
465
|
+
mfaAuthenticatedAt: previous?.mfaAuthenticatedAt ?? null,
|
|
466
|
+
instanceId: previous?.instanceId ?? "",
|
|
467
|
+
organizationId: session.organizationId,
|
|
468
|
+
organizationSlug: sameOrganization ? (previous?.organizationSlug ?? null) : null,
|
|
469
|
+
organizationRole: sameOrganization ? (previous?.organizationRole ?? null) : null,
|
|
470
|
+
organizationPermissions: sameOrganization ? (previous?.organizationPermissions ?? []) : [],
|
|
471
|
+
}),
|
|
472
|
+
});
|
|
473
|
+
if (this.token?.sessionId !== session.id || !sameOrganization)
|
|
474
|
+
await this.getToken({ skipCache: true }).catch(() => { });
|
|
475
|
+
}
|
|
476
|
+
applyClaims(claims) {
|
|
477
|
+
const user = this.snapshot.user;
|
|
478
|
+
if (!user)
|
|
479
|
+
return;
|
|
480
|
+
const permissions = Array.isArray(claims.org_permissions)
|
|
481
|
+
? claims.org_permissions.filter((value) => typeof value === "string")
|
|
482
|
+
: typeof claims.org_permissions === "string"
|
|
483
|
+
? [claims.org_permissions]
|
|
484
|
+
: [];
|
|
485
|
+
const next = Object.freeze({
|
|
486
|
+
...user,
|
|
487
|
+
instanceId: typeof claims.instance_id === "string" ? claims.instance_id : user.instanceId,
|
|
488
|
+
authenticatedAt: typeof claims.auth_time === "number" ? claims.auth_time : null,
|
|
489
|
+
mfaAuthenticatedAt: typeof claims.mfa_authenticated_at === "number" ? claims.mfa_authenticated_at : null,
|
|
490
|
+
organizationId: typeof claims.org_id === "string" ? claims.org_id : null,
|
|
491
|
+
organizationSlug: typeof claims.org_slug === "string" ? claims.org_slug : null,
|
|
492
|
+
organizationRole: typeof claims.org_role === "string" ? claims.org_role : null,
|
|
493
|
+
organizationPermissions: Object.freeze(permissions),
|
|
494
|
+
});
|
|
495
|
+
this.setSnapshot({ ...this.snapshot, user: next, sessionClaims: Object.freeze({ ...claims }) });
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Writes `__session` and `__client_uat` (the session's sign-in time, or 0 when signed out) on the
|
|
499
|
+
* app's origin. Backend SDKs use `__client_uat` to tell an expired cookie that needs a handshake
|
|
500
|
+
* from a visitor who is simply signed out.
|
|
501
|
+
*/
|
|
502
|
+
writeCookie(token) {
|
|
503
|
+
if (typeof document === "undefined")
|
|
504
|
+
return;
|
|
505
|
+
const secure = window.location.protocol === "https:" ? "; Secure" : "";
|
|
506
|
+
const authTime = token ? decodeClaims(token).auth_time : 0;
|
|
507
|
+
document.cookie = token
|
|
508
|
+
? `__session=${token}; Path=/; Max-Age=60; SameSite=Lax${secure}`
|
|
509
|
+
: `__session=; Path=/; Max-Age=0; SameSite=Lax${secure}`;
|
|
510
|
+
document.cookie = `__client_uat=${typeof authTime === "number" ? authTime : 0}; Path=/; Max-Age=31536000; SameSite=Lax${secure}`;
|
|
511
|
+
}
|
|
512
|
+
setSnapshot(next) {
|
|
513
|
+
this.snapshot = next;
|
|
514
|
+
for (const listener of this.listeners)
|
|
515
|
+
listener();
|
|
516
|
+
}
|
|
517
|
+
setModal(modal, options = this.modalOptions) {
|
|
518
|
+
this.modalOptions = modal ? options : {};
|
|
519
|
+
if (this.modal === modal)
|
|
520
|
+
return;
|
|
521
|
+
this.modal = modal;
|
|
522
|
+
for (const listener of this.modalListeners)
|
|
523
|
+
listener();
|
|
524
|
+
}
|
|
525
|
+
/** Adds the current page as `redirect_url`, so sign-in returns here. */
|
|
526
|
+
withReturn(target) {
|
|
527
|
+
if (typeof window === "undefined")
|
|
528
|
+
return target;
|
|
529
|
+
const url = new URL(target, window.location.href);
|
|
530
|
+
if (url.origin === window.location.origin && url.pathname === window.location.pathname)
|
|
531
|
+
return target;
|
|
532
|
+
url.searchParams.set("redirect_url", window.location.pathname + window.location.search + window.location.hash);
|
|
533
|
+
return url.origin === window.location.origin ? url.pathname + url.search + url.hash : url.href;
|
|
534
|
+
}
|
|
535
|
+
/** A same-origin `redirect_url` from the address bar, if any. Other origins are ignored to avoid open redirects. */
|
|
536
|
+
returnUrl() {
|
|
537
|
+
if (typeof window === "undefined")
|
|
538
|
+
return null;
|
|
539
|
+
const value = new URL(window.location.href).searchParams.get("redirect_url");
|
|
540
|
+
if (!value)
|
|
541
|
+
return null;
|
|
542
|
+
try {
|
|
543
|
+
const url = new URL(value, window.location.href);
|
|
544
|
+
if (url.origin === window.location.origin)
|
|
545
|
+
return url.pathname + url.search + url.hash;
|
|
546
|
+
const allowed = this.navigation.allowedRedirectOrigins ?? [];
|
|
547
|
+
return allowed.some((origin) => (typeof origin === "string" ? origin === url.origin : origin.test(url.origin)))
|
|
548
|
+
? url.href
|
|
549
|
+
: null;
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
requireSession() {
|
|
556
|
+
if (!this.snapshot.sessionId)
|
|
557
|
+
throw new PistiClientError("Sign in to continue.", 401, "signed_out");
|
|
558
|
+
return this.snapshot.sessionId;
|
|
559
|
+
}
|
|
560
|
+
requireIdentity(identity) {
|
|
561
|
+
if (identity !== this.snapshot.sessionId)
|
|
562
|
+
throw new PistiClientError("The signed-in account changed. Try again.", 409, "identity_changed");
|
|
563
|
+
}
|
|
564
|
+
async fapi(method, path, body) {
|
|
565
|
+
const headers = new Headers({ Accept: "application/json" });
|
|
566
|
+
if (body !== undefined)
|
|
567
|
+
headers.set("Content-Type", "application/json");
|
|
568
|
+
if (this.development) {
|
|
569
|
+
try {
|
|
570
|
+
const stored = localStorage.getItem(this.storageKey);
|
|
571
|
+
if (stored)
|
|
572
|
+
headers.set("X-Pisti-Client", stored);
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
/* Fall back to the cookie. */
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
let response;
|
|
579
|
+
try {
|
|
580
|
+
response = await fetch(new URL(this.frontendApi.pathname.replace(/\/?$/, "/") + path, this.frontendApi.origin), {
|
|
581
|
+
method,
|
|
582
|
+
headers,
|
|
583
|
+
credentials: "include",
|
|
584
|
+
cache: "no-store",
|
|
585
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
catch {
|
|
589
|
+
throw new PistiClientError("Pisti can’t be reached. Check your connection and try again.");
|
|
590
|
+
}
|
|
591
|
+
if (!response.ok)
|
|
592
|
+
throw await this.error(response);
|
|
593
|
+
if (response.status === 204)
|
|
594
|
+
return undefined;
|
|
595
|
+
return (await response.json()).data;
|
|
596
|
+
}
|
|
597
|
+
async resource(method, path, body) {
|
|
598
|
+
const identity = this.snapshot.sessionId;
|
|
599
|
+
if (!identity)
|
|
600
|
+
throw new PistiClientError("Sign in before managing your account.", 401);
|
|
601
|
+
const response = await this.authenticatedFetch(new URL(path, this.issuer), {
|
|
602
|
+
method,
|
|
603
|
+
headers: {
|
|
604
|
+
Accept: "application/json",
|
|
605
|
+
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
|
606
|
+
},
|
|
607
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
608
|
+
});
|
|
609
|
+
this.requireIdentity(identity);
|
|
610
|
+
if (!response.ok) {
|
|
611
|
+
if (response.status === 401)
|
|
612
|
+
await this.load();
|
|
613
|
+
throw await this.error(response);
|
|
614
|
+
}
|
|
615
|
+
const result = response.status === 204 ? undefined : (await response.json()).data;
|
|
616
|
+
if (method !== "GET") {
|
|
617
|
+
await this.load();
|
|
618
|
+
await this.getToken({ skipCache: true }).catch(() => { });
|
|
619
|
+
}
|
|
620
|
+
return result;
|
|
621
|
+
}
|
|
622
|
+
async error(response) {
|
|
623
|
+
try {
|
|
624
|
+
const problem = (await response.json());
|
|
625
|
+
const validation = problem.errors ? Object.values(problem.errors).flat().join(" ") : "";
|
|
626
|
+
return new PistiClientError(validation || problem.detail || problem.title || "The request could not be completed.", response.status, problem.code);
|
|
627
|
+
}
|
|
628
|
+
catch {
|
|
629
|
+
return new PistiClientError(response.status === 429 ? "Too many attempts. Wait a moment and try again." : "The request could not be completed.", response.status);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
//# sourceMappingURL=client.js.map
|