@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.
- package/lib/AppConfig/AppConfig.tsx +54 -0
- package/lib/HistoryState.ts +21 -0
- package/lib/Letterhead/style.css.ts +2 -0
- package/lib/LetterheadForm/index.tsx +53 -0
- package/lib/LetterheadForm/style.css.ts +8 -0
- package/lib/QRCode/QRCode.stories.tsx +47 -0
- package/lib/QRCode/QRCode.tsx +49 -0
- package/lib/QRCode/style.css.ts +19 -0
- package/lib/ShareButton/ShareButton.tsx +141 -0
- package/lib/SubscribeCard/LetterheadInfoCard.tsx +23 -0
- package/lib/SubscribeCard/SubscribeByEmailCard.stories.tsx +83 -0
- package/lib/SubscribeCard/SubscribeByEmailCard.tsx +177 -0
- package/lib/SubscribeCard/SubscribeCard.stories.tsx +27 -23
- package/lib/SubscribeCard/SubscribeCard.tsx +22 -17
- package/lib/Title/index.tsx +7 -2
- package/lib/account/AccountIssueView.tsx +40 -0
- package/lib/account/AlreadyLoggedInView.tsx +44 -0
- package/lib/account/CurrentUserFetcher.stories.tsx +339 -0
- package/lib/account/CurrentUserFetcher.tsx +119 -0
- package/lib/account/FailureFallbackView.tsx +36 -0
- package/lib/account/JoinPage.stories.tsx +270 -0
- package/lib/account/JoinPage.tsx +288 -0
- package/lib/account/LoadingView.tsx +14 -0
- package/lib/account/LoginPage.stories.tsx +318 -0
- package/lib/account/LoginPage.tsx +138 -0
- package/lib/account/LoginView.tsx +136 -0
- package/lib/account/NoConnectionView.tsx +34 -0
- package/lib/account/PasswordResetPage.stories.tsx +250 -0
- package/lib/account/PasswordResetPage.tsx +291 -0
- package/lib/account/UserMismatchView.tsx +61 -0
- package/lib/account/VerifyPage.tsx +217 -0
- package/lib/account/style.css.ts +57 -0
- package/lib/account/types.ts +9 -0
- package/lib/account/useCurrentUserResult.tsx +38 -0
- package/lib/class-names.ts +1 -1
- package/lib/client.ts +54 -7
- package/lib/globals.css.ts +5 -0
- package/lib/index.ts +11 -1
- package/lib/useEnsureValue.ts +31 -0
- package/package.json +3 -2
- package/lib/ClientContext/ClientContext.tsx +0 -25
- package/lib/LoginPage/LoginPage.stories.tsx +0 -107
- package/lib/LoginPage/LoginPage.tsx +0 -204
- 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
|
+
}
|