@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,250 @@
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 { sleep } from "../sleep.ts";
5
+ import type { CurrentUser, SessionInfo } from "../types.ts";
6
+ import { PasswordResetCard } from "./PasswordResetPage.tsx";
7
+
8
+ function createMocks(options?: { responseSpeed?: number }) {
9
+ const simulateNetwork = () => sleep(options?.responseSpeed ?? 2000);
10
+
11
+ const john: CurrentUser = {
12
+ id: "john",
13
+ email: "john@example.com",
14
+ isVerified: true,
15
+ };
16
+
17
+ const sessionInfo: SessionInfo = {
18
+ createdTs: 123,
19
+ expiresTs: 123,
20
+ };
21
+
22
+ return {
23
+ data: { john },
24
+ handlers: {
25
+ requestPasswordReset: {
26
+ success: () => {
27
+ return http.post(
28
+ "http://mock.api/v1/password-reset-tokens",
29
+ async () => {
30
+ await simulateNetwork();
31
+ return HttpResponse.json({ message: "OK", tokenId: "1" });
32
+ },
33
+ );
34
+ },
35
+ userNotFound: () => {
36
+ return http.post(
37
+ "http://mock.api/v1/password-reset-tokens",
38
+ async () => {
39
+ await simulateNetwork();
40
+ return HttpResponse.text("User not found", { status: 404 });
41
+ },
42
+ );
43
+ },
44
+ unknownFailure: () => {
45
+ return http.post(
46
+ "http://mock.api/v1/password-reset-tokens",
47
+ async () => {
48
+ await simulateNetwork();
49
+ return HttpResponse.text("Internal server error", {
50
+ status: 500,
51
+ });
52
+ },
53
+ );
54
+ },
55
+ },
56
+
57
+ checkPasswordResetCode: {
58
+ success() {
59
+ return http.get(
60
+ "http://mock.api/v1/password-reset-tokens/:id",
61
+ async () => {
62
+ await simulateNetwork();
63
+ return HttpResponse.json({ message: "OK" });
64
+ },
65
+ );
66
+ },
67
+ notFoundOrExpired() {
68
+ return http.get(
69
+ "http://mock.api/v1/password-reset-tokens/:id",
70
+ async () => {
71
+ await simulateNetwork();
72
+ return HttpResponse.text("Not found", { status: 404 });
73
+ },
74
+ );
75
+ },
76
+ unknownFailure() {
77
+ return http.get(
78
+ "http://mock.api/v1/password-reset-tokens/:id",
79
+ async () => {
80
+ await simulateNetwork();
81
+ return HttpResponse.text("Internal server error", {
82
+ status: 500,
83
+ });
84
+ },
85
+ );
86
+ },
87
+ },
88
+
89
+ setNewPassword: {
90
+ success() {
91
+ return http.put(
92
+ "http://mock.api/v1/password-reset-tokens/:id",
93
+ async () => {
94
+ await simulateNetwork();
95
+ return HttpResponse.json({ message: "OK" });
96
+ },
97
+ );
98
+ },
99
+ notFoundOrExpired() {
100
+ return http.put(
101
+ "http://mock.api/v1/password-reset-tokens/:id",
102
+ async () => {
103
+ await simulateNetwork();
104
+ return HttpResponse.text("Not found", { status: 404 });
105
+ },
106
+ );
107
+ },
108
+ unknownFailure() {
109
+ return http.put(
110
+ "http://mock.api/v1/password-reset-tokens/:id",
111
+ async () => {
112
+ await simulateNetwork();
113
+ return HttpResponse.text("Internal server error", {
114
+ status: 500,
115
+ });
116
+ },
117
+ );
118
+ },
119
+ },
120
+
121
+ createNewSession: {
122
+ success: (currentUser: CurrentUser) => {
123
+ return http.post("http://mock.api/v1/sessions", async () => {
124
+ await simulateNetwork();
125
+ return HttpResponse.json({ currentUser, sessionInfo });
126
+ });
127
+ },
128
+ },
129
+ },
130
+ };
131
+ }
132
+
133
+ const { data, handlers } = createMocks({ responseSpeed: 700 });
134
+
135
+ /**
136
+ * Allows the user to reset their password.
137
+ */
138
+ const meta = {
139
+ title: "Account/Password Reset Page",
140
+ component: PasswordResetCard,
141
+ tags: ["autodocs"],
142
+ args: {},
143
+ parameters: {
144
+ msw: {
145
+ handlers: {
146
+ request: handlers.requestPasswordReset.success(),
147
+ check: handlers.checkPasswordResetCode.success(),
148
+ set: handlers.setNewPassword.success(),
149
+ login: handlers.createNewSession.success(data.john),
150
+ },
151
+ },
152
+ },
153
+ } satisfies Meta<typeof PasswordResetCard>;
154
+
155
+ export default meta;
156
+
157
+ type Story = StoryObj<typeof meta>;
158
+
159
+ /**
160
+ * The default case in which all steps of the flow succeed.
161
+ */
162
+ export const Success: Story = {};
163
+
164
+ /**
165
+ * In this case, the initial step fails because the email address was not
166
+ * found in our database.
167
+ */
168
+ export const UserNotFoundOnRequestCode: Story = {
169
+ parameters: {
170
+ msw: {
171
+ handlers: {
172
+ request: handlers.requestPasswordReset.userNotFound(),
173
+ },
174
+ },
175
+ },
176
+ };
177
+
178
+ /**
179
+ * In this case, the initial step fails for a reason that doesn't have any
180
+ * special handling in this location. E.g. network error, server error, etc.
181
+ */
182
+ export const UnknownFailureOnRequestCode: Story = {
183
+ parameters: {
184
+ msw: {
185
+ handlers: {
186
+ request: handlers.requestPasswordReset.unknownFailure(),
187
+ },
188
+ },
189
+ },
190
+ };
191
+
192
+ /**
193
+ * In this case, the check code step fails because the token has expired,
194
+ * or is incorrect.
195
+ */
196
+ export const NotFoundOrExpiredOnCheckCode: Story = {
197
+ parameters: {
198
+ msw: {
199
+ handlers: {
200
+ check: handlers.checkPasswordResetCode.notFoundOrExpired(),
201
+ },
202
+ },
203
+ },
204
+ };
205
+
206
+ /**
207
+ * In this case, the check step fails for a reason that doesn't have any
208
+ * special handling in this location. E.g. network error, server error, etc.
209
+ */
210
+ export const UnknownFailureOnCheckCode: Story = {
211
+ parameters: {
212
+ msw: {
213
+ handlers: {
214
+ check: handlers.checkPasswordResetCode.unknownFailure(),
215
+ },
216
+ },
217
+ },
218
+ };
219
+
220
+ /**
221
+ * In this case, the final set password step fails because the token has
222
+ * expired.
223
+ *
224
+ * Note that technically the token could also be incorrect/not found, but
225
+ * because the user has gotten through the check step, that would require
226
+ * some very strange set of circumstances.
227
+ */
228
+ export const NotFoundOrExpiredOnSetPassword: Story = {
229
+ parameters: {
230
+ msw: {
231
+ handlers: {
232
+ set: handlers.setNewPassword.notFoundOrExpired(),
233
+ },
234
+ },
235
+ },
236
+ };
237
+
238
+ /**
239
+ * In this case, the set password step fails for a reason that doesn't have any
240
+ * special handling in this location. E.g. network error, server error, etc.
241
+ */
242
+ export const UnknownFailureOnSetPassword: Story = {
243
+ parameters: {
244
+ msw: {
245
+ handlers: {
246
+ set: handlers.setNewPassword.unknownFailure(),
247
+ },
248
+ },
249
+ },
250
+ };
@@ -0,0 +1,291 @@
1
+ import { Form, useStoreState } from "@ariakit/react";
2
+ import { type Dispatch, type SetStateAction, useState } from "react";
3
+ import { Link } from "wouter";
4
+ import { useAppConfig, useClient } from "../AppConfig/AppConfig.tsx";
5
+ import { cx } from "../class-names.ts";
6
+ import { interactiveText } from "../common.css.ts";
7
+ import { getSubmitFailureMessage } from "../failureMessages.ts";
8
+ import {
9
+ Letterhead,
10
+ LetterheadHeading,
11
+ LetterheadParagraph,
12
+ LetterheadSubmitButton,
13
+ } from "../Letterhead/index.tsx";
14
+ import { button } from "../Letterhead/style.css.ts";
15
+ import {
16
+ LetterheadFormActions,
17
+ LetterheadHeader,
18
+ LetterheadSubmitError,
19
+ LetterheadTextField,
20
+ } from "../LetterheadForm/index.tsx";
21
+ import { useForm } from "../use-form.ts";
22
+ import { validEmail, validPassword } from "../validations.ts";
23
+ import type { DefaultFormValues } from "./types.ts";
24
+
25
+ type SetStep = Dispatch<SetStateAction<ResetPasswordStep>>;
26
+
27
+ function RequestPasswordResetStep(props: {
28
+ defaultValues?: DefaultFormValues;
29
+ setStep: SetStep;
30
+ }) {
31
+ const { defaultValues } = props;
32
+ const { client, placeholders, hrefs } = useAppConfig();
33
+
34
+ const { form, submitName } = useForm({
35
+ defaultValues: {
36
+ email: defaultValues?.email ?? "",
37
+ },
38
+ validate: {
39
+ email: validEmail,
40
+ },
41
+
42
+ async onSubmit({ values }) {
43
+ const op = await client.requestPasswordReset(values);
44
+
45
+ return op.mapFailure((failure) => {
46
+ return getSubmitFailureMessage(failure, {
47
+ 404: "🤔 Couldn't find a user with this email address.",
48
+ });
49
+ });
50
+ },
51
+
52
+ onSuccess(response, { values }) {
53
+ props.setStep({
54
+ type: "SUBMIT_CODE",
55
+ tokenId: response.tokenId,
56
+ email: values.email,
57
+ });
58
+ },
59
+ });
60
+
61
+ const emailValue = useStoreState(form, (s) => s.values.email);
62
+
63
+ return (
64
+ <Letterhead>
65
+ <LetterheadHeader>
66
+ <LetterheadHeading>Password reset</LetterheadHeading>
67
+ <LetterheadParagraph>
68
+ Enter your Indie Tabletop Club account email to begin password reset.
69
+ We will send you a one-time code to verify your access.
70
+ </LetterheadParagraph>
71
+ </LetterheadHeader>
72
+
73
+ <Form store={form} resetOnSubmit={false}>
74
+ <LetterheadTextField
75
+ name={form.names.email}
76
+ label="Email"
77
+ type="email"
78
+ placeholder={placeholders.email}
79
+ required
80
+ />
81
+
82
+ <LetterheadFormActions>
83
+ <LetterheadSubmitError name={submitName} />
84
+ <LetterheadSubmitButton>Continue</LetterheadSubmitButton>
85
+
86
+ <LetterheadParagraph align="start">
87
+ {"Remembered your password? "}
88
+ <Link
89
+ href={hrefs.login()}
90
+ className={interactiveText}
91
+ state={{ emailValue }}
92
+ >
93
+ Log in
94
+ </Link>
95
+ {"."}
96
+ </LetterheadParagraph>
97
+ </LetterheadFormActions>
98
+ </Form>
99
+ </Letterhead>
100
+ );
101
+ }
102
+
103
+ function SubmitCodeStep(props: {
104
+ tokenId: string;
105
+ email: string;
106
+ setStep: SetStep;
107
+ }) {
108
+ const client = useClient();
109
+ const { form, submitName } = useForm({
110
+ defaultValues: {
111
+ code: "",
112
+ },
113
+
114
+ async onSubmit({ values }) {
115
+ const op = await client.checkPasswordResetCode({
116
+ ...values,
117
+ tokenId: props.tokenId,
118
+ });
119
+
120
+ return op.mapFailure((failure) => {
121
+ return getSubmitFailureMessage(failure, {
122
+ 404: "🚫 This code is incorrect or expired. Please try again.",
123
+ });
124
+ });
125
+ },
126
+
127
+ onSuccess(_, { values }) {
128
+ props.setStep({
129
+ type: "SET_NEW_PASSWORD",
130
+ tokenId: props.tokenId,
131
+ code: values.code,
132
+ email: props.email,
133
+ });
134
+ },
135
+ });
136
+
137
+ return (
138
+ <Letterhead>
139
+ <LetterheadHeader>
140
+ <LetterheadHeading>Submit code</LetterheadHeading>
141
+ <LetterheadParagraph>
142
+ We've sent a one-time code to the email address you have provided.
143
+ Please, enter the code in the field below to continue.
144
+ </LetterheadParagraph>
145
+ </LetterheadHeader>
146
+
147
+ <Form store={form} resetOnSubmit={false}>
148
+ <LetterheadTextField
149
+ name={form.names.code}
150
+ label="Code"
151
+ placeholder="E.g. 123123"
152
+ autoComplete="one-time-code"
153
+ required
154
+ />
155
+
156
+ <LetterheadFormActions>
157
+ <LetterheadSubmitError name={submitName} />
158
+ <LetterheadSubmitButton>Verify code</LetterheadSubmitButton>
159
+ </LetterheadFormActions>
160
+ </Form>
161
+ </Letterhead>
162
+ );
163
+ }
164
+
165
+ function SetNewPasswordStep(props: {
166
+ tokenId: string;
167
+ code: string;
168
+ email: string;
169
+ setStep: SetStep;
170
+ }) {
171
+ const client = useClient();
172
+
173
+ const { form, submitName } = useForm({
174
+ defaultValues: { password: "" },
175
+ validate: { password: validPassword },
176
+ async onSubmit({ values }) {
177
+ const res = await client.setNewPassword({
178
+ tokenId: props.tokenId,
179
+ code: props.code,
180
+ password: values.password,
181
+ });
182
+
183
+ if (res.isFailure) {
184
+ return res.mapFailure((failure) => {
185
+ return getSubmitFailureMessage(failure, {
186
+ 404: "⏱️ One-time code has expired. Please restart the password reset process.",
187
+ });
188
+ });
189
+ }
190
+
191
+ // Login attempt with new credentials. Must be performed in onSubmit so
192
+ // that errors are correctly propagated.
193
+ const loginOp = await client.login({
194
+ email: props.email,
195
+ password: values.password,
196
+ });
197
+
198
+ return loginOp.mapFailure(getSubmitFailureMessage);
199
+ },
200
+ onSuccess() {
201
+ props.setStep({ type: "SUCCESS" });
202
+ },
203
+ });
204
+
205
+ return (
206
+ <Letterhead>
207
+ <LetterheadHeader>
208
+ <LetterheadHeading>Choose New Password</LetterheadHeading>
209
+ <LetterheadParagraph>
210
+ Please choose a new password. Make it at least 8 characters long.
211
+ </LetterheadParagraph>
212
+ </LetterheadHeader>
213
+
214
+ <Form store={form} resetOnSubmit={false}>
215
+ <LetterheadTextField
216
+ name={form.names.password}
217
+ label="New Password"
218
+ type="password"
219
+ placeholder="Enter a new password"
220
+ required
221
+ />
222
+
223
+ <LetterheadFormActions>
224
+ <LetterheadSubmitError name={submitName} />
225
+ <LetterheadSubmitButton>Save & Login</LetterheadSubmitButton>
226
+ </LetterheadFormActions>
227
+ </Form>
228
+ </Letterhead>
229
+ );
230
+ }
231
+
232
+ function SuccessStep() {
233
+ const { hrefs } = useAppConfig();
234
+
235
+ return (
236
+ <Letterhead>
237
+ <LetterheadHeader>
238
+ <LetterheadHeading>Success!</LetterheadHeading>
239
+ <LetterheadParagraph>
240
+ Your password has been successfully reset and you've been
241
+ automatically logged in. Yay!
242
+ </LetterheadParagraph>
243
+ </LetterheadHeader>
244
+
245
+ <LetterheadFormActions>
246
+ <Link href={hrefs.dashboard()} {...cx(button())}>
247
+ Go to dashboard
248
+ </Link>
249
+ </LetterheadFormActions>
250
+ </Letterhead>
251
+ );
252
+ }
253
+
254
+ type ResetPasswordStep =
255
+ | { type: "REQUEST_PASSWORD_RESET" }
256
+ | { type: "SUBMIT_CODE"; tokenId: string; email: string }
257
+ | { type: "SET_NEW_PASSWORD"; tokenId: string; code: string; email: string }
258
+ | { type: "SUCCESS" };
259
+
260
+ export function PasswordResetCard(props: {
261
+ /**
262
+ * Default values for the initial request password reset step.
263
+ *
264
+ * You might want to provide a value for history state or query param, so that
265
+ * if a user jumps between login and password reset, their email address
266
+ * is maintained between the two locations.
267
+ */
268
+ defaultValues?: DefaultFormValues;
269
+ }) {
270
+ const [step, setStep] = useState<ResetPasswordStep>({
271
+ type: "REQUEST_PASSWORD_RESET",
272
+ });
273
+
274
+ switch (step.type) {
275
+ case "REQUEST_PASSWORD_RESET": {
276
+ return <RequestPasswordResetStep {...props} setStep={setStep} />;
277
+ }
278
+
279
+ case "SUBMIT_CODE": {
280
+ return <SubmitCodeStep {...step} setStep={setStep} />;
281
+ }
282
+
283
+ case "SET_NEW_PASSWORD": {
284
+ return <SetNewPasswordStep {...step} setStep={setStep} />;
285
+ }
286
+
287
+ case "SUCCESS": {
288
+ return <SuccessStep />;
289
+ }
290
+ }
291
+ }
@@ -0,0 +1,61 @@
1
+ import { Button } from "@ariakit/react";
2
+ import { cx } from "../class-names.ts";
3
+ import {
4
+ Letterhead,
5
+ LetterheadHeading,
6
+ LetterheadParagraph,
7
+ } from "../Letterhead/index.tsx";
8
+ import { LetterheadHeader } from "../LetterheadForm/index.tsx";
9
+ import type { CurrentUser } from "../types.ts";
10
+ import { accountPicker } from "./style.css.ts";
11
+ import type { EventHandler, EventHandlerWithReload } from "./types.ts";
12
+
13
+ export function UserMismatchView(props: {
14
+ serverUser: CurrentUser;
15
+ localUser: CurrentUser;
16
+ onClearLocalContent: EventHandler;
17
+ onServerLogout: EventHandlerWithReload;
18
+ reload: () => void;
19
+ }) {
20
+ const { localUser, serverUser, onClearLocalContent, onServerLogout, reload } =
21
+ props;
22
+
23
+ return (
24
+ <Letterhead>
25
+ <LetterheadHeader>
26
+ <LetterheadHeading>User mismatch</LetterheadHeading>
27
+
28
+ <LetterheadParagraph>
29
+ You are logged into Indie Tabletop Club as <em>{serverUser.email}</em>
30
+ , but locally stored data belong to <em>{localUser.email}</em>.
31
+ </LetterheadParagraph>
32
+
33
+ <LetterheadParagraph>
34
+ Which account do you want to use?
35
+ </LetterheadParagraph>
36
+ </LetterheadHeader>
37
+
38
+ <div {...cx(accountPicker.container)}>
39
+ <Button
40
+ {...cx(accountPicker.button)}
41
+ type="button"
42
+ onClick={() => onClearLocalContent()}
43
+ >
44
+ <div {...cx(accountPicker.buttonLabel)}>{serverUser.email}</div>
45
+ <div>Local data will be deleted.</div>
46
+ </Button>
47
+
48
+ <div {...cx(accountPicker.divider)} />
49
+
50
+ <Button
51
+ {...cx(accountPicker.button)}
52
+ type="button"
53
+ onClick={() => onServerLogout({ reload })}
54
+ >
55
+ <div {...cx(accountPicker.buttonLabel)}>{localUser.email}</div>
56
+ <div>You will be asked to log in again.</div>
57
+ </Button>
58
+ </div>
59
+ </Letterhead>
60
+ );
61
+ }