@indietabletop/appkit 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,3 +4,25 @@ A collection of modules used in Indie Tabletop Club's apps.
4
4
 
5
5
  - [Hobgoblin App](https://hobgoblin.indietabletop.club)
6
6
  - [Eternøl App](https://eternol.indietabletop.club)
7
+
8
+ ## Contents
9
+
10
+ ### Classes
11
+
12
+ - IndieTabletopClient
13
+
14
+ ### Hooks
15
+
16
+ - useAsyncPp
17
+ - useDocumentBackgroundColor
18
+ - useIsInstalled
19
+ - useMediaQuery
20
+ - useRevertingState
21
+ - useScrollRestoration
22
+
23
+ ### Utils
24
+
25
+ - appendCopyToText
26
+ - asyncOp
27
+ - caughtValue
28
+ - classNames
@@ -0,0 +1,127 @@
1
+ import { Failure, Success } from "@indietabletop/appkit/async-op";
2
+ import { Infer, Struct } from "superstruct";
3
+ import { CurrentUser, FailurePayload, SessionInfo } from "./types.js";
4
+ export declare class IndieTabletopClient {
5
+ origin: string;
6
+ private onCurrentUser?;
7
+ private onSessionInfo?;
8
+ private onSessionExpired?;
9
+ private refreshTokenPromise?;
10
+ constructor(props: {
11
+ apiOrigin: string;
12
+ /**
13
+ * Runs every time the current user is fetched from the API. Typically, this
14
+ * happens during login, signup, and when the current user is fetched.
15
+ */
16
+ onCurrentUser?: (currentUser: CurrentUser) => void;
17
+ /**
18
+ * Runs ever time new session info is fetched from the API. Typically, this
19
+ * happends during login, signup, and when tokens are refreshed.
20
+ */
21
+ onSessionInfo?: (sessionInfo: SessionInfo) => void;
22
+ /**
23
+ * Runs when token refresh is attempted, but fails due to 401 error.
24
+ */
25
+ onSessionExpired?: () => void;
26
+ });
27
+ protected fetch<T, S>(path: string, struct: Struct<T, S>, init?: RequestInit & {
28
+ json?: object;
29
+ }): Promise<Success<Infer<Struct<T, S>>> | Failure<FailurePayload>>;
30
+ /**
31
+ * Fetches data and retries 401 failures after attempting to refresh tokens.
32
+ */
33
+ protected fetchWithAuth<T, S>(path: string, struct: Struct<T, S>, init?: RequestInit & {
34
+ json?: object;
35
+ }): Promise<Success<Infer<Struct<T, S>>> | Failure<FailurePayload>>;
36
+ login(payload: {
37
+ email: string;
38
+ password: string;
39
+ }): Promise<Failure<FailurePayload> | Success<{
40
+ sessionInfo: {
41
+ expiresTs: number;
42
+ createdTs: number;
43
+ };
44
+ currentUser: {
45
+ id: string;
46
+ email: string;
47
+ isVerified: boolean;
48
+ prefersScrollbarVisibility?: "ALWAYS" | undefined;
49
+ };
50
+ }>>;
51
+ logout(): Promise<Failure<FailurePayload> | Success<{
52
+ message: string;
53
+ }>>;
54
+ join(payload: {
55
+ email: string;
56
+ password: string;
57
+ acceptedTos: boolean;
58
+ }): Promise<Failure<FailurePayload> | Success<{
59
+ sessionInfo: {
60
+ expiresTs: number;
61
+ createdTs: number;
62
+ };
63
+ currentUser: {
64
+ id: string;
65
+ email: string;
66
+ isVerified: boolean;
67
+ prefersScrollbarVisibility?: "ALWAYS" | undefined;
68
+ };
69
+ tokenId: string;
70
+ }>>;
71
+ /**
72
+ * Triggers token refresh process.
73
+ *
74
+ * Note that we do not want to perform multiple concurrent token refresh
75
+ * actions, as that will result in unnecessary 401s. For this reason, a
76
+ * reference to t
77
+ */
78
+ refreshTokens(): Promise<Success<{
79
+ sessionInfo: SessionInfo;
80
+ }> | Failure<FailurePayload>>;
81
+ requestPasswordReset(payload: {
82
+ email: string;
83
+ }): Promise<Failure<FailurePayload> | Success<{
84
+ message: string;
85
+ tokenId: string;
86
+ }>>;
87
+ checkPasswordResetCode(payload: {
88
+ tokenId: string;
89
+ code: string;
90
+ }): Promise<Failure<FailurePayload> | Success<{
91
+ message: string;
92
+ }>>;
93
+ setNewPassword(payload: {
94
+ tokenId: string;
95
+ code: string;
96
+ password: string;
97
+ }): Promise<Failure<FailurePayload> | Success<{
98
+ message: string;
99
+ }>>;
100
+ requestUserVerification(): Promise<Failure<FailurePayload> | Success<{
101
+ message: string;
102
+ tokenId: string;
103
+ }>>;
104
+ verifyUser(payload: {
105
+ tokenId: string;
106
+ code: string;
107
+ }): Promise<Failure<FailurePayload> | Success<{
108
+ message: string;
109
+ }>>;
110
+ getCurrentUser(): Promise<Failure<FailurePayload> | Success<{
111
+ sessionInfo: {
112
+ expiresTs: number;
113
+ createdTs: number;
114
+ };
115
+ currentUser: {
116
+ id: string;
117
+ email: string;
118
+ isVerified: boolean;
119
+ prefersScrollbarVisibility?: "ALWAYS" | undefined;
120
+ };
121
+ }> | Success<{
122
+ id: string;
123
+ email: string;
124
+ isVerified: boolean;
125
+ prefersScrollbarVisibility?: "ALWAYS" | undefined;
126
+ }>>;
127
+ }
package/dist/client.js ADDED
@@ -0,0 +1,211 @@
1
+ import { Failure, Success } from "@indietabletop/appkit/async-op";
2
+ import { mask, object, string } from "superstruct";
3
+ import { currentUser, sessionInfo } from "./structs.js";
4
+ export class IndieTabletopClient {
5
+ constructor(props) {
6
+ Object.defineProperty(this, "origin", {
7
+ enumerable: true,
8
+ configurable: true,
9
+ writable: true,
10
+ value: void 0
11
+ });
12
+ Object.defineProperty(this, "onCurrentUser", {
13
+ enumerable: true,
14
+ configurable: true,
15
+ writable: true,
16
+ value: void 0
17
+ });
18
+ Object.defineProperty(this, "onSessionInfo", {
19
+ enumerable: true,
20
+ configurable: true,
21
+ writable: true,
22
+ value: void 0
23
+ });
24
+ Object.defineProperty(this, "onSessionExpired", {
25
+ enumerable: true,
26
+ configurable: true,
27
+ writable: true,
28
+ value: void 0
29
+ });
30
+ Object.defineProperty(this, "refreshTokenPromise", {
31
+ enumerable: true,
32
+ configurable: true,
33
+ writable: true,
34
+ value: void 0
35
+ });
36
+ this.origin = props.apiOrigin;
37
+ this.onCurrentUser = props.onCurrentUser;
38
+ this.onSessionInfo = props.onSessionInfo;
39
+ this.onSessionExpired = props.onSessionExpired;
40
+ }
41
+ async fetch(path, struct, init) {
42
+ // If json was provided, we stringify it. Otherwise we use body.
43
+ const body = init?.json ? JSON.stringify(init.json) : init?.body;
44
+ // If json was provided, we make sure that content type is correctly set.
45
+ const headers = init?.json
46
+ ? { ...init?.headers, "Content-Type": "application/json" }
47
+ : init?.headers;
48
+ try {
49
+ const res = await fetch(`${this.origin}${path}`, {
50
+ // Defaults
51
+ credentials: "include",
52
+ // Overrides
53
+ ...init,
54
+ body,
55
+ headers,
56
+ });
57
+ if (!res.ok) {
58
+ return new Failure({
59
+ type: "API_ERROR",
60
+ code: res.status,
61
+ });
62
+ }
63
+ const data = mask(await res.json(), struct);
64
+ return new Success(data);
65
+ }
66
+ catch (error) {
67
+ if (error instanceof Error) {
68
+ return new Failure({ type: "NETWORK_ERROR" });
69
+ }
70
+ return new Failure({ type: "UNKNOWN_ERROR" });
71
+ }
72
+ }
73
+ /**
74
+ * Fetches data and retries 401 failures after attempting to refresh tokens.
75
+ */
76
+ async fetchWithAuth(path, struct, init) {
77
+ const op = await this.fetch(path, struct, init);
78
+ if (op.isSuccess) {
79
+ return op;
80
+ }
81
+ if (op.failure.type === "API_ERROR" && op.failure.code === 401) {
82
+ console.info("API request failed with error 401. Refreshing tokens.");
83
+ const refreshOp = await this.refreshTokens();
84
+ if (refreshOp.isSuccess) {
85
+ console.info("Tokens refreshed. Retrying request.");
86
+ return await this.fetch(path, struct, init);
87
+ }
88
+ else {
89
+ console.info("Could not refresh tokens.");
90
+ }
91
+ }
92
+ return op;
93
+ }
94
+ async login(payload) {
95
+ const result = await this.fetch("/v1/sessions", object({
96
+ currentUser: currentUser(),
97
+ sessionInfo: sessionInfo(),
98
+ }), {
99
+ method: "POST",
100
+ json: { email: payload.email, plaintextPassword: payload.password },
101
+ });
102
+ if (result.isSuccess) {
103
+ this.onCurrentUser?.(result.value.currentUser);
104
+ this.onSessionInfo?.(result.value.sessionInfo);
105
+ }
106
+ return result;
107
+ }
108
+ async logout() {
109
+ return await this.fetch("/v1/sessions", object({ message: string() }), {
110
+ method: "DELETE",
111
+ });
112
+ }
113
+ async join(payload) {
114
+ const res = await this.fetch("/v1/users", object({
115
+ currentUser: currentUser(),
116
+ sessionInfo: sessionInfo(),
117
+ tokenId: string(),
118
+ }), {
119
+ method: "POST",
120
+ json: {
121
+ email: payload.email,
122
+ plaintextPassword: payload.password,
123
+ acceptedTos: payload.acceptedTos,
124
+ },
125
+ });
126
+ if (res.isSuccess) {
127
+ this.onCurrentUser?.(res.value.currentUser);
128
+ this.onSessionInfo?.(res.value.sessionInfo);
129
+ }
130
+ return res;
131
+ }
132
+ /**
133
+ * Triggers token refresh process.
134
+ *
135
+ * Note that we do not want to perform multiple concurrent token refresh
136
+ * actions, as that will result in unnecessary 401s. For this reason, a
137
+ * reference to t
138
+ */
139
+ async refreshTokens() {
140
+ // If there is an ongoing token refresh in progress return that. This should
141
+ // only deal the response payload, none of the side-effects and cleanup,
142
+ // which will be handled by the initial invocation.
143
+ const ongoingRequest = this.refreshTokenPromise;
144
+ if (ongoingRequest) {
145
+ console.info("Token refresh ongoing. Reusing existing promise.");
146
+ return await ongoingRequest;
147
+ }
148
+ // Cache the promise on an instance property to share a reference from
149
+ // other potential invocations.
150
+ this.refreshTokenPromise = this.fetch("/v1/sessions/access-tokens", object({ sessionInfo: sessionInfo() }), { method: "POST" });
151
+ const result = await this.refreshTokenPromise;
152
+ if (result.isSuccess) {
153
+ this.onSessionInfo?.(result.value.sessionInfo);
154
+ }
155
+ if (result.isFailure &&
156
+ result.failure.type === "API_ERROR" &&
157
+ result.failure.code === 401) {
158
+ this.onSessionExpired?.();
159
+ }
160
+ // Make sure to reset the shared reference so that subsequent invocations
161
+ // once again initiate token refresh.
162
+ delete this.refreshTokenPromise;
163
+ return result;
164
+ }
165
+ async requestPasswordReset(payload) {
166
+ return await this.fetch(`/v1/password-reset-tokens`, object({ message: string(), tokenId: string() }), { method: "POST", json: payload });
167
+ }
168
+ async checkPasswordResetCode(payload) {
169
+ const queryParams = new URLSearchParams({ plaintextCode: payload.code });
170
+ return await this.fetch(`/v1/password-reset-tokens/${payload.tokenId}?${queryParams}`, object({ message: string() }), { method: "GET" });
171
+ }
172
+ async setNewPassword(payload) {
173
+ const queryParams = new URLSearchParams({ plaintextCode: payload.code });
174
+ return await this.fetch(`/v1/password-reset-tokens/${payload.tokenId}?${queryParams}`, object({ message: string() }), { method: "PUT", json: { plaintextPassword: payload.password } });
175
+ }
176
+ async requestUserVerification() {
177
+ return await this.fetch(`/v1/user-verification-tokens`, object({ message: string(), tokenId: string() }), { method: "POST" });
178
+ }
179
+ async verifyUser(payload) {
180
+ const queryParams = new URLSearchParams({ plaintextCode: payload.code });
181
+ const req = await this.fetch(`/v1/user-verification-tokens/${payload.tokenId}?${queryParams}`, object({ message: string() }), { method: "PUT" });
182
+ if (req.isSuccess) {
183
+ await this.refreshTokens();
184
+ await this.getCurrentUser();
185
+ }
186
+ return req;
187
+ }
188
+ async getCurrentUser() {
189
+ const result = await this.fetchWithAuth(`/v1/users/me`, currentUser());
190
+ if (result.isSuccess) {
191
+ this.onCurrentUser?.(result.value);
192
+ }
193
+ // If /users/me request failed with 401 error, try again with the Ory
194
+ // endpoint for legacy users. This code block can be removed once Ory
195
+ // users are fully migrated.
196
+ if (result.isFailure &&
197
+ result.failure.type === "API_ERROR" &&
198
+ result.failure.code === 401) {
199
+ const oryR = await this.fetch(`/v1/users/ory`, object({
200
+ currentUser: currentUser(),
201
+ sessionInfo: sessionInfo(),
202
+ }));
203
+ if (oryR.isSuccess) {
204
+ this.onCurrentUser?.(oryR.value.currentUser);
205
+ this.onSessionInfo?.(oryR.value.sessionInfo);
206
+ }
207
+ return oryR;
208
+ }
209
+ return result;
210
+ }
211
+ }
@@ -0,0 +1,4 @@
1
+ import { AnchorHTMLAttributes } from "react";
2
+ type ExternalLinkProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "rel" | "target">;
3
+ export declare function ExternalLink(props: ExternalLinkProps): import("react/jsx-runtime").JSX.Element;
4
+ export {};
@@ -0,0 +1,4 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ export function ExternalLink(props) {
3
+ return _jsx("a", { ...props, target: "_blank", rel: "noreferrer noopener" });
4
+ }
@@ -0,0 +1,20 @@
1
+ export declare function currentUser(): import("superstruct").Struct<{
2
+ id: string;
3
+ email: string;
4
+ isVerified: boolean;
5
+ prefersScrollbarVisibility?: "ALWAYS" | undefined;
6
+ }, {
7
+ id: import("superstruct").Struct<string, null>;
8
+ email: import("superstruct").Struct<string, null>;
9
+ isVerified: import("superstruct").Struct<boolean, null>;
10
+ prefersScrollbarVisibility: import("superstruct").Struct<"ALWAYS" | undefined, {
11
+ ALWAYS: "ALWAYS";
12
+ }>;
13
+ }>;
14
+ export declare function sessionInfo(): import("superstruct").Struct<{
15
+ expiresTs: number;
16
+ createdTs: number;
17
+ }, {
18
+ expiresTs: import("superstruct").Struct<number, null>;
19
+ createdTs: import("superstruct").Struct<number, null>;
20
+ }>;
@@ -0,0 +1,15 @@
1
+ import { boolean, enums, number, object, optional, string } from "superstruct";
2
+ export function currentUser() {
3
+ return object({
4
+ id: string(),
5
+ email: string(),
6
+ isVerified: boolean(),
7
+ prefersScrollbarVisibility: optional(enums(["ALWAYS"])),
8
+ });
9
+ }
10
+ export function sessionInfo() {
11
+ return object({
12
+ expiresTs: number(),
13
+ createdTs: number(),
14
+ });
15
+ }
@@ -0,0 +1,10 @@
1
+ import { Infer } from "superstruct";
2
+ import { currentUser, sessionInfo } from "./structs.js";
3
+ export type CurrentUser = Infer<ReturnType<typeof currentUser>>;
4
+ export type SessionInfo = Infer<ReturnType<typeof sessionInfo>>;
5
+ export type FailurePayload = {
6
+ type: "API_ERROR";
7
+ code: number;
8
+ } | {
9
+ type: "NETWORK_ERROR" | "UNKNOWN_ERROR";
10
+ };
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indietabletop/appkit",
3
- "version": "0.3.0",
3
+ "version": "1.0.0",
4
4
  "description": "A collection of modules used in apps built by Indie Tabletop Club",
5
5
  "private": false,
6
6
  "type": "module",
@@ -26,5 +26,8 @@
26
26
  "np": "^10.1.0",
27
27
  "typescript": "^5.7.2",
28
28
  "vitest": "^2.1.6"
29
+ },
30
+ "dependencies": {
31
+ "superstruct": "^2.0.2"
29
32
  }
30
33
  }
@@ -1,4 +0,0 @@
1
- /**
2
- * Sets document background color, reverting it to previous color on unmount.
3
- */
4
- export declare function useDocumentBackgroundColor(bodyColor: string): void;
@@ -1,14 +0,0 @@
1
- import { useEffect } from "react";
2
- /**
3
- * Sets document background color, reverting it to previous color on unmount.
4
- */
5
- export function useDocumentBackgroundColor(bodyColor) {
6
- useEffect(() => {
7
- const style = window.document.documentElement.style;
8
- const originalColor = style.backgroundColor;
9
- style.backgroundColor = bodyColor;
10
- return () => {
11
- style.backgroundColor = originalColor;
12
- };
13
- });
14
- }