@gopherium/react-auth 0.6.0 → 0.8.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/dist/AuthGate.js CHANGED
@@ -3,7 +3,7 @@ import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useQueryClient } from '@tanstack/react-query';
4
4
  import { __ } from '@wordpress/i18n';
5
5
  import { DOMAIN } from './domain.js';
6
- import { sessionQueryKey, useSession } from './session.js';
6
+ import { adoptSession, useSession } from './session.js';
7
7
  /**
8
8
  * Guards its children behind a login session, rendering the given login
9
9
  * screen until the user is authenticated.
@@ -23,10 +23,7 @@ export function AuthGate({ children, loginScreen, loading = _jsx("p", { role: "s
23
23
  return _jsx(_Fragment, { children: loading });
24
24
  }
25
25
  if (session.data === null) {
26
- return (_jsx(_Fragment, { children: loginScreen(async (user) => {
27
- await queryClient.cancelQueries({ queryKey: sessionQueryKey });
28
- queryClient.setQueryData(sessionQueryKey, user);
29
- }) }));
26
+ return (_jsx(_Fragment, { children: loginScreen((user) => adoptSession(queryClient, user)) }));
30
27
  }
31
28
  return _jsx(_Fragment, { children: children });
32
29
  }
@@ -15,17 +15,24 @@ export interface NewUser {
15
15
  password: string;
16
16
  role?: string;
17
17
  }
18
+ export interface NewInvite {
19
+ email: string;
20
+ name: string;
21
+ role?: string;
22
+ }
23
+ declare const invitationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
24
+ delivered: z.ZodLiteral<true>;
25
+ }, z.core.$strip>, z.ZodObject<{
26
+ delivered: z.ZodLiteral<false>;
27
+ activation_link: z.ZodString;
28
+ }, z.core.$strip>], "delivered">;
29
+ export type Invitation = z.infer<typeof invitationSchema>;
18
30
  /**
19
31
  * EmailTakenError is thrown when a new user's email is already in use.
20
32
  */
21
33
  export declare class EmailTakenError extends Error {
22
34
  }
23
- /**
24
- * ValidationError is thrown when the backend rejects the submitted user
25
- * details; its message is the backend's human-readable explanation.
26
- */
27
- export declare class ValidationError extends Error {
28
- }
35
+ export { ValidationError } from '../api.js';
29
36
  /**
30
37
  * RoleRefusedError is thrown when the actor holds no role the server admits.
31
38
  */
@@ -66,4 +73,10 @@ export declare function setUserDisabled(id: string, disabled: boolean): Promise<
66
73
  * @param role - The role the account is to hold.
67
74
  */
68
75
  export declare function setUserRole(id: string, role: string): Promise<void>;
69
- export {};
76
+ /**
77
+ * Sends an invitation, answering the same shape whatever the address's
78
+ * state, and holding every transport to the invitation contract.
79
+ * @param input - The email, name, and optional role of the invited account.
80
+ * @returns The delivery report, carrying the link when nothing mailed it.
81
+ */
82
+ export declare function invite(input: NewInvite): Promise<Invitation>;
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  import { __ } from '@wordpress/i18n';
3
3
  import { z } from 'zod';
4
- import { UnauthorizedError } from '../api.js';
4
+ import { RateLimitedError, UnauthorizedError, ValidationError } from '../api.js';
5
5
  import { DOMAIN } from '../domain.js';
6
6
  import { resolveTransport } from '../transport.js';
7
7
  export const usersQueryKey = ['users'];
@@ -13,17 +13,16 @@ const userSchema = z.object({
13
13
  role: z.string().default(''),
14
14
  created_at: z.coerce.date(),
15
15
  });
16
+ const invitationSchema = z.discriminatedUnion('delivered', [
17
+ z.object({ delivered: z.literal(true) }),
18
+ z.object({ delivered: z.literal(false), activation_link: z.string().trim().min(1) }),
19
+ ]);
16
20
  /**
17
21
  * EmailTakenError is thrown when a new user's email is already in use.
18
22
  */
19
23
  export class EmailTakenError extends Error {
20
24
  }
21
- /**
22
- * ValidationError is thrown when the backend rejects the submitted user
23
- * details; its message is the backend's human-readable explanation.
24
- */
25
- export class ValidationError extends Error {
26
- }
25
+ export { ValidationError } from '../api.js';
27
26
  /**
28
27
  * RoleRefusedError is thrown when the actor holds no role the server admits.
29
28
  */
@@ -95,6 +94,32 @@ async function restCreateUser(input) {
95
94
  }
96
95
  return userSchema.parse(await response.json());
97
96
  }
97
+ /**
98
+ * Sends an invitation over REST, answering the same shape whatever the
99
+ * address's state.
100
+ * @param input - The email, name, and optional role of the invited account.
101
+ * @returns The delivery report, carrying the link when nothing mailed it.
102
+ */
103
+ async function restInvite(input) {
104
+ const response = await fetch('/api/users/invite', {
105
+ method: 'POST',
106
+ headers: { 'Content-Type': 'application/json' },
107
+ body: JSON.stringify(input),
108
+ });
109
+ if (response.status === 401) {
110
+ throw new UnauthorizedError('session expired');
111
+ }
112
+ if (response.status === 422) {
113
+ throw new ValidationError(await errorMessage(response, __('invalid invitation details', DOMAIN)));
114
+ }
115
+ if (response.status === 429) {
116
+ throw new RateLimitedError('too many attempts');
117
+ }
118
+ if (!response.ok) {
119
+ throw new Error(`inviting failed with status ${response.status}`);
120
+ }
121
+ return invitationSchema.parse(await response.json());
122
+ }
98
123
  /**
99
124
  * Updates an account's disabled flag over REST.
100
125
  * @param id - The identifier of the user to update.
@@ -130,24 +155,24 @@ async function restSetUserRole(id, role) {
130
155
  if (response.status === 401) {
131
156
  throw new UnauthorizedError('session expired');
132
157
  }
133
- const refusal = await refusalOf(response);
134
- if (refusal.code === 'role_insufficient') {
135
- throw new RoleRefusedError(refusal.message);
158
+ const failed = await errorOf(response);
159
+ if (failed.code === 'role_insufficient') {
160
+ throw new RoleRefusedError(failed.message);
136
161
  }
137
- if (refusal.code === 'self_role_refused') {
138
- throw new SelfRoleError(refusal.message);
162
+ if (failed.code === 'self_role_refused') {
163
+ throw new SelfRoleError(failed.message);
139
164
  }
140
- if (refusal.code === 'last_privileged_refused') {
141
- throw new LastPrivilegedError(refusal.message);
165
+ if (failed.code === 'last_privileged_refused') {
166
+ throw new LastPrivilegedError(failed.message);
142
167
  }
143
168
  throw new Error(`updating role failed with status ${response.status}`);
144
169
  }
145
170
  /**
146
171
  * Returns the code and message a refused response carries.
147
172
  * @param response - The refused HTTP response.
148
- * @returns The refusal code, if any, and its message.
173
+ * @returns The error code, if any, and its message.
149
174
  */
