@aweftjs/auth 0.1.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/LICENSE +21 -0
- package/README.md +548 -0
- package/dist/auth-client.d.ts +248 -0
- package/dist/auth-client.js +287 -0
- package/dist/client-modules/Reset.d.ts +12 -0
- package/dist/client-modules/Reset.js +61 -0
- package/dist/client-modules/Session.d.ts +6 -0
- package/dist/client-modules/Session.js +59 -0
- package/dist/client-modules/SignIn.d.ts +13 -0
- package/dist/client-modules/SignIn.js +58 -0
- package/dist/client-modules/Verify.d.ts +12 -0
- package/dist/client-modules/Verify.js +54 -0
- package/dist/client-modules/stage-token.d.ts +3 -0
- package/dist/client-modules/stage-token.js +9 -0
- package/dist/client.d.ts +27 -0
- package/dist/client.js +33 -0
- package/dist/context.d.ts +11 -0
- package/dist/context.js +11 -0
- package/dist/cookie.d.ts +15 -0
- package/dist/cookie.js +39 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +42 -0
- package/dist/links.d.ts +37 -0
- package/dist/links.js +77 -0
- package/dist/mail.d.ts +55 -0
- package/dist/mail.js +53 -0
- package/dist/modules/Check.d.ts +10 -0
- package/dist/modules/Check.js +20 -0
- package/dist/modules/Enter.d.ts +57 -0
- package/dist/modules/Enter.js +143 -0
- package/dist/modules/Gate.d.ts +6 -0
- package/dist/modules/Gate.js +43 -0
- package/dist/modules/Password.d.ts +34 -0
- package/dist/modules/Password.js +138 -0
- package/dist/modules/Roles.d.ts +32 -0
- package/dist/modules/Roles.js +135 -0
- package/dist/modules/Session.d.ts +39 -0
- package/dist/modules/Session.js +159 -0
- package/dist/modules/State.d.ts +8 -0
- package/dist/modules/State.js +22 -0
- package/dist/modules/Verify.d.ts +32 -0
- package/dist/modules/Verify.js +97 -0
- package/dist/names.d.ts +25 -0
- package/dist/names.js +44 -0
- package/dist/password.d.ts +4 -0
- package/dist/password.js +41 -0
- package/dist/props.d.ts +15 -0
- package/dist/props.js +35 -0
- package/dist/token.d.ts +3 -0
- package/dist/token.js +11 -0
- package/dist/users.d.ts +9 -0
- package/dist/users.js +12 -0
- package/errors.txt +29 -0
- package/package.json +62 -0
- package/src/auth-client.ts +531 -0
- package/src/client-modules/Reset.tsx +97 -0
- package/src/client-modules/Session.ts +70 -0
- package/src/client-modules/SignIn.tsx +110 -0
- package/src/client-modules/Verify.tsx +82 -0
- package/src/client-modules/stage-token.ts +11 -0
- package/src/client.ts +38 -0
- package/src/context.ts +21 -0
- package/src/cookie.ts +35 -0
- package/src/index.ts +53 -0
- package/src/links.ts +107 -0
- package/src/mail.ts +88 -0
- package/src/modules/Check.ts +31 -0
- package/src/modules/Enter.ts +189 -0
- package/src/modules/Gate.ts +47 -0
- package/src/modules/Password.ts +155 -0
- package/src/modules/Roles.ts +163 -0
- package/src/modules/Session.ts +196 -0
- package/src/modules/State.ts +30 -0
- package/src/modules/Verify.ts +118 -0
- package/src/names.ts +49 -0
- package/src/password.ts +43 -0
- package/src/props.ts +47 -0
- package/src/token.ts +15 -0
- package/src/users.ts +18 -0
- package/surface.txt +19 -0
- package/text.json +50 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
// The browser half of the battery: who the page is, over a connection it already has
|
|
2
|
+
// (design 185).
|
|
3
|
+
//
|
|
4
|
+
// Every import here is a type or one of the four values `core` and `codec` hand out, so a page
|
|
5
|
+
// bundle that reaches for it carries no server module, no store and no Node module. Identity is
|
|
6
|
+
// fixed for a connection's life, so this asks once per socket and reconnects whenever the cookie
|
|
7
|
+
// changes underneath it.
|
|
8
|
+
|
|
9
|
+
import type { Client, Handle } from '@aweftjs/client';
|
|
10
|
+
import { codecError } from '@aweftjs/codec';
|
|
11
|
+
import { type Derived, immutable, mutable, observer } from '@aweftjs/core';
|
|
12
|
+
|
|
13
|
+
import type { Entered } from './modules/Enter.ts';
|
|
14
|
+
import { type Implies, holds, isName } from './names.ts';
|
|
15
|
+
import type { RolesDocument } from './modules/Roles.ts';
|
|
16
|
+
|
|
17
|
+
export type { Entered } from './modules/Enter.ts';
|
|
18
|
+
|
|
19
|
+
/** What the mail and password calls answer: done, or the route's reasons. */
|
|
20
|
+
export type Outcome = { readonly ok: true } | { readonly refused: Refusals };
|
|
21
|
+
|
|
22
|
+
/** What a refusal from the sign-in route carries, taken from the module's own answer shape. */
|
|
23
|
+
type Refusals = Extract<Entered, { readonly refused: unknown }>['refused'];
|
|
24
|
+
|
|
25
|
+
/** What `fetch` is given, stated here so this declaration names no DOM type. */
|
|
26
|
+
export interface FetchInit {
|
|
27
|
+
method: string;
|
|
28
|
+
headers: Record<string, string>;
|
|
29
|
+
body?: string;
|
|
30
|
+
credentials: 'same-origin';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** What `fetch` answers, stated here for the same reason. */
|
|
34
|
+
export interface FetchResponse {
|
|
35
|
+
status: number;
|
|
36
|
+
ok: boolean;
|
|
37
|
+
json(): Promise<unknown>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The two HTTP calls this half makes. The global `fetch` is one of these already. */
|
|
41
|
+
export type Fetcher = (url: string, init: FetchInit) => Promise<FetchResponse>;
|
|
42
|
+
|
|
43
|
+
/** What the client half may be told about how it runs. Both fields have a default in a page. */
|
|
44
|
+
export interface AuthOptions {
|
|
45
|
+
/** Where the session routes are. Defaults to the page's own origin. */
|
|
46
|
+
readonly origin?: string | undefined;
|
|
47
|
+
/** Makes the two HTTP calls. Defaults to the global `fetch`. */
|
|
48
|
+
readonly fetch?: Fetcher | undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Identity over one connection: who the page is, what they hold, and how it signs in and out. */
|
|
52
|
+
export interface Auth {
|
|
53
|
+
/**
|
|
54
|
+
* Who the connection is, as a read-only cell: `undefined` until the server has answered,
|
|
55
|
+
* `null` for an anonymous connection, the user's id otherwise. Writing it throws `read-only`.
|
|
56
|
+
*/
|
|
57
|
+
readonly user: Derived<string | null | undefined>;
|
|
58
|
+
/**
|
|
59
|
+
* The names the person was granted, as a read-only cell: `undefined` until the server has
|
|
60
|
+
* answered, `[]` for an anonymous connection, the granted list otherwise, following the
|
|
61
|
+
* server while the socket is open, so a grant made on the server reaches the page with no
|
|
62
|
+
* reconnect (design 289). For showing and hiding: the server's gate is what refuses.
|
|
63
|
+
*/
|
|
64
|
+
readonly names: Derived<readonly string[] | undefined>;
|
|
65
|
+
/**
|
|
66
|
+
* Does the person hold a name, by the same rule the server's gate applies: granted, covered
|
|
67
|
+
* by a granted name (`products` covers `products.abc123.read`, `*` covers everything), or
|
|
68
|
+
* implied by one through the table the server answered.
|
|
69
|
+
*
|
|
70
|
+
* Params:
|
|
71
|
+
* name: the name asked about
|
|
72
|
+
*
|
|
73
|
+
* Returns: false until `names` has been answered, then the answer.
|
|
74
|
+
*
|
|
75
|
+
* Example:
|
|
76
|
+
* const canDelete = auth.names.map(() => auth.may('posts.delete'));
|
|
77
|
+
*/
|
|
78
|
+
may(name: string): boolean;
|
|
79
|
+
/**
|
|
80
|
+
* Sign in, or sign up when nobody has the email.
|
|
81
|
+
*
|
|
82
|
+
* Params:
|
|
83
|
+
* email: the address to sign in as
|
|
84
|
+
* password: their password
|
|
85
|
+
* extra: fields the server's sign-up rule reads (an invite token, a role picked on the
|
|
86
|
+
* form), sent beside the two; a sign-in carries them too and the server ignores them
|
|
87
|
+
*
|
|
88
|
+
* Returns: `{ user, created }` once `user` reads the new id. The client reconnects first,
|
|
89
|
+
* because a cookie cannot be set on an open socket and identity is fixed per connection.
|
|
90
|
+
* A wrong password, a malformed address, or a sign-up the server's rule closed the door
|
|
91
|
+
* to resolves with `{ refused }` and reconnects nothing.
|
|
92
|
+
*
|
|
93
|
+
* Rejects with `enter-failed` for any other status, and with whatever `fetch` threw. On a
|
|
94
|
+
* stopped auth it rejects `stopped` before the route is called at all, and with `closed` when
|
|
95
|
+
* the route answered but the client was closed, so no socket can carry the new identity.
|
|
96
|
+
*
|
|
97
|
+
* Example:
|
|
98
|
+
* const outcome = await auth.enter('ada@example.com', 'correct horse battery staple');
|
|
99
|
+
* if ('refused' in outcome) show(outcome.refused);
|
|
100
|
+
*/
|
|
101
|
+
enter(email: string, password: string, extra?: Readonly<Record<string, unknown>>): Promise<Entered>;
|
|
102
|
+
/**
|
|
103
|
+
* Sign out.
|
|
104
|
+
*
|
|
105
|
+
* Params: none.
|
|
106
|
+
*
|
|
107
|
+
* Returns: nothing, once `user` reads `null` again. The state handle is stopped and the
|
|
108
|
+
* client reconnects, so the next connection is anonymous.
|
|
109
|
+
*
|
|
110
|
+
* Rejects with `leave-failed` when the route answers anything but `ok`, with `stopped` on a
|
|
111
|
+
* stopped auth before the route is called, and with `closed` when the route answered but the
|
|
112
|
+
* client was closed.
|
|
113
|
+
*
|
|
114
|
+
* Example:
|
|
115
|
+
* await auth.leave();
|
|
116
|
+
*/
|
|
117
|
+
leave(): Promise<void>;
|
|
118
|
+
/**
|
|
119
|
+
* Share the signed-in user's own state document.
|
|
120
|
+
*
|
|
121
|
+
* Params: none. `T` is the shape the application keeps in it.
|
|
122
|
+
*
|
|
123
|
+
* Returns: a handle over the user's `state` document, `document`, `ready` and `stop()` as
|
|
124
|
+
* `client.share` answers them. On an anonymous connection its `ready` rejects `anonymous`
|
|
125
|
+
* at once rather than waiting for a topic the server will never offer; before the server
|
|
126
|
+
* has answered, it waits and then does one or the other.
|
|
127
|
+
*
|
|
128
|
+
* One connection carries one state document, so asking twice hands back the same handle,
|
|
129
|
+
* stopped or not: the server offers the topic once per socket. `enter` and `leave` stop it
|
|
130
|
+
* and open a socket, and the call after either gives a new handle, because another user's
|
|
131
|
+
* state is another document.
|
|
132
|
+
*
|
|
133
|
+
* When `user` changes underneath the page (the server forgot the session, or another user's
|
|
134
|
+
* cookie replaced it, and the client came back on its own), the handle the page holds is
|
|
135
|
+
* stopped and follows the server no further. The next call gives a handle for whoever the
|
|
136
|
+
* connection is now, and a page follows `user` to notice. After `stop()` this refuses
|
|
137
|
+
* `stopped`.
|
|
138
|
+
*
|
|
139
|
+
* Example:
|
|
140
|
+
* const state = await auth.state<State>().ready;
|
|
141
|
+
* state.theme = 'dark';
|
|
142
|
+
*/
|
|
143
|
+
state<T extends object>(): Handle<T>;
|
|
144
|
+
/**
|
|
145
|
+
* Does anyone have this email, so a form can ask before it asks for a password.
|
|
146
|
+
*
|
|
147
|
+
* Params:
|
|
148
|
+
* email: the address to look for
|
|
149
|
+
*
|
|
150
|
+
* Returns: whether an account has it. On a stopped auth it rejects `stopped` instead.
|
|
151
|
+
*
|
|
152
|
+
* Example:
|
|
153
|
+
* const known = await auth.check('ada@example.com');
|
|
154
|
+
*/
|
|
155
|
+
check(email: string): Promise<boolean>;
|
|
156
|
+
/**
|
|
157
|
+
* Ask for a verification mail, or take the link from one.
|
|
158
|
+
*
|
|
159
|
+
* Params:
|
|
160
|
+
* token: the token from the link; with none, a mail is sent to the signed-in user
|
|
161
|
+
*
|
|
162
|
+
* Returns: `{ ok: true }`, or `{ refused }` with the route's reasons: `private` when nobody
|
|
163
|
+
* is signed in, `verified` when the email already is, `attempts` when too many were asked
|
|
164
|
+
* for, `mail` when the mailer did not take it, `token` when the link is not one that can be
|
|
165
|
+
* used, `taken` when it already was. Nothing reconnects: the name `verified` reaches `names`
|
|
166
|
+
* through the share.
|
|
167
|
+
*
|
|
168
|
+
* Rejects with `verify-failed` for any other status, with `stopped` on a stopped auth.
|
|
169
|
+
*
|
|
170
|
+
* Example:
|
|
171
|
+
* await auth.verify(); // the mail goes out
|
|
172
|
+
* await auth.verify(stage.query.get().token); // the link was opened
|
|
173
|
+
*/
|
|
174
|
+
verify(token?: string): Promise<Outcome>;
|
|
175
|
+
/**
|
|
176
|
+
* Change the signed-in user's password.
|
|
177
|
+
*
|
|
178
|
+
* Params:
|
|
179
|
+
* current: the password they have
|
|
180
|
+
* password: the one they want
|
|
181
|
+
*
|
|
182
|
+
* Returns: `{ ok: true }` once every other session of theirs is ended, this one kept; or
|
|
183
|
+
* `{ refused }`: `password` for a wrong current one or a new one the rules refuse,
|
|
184
|
+
* `attempts` for too many tries, `private` when nobody is signed in.
|
|
185
|
+
*
|
|
186
|
+
* Rejects with `change-failed` for any other status, with `stopped` on a stopped auth.
|
|
187
|
+
*
|
|
188
|
+
* Example:
|
|
189
|
+
* const outcome = await auth.change(current.get(), next.get());
|
|
190
|
+
*/
|
|
191
|
+
change(current: string, password: string): Promise<Outcome>;
|
|
192
|
+
/**
|
|
193
|
+
* Ask for a reset mail.
|
|
194
|
+
*
|
|
195
|
+
* Params:
|
|
196
|
+
* email: the address; an address nobody has gets the same `{ ok: true }` and no mail
|
|
197
|
+
*
|
|
198
|
+
* Returns: `{ ok: true }`, or `{ refused }`: `email` for text that is not an address,
|
|
199
|
+
* `attempts` for too many asks, `mail` when the mailer did not take it.
|
|
200
|
+
*
|
|
201
|
+
* Rejects with `forgot-failed` for any other status, with `stopped` on a stopped auth.
|
|
202
|
+
*
|
|
203
|
+
* Example:
|
|
204
|
+
* await auth.forgot('ada@example.com');
|
|
205
|
+
*/
|
|
206
|
+
forgot(email: string): Promise<Outcome>;
|
|
207
|
+
/**
|
|
208
|
+
* Set a new password from a reset link.
|
|
209
|
+
*
|
|
210
|
+
* Params:
|
|
211
|
+
* token: the token from the link
|
|
212
|
+
* password: the new password
|
|
213
|
+
*
|
|
214
|
+
* Returns: `{ ok: true }` once every session of the person is ended and the client has
|
|
215
|
+
* reconnected, so a page that was signed in as them reads `user` as `null`; or `{ refused }`:
|
|
216
|
+
* `token` for a link that is not live, `taken` for one already used, `password` for one the
|
|
217
|
+
* rules refuse.
|
|
218
|
+
*
|
|
219
|
+
* Rejects with `reset-failed` for any other status, with `stopped` on a stopped auth, and
|
|
220
|
+
* with `closed` when the route answered but the client was closed.
|
|
221
|
+
*
|
|
222
|
+
* Example:
|
|
223
|
+
* const outcome = await auth.reset(stage.query.get().token, password.get());
|
|
224
|
+
*/
|
|
225
|
+
reset(token: string, password: string): Promise<Outcome>;
|
|
226
|
+
/**
|
|
227
|
+
* Stop following the connection.
|
|
228
|
+
*
|
|
229
|
+
* Params: none.
|
|
230
|
+
*
|
|
231
|
+
* Returns: nothing. The status watcher goes and the current state handle stops; the client
|
|
232
|
+
* is left open, because it is not this half's to close. Every method after this refuses with
|
|
233
|
+
* `stopped`. Calling it twice is not an error.
|
|
234
|
+
*
|
|
235
|
+
* Example:
|
|
236
|
+
* auth.stop();
|
|
237
|
+
*/
|
|
238
|
+
stop(): void;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const NO_ORIGIN_FIX = 'Pass origin to createAuth; outside a page there is no origin to read one from.';
|
|
242
|
+
const ANONYMOUS_FIX = 'Wait for user to read a string, or call enter first; an anonymous connection has no state.';
|
|
243
|
+
const ENTER_FIX = 'Check the server is running and that auth/Enter is loaded, then try again.';
|
|
244
|
+
const LEAVE_FIX = 'Check the server is running and that auth/Session is loaded, then try again.';
|
|
245
|
+
const STOPPED_FIX = 'Make a new auth with createAuth; a stopped one follows no connection.';
|
|
246
|
+
const CLIENT_CLOSED_FIX = 'Make a new client with createClient, and a new auth over it.';
|
|
247
|
+
const VERIFY_FIX = 'Check the server is running and that auth/Verify is loaded from the mail source, then try again.';
|
|
248
|
+
const CHANGE_FIX = 'Check the server is running and that auth/Password is loaded from the mail source, then try again.';
|
|
249
|
+
const FORGOT_FIX = 'Check the server is running and that auth/Password is loaded from the mail source, then try again.';
|
|
250
|
+
const RESET_FIX = 'Check the server is running and that auth/Password is loaded from the mail source, then try again.';
|
|
251
|
+
|
|
252
|
+
const anonymous = (): Error =>
|
|
253
|
+
codecError('anonymous', 'there is no signed-in user to share a state document for', ANONYMOUS_FIX);
|
|
254
|
+
|
|
255
|
+
const halted = (detail: string): Error => codecError('stopped', detail, STOPPED_FIX);
|
|
256
|
+
|
|
257
|
+
/** The page's own origin, the one address this half will take without being told. */
|
|
258
|
+
const pageOrigin = (): string => {
|
|
259
|
+
const held = (globalThis as { location?: { origin?: unknown } }).location?.origin;
|
|
260
|
+
if (typeof held !== 'string' || held === '') {
|
|
261
|
+
throw codecError('no-origin', 'there is no page origin to take the session routes from', NO_ORIGIN_FIX);
|
|
262
|
+
}
|
|
263
|
+
return held;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const globalFetch: Fetcher = (url, init) =>
|
|
267
|
+
(globalThis.fetch as unknown as Fetcher)(url, init);
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Add identity to a connection.
|
|
271
|
+
*
|
|
272
|
+
* Params:
|
|
273
|
+
* client: the connection, from `createClient`. This never opens or closes the connection
|
|
274
|
+
* for good; after sign-in and sign-out it asks the client to reconnect, because
|
|
275
|
+
* identity is fixed per socket
|
|
276
|
+
* options.origin: where the session routes are; the page's own origin by default
|
|
277
|
+
* options.fetch: makes the two HTTP calls; the global `fetch` by default
|
|
278
|
+
*
|
|
279
|
+
* Returns: `user`, `enter`, `leave`, `state`, `check` and `stop`. `user` reads `undefined`
|
|
280
|
+
* until the first socket answers, and is asked again on every socket that opens.
|
|
281
|
+
*
|
|
282
|
+
* `enter`, `leave` and every other call that sends over HTTP rejects with `no-origin` when no
|
|
283
|
+
* `origin` was given and there is no page to read one from. Making the auth is safe anywhere.
|
|
284
|
+
*
|
|
285
|
+
* Example:
|
|
286
|
+
* const client = createClient();
|
|
287
|
+
* const auth = createAuth(client);
|
|
288
|
+
* auth.user.effect((who) => header.textContent = who ?? 'signed out');
|
|
289
|
+
* await auth.enter('ada@example.com', 'correct horse battery staple');
|
|
290
|
+
*/
|
|
291
|
+
export const createAuth = (client: Client, options: AuthOptions = {}): Auth => {
|
|
292
|
+
const send = options.fetch ?? globalFetch;
|
|
293
|
+
// Read when a route is called, not when the auth is made: a module that holds one is built by
|
|
294
|
+
// a static render as well as by a page, and only a page has an origin (design 245).
|
|
295
|
+
const routeUrl = (path = '/api/session'): string => `${options.origin ?? pageOrigin()}${path}`;
|
|
296
|
+
const identity = mutable<string | null | undefined>(undefined);
|
|
297
|
+
const names = mutable<readonly string[] | undefined>(undefined);
|
|
298
|
+
let implies: Implies = {};
|
|
299
|
+
/** The roles share of the socket that is open, and how to stop following it. */
|
|
300
|
+
let roles: { handle: Handle<RolesDocument>; off?: (() => void) | undefined } | undefined;
|
|
301
|
+
|
|
302
|
+
let stopped = false;
|
|
303
|
+
let held: Handle<object> | undefined;
|
|
304
|
+
/** Who the held handle was made for, so an identity that changes underneath it is visible. */
|
|
305
|
+
let heldFor: string | null | undefined;
|
|
306
|
+
|
|
307
|
+
const stopRoles = (): void => {
|
|
308
|
+
roles?.off?.();
|
|
309
|
+
roles?.handle.stop();
|
|
310
|
+
roles = undefined;
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// The names follow the shared document: read once it is here, and again on every commit
|
|
314
|
+
// the server makes to it, so a grant reaches the page while the socket is open.
|
|
315
|
+
const followRoles = (who: string | null): void => {
|
|
316
|
+
stopRoles();
|
|
317
|
+
if (who === null) {
|
|
318
|
+
implies = {};
|
|
319
|
+
names.set([]);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const handle = client.share<RolesDocument>('roles');
|
|
323
|
+
const mine: typeof roles = { handle };
|
|
324
|
+
roles = mine;
|
|
325
|
+
void Promise.all([handle.ready, client.ask('auth/Roles')]).then(([doc, answer]) => {
|
|
326
|
+
if (roles !== mine) return;
|
|
327
|
+
const table: unknown = (answer as { implies?: unknown } | null)?.implies;
|
|
328
|
+
implies = table !== null && typeof table === 'object' ? table as Implies : {};
|
|
329
|
+
const read = (): readonly string[] => [...(doc.names ?? [])];
|
|
330
|
+
names.set(read());
|
|
331
|
+
mine.off = observer(doc).skip(Infinity).watch(() => { names.set(read()); });
|
|
332
|
+
}, () => {});
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
// Identity is fixed at the handshake, so every socket is a new answer and the old one is
|
|
336
|
+
// worth nothing. An ask that rejects took the socket with it; the next one asks again.
|
|
337
|
+
const refresh = (): void => {
|
|
338
|
+
client.ask('auth/Session').then((answer) => {
|
|
339
|
+
if (stopped) return;
|
|
340
|
+
const who: unknown = (answer as { user?: unknown } | null)?.user;
|
|
341
|
+
const next = typeof who === 'string' ? who : null;
|
|
342
|
+
identity.set(next);
|
|
343
|
+
followRoles(next);
|
|
344
|
+
// The server forgot the session, or another user's cookie replaced it. The handle the
|
|
345
|
+
// page holds is the old user's document and every later socket would re-share it, so
|
|
346
|
+
// it stops here and the next `state()` answers for whoever this is now.
|
|
347
|
+
if (held !== undefined && heldFor !== next) stopState();
|
|
348
|
+
}, () => {});
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const release = client.status.watch((now) => {
|
|
352
|
+
if (now === 'open') refresh();
|
|
353
|
+
});
|
|
354
|
+
if (client.status.get() === 'open') refresh();
|
|
355
|
+
|
|
356
|
+
/** The next value `user` takes that is not `undefined`, which is what a reconnect settles to. */
|
|
357
|
+
const answered = (): { known: Promise<string | null>; cancel(): void } => {
|
|
358
|
+
let off: (() => void) | undefined;
|
|
359
|
+
const known = new Promise<string | null>((done) => {
|
|
360
|
+
off = identity.watch((who) => {
|
|
361
|
+
if (who === undefined) return;
|
|
362
|
+
off?.();
|
|
363
|
+
off = undefined;
|
|
364
|
+
done(who);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
return { known, cancel: () => { off?.(); off = undefined; } };
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const stopState = (): void => {
|
|
371
|
+
held?.stop();
|
|
372
|
+
held = undefined;
|
|
373
|
+
heldFor = undefined;
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const keep = <T extends object>(handle: Handle<T>, who: string | null | undefined): Handle<T> => {
|
|
377
|
+
held = handle as unknown as Handle<object>;
|
|
378
|
+
heldFor = who;
|
|
379
|
+
return handle;
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
// The one shape every route answer takes: the reasons for the statuses that are a refusal,
|
|
383
|
+
// the body for a success, and a rejection naming the call for anything else.
|
|
384
|
+
const post = async (reason: string, fix: string, what: string, path: string, body: unknown, refusals: readonly number[], method = 'POST'): Promise<Outcome & { body?: unknown }> => {
|
|
385
|
+
if (stopped) throw halted(`the auth is stopped and no ${what} was sent`);
|
|
386
|
+
const route = routeUrl(path);
|
|
387
|
+
const answer = await send(route, {
|
|
388
|
+
method,
|
|
389
|
+
headers: body === undefined ? {} : { 'content-type': 'application/json' },
|
|
390
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
391
|
+
credentials: 'same-origin',
|
|
392
|
+
});
|
|
393
|
+
if (refusals.includes(answer.status)) {
|
|
394
|
+
const refused: unknown = ((await answer.json()) as { reasons?: unknown } | null)?.reasons;
|
|
395
|
+
return { refused: (Array.isArray(refused) ? refused : []) as Refusals };
|
|
396
|
+
}
|
|
397
|
+
if (!answer.ok) throw codecError(reason, `${method} ${route} answered ${String(answer.status)}`, fix);
|
|
398
|
+
return { ok: true, body: await answer.json() };
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
// Both routes change who the cookie says this browser is, and the socket that is open was
|
|
402
|
+
// identified before that. Dropping it is the whole reason these two reconnect.
|
|
403
|
+
const again = async (): Promise<string | null> => {
|
|
404
|
+
stopState();
|
|
405
|
+
stopRoles();
|
|
406
|
+
identity.set(undefined);
|
|
407
|
+
names.set(undefined);
|
|
408
|
+
const next = answered();
|
|
409
|
+
client.reconnect();
|
|
410
|
+
// A live client goes to `connecting` inside `reconnect()`; a closed one does nothing at
|
|
411
|
+
// all, so no socket would ever carry the new identity and this would wait forever.
|
|
412
|
+
if (client.status.get() !== 'connecting') {
|
|
413
|
+
next.cancel();
|
|
414
|
+
throw codecError('closed', 'the session route answered but the client is closed, so no socket can carry the new identity', CLIENT_CLOSED_FIX);
|
|
415
|
+
}
|
|
416
|
+
return await next.known;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const outcome = ({ body: _body, ...rest }: Outcome & { body?: unknown }): Outcome => rest;
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
user: immutable(identity),
|
|
423
|
+
names: immutable(names),
|
|
424
|
+
// The same answer the server's `may` gives, text that is not a name included.
|
|
425
|
+
may: (name) => {
|
|
426
|
+
const granted = names.get();
|
|
427
|
+
return granted !== undefined && isName(name) && holds(granted, implies, name);
|
|
428
|
+
},
|
|
429
|
+
|
|
430
|
+
enter: async (email, password, extra) => {
|
|
431
|
+
const answer = await post('enter-failed', ENTER_FIX, 'sign-in', '/api/session', { ...extra, email, password }, [400, 401, 403]);
|
|
432
|
+
if ('refused' in answer) return answer;
|
|
433
|
+
const body = answer.body as { user: string; created: boolean };
|
|
434
|
+
await again();
|
|
435
|
+
return { user: body.user, created: body.created };
|
|
436
|
+
},
|
|
437
|
+
|
|
438
|
+
leave: async () => {
|
|
439
|
+
await post('leave-failed', LEAVE_FIX, 'sign-out', '/api/session', undefined, [], 'DELETE');
|
|
440
|
+
// The connection after a sign-out carries no session, so the value it settles to is null.
|
|
441
|
+
await again();
|
|
442
|
+
},
|
|
443
|
+
|
|
444
|
+
verify: async (token) => outcome(token === undefined
|
|
445
|
+
? await post('verify-failed', VERIFY_FIX, 'verification', '/api/verify/send', undefined, [401, 409, 429, 502])
|
|
446
|
+
: await post('verify-failed', VERIFY_FIX, 'verification', '/api/verify', { token }, [400])),
|
|
447
|
+
|
|
448
|
+
change: async (current, password) => outcome(await post('change-failed', CHANGE_FIX, 'password change', '/api/password', { current, password }, [400, 401, 429])),
|
|
449
|
+
|
|
450
|
+
forgot: async (email) => outcome(await post('forgot-failed', FORGOT_FIX, 'reset mail', '/api/password/forgot', { email }, [400, 429, 502])),
|
|
451
|
+
|
|
452
|
+
reset: async (token, password) => {
|
|
453
|
+
const answer = outcome(await post('reset-failed', RESET_FIX, 'reset', '/api/password/reset', { token, password }, [400]));
|
|
454
|
+
if ('refused' in answer) return answer;
|
|
455
|
+
// Every session of the person is over, this page's included when it was theirs.
|
|
456
|
+
await again();
|
|
457
|
+
return answer;
|
|
458
|
+
},
|
|
459
|
+
|
|
460
|
+
state: <T extends object>(): Handle<T> => {
|
|
461
|
+
if (stopped) {
|
|
462
|
+
const ready = Promise.reject(halted('the auth is stopped and follows no connection'));
|
|
463
|
+
ready.catch(() => {});
|
|
464
|
+
return { document: undefined, ready, stop: () => {} };
|
|
465
|
+
}
|
|
466
|
+
// One connection carries one state document, and the server offers the topic once.
|
|
467
|
+
// A second share of a name the peer has already paired waits for an offer that never
|
|
468
|
+
// comes, and so does a share made after `stop()`. So this hands back the same handle
|
|
469
|
+
// for the connection's life. `enter` and `leave` let go of it, and the socket they
|
|
470
|
+
// open offers the topic again.
|
|
471
|
+
if (held !== undefined) return held as unknown as Handle<T>;
|
|
472
|
+
|
|
473
|
+
const who = identity.get();
|
|
474
|
+
if (who === null) {
|
|
475
|
+
const ready = Promise.reject(anonymous());
|
|
476
|
+
// A page that renders `user` and never reads this promise is not killed by it,
|
|
477
|
+
// which is the rule `sync`'s link already keeps for a topic that ends early.
|
|
478
|
+
ready.catch(() => {});
|
|
479
|
+
return keep<T>({ document: undefined, ready, stop: () => {} }, null);
|
|
480
|
+
}
|
|
481
|
+
if (who !== undefined) return keep(client.share<T>('state'), who);
|
|
482
|
+
|
|
483
|
+
// Nobody has answered yet. The handle exists now because the page asked for it now,
|
|
484
|
+
// and it stands in for the share the first answer either makes or refuses.
|
|
485
|
+
let inner: Handle<T> | undefined;
|
|
486
|
+
let off: (() => void) | undefined;
|
|
487
|
+
const ready = new Promise<T>((settle, fail) => {
|
|
488
|
+
off = identity.watch((now) => {
|
|
489
|
+
if (now === undefined) return;
|
|
490
|
+
off?.();
|
|
491
|
+
off = undefined;
|
|
492
|
+
// The first answer is who this handle is for, and `refresh` reads it back to
|
|
493
|
+
// tell an identity that changed later from this one arriving.
|
|
494
|
+
if (held === waited) heldFor = now;
|
|
495
|
+
if (now === null) {
|
|
496
|
+
fail(anonymous());
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
inner = client.share<T>('state');
|
|
500
|
+
inner.ready.then(settle, fail);
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
ready.catch(() => {});
|
|
504
|
+
const waited: Handle<T> = {
|
|
505
|
+
get document() {
|
|
506
|
+
return inner?.document;
|
|
507
|
+
},
|
|
508
|
+
ready,
|
|
509
|
+
stop: () => {
|
|
510
|
+
off?.();
|
|
511
|
+
off = undefined;
|
|
512
|
+
inner?.stop();
|
|
513
|
+
},
|
|
514
|
+
};
|
|
515
|
+
return keep(waited, undefined);
|
|
516
|
+
},
|
|
517
|
+
|
|
518
|
+
check: async (email) => {
|
|
519
|
+
if (stopped) throw halted('the auth is stopped and no lookup was sent');
|
|
520
|
+
return ((await client.ask('auth/Check', { email })) as { exists: boolean }).exists;
|
|
521
|
+
},
|
|
522
|
+
|
|
523
|
+
stop: () => {
|
|
524
|
+
if (stopped) return;
|
|
525
|
+
stopped = true;
|
|
526
|
+
release();
|
|
527
|
+
stopState();
|
|
528
|
+
stopRoles();
|
|
529
|
+
},
|
|
530
|
+
};
|
|
531
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// auth/Reset: the forgot form, and the new-password form a reset link opens (design 290).
|
|
2
|
+
//
|
|
3
|
+
// Without a token in the act's parameters or the URL's query it asks for an address and mails
|
|
4
|
+
// the link; with one it asks for the new password and sets it. It picks no URL: the application
|
|
5
|
+
// names it in the acts map, and points the mail at that address.
|
|
6
|
+
|
|
7
|
+
import { mutable } from '@aweftjs/core';
|
|
8
|
+
import { Button, TextField, h, text } from '@aweftjs/ui';
|
|
9
|
+
import type { StageValue } from '@aweftjs/ui';
|
|
10
|
+
|
|
11
|
+
import type { Auth } from '../auth-client.ts';
|
|
12
|
+
import { tokenOf } from './stage-token.ts';
|
|
13
|
+
|
|
14
|
+
export const deps = ['auth/Session'];
|
|
15
|
+
|
|
16
|
+
interface Reason {
|
|
17
|
+
readonly code: string;
|
|
18
|
+
readonly message: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ResetProps {
|
|
22
|
+
readonly stage?: StageValue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const LAYOUT = 'display:flex;flex-direction:column;gap:1rem;max-width:22rem';
|
|
26
|
+
|
|
27
|
+
const about = (reasons: readonly Reason[], code: string): string =>
|
|
28
|
+
reasons.filter((one) => one.code === code).map((one) => one.message).join(' ');
|
|
29
|
+
|
|
30
|
+
const rest = (reasons: readonly Reason[], codes: readonly string[]): string =>
|
|
31
|
+
reasons.filter((one) => !codes.includes(one.code)).map((one) => one.message).join(' ');
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
export default ({ imports }: { imports: Readonly<Record<string, unknown>> }): {
|
|
35
|
+
title: unknown;
|
|
36
|
+
component: (props: ResetProps) => unknown;
|
|
37
|
+
} => {
|
|
38
|
+
const session = imports['Session'] as Auth;
|
|
39
|
+
|
|
40
|
+
const component = (props: ResetProps): unknown => {
|
|
41
|
+
const token = tokenOf(props.stage);
|
|
42
|
+
const email = mutable('');
|
|
43
|
+
const password = mutable('');
|
|
44
|
+
const fieldProblem = mutable('');
|
|
45
|
+
const problem = mutable('');
|
|
46
|
+
const note = mutable<unknown>('');
|
|
47
|
+
const busy = mutable(false);
|
|
48
|
+
const done = mutable(false);
|
|
49
|
+
|
|
50
|
+
const run = async (call: () => Promise<{ ok: true } | { refused: readonly Reason[] }>, field: string, onOk: unknown): Promise<void> => {
|
|
51
|
+
if (busy.get()) return;
|
|
52
|
+
busy.set(true);
|
|
53
|
+
fieldProblem.set('');
|
|
54
|
+
problem.set('');
|
|
55
|
+
try {
|
|
56
|
+
const answer = await call();
|
|
57
|
+
if ('refused' in answer) {
|
|
58
|
+
fieldProblem.set(about(answer.refused, field));
|
|
59
|
+
problem.set(rest(answer.refused, [field]));
|
|
60
|
+
} else {
|
|
61
|
+
note.set(onOk);
|
|
62
|
+
done.set(true);
|
|
63
|
+
}
|
|
64
|
+
} catch (error) {
|
|
65
|
+
problem.set(String((error as Error)?.message ?? error));
|
|
66
|
+
} finally {
|
|
67
|
+
busy.set(false);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const submit = (): Promise<void> => token === undefined
|
|
72
|
+
? run(() => session.forgot(email.get()), 'email', text('If that address has an account, a link is on its way.'))
|
|
73
|
+
: run(() => session.reset(token, password.get()), 'password', text('Your password is set. Sign in with it.'));
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<form
|
|
77
|
+
aria-label={text('Reset your password')}
|
|
78
|
+
style={LAYOUT}
|
|
79
|
+
onSubmit={(event: unknown) => {
|
|
80
|
+
(event as { preventDefault(): void }).preventDefault();
|
|
81
|
+
void submit();
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
{token === undefined
|
|
85
|
+
? <TextField label={text('Email')} value={email} error={fieldProblem} placeholder={text('you@example.com')} name="email" autocomplete="email" />
|
|
86
|
+
: <TextField label={text('New password')} value={password} error={fieldProblem} password name="password" autocomplete="new-password" />}
|
|
87
|
+
<p>{note}</p>
|
|
88
|
+
<p role="alert">{problem}</p>
|
|
89
|
+
{/* `Button` is a `type="button"`, so the click is the submit and the form's own
|
|
90
|
+
handler is what the Enter key reaches. */}
|
|
91
|
+
<Button label={token === undefined ? text('Send the link') : text('Set the password')} loading={busy} disabled={done} onClick={submit} />
|
|
92
|
+
</form>
|
|
93
|
+
);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return { title: text('Reset your password'), component };
|
|
97
|
+
};
|