@indietabletop/appkit 3.6.0-1 → 3.6.0-3

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.
Files changed (44) hide show
  1. package/lib/AppConfig/AppConfig.tsx +54 -0
  2. package/lib/HistoryState.ts +21 -0
  3. package/lib/Letterhead/style.css.ts +2 -0
  4. package/lib/LetterheadForm/index.tsx +53 -0
  5. package/lib/LetterheadForm/style.css.ts +8 -0
  6. package/lib/QRCode/QRCode.stories.tsx +47 -0
  7. package/lib/QRCode/QRCode.tsx +49 -0
  8. package/lib/QRCode/style.css.ts +19 -0
  9. package/lib/ShareButton/ShareButton.tsx +141 -0
  10. package/lib/SubscribeCard/LetterheadInfoCard.tsx +23 -0
  11. package/lib/SubscribeCard/SubscribeByEmailCard.stories.tsx +83 -0
  12. package/lib/SubscribeCard/SubscribeByEmailCard.tsx +177 -0
  13. package/lib/SubscribeCard/SubscribeCard.stories.tsx +27 -23
  14. package/lib/SubscribeCard/SubscribeCard.tsx +22 -17
  15. package/lib/Title/index.tsx +7 -2
  16. package/lib/account/AccountIssueView.tsx +40 -0
  17. package/lib/account/AlreadyLoggedInView.tsx +44 -0
  18. package/lib/account/CurrentUserFetcher.stories.tsx +339 -0
  19. package/lib/account/CurrentUserFetcher.tsx +119 -0
  20. package/lib/account/FailureFallbackView.tsx +36 -0
  21. package/lib/account/JoinPage.stories.tsx +270 -0
  22. package/lib/account/JoinPage.tsx +288 -0
  23. package/lib/account/LoadingView.tsx +14 -0
  24. package/lib/account/LoginPage.stories.tsx +318 -0
  25. package/lib/account/LoginPage.tsx +138 -0
  26. package/lib/account/LoginView.tsx +136 -0
  27. package/lib/account/NoConnectionView.tsx +34 -0
  28. package/lib/account/PasswordResetPage.stories.tsx +250 -0
  29. package/lib/account/PasswordResetPage.tsx +291 -0
  30. package/lib/account/UserMismatchView.tsx +61 -0
  31. package/lib/account/VerifyPage.tsx +217 -0
  32. package/lib/account/style.css.ts +57 -0
  33. package/lib/account/types.ts +9 -0
  34. package/lib/account/useCurrentUserResult.tsx +38 -0
  35. package/lib/class-names.ts +1 -1
  36. package/lib/client.ts +54 -7
  37. package/lib/globals.css.ts +5 -0
  38. package/lib/index.ts +11 -1
  39. package/lib/useEnsureValue.ts +31 -0
  40. package/package.json +3 -2
  41. package/lib/ClientContext/ClientContext.tsx +0 -25
  42. package/lib/LoginPage/LoginPage.stories.tsx +0 -107
  43. package/lib/LoginPage/LoginPage.tsx +0 -204
  44. package/lib/LoginPage/style.css.ts +0 -17