150
- async function refusalOf(response) {
175
+ async function errorOf(response) {
151
176
  try {
152
177
  const held = errorSchema.parse(await response.json());
153
178
  return { code: held.code, message: held.error };
@@ -188,3 +213,12 @@ export async function setUserDisabled(id, disabled) {
188
213
  export async function setUserRole(id, role) {
189
214
  await resolveTransport('setUserRole', restSetUserRole)(id, role);
190
215
  }
216
+ /**
217
+ * Sends an invitation, answering the same shape whatever the address's
218
+ * state, and holding every transport to the invitation contract.
219
+ * @param input - The email, name, and optional role of the invited account.
220
+ * @returns The delivery report, carrying the link when nothing mailed it.
221
+ */
222
+ export async function invite(input) {
223
+ return invitationSchema.parse(await resolveTransport('invite', restInvite)(input));
224
+ }
package/dist/api.d.ts CHANGED
@@ -23,6 +23,18 @@ export declare class UnauthorizedError extends Error {
23
23
  */
24
24
  export declare class RateLimitedError extends Error {
25
25
  }
26
+ /**
27
+ * InvalidTokenError is thrown when an invitation or reset link is spent,
28
+ * expired, or unknown.
29
+ */
30
+ export declare class InvalidTokenError extends Error {
31
+ }
32
+ /**
33
+ * ValidationError is thrown when the backend rejects submitted details;
34
+ * its message is the backend's human-readable explanation.
35
+ */
36
+ export declare class ValidationError extends Error {
37
+ }
26
38
  /**
27
39
  * Returns the logged-in user, or null when no session is active.
28
40
  * @param signal - Aborts the in-flight request.
@@ -40,4 +52,22 @@ export declare function login(email: string, password: string): Promise<User>;
40
52
  * Ends the current session.
41
53
  */
42
54
  export declare function logout(): Promise<void>;
55
+ /**
56
+ * Accepts an invitation, setting the password and starting the session.
57
+ * @param token - The invitation link's secret.
58
+ * @param password - The password the person chose.
59
+ * @returns The signed-in user.
60
+ */
61
+ export declare function acceptInvite(token: string, password: string): Promise<User>;
62
+ /**
63
+ * Asks for a password reset link, answering nothing about the address.
64
+ * @param email - The address the link is mailed to.
65
+ */
66
+ export declare function requestPasswordReset(email: string): Promise<void>;
67
+ /**
68
+ * Replaces the password using a reset link's secret.
69
+ * @param token - The reset link's secret.
70
+ * @param password - The password the person chose.
71
+ */
72
+ export declare function resetPassword(token: string, password: string): Promise<void>;
43
73
  export {};
package/dist/api.js CHANGED
@@ -1,5 +1,7 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
+ import { __ } from '@wordpress/i18n';
2
3
  import { z } from 'zod';
4
+ import { DOMAIN } from './domain.js';
3
5
  import { resolveTransport } from './transport.js';
4
6
  const userSchema = z.object({
5
7
  id: z.string(),
@@ -24,6 +26,52 @@ export class UnauthorizedError extends Error {
24
26
  */
25
27
  export class RateLimitedError extends Error {
26
28
  }
29
+ /**
30
+ * InvalidTokenError is thrown when an invitation or reset link is spent,
31
+ * expired, or unknown.
32
+ */
33
+ export class InvalidTokenError extends Error {
34
+ }
35
+ /**
36
+ * ValidationError is thrown when the backend rejects submitted details;
37
+ * its message is the backend's human-readable explanation.
38
+ */
39
+ export class ValidationError extends Error {
40
+ }
41
+ const refusalSchema = z.object({ error: z.string(), code: z.string().optional() });
42
+ /**
43
+ * Reads the backend's refusal from a failed response body.
44
+ * @param response - The failed HTTP response.
45
+ * @param fallback - The message to use when the body is unreadable.
46
+ * @returns The refusal message and optional code.
47
+ */
48
+ async function refusalOf(response, fallback) {
49
+ try {
50
+ const held = refusalSchema.parse(await response.json());
51
+ return { message: held.error, code: held.code };
52
+ }
53
+ catch {
54
+ return { message: fallback };
55
+ }
56
+ }
57
+ /**
58
+ * Maps a token route refusal onto the error class contract.
59
+ * @param response - The failed HTTP response.
60
+ * @returns The error to throw.
61
+ */
62
+ async function tokenRefusal(response) {
63
+ if (response.status === 429) {
64
+ return new RateLimitedError('too many attempts');
65
+ }
66
+ if (response.status === 422) {
67
+ const refusal = await refusalOf(response, __('invalid password details', DOMAIN));
68
+ if (refusal.code === 'token_invalid') {
69
+ return new InvalidTokenError('the link is no longer valid');
70
+ }
71
+ return new ValidationError(refusal.message);
72
+ }
73
+ return new Error(`the request failed with status ${response.status}`);
74
+ }
27
75
  /**
28
76
  * Loads the session over REST.
29
77
  * @param signal - Aborts the in-flight request.
@@ -62,6 +110,56 @@ async function restLogin(email, password) {
62
110
  }
63
111
  return userSchema.parse(await response.json());
64
112
  }
113
+ /**
114
+ * Accepts an invitation over REST, setting the password and starting the
115
+ * session.
116
+ * @param token - The invitation link's secret.
117
+ * @param password - The password the person chose.
118
+ * @returns The signed-in user.
119
+ */
120
+ async function restAcceptInvite(token, password) {
121
+ const response = await fetch('/api/auth/activate', {
122
+ method: 'POST',
123
+ headers: { 'Content-Type': 'application/json' },
124
+ body: JSON.stringify({ token, password }),
125
+ });
126
+ if (!response.ok) {
127
+ throw await tokenRefusal(response);
128
+ }
129
+ return userSchema.parse(await response.json());
130
+ }
131
+ /**
132
+ * Asks for a password reset link over REST.
133
+ * @param email - The address the link is mailed to.
134
+ */
135
+ async function restRequestPasswordReset(email) {
136
+ const response = await fetch('/api/auth/password-reset', {
137
+ method: 'POST',
138
+ headers: { 'Content-Type': 'application/json' },
139
+ body: JSON.stringify({ email }),
140
+ });
141
+ if (response.status === 429) {
142
+ throw new RateLimitedError('too many attempts');
143
+ }
144
+ if (!response.ok) {
145
+ throw new Error(`the request failed with status ${response.status}`);
146
+ }
147
+ }
148
+ /**
149
+ * Replaces the password over REST using a reset link's secret.
150
+ * @param token - The reset link's secret.
151
+ * @param password - The password the person chose.
152
+ */
153
+ async function restResetPassword(token, password) {
154
+ const response = await fetch('/api/auth/password-reset/confirm', {
155
+ method: 'POST',
156
+ headers: { 'Content-Type': 'application/json' },
157
+ body: JSON.stringify({ token, password }),
158
+ });
159
+ if (!response.ok) {
160
+ throw await tokenRefusal(response);
161
+ }
162
+ }
65
163
  /**
66
164
  * Ends the session over REST.
67
165
  */
@@ -94,3 +192,27 @@ export async function login(email, password) {
94
192
  export async function logout() {
95
193
  await resolveTransport('logout', restLogout)();
96
194
  }
195
+ /**
196
+ * Accepts an invitation, setting the password and starting the session.
197
+ * @param token - The invitation link's secret.
198
+ * @param password - The password the person chose.
199
+ * @returns The signed-in user.
200
+ */
201
+ export async function acceptInvite(token, password) {
202
+ return resolveTransport('acceptInvite', restAcceptInvite)(token, password);
203
+ }
204
+ /**
205
+ * Asks for a password reset link, answering nothing about the address.
206
+ * @param email - The address the link is mailed to.
207
+ */
208
+ export async function requestPasswordReset(email) {
209
+ await resolveTransport('requestPasswordReset', restRequestPasswordReset)(email);
210
+ }
211
+ /**
212
+ * Replaces the password using a reset link's secret.
213
+ * @param token - The reset link's secret.
214
+ * @param password - The password the person chose.
215
+ */
216
+ export async function resetPassword(token, password) {
217
+ await resolveTransport('resetPassword', restResetPassword)(token, password);
218
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { InvalidCredentialsError, RateLimitedError, UnauthorizedError, fetchSession, login, logout, } from './api.js';
1
+ export { InvalidCredentialsError, InvalidTokenError, RateLimitedError, UnauthorizedError, ValidationError, acceptInvite, fetchSession, login, logout, requestPasswordReset, resetPassword, } from './api.js';
2
2
  export type { User } from './api.js';
3
3
  export { AuthGate } from './AuthGate.js';
4
4
  export { DOMAIN } from './domain.js';
@@ -6,6 +6,6 @@ export { catalogFor } from './languages.js';
6
6
  export type { Catalog } from './languages.js';
7
7
  export { isSessionRevoked } from './probe.js';
8
8
  export { createAuthQueryClient } from './queryClient.js';
9
- export { sessionQueryKey, useLogout, useSession } from './session.js';
9
+ export { adoptSession, sessionQueryKey, useLogout, useSession } from './session.js';
10
10
  export { configureAuthTransport, resetAuthTransport } from './transport.js';
11
11
  export type { AuthTransport } from './transport.js';
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
- export { InvalidCredentialsError, RateLimitedError, UnauthorizedError, fetchSession, login, logout, } from './api.js';
2
+ export { InvalidCredentialsError, InvalidTokenError, RateLimitedError, UnauthorizedError, ValidationError, acceptInvite, fetchSession, login, logout, requestPasswordReset, resetPassword, } from './api.js';
3
3
  export { AuthGate } from './AuthGate.js';
4
4
  export { DOMAIN } from './domain.js';
5
5
  export { catalogFor } from './languages.js';
6
6
  export { isSessionRevoked } from './probe.js';
7
7
  export { createAuthQueryClient } from './queryClient.js';
8
- export { sessionQueryKey, useLogout, useSession } from './session.js';
8
+ export { adoptSession, sessionQueryKey, useLogout, useSession } from './session.js';
9
9
  export { configureAuthTransport, resetAuthTransport } from './transport.js';
@@ -1 +1 @@
1
- { "": { "lang": "es-ES", "plural-forms": "nplurals=2; plural=(n != 1);" }, "Actions": ["Acciones"], "Active": ["Activo"], "Create user": ["Crear usuario"], "Disable": ["Desactivar"], "Disable %s": ["Desactivar a %s"], "Disabled": ["Desactivado"], "Enable": ["Activar"], "Enable %s": ["Activar a %s"], "Invalid email or password.": ["Correo o contraseña no válidos."], "Loading users…": ["Cargando usuarios…"], "Loading…": ["Cargando…"], "Log in": ["Iniciar sesión"], "Log out": ["Cerrar sesión"], "Login failed, please try again.": ["No se pudo iniciar sesión, inténtalo de nuevo."], "Logout failed, please try again.": ["No se pudo cerrar sesión, inténtalo de nuevo."], "Password": ["Contraseña"], "Something went wrong.": ["Algo ha ido mal."], "That email is already in use.": ["Ese correo ya está en uso."], "The user could not be created.": ["No se pudo crear el usuario."], "Too many attempts. Please wait a minute and try again.": ["Demasiados intentos. Espera un minuto e inténtalo de nuevo."], "Update failed.": ["No se pudo actualizar."], "Users could not be loaded.": ["No se pudieron cargar los usuarios."], "admin section\u0004Users": ["Usuarios"], "button\u0004New user": ["Nuevo usuario"], "column\u0004Email": ["Correo"], "column\u0004Name": ["Nombre"], "column\u0004Status": ["Estado"], "field label\u0004Email": ["Correo"], "field label\u0004Name": ["Nombre"], "invalid user details": ["datos de usuario no válidos"], "page heading\u0004New user": ["Nuevo usuario"], "page heading\u0004Users": ["Usuarios"], "the account could not be updated": ["no se ha podido actualizar la cuenta"] }
1
+ { "": { "lang": "es-ES", "plural-forms": "nplurals=2; plural=(n != 1);" }, "Actions": ["Acciones"], "Active": ["Activo"], "Back to login": ["Volver al inicio de sesión"], "Disable": ["Desactivar"], "Disable %s": ["Desactivar a %s"], "Disabled": ["Desactivado"], "Done": ["Hecho"], "Enable": ["Activar"], "Enable %s": ["Activar a %s"], "Forgot your password?": ["¿Has olvidado tu contraseña?"], "Go to login": ["Ir al inicio de sesión"], "If that address has an account, a reset link is on its way.": ["Si esa dirección tiene una cuenta, le llegará un enlace para restablecerla."], "Invalid email or password.": ["Correo o contraseña no válidos."], "Loading users…": ["Cargando usuarios…"], "Loading…": ["Cargando…"], "Log in": ["Iniciar sesión"], "Log out": ["Cerrar sesión"], "Login failed, please try again.": ["No se pudo iniciar sesión, inténtalo de nuevo."], "Logout failed, please try again.": ["No se pudo cerrar sesión, inténtalo de nuevo."], "No mail server is configured. Deliver the activation link by hand.": ["No hay servidor de correo configurado. Entrega el enlace de activación a mano."], "Password": ["Contraseña"], "Send invitation": ["Enviar invitación"], "Send reset link": ["Enviar enlace de restablecimiento"], "Set password": ["Guardar contraseña"], "Something went wrong.": ["Algo ha ido mal."], "The invitation could not be sent.": ["No se pudo enviar la invitación."], "The password could not be set, please try again.": ["No se pudo guardar la contraseña, inténtalo de nuevo."], "The request failed, please try again.": ["No se pudo completar la petición, inténtalo de nuevo."], "This link is no longer valid. Ask for a new one.": ["Este enlace ya no es válido. Pide uno nuevo."], "This link is no longer valid. Ask the person who invited you for a new one.": ["Este enlace ya no es válido. Pide uno nuevo a quien te invitó."], "Too many attempts. Please wait a minute and try again.": ["Demasiados intentos. Espera un minuto e inténtalo de nuevo."], "Try again": ["Inténtalo de nuevo"], "Update failed.": ["No se pudo actualizar."], "Users could not be loaded.": ["No se pudieron cargar los usuarios."], "Your password is set. Sign in with it.": ["Tu contraseña está guardada. Inicia sesión con ella."], "admin section\u0004Users": ["Usuarios"], "button\u0004New user": ["Nuevo usuario"], "column\u0004Email": ["Correo"], "column\u0004Name": ["Nombre"], "column\u0004Status": ["Estado"], "field label\u0004Activation link": ["Enlace de activación"], "field label\u0004Email": ["Correo"], "field label\u0004Name": ["Nombre"], "invalid invitation details": ["datos de invitación no válidos"], "invalid password details": ["datos de contraseña no válidos"], "invalid user details": ["datos de usuario no válidos"], "page heading\u0004New user": ["Nuevo usuario"], "page heading\u0004Users": ["Usuarios"], "the account could not be updated": ["no se ha podido actualizar la cuenta"] }
@@ -1 +1 @@
1
- {"":{"lang":"es-ES","plural-forms":"nplurals=2; plural=(n != 1);"},"Actions":["Acciones"],"Active":["Activo"],"Create user":["Crear usuario"],"Disable":["Desactivar"],"Disable %s":["Desactivar a %s"],"Disabled":["Desactivado"],"Enable":["Activar"],"Enable %s":["Activar a %s"],"Invalid email or password.":["Correo o contraseña no válidos."],"Loading users…":["Cargando usuarios…"],"Loading…":["Cargando…"],"Log in":["Iniciar sesión"],"Log out":["Cerrar sesión"],"Login failed, please try again.":["No se pudo iniciar sesión, inténtalo de nuevo."],"Logout failed, please try again.":["No se pudo cerrar sesión, inténtalo de nuevo."],"Password":["Contraseña"],"Something went wrong.":["Algo ha ido mal."],"That email is already in use.":["Ese correo ya está en uso."],"The user could not be created.":["No se pudo crear el usuario."],"Too many attempts. Please wait a minute and try again.":["Demasiados intentos. Espera un minuto e inténtalo de nuevo."],"Update failed.":["No se pudo actualizar."],"Users could not be loaded.":["No se pudieron cargar los usuarios."],"admin section\u0004Users":["Usuarios"],"button\u0004New user":["Nuevo usuario"],"column\u0004Email":["Correo"],"column\u0004Name":["Nombre"],"column\u0004Status":["Estado"],"field label\u0004Email":["Correo"],"field label\u0004Name":["Nombre"],"invalid user details":["datos de usuario no válidos"],"page heading\u0004New user":["Nuevo usuario"],"page heading\u0004Users":["Usuarios"],"the account could not be updated":["no se ha podido actualizar la cuenta"]}
1
+ {"":{"lang":"es-ES","plural-forms":"nplurals=2; plural=(n != 1);"},"Actions":["Acciones"],"Active":["Activo"],"Back to login":["Volver al inicio de sesión"],"Disable":["Desactivar"],"Disable %s":["Desactivar a %s"],"Disabled":["Desactivado"],"Done":["Hecho"],"Enable":["Activar"],"Enable %s":["Activar a %s"],"Forgot your password?":["¿Has olvidado tu contraseña?"],"Go to login":["Ir al inicio de sesión"],"If that address has an account, a reset link is on its way.":["Si esa dirección tiene una cuenta, le llegará un enlace para restablecerla."],"Invalid email or password.":["Correo o contraseña no válidos."],"Loading users…":["Cargando usuarios…"],"Loading…":["Cargando…"],"Log in":["Iniciar sesión"],"Log out":["Cerrar sesión"],"Login failed, please try again.":["No se pudo iniciar sesión, inténtalo de nuevo."],"Logout failed, please try again.":["No se pudo cerrar sesión, inténtalo de nuevo."],"No mail server is configured. Deliver the activation link by hand.":["No hay servidor de correo configurado. Entrega el enlace de activación a mano."],"Password":["Contraseña"],"Send invitation":["Enviar invitación"],"Send reset link":["Enviar enlace de restablecimiento"],"Set password":["Guardar contraseña"],"Something went wrong.":["Algo ha ido mal."],"The invitation could not be sent.":["No se pudo enviar la invitación."],"The password could not be set, please try again.":["No se pudo guardar la contraseña, inténtalo de nuevo."],"The request failed, please try again.":["No se pudo completar la petición, inténtalo de nuevo."],"This link is no longer valid. Ask for a new one.":["Este enlace ya no es válido. Pide uno nuevo."],"This link is no longer valid. Ask the person who invited you for a new one.":["Este enlace ya no es válido. Pide uno nuevo a quien te invitó."],"Too many attempts. Please wait a minute and try again.":["Demasiados intentos. Espera un minuto e inténtalo de nuevo."],"Try again":["Inténtalo de nuevo"],"Update failed.":["No se pudo actualizar."],"Users could not be loaded.":["No se pudieron cargar los usuarios."],"Your password is set. Sign in with it.":["Tu contraseña está guardada. Inicia sesión con ella."],"admin section\u0004Users":["Usuarios"],"button\u0004New user":["Nuevo usuario"],"column\u0004Email":["Correo"],"column\u0004Name":["Nombre"],"column\u0004Status":["Estado"],"field label\u0004Activation link":["Enlace de activación"],"field label\u0004Email":["Correo"],"field label\u0004Name":["Nombre"],"invalid invitation details":["datos de invitación no válidos"],"invalid password details":["datos de contraseña no válidos"],"invalid user details":["datos de usuario no válidos"],"page heading\u0004New user":["Nuevo usuario"],"page heading\u0004Users":["Usuarios"],"the account could not be updated":["no se ha podido actualizar la cuenta"]}
package/dist/session.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { QueryClient } from '@tanstack/react-query';
2
+ import type { User } from './api.js';
1
3
  /**
2
4
  * sessionQueryKey is the react-query key the login session is cached under.
3
5
  */
@@ -18,3 +20,10 @@ export declare function useSession(): import("@tanstack/react-query").UseQueryRe
18
20
  * @returns The logout mutation.
19
21
  */
20
22
  export declare function useLogout(): import("@tanstack/react-query").UseMutationResult<void, Error, void, unknown>;
23
+ /**
24
+ * Installs a freshly authenticated user as the cached session, cancelling
25
+ * any in-flight fetch and dropping what the previous account left cached.
26
+ * @param client - The query client holding the session.
27
+ * @param user - The authenticated user to install.
28
+ */
29
+ export declare function adoptSession(client: QueryClient, user: User): Promise<void>;
package/dist/session.js CHANGED
@@ -33,3 +33,16 @@ export function useLogout() {
33
33
  },
34
34
  });
35
35
  }
36
+ /**
37
+ * Installs a freshly authenticated user as the cached session, cancelling
38
+ * any in-flight fetch and dropping what the previous account left cached.
39
+ * @param client - The query client holding the session.
40
+ * @param user - The authenticated user to install.
41
+ */
42
+ export async function adoptSession(client, user) {
43
+ await client.cancelQueries();
44
+ client.setQueryData(sessionQueryKey, user);
45
+ client.removeQueries({
46
+ predicate: (query) => query.queryHash !== hashKey(sessionQueryKey),
47
+ });
48
+ }
@@ -17,6 +17,53 @@ export declare const defaultUser: User;
17
17
  * @returns The account.
18
18
  */
19
19
  export declare function userWithRole(role: string): User;
20
+ /**
21
+ * Answers an invitation as mailed.
22
+ * @returns The msw handler.
23
+ */
24
+ export declare function inviteDelivered(): import("msw").HttpHandler;
25
+ /**
26
+ * Answers an invitation with the by-hand activation link.
27
+ * @param link - The activation link the admin delivers by hand.
28
+ * @returns The msw handler.
29
+ */
30
+ export declare function inviteUndelivered(link: string): import("msw").HttpHandler;
31
+ /**
32
+ * Fails the invitation endpoint with a server error.
33
+ * @returns The msw handler.
34
+ */
35
+ export declare function inviteFailure(): import("msw").HttpHandler;
36
+ /**
37
+ * Accepts an activation with the given user.
38
+ * @param user - The user the activation signs in.
39
+ * @returns The msw handler.
40
+ */
41
+ export declare function activateOk(user?: User): import("msw").HttpHandler;
42
+ /**
43
+ * Refuses an activation for a dead link.
44
+ * @returns The msw handler.
45
+ */
46
+ export declare function activateInvalidToken(): import("msw").HttpHandler;
47
+ /**
48
+ * Accepts a password reset request with the neutral answer.
49
+ * @returns The msw handler.
50
+ */
51
+ export declare function resetRequestOk(): import("msw").HttpHandler;
52
+ /**
53
+ * Refuses a password reset request for too many attempts.
54
+ * @returns The msw handler.
55
+ */
56
+ export declare function resetRequestRateLimited(): import("msw").HttpHandler;
57
+ /**
58
+ * Accepts a password reset confirmation.
59
+ * @returns The msw handler.
60
+ */
61
+ export declare function resetOk(): import("msw").HttpHandler;
62
+ /**
63
+ * Refuses a password reset confirmation for a dead link.
64
+ * @returns The msw handler.
65
+ */
66
+ export declare function resetInvalidToken(): import("msw").HttpHandler;
20
67
  /**
21
68
  * Accepts a role change.
22
69
  * @returns The msw handler.
@@ -46,6 +46,71 @@ export function userWithRole(role) {
46
46
  role,
47
47
  };
48
48
  }
49
+ /**
50
+ * Answers an invitation as mailed.
51
+ * @returns The msw handler.
52
+ */
53
+ export function inviteDelivered() {
54
+ return http.post('/api/users/invite', () => HttpResponse.json({ delivered: true }));
55
+ }
56
+ /**
57
+ * Answers an invitation with the by-hand activation link.
58
+ * @param link - The activation link the admin delivers by hand.
59
+ * @returns The msw handler.
60
+ */
61
+ export function inviteUndelivered(link) {
62
+ return http.post('/api/users/invite', () => HttpResponse.json({ delivered: false, activation_link: link }));
63
+ }
64
+ /**
65
+ * Fails the invitation endpoint with a server error.
66
+ * @returns The msw handler.
67
+ */
68
+ export function inviteFailure() {
69
+ return http.post('/api/users/invite', () => HttpResponse.json({ error: 'internal error' }, { status: 500 }));
70
+ }
71
+ /**
72
+ * Accepts an activation with the given user.
73
+ * @param user - The user the activation signs in.
74
+ * @returns The msw handler.
75
+ */
76
+ export function activateOk(user = defaultUser) {
77
+ return http.post('/api/auth/activate', () => HttpResponse.json(user));
78
+ }
79
+ /**
80
+ * Refuses an activation for a dead link.
81
+ * @returns The msw handler.
82
+ */
83
+ export function activateInvalidToken() {
84
+ return http.post('/api/auth/activate', () => HttpResponse.json({ error: 'token not found', code: 'token_invalid' }, { status: 422 }));
85
+ }
86
+ /**
87
+ * Accepts a password reset request with the neutral answer.
88
+ * @returns The msw handler.
89
+ */
90
+ export function resetRequestOk() {
91
+ return http.post('/api/auth/password-reset', () => new HttpResponse(null, { status: 204 }));
92
+ }
93
+ /**
94
+ * Refuses a password reset request for too many attempts.
95
+ * @returns The msw handler.
96
+ */
97
+ export function resetRequestRateLimited() {
98
+ return http.post('/api/auth/password-reset', () => HttpResponse.json({ error: 'slow down' }, { status: 429 }));
99
+ }
100
+ /**
101
+ * Accepts a password reset confirmation.
102
+ * @returns The msw handler.
103
+ */
104
+ export function resetOk() {
105
+ return http.post('/api/auth/password-reset/confirm', () => new HttpResponse(null, { status: 204 }));
106
+ }
107
+ /**
108
+ * Refuses a password reset confirmation for a dead link.
109
+ * @returns The msw handler.
110
+ */
111
+ export function resetInvalidToken() {
112
+ return http.post('/api/auth/password-reset/confirm', () => HttpResponse.json({ error: 'token not found', code: 'token_invalid' }, { status: 422 }));
113
+ }
49
114
  /**
50
115
  * Accepts a role change.
51
116
  * @returns The msw handler.
@@ -1,4 +1,4 @@
1
- import type { User as Account, NewUser } from './admin/index.js';
1
+ import type { Invitation, NewInvite, User as Account, NewUser } from './admin/index.js';
2
2
  import type { User } from './api.js';
3
3
  /**
4
4
  * AuthTransport carries every backend call of the package, so a consumer
@@ -15,6 +15,10 @@ export interface AuthTransport {
15
15
  createUser(input: NewUser): Promise<Account>;
16
16
  setUserDisabled(id: string, disabled: boolean): Promise<void>;
17
17
  setUserRole(id: string, role: string): Promise<void>;
18
+ invite(input: NewInvite): Promise<Invitation>;
19
+ acceptInvite(token: string, password: string): Promise<User>;
20
+ requestPasswordReset(email: string): Promise<void>;
21
+ resetPassword(token: string, password: string): Promise<void>;
18
22
  }
19
23
  /**
20
24
  * Replaces backend calls with the given implementations, keeping the REST
@@ -5,7 +5,8 @@ import type { User } from '../api.js';
5
5
  * @param onLogin - Called with the user after a successful login.
6
6
  * @returns The login screen element.
7
7
  */
8
- export declare function LoginScreen({ brand, onLogin, }: {
8
+ export declare function LoginScreen({ brand, onLogin, onForgotPassword, }: {
9
9
  brand: string;
10
10
  onLogin: (user: User) => void | Promise<void>;
11
+ onForgotPassword?: () => void;
11
12
  }): import("react").JSX.Element;
@@ -26,7 +26,7 @@ function loginErrorMessage(error) {
26
26
  * @param onLogin - Called with the user after a successful login.
27
27
  * @returns The login screen element.
28
28
  */
29
- export function LoginScreen({ brand, onLogin, }) {
29
+ export function LoginScreen({ brand, onLogin, onForgotPassword, }) {
30
30
  const [email, setEmail] = useState('');
31
31
  const [password, setPassword] = useState('');
32
32
  const attempt = useMutation({
@@ -36,5 +36,5 @@ export function LoginScreen({ brand, onLogin, }) {
36
36
  return (_jsx("div", { className: "gopherium-login", children: _jsx(Card.Root, { className: "gopherium-login__card", children: _jsx(Card.Content, { children: _jsx("form", { onSubmit: (event) => {
37
37
  event.preventDefault();
38
38
  attempt.mutate();
39
- }, children: _jsxs(Stack, { direction: "column", gap: "lg", children: [_jsx(Text, { variant: "heading-lg", render: _jsx("h1", {}), children: brand }), _jsx(InputControl, { label: _x('Email', 'field label', DOMAIN), type: "email", value: email, onChange: (event) => setEmail(event.target.value) }), _jsx(InputControl, { label: __('Password', DOMAIN), type: "password", value: password, onChange: (event) => setPassword(event.target.value) }), _jsx(Button, { type: "submit", disabled: email.trim() === '' || password === '' || attempt.isPending, loading: attempt.isPending, children: __('Log in', DOMAIN) }), attempt.isError ? (_jsx(Text, { role: "alert", children: loginErrorMessage(attempt.error) })) : null] }) }) }) }) }));
39
+ }, children: _jsxs(Stack, { direction: "column", gap: "lg", children: [_jsx(Text, { variant: "heading-lg", render: _jsx("h1", {}), children: brand }), _jsx(InputControl, { label: _x('Email', 'field label', DOMAIN), type: "email", autoComplete: "username", value: email, onChange: (event) => setEmail(event.target.value) }), _jsx(InputControl, { label: __('Password', DOMAIN), type: "password", autoComplete: "current-password", value: password, onChange: (event) => setPassword(event.target.value) }), _jsx(Button, { type: "submit", disabled: email.trim() === '' || password === '' || attempt.isPending, loading: attempt.isPending, children: __('Log in', DOMAIN) }), attempt.isError ? (_jsx(Text, { role: "alert", children: loginErrorMessage(attempt.error) })) : null, onForgotPassword ? (_jsx(Button, { type: "button", variant: "outline", onClick: onForgotPassword, children: __('Forgot your password?', DOMAIN) })) : null] }) }) }) }) }));
40
40
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Renders the new user form, creating an account and reporting success
3
- * upward, typically for the app to navigate back to its user list.
4
- * @param onCreated - Called after the account is created.
2
+ * Renders the new user form, sending an invitation and reporting success
3
+ * upward, or showing the activation link when no mail server delivered it.
4
+ * @param onCreated - Called after the invitation is handled.
5
5
  * @returns The new user screen element.
6
6
  */
7
7
  export declare function NewUserScreen({ onCreated, }: {
@@ -4,46 +4,67 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
4
4
  import { __, _x } from '@wordpress/i18n';
5
5
  import { Button, InputControl, Stack, Text } from '@wordpress/ui';
6
6
  import { useState } from 'react';
7
- import { EmailTakenError, ValidationError, createUser, usersQueryKey } from '../admin/index.js';
7
+ import { ValidationError, invite, usersQueryKey } from '../admin/index.js';
8
8
  import { DOMAIN } from '../domain.js';
9
+ import { Outcome } from './Outcome.js';
9
10
  /**
10
- * Maps a creation failure to the message shown under the form, surfacing
11
- * the backend's explanation for rejected input.
12
- * @param error - The error thrown by the create mutation.
13
- * @returns The human-readable failure message.
11
+ * Maps the screen's failures to the message shown beside the form,
12
+ * surfacing the backend's explanation for rejected input.
13
+ * @param invited - The error the invitation raised, if any.
14
+ * @param handedOff - The error handing the invitation onward raised, if any.
15
+ * @returns The message to display, or null when nothing failed.
14
16
  */
15
- function createErrorMessage(error) {
16
- if (error instanceof EmailTakenError) {
17
- return __('That email is already in use.', DOMAIN);
17
+ function failureMessage(invited, handedOff) {
18
+ if (invited instanceof ValidationError) {
19
+ return invited.message;
18
20
  }
19
- if (error instanceof ValidationError) {
20
- return error.message;
21
+ if (invited) {
22
+ return __('The invitation could not be sent.', DOMAIN);
21
23
  }
22
- return __('The user could not be created.', DOMAIN);
24
+ if (handedOff) {
25
+ return __('Something went wrong.', DOMAIN);
26
+ }
27
+ return null;
23
28
  }
24
29
  /**
25
- * Renders the new user form, creating an account and reporting success
26
- * upward, typically for the app to navigate back to its user list.
27
- * @param onCreated - Called after the account is created.
30
+ * Renders the new user form, sending an invitation and reporting success
31
+ * upward, or showing the activation link when no mail server delivered it.
32
+ * @param onCreated - Called after the invitation is handled.
28
33
  * @returns The new user screen element.
29
34
  */
30
35
  export function NewUserScreen({ onCreated, }) {
31
36
  const queryClient = useQueryClient();
32
37
  const [email, setEmail] = useState('');
33
38
  const [name, setName] = useState('');
34
- const [password, setPassword] = useState('');
39
+ const [activationLink, setActivationLink] = useState(null);
40
+ const finish = useMutation({
41
+ mutationFn: async () => {
42
+ await onCreated?.();
43
+ },
44
+ });
35
45
  const create = useMutation({
36
- mutationFn: () => createUser({ email: email.trim(), name: name.trim(), password }),
37
- onSuccess: async () => {
46
+ mutationFn: () => invite({ email: email.trim(), name: name.trim() }),
47
+ onMutate: () => {
48
+ finish.reset();
49
+ },
50
+ onSuccess: async (invitation) => {
38
51
  await queryClient.invalidateQueries({ queryKey: usersQueryKey });
39
- await onCreated?.();
52
+ if (invitation.delivered) {
53
+ finish.mutate();
54
+ return;
55
+ }
56
+ setActivationLink(invitation.activation_link);
40
57
  },
41
58
  });
59
+ const failure = failureMessage(create.error, finish.error);
60
+ if (activationLink !== null) {
61
+ return (_jsxs(Stack, { direction: "column", gap: "lg", children: [_jsx(Text, { variant: "heading-lg", render: _jsx("h1", {}), children: _x('New user', 'page heading', DOMAIN) }), _jsx(Outcome, { children: _jsx(Text, { children: __('No mail server is configured. Deliver the activation link by hand.', DOMAIN) }) }), _jsx(InputControl, { label: _x('Activation link', 'field label', DOMAIN), readOnly: true, value: activationLink }), _jsx(Button, { type: "button", disabled: finish.isPending, loading: finish.isPending, onClick: () => finish.mutate(), children: __('Done', DOMAIN) }), failure ? _jsx(Text, { role: "alert", children: failure }) : null] }));
62
+ }
42
63
  return (_jsxs(Stack, { direction: "column", gap: "lg", children: [_jsx(Text, { variant: "heading-lg", render: _jsx("h1", {}), children: _x('New user', 'page heading', DOMAIN) }), _jsx("form", { onSubmit: (event) => {
43
64
  event.preventDefault();
44
65
  create.mutate();
45
- }, children: _jsxs(Stack, { direction: "column", gap: "md", children: [_jsx(InputControl, { label: _x('Email', 'field label', DOMAIN), type: "email", autoComplete: "off", value: email, onChange: (event) => setEmail(event.target.value) }), _jsx(InputControl, { label: _x('Name', 'field label', DOMAIN), autoComplete: "off", value: name, onChange: (event) => setName(event.target.value) }), _jsx(InputControl, { label: __('Password', DOMAIN), type: "password", autoComplete: "new-password", value: password, onChange: (event) => setPassword(event.target.value) }), _jsx(Button, { type: "submit", disabled: email.trim() === '' ||
66
+ }, children: _jsxs(Stack, { direction: "column", gap: "md", children: [_jsx(InputControl, { label: _x('Email', 'field label', DOMAIN), type: "email", autoComplete: "off", value: email, onChange: (event) => setEmail(event.target.value) }), _jsx(InputControl, { label: _x('Name', 'field label', DOMAIN), autoComplete: "off", value: name, onChange: (event) => setName(event.target.value) }), _jsx(Button, { type: "submit", disabled: email.trim() === '' ||
46
67
  name.trim() === '' ||
47
- password === '' ||
48
- create.isPending, children: __('Create user', DOMAIN) }), create.isError ? (_jsx(Text, { role: "alert", children: createErrorMessage(create.error) })) : null] }) })] }));
68
+ create.isPending ||
69
+ finish.isPending, loading: create.isPending || finish.isPending, children: __('Send invitation', DOMAIN) }), failure ? _jsx(Text, { role: "alert", children: failure }) : null] }) })] }));
49
70
  }
@@ -0,0 +1,9 @@
1
+ import type { ReactNode } from 'react';
2
+ /**
3
+ * Announces an in-screen outcome, taking focus so it is read aloud.
4
+ * @param children - The outcome to announce.
5
+ * @returns The focused status element.
6
+ */
7
+ export declare function Outcome({ children }: {
8
+ children: ReactNode;
9
+ }): import("react").JSX.Element;
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { useEffect, useRef } from 'react';
4
+ /**
5
+ * Announces an in-screen outcome, taking focus so it is read aloud.
6
+ * @param children - The outcome to announce.
7
+ * @returns The focused status element.
8
+ */
9
+ export function Outcome({ children }) {
10
+ const held = useRef(null);
11
+ useEffect(() => {
12
+ held.current?.focus();
13
+ }, []);
14
+ return (_jsx("div", { role: "status", tabIndex: -1, ref: held, children: children }));
15
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Renders the forgotten password form, answering the same sentence
3
+ * whatever the address.
4
+ * @param brand - The product name shown above the form.
5
+ * @param onBack - Called when the user heads back to the login.
6
+ * @returns The request reset screen element.
7
+ */
8
+ export declare function RequestResetScreen({ brand, onBack, }: {
9
+ brand: string;
10
+ onBack?: () => void;
11
+ }): import("react").JSX.Element;
@@ -0,0 +1,37 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { useMutation } from '@tanstack/react-query';
4
+ import { __, _x } from '@wordpress/i18n';
5
+ import { Button, Card, InputControl, Stack, Text } from '@wordpress/ui';
6
+ import { useState } from 'react';
7
+ import { RateLimitedError, requestPasswordReset } from '../api.js';
8
+ import { DOMAIN } from '../domain.js';
9
+ import { Outcome } from './Outcome.js';
10
+ /**
11
+ * Maps a reset request failure to the message shown to the user.
12
+ * @param error - The error thrown by the request attempt.
13
+ * @returns The message to display.
14
+ */
15
+ function requestErrorMessage(error) {
16
+ if (error instanceof RateLimitedError) {
17
+ return __('Too many attempts. Please wait a minute and try again.', DOMAIN);
18
+ }
19
+ return __('The request failed, please try again.', DOMAIN);
20
+ }
21
+ /**
22
+ * Renders the forgotten password form, answering the same sentence
23
+ * whatever the address.
24
+ * @param brand - The product name shown above the form.
25
+ * @param onBack - Called when the user heads back to the login.
26
+ * @returns The request reset screen element.
27
+ */
28
+ export function RequestResetScreen({ brand, onBack, }) {
29
+ const [email, setEmail] = useState('');
30
+ const attempt = useMutation({
31
+ mutationFn: () => requestPasswordReset(email.trim()),
32
+ });
33
+ return (_jsx("div", { className: "gopherium-login", children: _jsx(Card.Root, { className: "gopherium-login__card", children: _jsx(Card.Content, { children: _jsxs(Stack, { direction: "column", gap: "lg", children: [_jsx(Text, { variant: "heading-lg", render: _jsx("h1", {}), children: brand }), attempt.isSuccess ? (_jsx(Outcome, { children: _jsx(Text, { children: __('If that address has an account, a reset link is on its way.', DOMAIN) }) })) : (_jsx("form", { onSubmit: (event) => {
34
+ event.preventDefault();
35
+ attempt.mutate();
36
+ }, children: _jsxs(Stack, { direction: "column", gap: "md", children: [_jsx(InputControl, { label: _x('Email', 'field label', DOMAIN), type: "email", autoComplete: "email", value: email, onChange: (event) => setEmail(event.target.value) }), _jsx(Button, { type: "submit", disabled: email.trim() === '' || attempt.isPending, loading: attempt.isPending, children: __('Send reset link', DOMAIN) }), attempt.isError ? (_jsx(Text, { role: "alert", children: requestErrorMessage(attempt.error) })) : null] }) })), onBack ? (_jsx(Button, { type: "button", variant: "outline", onClick: onBack, children: __('Back to login', DOMAIN) })) : null] }) }) }) }));
37
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Renders the password reset form, replacing it with the outcome once the
3
+ * password is set.
4
+ * @param brand - The product name shown above the form.
5
+ * @param token - The reset link's secret.
6
+ * @param onDone - Called when the user heads to the login afterward.
7
+ * @returns The reset password screen element.
8
+ */
9
+ export declare function ResetPasswordScreen({ brand, token, onDone, }: {
10
+ brand: string;
11
+ token: string;
12
+ onDone?: () => void;
13
+ }): import("react").JSX.Element;
@@ -0,0 +1,44 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { useMutation } from '@tanstack/react-query';
4
+ import { __ } from '@wordpress/i18n';
5
+ import { Button, Card, InputControl, Stack, Text } from '@wordpress/ui';
6
+ import { useState } from 'react';
7
+ import { InvalidTokenError, RateLimitedError, ValidationError, resetPassword, } from '../api.js';
8
+ import { DOMAIN } from '../domain.js';
9
+ import { Outcome } from './Outcome.js';
10
+ /**
11
+ * Maps a reset failure to the message shown to the user.
12
+ * @param error - The error thrown by the reset attempt.
13
+ * @returns The message to display.
14
+ */
15
+ function resetErrorMessage(error) {
16
+ if (error instanceof InvalidTokenError) {
17
+ return __('This link is no longer valid. Ask for a new one.', DOMAIN);
18
+ }
19
+ if (error instanceof ValidationError) {
20
+ return error.message;
21
+ }
22
+ if (error instanceof RateLimitedError) {
23
+ return __('Too many attempts. Please wait a minute and try again.', DOMAIN);
24
+ }
25
+ return __('The password could not be set, please try again.', DOMAIN);
26
+ }
27
+ /**
28
+ * Renders the password reset form, replacing it with the outcome once the
29
+ * password is set.
30
+ * @param brand - The product name shown above the form.
31
+ * @param token - The reset link's secret.
32
+ * @param onDone - Called when the user heads to the login afterward.
33
+ * @returns The reset password screen element.
34
+ */
35
+ export function ResetPasswordScreen({ brand, token, onDone, }) {
36
+ const [password, setPassword] = useState('');
37
+ const attempt = useMutation({
38
+ mutationFn: () => resetPassword(token, password),
39
+ });
40
+ return (_jsx("div", { className: "gopherium-login", children: _jsx(Card.Root, { className: "gopherium-login__card", children: _jsx(Card.Content, { children: _jsxs(Stack, { direction: "column", gap: "lg", children: [_jsx(Text, { variant: "heading-lg", render: _jsx("h1", {}), children: brand }), attempt.isSuccess ? (_jsxs(Stack, { direction: "column", gap: "md", children: [_jsx(Outcome, { children: _jsx(Text, { children: __('Your password is set. Sign in with it.', DOMAIN) }) }), onDone ? (_jsx(Button, { type: "button", onClick: onDone, children: __('Go to login', DOMAIN) })) : null] })) : (_jsx("form", { onSubmit: (event) => {
41
+ event.preventDefault();
42
+ attempt.mutate();
43
+ }, children: _jsxs(Stack, { direction: "column", gap: "md", children: [_jsx(InputControl, { label: __('Password', DOMAIN), type: "password", autoComplete: "new-password", value: password, onChange: (event) => setPassword(event.target.value) }), _jsx(Button, { type: "submit", disabled: password === '' || attempt.isPending, loading: attempt.isPending, children: __('Set password', DOMAIN) }), attempt.isError ? (_jsx(Text, { role: "alert", children: resetErrorMessage(attempt.error) })) : null] }) }))] }) }) }) }));
44
+ }
@@ -0,0 +1,14 @@
1
+ import type { User } from '../api.js';
2
+ /**
3
+ * Renders the invitation acceptance form and reports the signed-in user
4
+ * upward.
5
+ * @param brand - The product name shown above the form.
6
+ * @param token - The invitation link's secret.
7
+ * @param onAccepted - Called with the user after a successful activation.
8
+ * @returns The set password screen element.
9
+ */
10
+ export declare function SetPasswordScreen({ brand, token, onAccepted, }: {
11
+ brand: string;
12
+ token: string;
13
+ onAccepted: (user: User) => void | Promise<void>;
14
+ }): import("react").JSX.Element;
@@ -0,0 +1,62 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { useMutation } from '@tanstack/react-query';
4
+ import { __ } from '@wordpress/i18n';
5
+ import { Button, Card, InputControl, Stack, Text } from '@wordpress/ui';
6
+ import { useState } from 'react';
7
+ import { InvalidTokenError, RateLimitedError, ValidationError, acceptInvite, } from '../api.js';
8
+ import { DOMAIN } from '../domain.js';
9
+ /**
10
+ * Maps an activation failure to the message shown to the user.
11
+ * @param error - The error thrown by the activation attempt.
12
+ * @returns The message to display.
13
+ */
14
+ function setPasswordErrorMessage(error) {
15
+ if (error instanceof InvalidTokenError) {
16
+ return __('This link is no longer valid. Ask the person who invited you for a new one.', DOMAIN);
17
+ }
18
+ if (error instanceof ValidationError) {
19
+ return error.message;
20
+ }
21
+ if (error instanceof RateLimitedError) {
22
+ return __('Too many attempts. Please wait a minute and try again.', DOMAIN);
23
+ }
24
+ return __('The password could not be set, please try again.', DOMAIN);
25
+ }
26
+ /**
27
+ * Renders the invitation acceptance form and reports the signed-in user
28
+ * upward.
29
+ * @param brand - The product name shown above the form.
30
+ * @param token - The invitation link's secret.
31
+ * @param onAccepted - Called with the user after a successful activation.
32
+ * @returns The set password screen element.
33
+ */
34
+ export function SetPasswordScreen({ brand, token, onAccepted, }) {
35
+ const [password, setPassword] = useState('');
36
+ const [activated, setActivated] = useState(null);
37
+ const [handoffFailed, setHandoffFailed] = useState(false);
38
+ const finish = useMutation({
39
+ mutationFn: async (user) => {
40
+ await onAccepted(user);
41
+ },
42
+ onError: () => setHandoffFailed(true),
43
+ onSuccess: () => setHandoffFailed(false),
44
+ });
45
+ const attempt = useMutation({
46
+ mutationFn: () => acceptInvite(token, password),
47
+ onSuccess: (user) => {
48
+ setActivated(user);
49
+ finish.mutate(user);
50
+ },
51
+ });
52
+ if (activated !== null && handoffFailed) {
53
+ return (_jsx("div", { className: "gopherium-login", children: _jsx(Card.Root, { className: "gopherium-login__card", children: _jsx(Card.Content, { children: _jsxs(Stack, { direction: "column", gap: "lg", children: [_jsx(Text, { variant: "heading-lg", render: _jsx("h1", {}), children: brand }), _jsx(Text, { role: "alert", children: __('Something went wrong.', DOMAIN) }), _jsx(Button, { type: "button", disabled: finish.isPending, loading: finish.isPending, onClick: () => finish.mutate(activated), children: __('Try again', DOMAIN) })] }) }) }) }));
54
+ }
55
+ return (_jsx("div", { className: "gopherium-login", children: _jsx(Card.Root, { className: "gopherium-login__card", children: _jsx(Card.Content, { children: _jsx("form", { onSubmit: (event) => {
56
+ event.preventDefault();
57
+ attempt.mutate();
58
+ }, children: _jsxs(Stack, { direction: "column", gap: "lg", children: [_jsx(Text, { variant: "heading-lg", render: _jsx("h1", {}), children: brand }), _jsx(InputControl, { label: __('Password', DOMAIN), type: "password", autoComplete: "new-password", value: password, onChange: (event) => setPassword(event.target.value) }), _jsx(Button, { type: "submit", disabled: password === '' ||
59
+ attempt.isPending ||
60
+ attempt.isSuccess ||
61
+ finish.isPending, loading: attempt.isPending || finish.isPending, children: __('Set password', DOMAIN) }), attempt.isError ? (_jsx(Text, { role: "alert", children: setPasswordErrorMessage(attempt.error) })) : null] }) }) }) }) }));
62
+ }
@@ -2,4 +2,7 @@ export { AccountPanel } from './AccountPanel.js';
2
2
  export { LoginScreen } from './LoginScreen.js';
3
3
  export { usersNavItem } from './nav.js';
4
4
  export { NewUserScreen } from './NewUserScreen.js';
5
+ export { RequestResetScreen } from './RequestResetScreen.js';
6
+ export { ResetPasswordScreen } from './ResetPasswordScreen.js';
7
+ export { SetPasswordScreen } from './SetPasswordScreen.js';
5
8
  export { UsersScreen } from './UsersScreen.js';
@@ -3,4 +3,7 @@ export { AccountPanel } from './AccountPanel.js';
3
3
  export { LoginScreen } from './LoginScreen.js';
4
4
  export { usersNavItem } from './nav.js';
5
5
  export { NewUserScreen } from './NewUserScreen.js';
6
+ export { RequestResetScreen } from './RequestResetScreen.js';
7
+ export { ResetPasswordScreen } from './ResetPasswordScreen.js';
8
+ export { SetPasswordScreen } from './SetPasswordScreen.js';
6
9
  export { UsersScreen } from './UsersScreen.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gopherium/react-auth",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Session authentication for React apps backed by gouncer's authkit.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "peerDependencies": {
50
50
  "@tanstack/react-query": "^5.101.2",
51
- "@testing-library/jest-dom": "^6.9.1",
51
+ "@testing-library/jest-dom": "^6.9.1 || ^7.0.0",
52
52
  "@testing-library/react": "^16.3.2",
53
53
  "@wordpress/i18n": ">=6.26.0 <7.0.0",
54
54
  "@wordpress/theme": ">=1.0.0 <2.0.0",
@@ -79,7 +79,7 @@
79
79
  },
80
80
  "devDependencies": {
81
81
  "@tanstack/react-query": "^5.101.2",
82
- "@testing-library/jest-dom": "^6.9.1",
82
+ "@testing-library/jest-dom": "^7.0.0",
83
83
  "@testing-library/react": "^16.3.2",
84
84
  "@testing-library/user-event": "^14.6.1",
85
85
  "@types/gettext-parser": "9.0.0",