watchword_yew023/lib.rs
1//! [<img alt="github" src="https://img.shields.io/badge/github-udoprog/watchword-8da0cb?style=for-the-badge&logo=github" height="20">](https://github.com/udoprog/watchword)
2//! [<img alt="crates.io" src="https://img.shields.io/crates/v/watchword-yew023.svg?style=for-the-badge&color=fc8d62&logo=rust" height="20">](https://crates.io/crates/watchword-yew023)
3//! [<img alt="docs.rs" src="https://img.shields.io/badge/docs.rs-watchword--yew023-66c2a5?style=for-the-badge&logoColor=white&logo=data:image/svg+xml;base64,PHN2ZyByb2xlPSJpbWciIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDUxMiA1MTIiPjxwYXRoIGZpbGw9IiNmNWY1ZjUiIGQ9Ik00ODguNiAyNTAuMkwzOTIgMjE0VjEwNS41YzAtMTUtOS4zLTI4LjQtMjMuNC0zMy43bC0xMDAtMzcuNWMtOC4xLTMuMS0xNy4xLTMuMS0yNS4zIDBsLTEwMCAzNy41Yy0xNC4xIDUuMy0yMy40IDE4LjctMjMuNCAzMy43VjIxNGwtOTYuNiAzNi4yQzkuMyAyNTUuNSAwIDI2OC45IDAgMjgzLjlWMzk0YzAgMTMuNiA3LjcgMjYuMSAxOS45IDMyLjJsMTAwIDUwYzEwLjEgNS4xIDIyLjEgNS4xIDMyLjIgMGwxMDMuOS01MiAxMDMuOSA1MmMxMC4xIDUuMSAyMi4xIDUuMSAzMi4yIDBsMTAwLTUwYzEyLjItNi4xIDE5LjktMTguNiAxOS45LTMyLjJWMjgzLjljMC0xNS05LjMtMjguNC0yMy40LTMzLjd6TTM1OCAyMTQuOGwtODUgMzEuOXYtNjguMmw4NS0zN3Y3My4zek0xNTQgMTA0LjFsMTAyLTM4LjIgMTAyIDM4LjJ2LjZsLTEwMiA0MS40LTEwMi00MS40di0uNnptODQgMjkxLjFsLTg1IDQyLjV2LTc5LjFsODUtMzguOHY3NS40em0wLTExMmwtMTAyIDQxLjQtMTAyLTQxLjR2LS42bDEwMi0zOC4yIDEwMiAzOC4ydi42em0yNDAgMTEybC04NSA0Mi41di03OS4xbDg1LTM4Ljh2NzUuNHptMC0xMTJsLTEwMiA0MS40LTEwMi00MS40di0uNmwxMDItMzguMiAxMDIgMzguMnYuNnoiPjwvcGF0aD48L3N2Zz4K" height="20">](https://docs.rs/watchword-yew023)
4//!
5//! [watchword]'s frontend for [Yew] 0.23: the sign-in, registration, account
6//! and user management components, the auth context and hook, and the
7//! service the components and the app make their requests with, over HTTP,
8//! the app's socket, or a transport of the app's. Built for
9//! `wasm32-unknown-unknown`.
10//!
11//! Apps take it through watchword's `yew023` feature, as `watchword::yew023`,
12//! so a frontend needs only one dependency. A frontend that talks to its
13//! server over a [musli-web] 0.5 WebSocket also takes `musliweb05`, so the
14//! components use that socket (see [below](#with-a-musli-web-socket)):
15//!
16//! ```toml
17//! [dependencies]
18//! watchword = { version = "0.3", features = ["yew023", "musliweb05"] }
19//! ```
20//!
21//! It is a crate of its own because yew's macros expand to `::yew::` paths, so
22//! its code must depend on yew under the key `yew`. A later yew gets a crate of
23//! its own beside it, so an app moves to it when it chooses. Released in
24//! lockstep with watchword.
25//!
26//! <br>
27//!
28//! ## Features
29//!
30//! yew needs the standard library, so everything here is behind `std`, which
31//! is on by default. The crate is `no_std` only so its features line up with
32//! watchword's.
33//!
34//! | Feature | What it provides |
35//! |---|---|
36//! | `std` *(default)* | everything here |
37//! | `alloc` | nothing yet beyond watchword-core's `alloc` |
38//! | `musliweb05` | `std` + [`AuthContext::set_socket`]: the components follow a [musli-web] 0.5 socket's broadcasts and send their requests over it; [`AuthContext::on_update`] and `use_auth_update` pass the broadcasts on to the app |
39//!
40//! <br>
41//!
42//! ## Usage
43//!
44//! Wrap the app in [`AuthProvider`]. It signs the user in (or registers them
45//! from a `/register/{token}` link) before rendering its children, and gives
46//! them an [`AuthContext`] through [`use_auth`]:
47//!
48//! ```ignore
49//! use watchword::yew023::{AuthProvider, use_auth};
50//! use yew::prelude::*;
51//!
52//! #[function_component]
53//! fn Root() -> Html {
54//! html! {
55//! <AuthProvider title="My App">
56//! <App />
57//! </AuthProvider>
58//! }
59//! }
60//!
61//! #[function_component]
62//! fn App() -> Html {
63//! let auth = use_auth();
64//! let onclick = Callback::from(move |_| auth.sign_out());
65//! html! { <button {onclick}>{"Sign out"}</button> }
66//! }
67//! ```
68//!
69//! Inside the provider, [`UserMenu`] gives the signed-in user Account, Users
70//! (for admins), Sign out, and a way back while an admin impersonates them.
71//! [`AccountPanel`] is the user's own account, [`AdminUsers`] manages users
72//! for admins, and [`AdminCloudflare`] is the Cloudflare Access settings
73//! page for admins, which loads and saves the settings watchword keeps.
74//! [`CloudflareSettings`] is its form alone, for an app that stores the
75//! settings itself.
76//!
77//! <br>
78//!
79//! ## Making requests
80//!
81//! watchword's components make every request with the [`AuthService`] that
82//! [`AuthProvider`] provides, and an app's own components get the same one
83//! with [`use_auth_service`], so an app's account page behaves as
84//! watchword's does: over the app's socket once it is handed over (see
85//! [below](#with-a-musli-web-socket)), and over HTTP otherwise. The service
86//! has a method for each operation. It only makes the request, so a
87//! component passes a changed user on to the context itself:
88//!
89//! ```ignore
90//! use watchword::types::UserChange;
91//! use watchword::yew023::{use_auth, use_auth_service};
92//!
93//! #[function_component]
94//! fn ClearEmail() -> Html {
95//! let auth = use_auth();
96//! let service = use_auth_service();
97//!
98//! let onclick = Callback::from(move |_| {
99//! let auth = auth.clone();
100//! let service = service.clone();
101//!
102//! yew::platform::spawn_local(async move {
103//! let change = UserChange {
104//! email: Some(None),
105//! ..UserChange::default()
106//! };
107//!
108//! match service.update_account(change).await {
109//! Ok(user) => auth.set_user(user),
110//! Err(error) if error.is_unauthorized() => auth.check_session(),
111//! Err(..) => {}
112//! }
113//! });
114//! });
115//!
116//! html! { <button {onclick}>{"Forget my email"}</button> }
117//! }
118//! ```
119//!
120//! The service is a context of its own, apart from [`AuthContext`], so a
121//! component that only shows the user does not render again when it
122//! changes.
123//!
124//! Underneath is a [`Transport`], which carries an `AuthRequest` to the
125//! server and brings back its `AuthResponse`. An app or a test supplies its
126//! own with the provider's `service` prop, such as one that calls the
127//! server's `Auth::dispatch` directly over an in-memory store, so components
128//! can be tested without HTTP or a socket:
129//!
130//! ```ignore
131//! use watchword::yew023::{AuthProvider, AuthService};
132//!
133//! let service = AuthService::new(MyTransport::new());
134//!
135//! html! {
136//! <AuthProvider title="My App" service={service}>
137//! <App />
138//! </AuthProvider>
139//! }
140//! ```
141//!
142//! The [`http`] module is the low-level layer under [`HttpTransport`]: one
143//! function per `/api/auth` route, always over HTTP.
144//!
145//! <br>
146//!
147//! ## Following auth changes
148//!
149//! The provider gives a new [`AuthContext`] whenever the user, their role or
150//! the config changes, so app components follow it by reading the context,
151//! without forwarding watchword's events through broadcasts of their own:
152//!
153//! - Function components use [`use_auth`], or [`use_is_admin`] for just the
154//! role; they render again on each change.
155//! - Struct components keep an [`AuthSubscription`], which subscribes with a
156//! message of theirs, so they render again too. Reading the context once,
157//! with `ctx.link().context::<AuthContext>(Callback::noop())`, does not.
158//! - The users list is shared: [`use_users`] (or
159//! [`AuthContext::subscribe_users`]) gives the same [`Users`] that
160//! [`AdminUsers`] shows, fetched once for both and kept current, so an app
161//! page beside the admin page, such as one managing groups, does not fetch
162//! it again or reload it on each change.
163//!
164//! ```ignore
165//! use watchword::yew023::{use_is_admin, use_users};
166//!
167//! #[function_component]
168//! fn Groups() -> Html {
169//! let users = use_users();
170//! let count = users.entries().map_or(0, |entries| entries.len());
171//! html! { <p>{format!("{count} users")}</p> }
172//! }
173//!
174//! #[function_component]
175//! fn Nav() -> Html {
176//! html! {
177//! if use_is_admin() {
178//! <a href="/admin">{"Admin"}</a>
179//! }
180//! }
181//! }
182//! ```
183//!
184//! With a musli-web socket (below), changes made elsewhere reach all of
185//! these as they happen. An app that acts on an update itself, such as
186//! reloading its own data when the user's role changes, listens with
187//! [`AuthContext::on_update`] or `use_auth_update`. On the server, the
188//! app's own authorization of a socket's requests follows the session's
189//! guard too: watchword's `musliweb05::Connection::require_admin` reads the
190//! role as stored now, and the `SessionGuard` (`user`, `watch_user`) the role
191//! as last seen, so the server needs no event mirroring of its own either.
192//!
193//! <br>
194//!
195//! ## With a musli-web socket
196//!
197//! With the `musliweb05` feature (watchword's `musliweb05` and `yew023`
198//! together), an app that talks to its server over a [musli-web] 0.5
199//! WebSocket hands its handle to the context once the socket is open inside
200//! [`AuthProvider`]:
201//!
202//! ```ignore
203//! let auth = use_auth();
204//! auth.set_socket(Some(service.handle().clone()));
205//! ```
206//!
207//! The components then follow the server's `AuthBroadcast`: the context's
208//! user is replaced when it changes elsewhere, and the shared [`Users`]
209//! (which [`AdminUsers`] shows) applies users created, changed and deleted
210//! by others in place, so the app no longer bumps its `refresh` prop. Each
211//! update is also passed to the app's [`AuthContext::on_update`] listeners.
212//! The [`AuthService`]'s requests go over the socket too while it is open,
213//! the app's own included, with the same `AuthError`s as over HTTP, and over
214//! HTTP when the socket could not send them, such as while it reconnects;
215//! signing in and out, registration and impersonation always use HTTP. When
216//! the socket closes, and every 10 seconds while it stays closed,
217//! the provider checks the session over HTTP, so a session that ended, whose
218//! upgrade the server now refuses, shows the sign-in page.
219//!
220//! <br>
221//!
222//! ## Translating
223//!
224//! The components look their text up through [phrasebook] 0.2: in the
225//! `Phrases` the app provides with `ContextProvider<Phrases>`, falling back to
226//! watchword's own English messages, [`L10N`], for any message the app's
227//! phrasebook lacks. Without a provider they render that English, so an app
228//! that does not translate needs nothing.
229//!
230//! The messages are the `watchword-*` ids of [`locales/en-US.ftl`][ftl]. To
231//! translate them, an app adds those ids to its own `.ftl` resources and adds
232//! watchword's catalog after its own, so its messages win:
233//!
234//! ```ignore
235//! let book = Phrasebook::builder()
236//! .language(Lang::EN_US, "English")
237//! .language(SV_SE, "Svenska")
238//! .catalog(&APP)
239//! .catalog(&watchword::yew023::L10N)
240//! .build();
241//! ```
242//!
243//! Errors from the server, including why a request is invalid, are phrased
244//! from their codes here, so they are translated the same way.
245//! [`AuthProvider`]'s `signed_out_notice` and [`CloudflareSettings`]'s
246//! `app_name` override their messages when given.
247//!
248//! <br>
249//!
250//! ## Styling
251//!
252//! The components render plain semantic HTML with stable class names and take
253//! a `class` prop that is added to their outermost element. The class names
254//! are:
255//!
256//! | Class | Element |
257//! |---|---|
258//! | `auth-page` | the outermost `<main>` of the splash, sign-in and registration pages |
259//! | `auth-splash` | the splash while loading or when the server is unreachable |
260//! | `auth-card` | the box holding a page's content |
261//! | `auth-title` | the app's title, when given |
262//! | `auth-heading` | the page's `<h1>` |
263//! | `auth-message` | the live region holding `auth-error` and `auth-notice` |
264//! | `auth-error` | an error |
265//! | `auth-notice` | a notice, such as that the session has ended |
266//! | `auth-status` | progress, such as checking a link |
267//! | `auth-form` | a form |
268//! | `auth-field` | a label with its input or value |
269//! | `auth-label` | the label of a read-only value |
270//! | `auth-input` | a text or password input |
271//! | `auth-value` | a read-only value, such as the login on a registration page |
272//! | `auth-actions` | a row of buttons |
273//! | `auth-submit` | a form's submit button |
274//! | `auth-retry` | the splash's Retry button |
275//! | `auth-alternatives` | the other sign-in methods |
276//! | `auth-cloudflare` | the "Sign in with Cloudflare" button |
277//! | `auth-google` | the "Sign in with Google" or "Sign up with Google" link, or "Switch to Google" on the account panel |
278//! | `auth-hint` | help text under a field or form |
279//! | `auth-subheading` | a form's `<h3>` inside a panel |
280//! | `auth-account` | the outermost `<section>` of [`AccountPanel`] |
281//! | `auth-details` | the account's role and sign-in method |
282//! | `auth-profile` | the account's login and email form |
283//! | `auth-password` | the account's change or set password form |
284//! | `auth-sign-out` | a Sign out button |
285//! | `auth-menu` | the outermost `<nav>` of [`UserMenu`], with `auth-open` while open |
286//! | `auth-menu-button` | the button that opens the menu |
287//! | `auth-name` | the user's name in the menu button |
288//! | `auth-menu-items` | the open menu's `<ul>` |
289//! | `auth-menu-item` | an item of the menu |
290//! | `auth-impersonating` | the banner in the menu while an admin impersonates the user, holding `auth-impersonating-text` and the `auth-stop-impersonating` button |
291//! | `auth-admin-users` | the outermost `<section>` of [`AdminUsers`] |
292//! | `auth-toolbar` | the heading row of the users list |
293//! | `auth-add` | the Add user button |
294//! | `auth-add-user` | the add user form |
295//! | `auth-filter` | the filter input and its `auth-clear` button |
296//! | `auth-users` | the users `<table>` |
297//! | `auth-user` | a user's row, with `auth-me` on the admin's own |
298//! | `auth-user-login`, `auth-user-email`, `auth-user-role`, `auth-user-access`, `auth-user-link`, `auth-user-actions` | the cells of a row |
299//! | `auth-badge` | the "you" badge on the admin's own row |
300//! | `auth-user-extra` | the app's `extra` content under a user |
301//! | `auth-muted` | a missing value, such as "No email" |
302//! | `auth-select` | a role select |
303//! | `auth-access` | a user's account state, with `auth-pending`, `auth-active` or `auth-revoked` |
304//! | `auth-link-state` | when a user's link expires, or "No link" |
305//! | `auth-link` | a link shown once: its `auth-link-url` input, `auth-copy` button and hint |
306//! | `auth-danger` | a destructive button, such as Delete |
307//! | `auth-confirm` | an inline confirmation, with `auth-confirm-no` and `auth-confirm-yes` |
308//! | `auth-pages` | the paging row: `auth-previous`, `auth-page-number` and `auth-next` |
309//! | `auth-hidden` | text for screen readers only, which the app should hide visually |
310//! | `auth-admin-cloudflare` | the outermost `<section>` of [`AdminCloudflare`], around its message and [`CloudflareSettings`] |
311//! | `auth-cloudflare-settings` | the outermost `<section>` of [`CloudflareSettings`] |
312//! | `auth-instructions` | the Cloudflare setup instructions |
313//! | `auth-toggle` | a field with a checkbox |
314//!
315//! Every body cell of the `auth-users` table also carries a `data-label`
316//! attribute holding its column's name, the same localized text as the
317//! column header. On a narrow screen an app can use it to show each row as a
318//! stacked card whose cells name themselves, without assuming the column
319//! order:
320//!
321//! ```css
322//! @media (max-width: 600px) {
323//! .auth-users thead { display: none; }
324//! .auth-users, .auth-users tbody, .auth-users tr, .auth-users td { display: block; }
325//! .auth-users tr { padding: 8px 0; border-bottom: 1px solid #ccc; }
326//! .auth-users td { display: grid; grid-template-columns: 7em 1fr; gap: 8px; border: 0; }
327//! .auth-users td::before { content: attr(data-label); color: #666; }
328//! .auth-users td.auth-user-actions { display: flex; flex-wrap: wrap; gap: 4px; }
329//! .auth-users td.auth-user-actions::before { content: none; }
330//! }
331//! ```
332//!
333//! [`AccountPanel`]: https://docs.rs/watchword-yew023/latest/watchword_yew023/struct.AccountPanel.html
334//! [`AdminCloudflare`]: https://docs.rs/watchword-yew023/latest/watchword_yew023/struct.AdminCloudflare.html
335//! [`AdminUsers`]: https://docs.rs/watchword-yew023/latest/watchword_yew023/struct.AdminUsers.html
336//! [`AuthContext`]: https://docs.rs/watchword-yew023/latest/watchword_yew023/struct.AuthContext.html
337//! [`AuthContext::on_update`]: https://docs.rs/watchword-yew023/latest/watchword_yew023/struct.AuthContext.html#method.on_update
338//! [`AuthContext::set_socket`]: https://docs.rs/watchword-yew023/latest/watchword_yew023/struct.AuthContext.html#method.set_socket
339//! [`AuthContext::subscribe_users`]: https://docs.rs/watchword-yew023/latest/watchword_yew023/struct.AuthContext.html#method.subscribe_users
340//! [`AuthSubscription`]: https://docs.rs/watchword-yew023/latest/watchword_yew023/struct.AuthSubscription.html
341//! [`AuthProvider`]: https://docs.rs/watchword-yew023/latest/watchword_yew023/struct.AuthProvider.html
342//! [`AuthService`]: https://docs.rs/watchword-yew023/latest/watchword_yew023/struct.AuthService.html
343//! [`CloudflareSettings`]: https://docs.rs/watchword-yew023/latest/watchword_yew023/struct.CloudflareSettings.html
344//! [`http`]: https://docs.rs/watchword-yew023/latest/watchword_yew023/http/index.html
345//! [`HttpTransport`]: https://docs.rs/watchword-yew023/latest/watchword_yew023/struct.HttpTransport.html
346//! [`L10N`]: https://docs.rs/watchword-yew023/latest/watchword_yew023/static.L10N.html
347//! [`Transport`]: https://docs.rs/watchword-yew023/latest/watchword_yew023/trait.Transport.html
348//! [`use_auth_service`]: https://docs.rs/watchword-yew023/latest/watchword_yew023/fn.use_auth_service.html
349//! [`use_auth`]: https://docs.rs/watchword-yew023/latest/watchword_yew023/fn.use_auth.html
350//! [`use_is_admin`]: https://docs.rs/watchword-yew023/latest/watchword_yew023/fn.use_is_admin.html
351//! [`use_users`]: https://docs.rs/watchword-yew023/latest/watchword_yew023/fn.use_users.html
352//! [`UserMenu`]: https://docs.rs/watchword-yew023/latest/watchword_yew023/struct.UserMenu.html
353//! [ftl]: https://github.com/udoprog/watchword/blob/main/crates/watchword-core/locales/en-US.ftl
354//! [`Users`]: https://docs.rs/watchword-yew023/latest/watchword_yew023/struct.Users.html
355//! [musli-web]: https://docs.rs/musli-web
356//! [phrasebook]: https://docs.rs/phrasebook
357//! [watchword]: https://docs.rs/watchword
358//! [Yew]: https://yew.rs
359
360#![no_std]
361#![cfg_attr(watchword_docsrs, feature(doc_cfg))]
362
363#[cfg(feature = "alloc")]
364extern crate alloc;
365
366// yew, `Rc` and `HashMap` need std, so everything here is behind `std`.
367#[cfg(feature = "std")]
368#[macro_use]
369extern crate std;
370
371#[cfg(feature = "std")]
372#[cfg_attr(watchword_docsrs, doc(cfg(feature = "std")))]
373pub mod http;
374
375#[cfg(feature = "std")]
376mod account;
377#[cfg(feature = "std")]
378mod admin_users;
379#[cfg(feature = "std")]
380mod browser;
381#[cfg(feature = "std")]
382mod cloudflare_settings;
383#[cfg(feature = "std")]
384mod context;
385#[cfg(feature = "std")]
386mod error;
387#[cfg(feature = "std")]
388mod form;
389#[cfg(feature = "std")]
390mod l10n;
391#[cfg(feature = "std")]
392mod listener;
393#[cfg(feature = "std")]
394mod provider;
395#[cfg(feature = "std")]
396mod register;
397#[cfg(feature = "std")]
398mod service;
399#[cfg(feature = "std")]
400mod sign_in;
401#[cfg(feature = "musliweb05")]
402mod socket;
403#[cfg(feature = "std")]
404mod transport;
405#[cfg(feature = "std")]
406mod user_menu;
407#[cfg(feature = "std")]
408mod users;
409
410#[cfg(all(test, feature = "std"))]
411mod tests;
412
413#[cfg(feature = "std")]
414#[cfg_attr(watchword_docsrs, doc(cfg(feature = "std")))]
415pub use self::account::{AccountPanel, AccountPanelProps};
416
417#[cfg(feature = "std")]
418#[cfg_attr(watchword_docsrs, doc(cfg(feature = "std")))]
419pub use self::admin_users::{AdminUsers, AdminUsersProps, PAGE_SIZE, UsersQuery};
420
421#[cfg(feature = "std")]
422#[cfg_attr(watchword_docsrs, doc(cfg(feature = "std")))]
423pub use self::cloudflare_settings::{
424 AdminCloudflare, AdminCloudflareProps, CloudflareSettings, CloudflareSettingsProps,
425};
426
427#[cfg(feature = "std")]
428#[cfg_attr(watchword_docsrs, doc(cfg(feature = "std")))]
429pub use self::context::{AuthContext, AuthSubscription, use_auth, use_is_admin};
430
431#[cfg(feature = "musliweb05")]
432#[cfg_attr(watchword_docsrs, doc(cfg(feature = "musliweb05")))]
433pub use self::context::use_auth_update;
434
435#[cfg(feature = "std")]
436#[cfg_attr(watchword_docsrs, doc(cfg(feature = "std")))]
437pub use self::error::Error;
438
439#[cfg(feature = "std")]
440#[cfg_attr(watchword_docsrs, doc(cfg(feature = "std")))]
441pub use self::service::{AuthService, use_auth_service};
442
443#[cfg(feature = "std")]
444#[cfg_attr(watchword_docsrs, doc(cfg(feature = "std")))]
445pub use self::transport::{HttpTransport, Transport, TransportFuture};
446
447#[cfg(feature = "std")]
448#[cfg_attr(watchword_docsrs, doc(cfg(feature = "std")))]
449pub use self::listener::Listener;
450
451#[cfg(feature = "std")]
452#[cfg_attr(watchword_docsrs, doc(cfg(feature = "std")))]
453#[doc(inline)]
454pub use watchword_core::phrasebookcatalog01::L10N;
455
456#[cfg(feature = "std")]
457#[cfg_attr(watchword_docsrs, doc(cfg(feature = "std")))]
458pub use self::provider::{AuthProvider, AuthProviderProps};
459
460#[cfg(feature = "std")]
461#[cfg_attr(watchword_docsrs, doc(cfg(feature = "std")))]
462pub use self::register::{Register, RegisterProps};
463
464#[cfg(feature = "std")]
465#[cfg_attr(watchword_docsrs, doc(cfg(feature = "std")))]
466pub use self::sign_in::{SignIn, SignInProps};
467
468#[cfg(feature = "std")]
469#[cfg_attr(watchword_docsrs, doc(cfg(feature = "std")))]
470pub use self::user_menu::{UserMenu, UserMenuProps};
471
472#[cfg(feature = "std")]
473#[cfg_attr(watchword_docsrs, doc(cfg(feature = "std")))]
474pub use self::users::{Users, use_users};