@@ -0,0 +1,318 @@
1
+ import { Story } from "@storybook/addon-docs/blocks";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { http, HttpResponse } from "msw";
4
+ import { fn } from "storybook/test";
5
+ import { sleep } from "../sleep.ts";
6
+ import type { CurrentUser, SessionInfo } from "../types.ts";
7
+ import { LoginCard } from "./LoginPage.tsx";
8
+
9
+ function createMocks(options?: { responseSpeed?: number }) {
10
+ const simulateNetwork = () => sleep(options?.responseSpeed ?? 2000);
11
+
12
+ const john: CurrentUser = {
13
+ id: "john",
14
+ email: "john@example.com",
15
+ isVerified: true,
16
+ };
17
+
18
+ const mary: CurrentUser = {
19
+ id: "mary",
20
+ email: "mary@example.com",
21
+ isVerified: true,
22
+ };
23
+
24
+ const sessionInfo: SessionInfo = {
25
+ createdTs: 123,
26
+ expiresTs: 123,
27
+ };
28
+
29
+ return {
30
+ data: { john, mary },
31
+ handlers: {
32
+ refreshTokens: {
33
+ failed: () => {
34
+ return http.post(
35
+ "http://mock.api/v1/sessions/access-tokens",
36
+ async () => {
37
+ await simulateNetwork();
38
+ return HttpResponse.text("Refresh token expired or missing", {
39
+ status: 401,
40
+ });
41
+ },
42
+ );
43
+ },
44
+ },
45
+
46
+ getCurrentUser: {
47
+ success: (currentUser: CurrentUser) => {
48
+ return http.get("http://mock.api/v1/users/me", async () => {
49
+ await simulateNetwork();
50
+ return HttpResponse.json(currentUser);
51
+ });
52
+ },
53
+
54
+ /**
55
+ * Cookie is valid, but user doesn't exist any more. This can happen
56
+ * after user deletion.
57
+ */
58
+ notFound: () => {
59
+ return http.get("http://mock.api/v1/users/me", async () => {
60
+ await simulateNetwork();
61
+ return HttpResponse.text("User not found", { status: 404 });
62
+ });
63
+ },
64
+
65
+ noConnection: () => {
66
+ return http.get("http://mock.api/v1/users/me", async () => {
67
+ return HttpResponse.error();
68
+ });
69
+ },
70
+
71
+ unknownFailure: () => {
72
+ return http.get("http://mock.api/v1/users/me", async () => {
73
+ await simulateNetwork();
74
+ return HttpResponse.text("Internal server error", { status: 500 });
75
+ });
76
+ },
77
+
78
+ /**
79
+ * Auth cookies no longer valid to make this request.
80
+ */
81
+ notAuthenticated: () => {
82
+ return http.get("http://mock.api/v1/users/me", async () => {
83
+ await simulateNetwork();
84
+ return HttpResponse.text("Not authenticated", { status: 401 });
85
+ });
86
+ },
87
+ },
88
+
89
+ createNewSession: {
90
+ success: (currentUser: CurrentUser) => {
91
+ return http.post("http://mock.api/v1/sessions", async () => {
92
+ await simulateNetwork();
93
+ return HttpResponse.json({ currentUser, sessionInfo });
94
+ });
95
+ },
96
+
97
+ invalidCredentials: () => {
98
+ return http.post("http://mock.api/v1/sessions", async () => {
99
+ await simulateNetwork();
100
+ return HttpResponse.text("Credentials do not match", {
101
+ status: 401,
102
+ });
103
+ });
104
+ },
105
+
106
+ userNotFound: () => {
107
+ return http.post("http://mock.api/v1/sessions", async () => {
108
+ await simulateNetwork();
109
+ return HttpResponse.text("User not found", { status: 404 });
110
+ });
111
+ },
112
+
113
+ unknownFailure: () => {
114
+ return http.post("http://mock.api/v1/sessions", async () => {
115
+ await simulateNetwork();
116
+ return HttpResponse.text("Internal server error", { status: 500 });
117
+ });
118
+ },
119
+ },
120
+ },
121
+ };
122
+ }
123
+
124
+ const { data, handlers } = createMocks({ responseSpeed: 700 });
125
+
126
+ /**
127
+ * Allows the user to log into Indie Tabletop Club.
128
+ */
129
+ const meta = {
130
+ title: "Account/Login Page",
131
+ component: LoginCard,
132
+ tags: ["autodocs"],
133
+ args: {
134
+ currentUser: null,
135
+ description: "Log in to Indie Tabletop Club to enable backup & sync.",
136
+ onLogin: fn(),
137
+ onLogout: fn(),
138
+ onClearLocalContent: fn(),
139
+ onServerLogout: fn(),
140
+ },
141
+ parameters: {
142
+ msw: {
143
+ handlers: {
144
+ refreshTokens: handlers.refreshTokens.failed(),
145
+ },
146
+ },
147
+ },
148
+ } satisfies Meta<typeof LoginCard>;
149
+
150
+ export default meta;
151
+
152
+ type Story = StoryObj<typeof meta>;
153
+
154
+ /**
155
+ * The majority case where no user is stored locally, proactive user session
156
+ * check returns 401, and subsequently correct credentials are provided.
157
+ */
158
+ export const Default: Story = {
159
+ parameters: {
160
+ msw: {
161
+ handlers: {
162
+ getCurrentUser: handlers.getCurrentUser.notAuthenticated(),
163
+ createNewSession: handlers.createNewSession.success(data.john),
164
+ },
165
+ },
166
+ },
167
+ };
168
+
169
+ /**
170
+ * Similar to the default case, but invalid credentials are provided to when
171
+ * attempting to create a new session.
172
+ */
173
+ export const InvalidCredentialsOnSubmit: Story = {
174
+ args: {
175
+ currentUser: null,
176
+ },
177
+ parameters: {
178
+ msw: {
179
+ handlers: {
180
+ getCurrentUser: handlers.getCurrentUser.notAuthenticated(),
181
+ createNewSession: handlers.createNewSession.invalidCredentials(),
182
+ },
183
+ },
184
+ },
185
+ };
186
+
187
+ /**
188
+ * Similar to the default case, but when credentials are provided, an account
189
+ * email is used that is not currently in the database.
190
+ */
191
+ export const UserNotFoundOnSubmit: Story = {
192
+ args: {
193
+ currentUser: null,
194
+ },
195
+ parameters: {
196
+ msw: {
197
+ handlers: {
198
+ getCurrentUser: handlers.getCurrentUser.notAuthenticated(),
199
+ createNewSession: handlers.createNewSession.userNotFound(),
200
+ },
201
+ },
202
+ },
203
+ };
204
+
205
+ /**
206
+ * Similar to the default case, but the session creation call returns an error
207
+ * that doesn't have a specific meaning in the context of the login page.
208
+ */
209
+ export const UnknownFailureOnSubmit: Story = {
210
+ args: {
211
+ currentUser: null,
212
+ },
213
+ parameters: {
214
+ msw: {
215
+ handlers: {
216
+ getCurrentUser: handlers.getCurrentUser.notAuthenticated(),
217
+ createNewSession: handlers.createNewSession.unknownFailure(),
218
+ },
219
+ },
220
+ },
221
+ };
222
+
223
+ /**
224
+ * A case when user is stored locally, but their server session has expired.
225
+ */
226
+ export const ReauthenticateSession: Story = {
227
+ args: {
228
+ currentUser: data.john,
229
+ },
230
+ parameters: {
231
+ msw: {
232
+ handlers: {
233
+ getCurrentUser: handlers.getCurrentUser.notAuthenticated(),
234
+ createNewSession: handlers.createNewSession.success(data.john),
235
+ },
236
+ },
237
+ },
238
+ };
239
+
240
+ /**
241
+ * The user is already stored in the app, and proactive user-session check
242
+ * returns the same user (based on ID).
243
+ *
244
+ * The user is directed to app without any further steps.
245
+ */
246
+ export const AlreadyLoggedIn: Story = {
247
+ args: {
248
+ currentUser: data.john,
249
+ },
250
+ parameters: {
251
+ msw: {
252
+ handlers: {
253
+ getCurrentUser: handlers.getCurrentUser.success(data.john),
254
+ },
255
+ },
256
+ },
257
+ };
258
+
259
+ /**
260
+ * A user is provided, and proactive user session check returns a different
261
+ * user. This can happen when different users log into separate apps with
262
+ * different credentials.
263
+ */
264
+ export const UserMismatch: Story = {
265
+ args: {
266
+ currentUser: data.john,
267
+ },
268
+
269
+ parameters: {
270
+ msw: {
271
+ handlers: {
272
+ getCurrentUser: handlers.getCurrentUser.success(data.mary),
273
+ },
274
+ },
275
+ },
276
+ };
277
+
278
+ /**
279
+ * During the proactive user session check, the user is reportd as not found.
280
+ * This means that the tokens are still valid, but the user DB entity is gone.
281
+ * This can happen if a user closed their account.
282
+ */
283
+ export const UserNotFound: Story = {
284
+ parameters: {
285
+ msw: {
286
+ handlers: {
287
+ getCurrentUser: handlers.getCurrentUser.notFound(),
288
+ },
289
+ },
290
+ },
291
+ };
292
+
293
+ /**
294
+ * The proactive user session check has failed due to connection issues.
295
+ */
296
+ export const NoConnection: Story = {
297
+ parameters: {
298
+ msw: {
299
+ handlers: {
300
+ getCurrentUser: handlers.getCurrentUser.noConnection(),
301
+ },
302
+ },
303
+ },
304
+ };
305
+
306
+ /**
307
+ * The proactive user session check has failed due to an error that doesn't
308
+ * carry any special meaning.
309
+ */
310
+ export const UnknownFailure: Story = {
311
+ parameters: {
312
+ msw: {
313
+ handlers: {
314
+ getCurrentUser: handlers.getCurrentUser.unknownFailure(),
315
+ },
316
+ },
317
+ },
318
+ };
@@ -0,0 +1,138 @@
1
+ import { type ReactNode } from "react";
2
+ import type { CurrentUser } from "../types.ts";
3
+ import { AccountIssueView } from "./AccountIssueView.tsx";
4
+ import { AlreadyLoggedInView } from "./AlreadyLoggedInView.tsx";
5
+ import { FailureFallbackView } from "./FailureFallbackView.tsx";
6
+ import { LoadingView } from "./LoadingView.tsx";
7
+ import { LoginView } from "./LoginView.tsx";
8
+ import { NoConnectionView } from "./NoConnectionView.tsx";
9
+ import type {
10
+ DefaultFormValues,
11
+ EventHandler,
12
+ EventHandlerWithReload,
13
+ } from "./types.ts";
14
+ import { useCurrentUserResult } from "./useCurrentUserResult.tsx";
15
+ import { UserMismatchView } from "./UserMismatchView.tsx";
16
+
17
+ export type LoginPageProps = {
18
+ /**
19
+ * Any user data that might currently be stored in persistent storage like
20
+ * `localStorage` or IndexedDB.
21
+ *
22
+ * If the app contains any local data, it is important that this value is
23
+ * provided so that we don't run into strange user mismatch issues!
24
+ */
25
+ currentUser: CurrentUser | null;
26
+
27
+ /**
28
+ * A description that will appear in the default login case (i.e. when an
29
+ * unauthenticated user is prompted to log in).
30
+ *
31
+ * This should reinforce to the user the benefits that they are going to
32
+ * get by signing in. Every app has slightly different capabilities, so this
33
+ * should be tailored for each individual app.
34
+ */
35
+ description: ReactNode;
36
+
37
+ /**
38
+ * Default values for the login form.
39
+ *
40
+ * You might want to provide a value from history state or query param, so
41
+ * that if a user jumps between multiple pages with email field, the email
42
+ * address is maintained across these locations.
43
+ */
44
+ defaultValues?: DefaultFormValues;
45
+
46
+ /**
47
+ * Called when the login action succeeds.
48
+ *
49
+ * Typically you want to redirect the user at this point, and possibly
50
+ * run additional side-effects.
51
+ *
52
+ * Note that it is not necessary to save the new current user in any way,
53
+ * as they will already be saved via the client onCurrentUser handler.
54
+ */
55
+ onLogin: EventHandler;
56
+
57
+ /**
58
+ * Called when the user indicates that they would like to log out.
59
+ *
60
+ * Typically, you might want to clear all local data, perform
61
+ * a server logout, and redirect to some sensible new location.
62
+ */
63
+ onLogout: EventHandlerWithReload;
64
+
65
+ /**
66
+ * Called when there is a mismatch between local user data and data returned
67
+ * from the server and the user chooses to use the server account.
68
+ *
69
+ * Local content should be cleared in response to this action, but **not**
70
+ * server logout (as the user is choosing to continue as the user that is
71
+ * currently referenced in the cookie).
72
+ */
73
+ onClearLocalContent: EventHandler;
74
+
75
+ /**
76
+ * Called when there is a mismatch between local user data and data returned
77
+ * from the server and the user chooses to use the local account.
78
+ *
79
+ * A server logout should be performed in response to this event, but local
80
+ * data should not be touched.
81
+ */
82
+ onServerLogout: EventHandlerWithReload;
83
+ };
84
+
85
+ export function LoginCard(props: LoginPageProps) {
86
+ const { currentUser } = props;
87
+ const { result, latestAttemptTs, reload } = useCurrentUserResult();
88
+
89
+ return result.unpack(
90
+ (serverUser) => {
91
+ if (currentUser && currentUser.id !== serverUser.id) {
92
+ return (
93
+ <UserMismatchView
94
+ {...props}
95
+ serverUser={serverUser}
96
+ localUser={currentUser}
97
+ reload={reload}
98
+ />
99
+ );
100
+ }
101
+
102
+ return (
103
+ <AlreadyLoggedInView
104
+ {...props}
105
+ currentUser={serverUser}
106
+ reload={reload}
107
+ />
108
+ );
109
+ },
110
+
111
+ (failure) => {
112
+ if (failure.type === "API_ERROR") {
113
+ if (failure.code === 401) {
114
+ return <LoginView {...props} reload={reload} />;
115
+ }
116
+
117
+ if (failure.code === 404) {
118
+ return <AccountIssueView {...props} reload={reload} />;
119
+ }
120
+ }
121
+
122
+ if (failure.type === "NETWORK_ERROR") {
123
+ return (
124
+ <NoConnectionView
125
+ latestAttemptTs={latestAttemptTs}
126
+ onRetry={() => reload()}
127
+ />
128
+ );
129
+ }
130
+
131
+ return <FailureFallbackView />;
132
+ },
133
+
134
+ () => {
135
+ return <LoadingView />;
136
+ },
137
+ );
138
+ }
@@ -0,0 +1,136 @@
1
+ import { Button, Form, useStoreState } from "@ariakit/react";
2
+ import type { ReactNode } from "react";
3
+ import { Link } from "wouter";
4
+ import { useAppConfig } from "../AppConfig/AppConfig.tsx";
5
+ import { interactiveText } from "../common.css.ts";
6
+ import { getSubmitFailureMessage } from "../failureMessages.ts";
7
+ import {
8
+ Letterhead,
9
+ LetterheadHeading,
10
+ LetterheadParagraph,
11
+ LetterheadSubmitButton,
12
+ } from "../Letterhead/index.tsx";
13
+ import {
14
+ InputsStack,
15
+ LetterheadFormActions,
16
+ LetterheadHeader,
17
+ LetterheadSubmitError,
18
+ LetterheadTextField,
19
+ } from "../LetterheadForm/index.tsx";
20
+ import type { CurrentUser } from "../types.ts";
21
+ import { useForm } from "../use-form.ts";
22
+ import { validEmail } from "../validations.ts";
23
+ import type { DefaultFormValues, EventHandlerWithReload } from "./types.ts";
24
+
25
+ export function LoginView(props: {
26
+ defaultValues?: DefaultFormValues;
27
+ onLogin: EventHandlerWithReload;
28
+ onLogout: EventHandlerWithReload;
29
+ currentUser: CurrentUser | null;
30
+ description: ReactNode;
31
+ reload: () => void;
32
+ }) {
33
+ const { placeholders, client, hrefs } = useAppConfig();
34
+ const { defaultValues, description, currentUser, onLogin, onLogout, reload } =
35
+ props;
36
+ const localUserPresent = !!currentUser?.email;
37
+ const defaultEmailValue = currentUser?.email ?? defaultValues?.email ?? "";
38
+
39
+ const { form, submitName } = useForm({
40
+ defaultValues: { email: defaultEmailValue, password: "" },
41
+ validate: { email: validEmail },
42
+ async onSubmit({ values }) {
43
+ const result = await client.login(values);
44
+
45
+ return result.mapFailure((failure) => {
46
+ return getSubmitFailureMessage(failure, {
47
+ 401: "🔐 Username and password do not match.",
48
+ 404: "🤔 Couldn't find a user with this email address.",
49
+ });
50
+ });
51
+ },
52
+ onSuccess() {
53
+ onLogin({ reload });
54
+ },
55
+ });
56
+
57
+ const emailValue = useStoreState(form, (state) => state.values.email);
58
+
59
+ return (
60
+ <Letterhead>
61
+ <LetterheadHeader>
62
+ <LetterheadHeading>Log in</LetterheadHeading>
63
+
64
+ {localUserPresent ? (
65
+ <>
66
+ <LetterheadParagraph>
67
+ Your session has expired. Please log into Indie Tabletop Club
68
+ again.
69
+ </LetterheadParagraph>
70
+
71
+ <LetterheadParagraph>
72
+ {"To use a different account, please "}
73
+ <Button
74
+ className={interactiveText}
75
+ onClick={() => onLogout({ reload })}
76
+ >
77
+ log out
78
+ </Button>
79
+ {" first."}
80
+ </LetterheadParagraph>
81
+ </>
82
+ ) : (
83
+ <LetterheadParagraph>
84
+ {description}
85
+ {" Do not have an account? "}
86
+ <Link
87
+ href={hrefs.join()}
88
+ className={interactiveText}
89
+ state={{ emailValue }}
90
+ >
91
+ Join now
92
+ </Link>
93
+ {"."}
94
+ </LetterheadParagraph>
95
+ )}
96
+ </LetterheadHeader>
97
+
98
+ <Form store={form} resetOnSubmit={false}>
99
+ <InputsStack>
100
+ <LetterheadTextField
101
+ name={form.names.email}
102
+ placeholder={placeholders.email}
103
+ label="Email"
104
+ type="email"
105
+ readOnly={localUserPresent}
106
+ required
107
+ />
108
+ <LetterheadTextField
109
+ name={form.names.password}
110
+ label="Password"
111
+ placeholder="Your password"
112
+ type="password"
113
+ required
114
+ />
115
+ </InputsStack>
116
+
117
+ <LetterheadFormActions>
118
+ <LetterheadSubmitError name={submitName} />
119
+ <LetterheadSubmitButton>Log in</LetterheadSubmitButton>
120
+
121
+ <LetterheadParagraph align="start">
122
+ Forgot password?{" "}
123
+ <Link
124
+ href="/password"
125
+ className={interactiveText}
126
+ state={{ emailValue }}
127
+ >
128
+ Reset it
129
+ </Link>
130
+ {"."}
131
+ </LetterheadParagraph>
132
+ </LetterheadFormActions>
133
+ </Form>
134
+ </Letterhead>
135
+ );
136
+ }
@@ -0,0 +1,34 @@
1
+ import { Button } from "@ariakit/react";
2
+ import { cx } from "../class-names.ts";
3
+ import { interactiveText } from "../common.css.ts";
4
+ import {
5
+ Letterhead,
6
+ LetterheadHeading,
7
+ LetterheadParagraph,
8
+ } from "../Letterhead/index.tsx";
9
+
10
+ export function NoConnectionView(props: {
11
+ latestAttemptTs: number;
12
+ onRetry: () => void;
13
+ }) {
14
+ const { latestAttemptTs, onRetry } = props;
15
+
16
+ return (
17
+ <Letterhead>
18
+ <LetterheadHeading>No connection</LetterheadHeading>
19
+
20
+ <LetterheadParagraph>
21
+ {"There seems to be an issue reaching to our servers. "}
22
+ {"Are you connected to the internet? Last attempt at "}
23
+ {new Date(latestAttemptTs).toLocaleTimeString()}
24
+ {"."}
25
+ </LetterheadParagraph>
26
+
27
+ <LetterheadParagraph>
28
+ <Button {...cx(interactiveText)} onClick={() => onRetry()}>
29
+ Try again
30
+ </Button>
31
+ </LetterheadParagraph>
32
+ </Letterhead>
33
+ );
34
+ }