diff --git a/crates/db/src/invitation_dao.rs b/crates/db/src/invitation_dao.rs index 9b66bd27..3019f817 100644 --- a/crates/db/src/invitation_dao.rs +++ b/crates/db/src/invitation_dao.rs @@ -249,6 +249,43 @@ pub async fn accept( Ok(user) } +/// Accept the invitation of the account `user_id` on its user's behalf, with +/// a password an administrator sets: its password becomes `password_hash` +/// and it is no longer invited, and its invitation, live or expired, is +/// marked accepted, so its link stops working. Whether an invitation waits +/// or not does not matter. `None` when the account is not an active invited +/// local one (its user chose a password already, an account of another +/// realm, a service account, a deactivated or a deleted account). +pub async fn accept_for_user( + pool: &PgPool, + user_id: i64, + password_hash: &str, +) -> sqlx::Result> { + let mut tx = crate::begin(pool).await?; + let user: Option = sqlx::query_as( + "UPDATE app_user SET password_hash = $2, invited = false, updated_at = now() \ + WHERE id = $1 AND invited AND active AND auth_source = 'local' AND kind = 'user' \ + RETURNING id, username, password_hash, active, auth_source, external_subject, \ + kind, created_at, updated_at", + ) + .bind(user_id) + .bind(password_hash) + .fetch_optional(&mut *tx) + .await?; + if user.is_none() { + return Ok(None); + } + sqlx::query( + "UPDATE user_invitation SET accepted_at = now() \ + WHERE user_id = $1 AND accepted_at IS NULL", + ) + .bind(user_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(user) +} + /// Revoke the invitation of `user_id` (unless accepted). Returns whether /// there was one. pub async fn revoke<'e, E>(executor: E, user_id: i64) -> sqlx::Result diff --git a/crates/server/src/admin/mod.rs b/crates/server/src/admin/mod.rs index 0e979f20..95014a1f 100644 --- a/crates/server/src/admin/mod.rs +++ b/crates/server/src/admin/mod.rs @@ -14,8 +14,9 @@ //! * [`images`] — deleting a tag, a manifest or an image of a hosted //! repository of images. //! * [`users`] — user CRUD: local accounts are invited to choose their -//! password ([`crate::invitations`]), never given one here; service -//! accounts (#508) have none at all. +//! password ([`crate::invitations`]), or an administrator accepts the +//! invitation for them with a password they set; service accounts (#508) +//! have none at all. //! * [`tokens`] — token issue (plaintext returned **once**, only the hash is //! stored) + revoke + list. //! * [`members`] — group membership management. @@ -272,6 +273,10 @@ pub fn admin_router(state: AppState) -> Router { "/users/{id}/invitation", axum::routing::post(users::invite).delete(users::revoke_invitation), ) + .route( + "/users/{id}/invitation/accept", + axum::routing::post(users::accept_invitation), + ) .route( "/users/{id}/convert-to-local", axum::routing::post(users::convert_to_local), diff --git a/crates/server/src/admin/users.rs b/crates/server/src/admin/users.rs index e29e449d..c3c83a25 100644 --- a/crates/server/src/admin/users.rs +++ b/crates/server/src/admin/users.rs @@ -28,6 +28,10 @@ //! password. `POST /admin/users/{id}/invitation` makes a new invitation //! (sent, or as a link to copy), replacing the previous one, and `DELETE` //! revokes it; deactivating or deleting the account revokes it too. +//! `POST /admin/users/{id}/invitation/accept` accepts it on the user's +//! behalf with a password the administrator sets (#653), for an account +//! handed over ready to use: the only way an administrator sets another +//! person's password. //! //! A credential-forwarding account whose realm keeps password hashes //! (`capture_password_hash`) can be switched to a local account with the last @@ -812,6 +816,81 @@ pub async fn revoke_invitation(State(state): State, Path(id): Path, + Extension(principal): Extension, + Path(id): Path, + Json(body): Json, +) -> Response { + const NOT_INVITED: &str = "only an active local account whose user has not chosen a \ + password yet can have its invitation accepted"; + if let Err(why) = invitations::check_password(&body.password) { + return (StatusCode::BAD_REQUEST, why).into_response(); + } + let user = match user_dao::find_user_by_id(&state.pool, id).await { + Ok(Some(u)) => u, + Ok(None) => return (StatusCode::NOT_FOUND, "user not found").into_response(), + Err(e) => return db_error(e), + }; + // Before the argon2 work; the update checks again, in its transaction. + let invited = match invitation_dao::onboarding(&state.pool, id).await { + Ok(onboarding) => onboarding.is_some_and(|o| o.invited), + Err(e) => return db_error(e), + }; + if !invited || !user.active || user.auth_source != "local" || user.is_service_account() { + return (StatusCode::CONFLICT, NOT_INVITED).into_response(); + } + let password_hash = match super::hash_password(body.password).await { + Ok(hash) => hash, + Err(resp) => return resp, + }; + let accepted = match invitation_dao::accept_for_user(&state.pool, id, &password_hash).await { + Ok(Some(user)) => user, + // Accepted, deactivated or deleted meanwhile. + Ok(None) => { + return match user_dao::find_user_by_id(&state.pool, id).await { + Ok(Some(_)) => (StatusCode::CONFLICT, NOT_INVITED).into_response(), + Ok(None) => (StatusCode::NOT_FOUND, "user not found").into_response(), + Err(e) => db_error(e), + } + } + Err(e) => return db_error(e), + }; + tracing::info!( + target: "forge::audit", + user_id = accepted.id, + username = %accepted.username, + principal = %principal.audit_label(), + "invitation accepted by an administrator: the password is set" + ); + match UserView::load(&state, &accepted).await { + Ok(view) => Json(view).into_response(), + Err(resp) => resp, + } +} + /// `PATCH /admin/users/{id}` — deactivate (`{"active": false}`) or reactivate /// (`{"active": true}`) a user. Returns the updated [`UserView`]; idempotent. /// diff --git a/crates/server/tests/invitations.rs b/crates/server/tests/invitations.rs index d7e056cb..caee4e1d 100644 --- a/crates/server/tests/invitations.rs +++ b/crates/server/tests/invitations.rs @@ -592,6 +592,208 @@ async fn creating_a_user_takes_no_password() { assert_eq!(status, 409, "only local accounts are invited"); } +/// `POST /admin/users/{id}/invitation/accept` (#653): an administrator +/// accepts an invited account's invitation for its user, with a password +/// they set, whether a live invitation waits or not. The user signs in with +/// it, the account is active, and the old link answers as invalid. The +/// password follows the invitation page's rules (400); an account that is +/// not an active invited local one is refused (409), and no account is 404. +#[tokio::test] +async fn an_administrator_accepts_an_invitation_with_a_password_they_set() { + let upstream = MockServer::start().await; + let srv = server().await; + let n = common::unique(); + let accept = |id: i64, password: &str| { + let srv = &srv; + let body = json!({ "password": password }); + async move { + admin( + srv, + reqwest::Method::POST, + &format!("/admin/users/{id}/invitation/accept"), + Some(body), + ) + .await + } + }; + let create = |body: Value| { + let srv = &srv; + async move { + let (status, created) = + admin(srv, reqwest::Method::POST, "/admin/users", Some(body)).await; + assert_eq!(status, 201, "{created}"); + created + } + }; + + // A live invitation waits. + let username = format!("gina{n}@example.com"); + let created = create(json!({ "username": username })).await; + let id = created["id"].as_i64().unwrap(); + let token = token_of(created["invitation"]["link"].as_str().unwrap()); + assert_eq!(created["status"], "invited"); + + // The invitation page's rules. + let (status, body) = accept(id, "too-short").await; + assert_eq!(status, 400, "{body}"); + assert!(body.as_str().unwrap().contains("12"), "{body}"); + let (status, body) = accept(id, &"x".repeat(1025)).await; + assert_eq!(status, 400, "{body}"); + assert!(body.as_str().unwrap().contains("1024"), "{body}"); + assert_eq!(user_view(&srv, id).await["status"], "invited"); + assert!( + live(&srv, &token).await, + "a refused password changes nothing" + ); + + let (status, view) = accept(id, "set-by-the-administrator").await; + assert_eq!(status, 200, "{view}"); + assert_eq!(view["id"], json!(id)); + assert_eq!(view["status"], "active"); + assert_eq!(view["invitation"], Value::Null); + assert_eq!(user_view(&srv, id).await["status"], "active"); + assert_eq!( + login(&srv, &username, "set-by-the-administrator").await, + 200 + ); + let accepted_at: Option> = + sqlx::query_scalar("SELECT accepted_at FROM user_invitation WHERE user_id = $1") + .bind(id) + .fetch_one(&srv.pool) + .await + .unwrap(); + assert!(accepted_at.is_some(), "the invitation is marked accepted"); + + // The old link answers as invalid. + assert!(!live(&srv, &token).await); + let resp = srv.accept_invitation(&token, "the-users-own-choice").await; + assert_eq!(resp.status(), 400); + assert_eq!(login(&srv, &username, "the-users-own-choice").await, 401); + + // Its user has a password now: once only. + let (status, _) = accept(id, "another-long-password").await; + assert_eq!(status, 409); + assert_eq!( + login(&srv, &username, "set-by-the-administrator").await, + 200 + ); + + // The invitation expired. + let expired = format!("hank{n}@example.com"); + let created = create(json!({ "username": expired })).await; + let expired_id = created["id"].as_i64().unwrap(); + sqlx::query( + "UPDATE user_invitation SET expires_at = now() - interval '1 second' WHERE user_id = $1", + ) + .bind(expired_id) + .execute(&srv.pool) + .await + .unwrap(); + assert_eq!( + user_view(&srv, expired_id).await["status"], + "invitation_expired" + ); + let (status, view) = accept(expired_id, "set-after-it-expired").await; + assert_eq!(status, 200, "{view}"); + assert_eq!(view["status"], "active"); + assert_eq!(login(&srv, &expired, "set-after-it-expired").await, 200); + + // No invitation at all: revoked. + let revoked = format!("iris{n}@example.com"); + let created = create(json!({ "username": revoked })).await; + let revoked_id = created["id"].as_i64().unwrap(); + let (status, _) = admin( + &srv, + reqwest::Method::DELETE, + &format!("/admin/users/{revoked_id}/invitation"), + None, + ) + .await; + assert_eq!(status, 204); + let (status, view) = accept(revoked_id, "set-without-a-link").await; + assert_eq!(status, 200, "{view}"); + assert_eq!(view["status"], "active"); + assert_eq!(login(&srv, &revoked, "set-without-a-link").await, 200); + + // A deactivated invited account: refused, and still invited. + let inactive = format!("jack{n}@example.com"); + let created = create(json!({ "username": inactive })).await; + let inactive_id = created["id"].as_i64().unwrap(); + let (status, _) = admin( + &srv, + reqwest::Method::PATCH, + &format!("/admin/users/{inactive_id}"), + Some(json!({"active": false})), + ) + .await; + assert_eq!(status, 200); + let (status, _) = accept(inactive_id, "set-while-deactivated").await; + assert_eq!(status, 409); + let (status, _) = admin( + &srv, + reqwest::Method::PATCH, + &format!("/admin/users/{inactive_id}"), + Some(json!({"active": true})), + ) + .await; + assert_eq!(status, 200); + assert_eq!( + user_view(&srv, inactive_id).await["status"], + "invitation_expired" + ); + assert_eq!(login(&srv, &inactive, "set-while-deactivated").await, 401); + + // An account of another realm, a service account, the administrator. + let (status, _) = admin( + &srv, + reqwest::Method::POST, + "/admin/realms", + Some(json!({ + "kind": "credential_forwarding", + "name": "directory", + "config": {"url": format!("{}/status", upstream.uri()), "key_pepper": "pepper"}, + })), + ) + .await; + assert_eq!(status, 201); + let forwarded = create(json!({ + "username": format!("kate{n}@example.com"), + "realm": "directory", + })) + .await; + let service = create(json!({ + "username": format!("ci{n}@example.com"), + "kind": "service", + "description": "The build agents", + })) + .await; + let (admin_name, _) = srv.admin_basic(); + let admin_id = { + let (status, users) = admin(&srv, reqwest::Method::GET, "/admin/users", None).await; + assert_eq!(status, 200); + users + .as_array() + .unwrap() + .iter() + .find(|u| u["username"] == json!(admin_name)) + .expect("the administrator is listed")["id"] + .as_i64() + .unwrap() + }; + for id in [ + forwarded["id"].as_i64().unwrap(), + service["id"].as_i64().unwrap(), + admin_id, + ] { + let (status, body) = accept(id, "not-for-this-account").await; + assert_eq!(status, 409, "{id}: {body}"); + } + + // No account. + let (status, _) = accept(i64::MAX, "for-nobody-at-all").await; + assert_eq!(status, 404); +} + /// Failed lookups and acceptances are throttled per client network. #[tokio::test] async fn invitation_failures_are_throttled() { diff --git a/docs/changes/2.2.0/653.feature.md b/docs/changes/2.2.0/653.feature.md new file mode 100644 index 00000000..e14df12c --- /dev/null +++ b/docs/changes/2.2.0/653.feature.md @@ -0,0 +1,10 @@ +An administrator can accept an invited user's invitation for them, with a +password they set: **Accept invitation** on the user's page under +**Admin → Users**, or `POST /admin/users/{id}/invitation/accept`. It hands an +account over ready to use (an install without email, a user without a +mailbox) whether its invitation is still live or has expired: the password +becomes the account's, the account is active, and the invitation link stops +working. The password follows the invitation page's rules (at least 12 +characters); an account whose user chose a password already, an account of +another realm, a service account and a deactivated account are refused. This +is the only way an administrator sets another person's password. diff --git a/docs/deployment.md b/docs/deployment.md index 3ff1cb59..e715d8e8 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -389,7 +389,8 @@ admin API (`/admin/*` answers 403; only `/admin/me` answers, and reports the ### Inviting users -An administrator never sets another person's password. **Admin → Users → +An administrator sets another person's password only by accepting their +invitation for them (below). **Admin → Users → New user** (`/manage/users/new`) takes the [username](#usernames), the email address the invitation goes to (the username unless entered: a UPN is not always a mailbox) and the access level (reader, writer or admin). Forge @@ -418,6 +419,17 @@ password, at least 12 characters, and then sign in. Deactivating or deleting the account revokes its invitation. - **Without an email service**, or when the send fails, the account is still created and the link is shown once on the page to pass on yourself. +- **Accept invitation** on the user's page finishes the invitation for its + user, with a password you set (twice, by the same rules: at least 12 + characters). Use it to hand an account over ready to use: an install + without email, a user without a mailbox, an account set up by hand. It + works whether the account is *invited* or its *invitation expired*. The + password becomes the account's, the account is active, and its link, if + one is still out, stops working. This is the only way an administrator + sets another person's password, and only before they chose one: an + account whose user has a password, an account of another realm, a service + account and a deactivated account are refused. Pass the password on + through a channel you trust. - Accounts of a credential-forwarding realm (see [Usernames](#usernames)) and OIDC accounts are not invited: they sign in through their realm. The bootstrap admin keeps its configured password. @@ -427,7 +439,10 @@ The admin API is `POST /admin/users` with `{"username", "email", "role"}` credential-forwarding account. It takes no password: the answer carries `invitation` (`email_sent`, `error`, `expires_at`, and the `link` when no email went out). `POST /admin/users/{id}/invitation` (`{"send_email": true | -false, "email": "…"}`) makes a new invitation and `DELETE` revokes it. The +false, "email": "…"}`) makes a new invitation and `DELETE` revokes it. +`POST /admin/users/{id}/invitation/accept` (`{"password": "…"}`) accepts it +for the user: **400** for a password the rules refuse, **409** for an account +that is not an active invited one, **404** for no account. The invited user's page calls `POST /auth/invitations/lookup` and `POST /auth/invitations/accept` (`{"token", "password"}`). diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index 7403cf3f..b1d92601 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -178,6 +178,26 @@ export const INVALID_INVITATION = /** The fewest characters a password chosen from an invitation has. */ export const MIN_INVITATION_PASSWORD_CHARS = 12 +/** The most bytes it has (the server's limit). */ +export const MAX_INVITATION_PASSWORD_BYTES = 1024 + +/** + * Why `password` cannot be an invited account's password (the server's + * rules, and both entries the same), or `null` when it can. + */ +export function invitationPasswordProblem(password: string, confirm: string): string | null { + if ([...password].length < MIN_INVITATION_PASSWORD_CHARS) { + return `Choose at least ${MIN_INVITATION_PASSWORD_CHARS} characters.` + } + if (new TextEncoder().encode(password).length > MAX_INVITATION_PASSWORD_BYTES) { + return 'That password is too long.' + } + if (password !== confirm) { + return 'The two passwords differ.' + } + return null +} + /** * `POST /auth/invitations/lookup` — whose account the invitation `token` * (the link's fragment) is for. A 400 is the generic answer. diff --git a/web/src/api/users.ts b/web/src/api/users.ts index acc62401..023625bf 100644 --- a/web/src/api/users.ts +++ b/web/src/api/users.ts @@ -26,7 +26,9 @@ import type { AccessLevel } from '~/api/rbac' * administrator issues for it, or through its trusted-header realm. * `POST /admin/users/{id}/reauth` signs the user out everywhere. The * other per-user writes are `DELETE /admin/users/{id}`, invitations - * (`POST`/`DELETE /admin/users/{id}/invitation`), switching a + * (`POST`/`DELETE /admin/users/{id}/invitation`, and `POST + * /admin/users/{id}/invitation/accept`, which accepts one for its user + * with a password the administrator sets), switching a * credential-forwarding account to a local one (`POST * /admin/users/{id}/convert-to-local`), token management (see * `tokens.ts`) and role assignment (see `rbac.ts`). @@ -254,6 +256,17 @@ export function revokeInvitation(id: number): Promise { return httpClient.delete(`/admin/users/${id}/invitation`) } +/** + * `POST /admin/users/{id}/invitation/accept` — accept an invited account's + * invitation for its user, with a password the administrator sets: the + * account is active, and its link stops working. Answers the updated view; + * 400 for a password the invitation page refuses too, 409 for an account + * that is not an active invited one. + */ +export function acceptUserInvitation(id: number, password: string): Promise { + return httpClient.post(`/admin/users/${id}/invitation/accept`, { password }) +} + /** * `POST /admin/users/{id}/convert-to-local` — switch a credential-forwarding * account to a local one whose password is its kept hash (409 when it keeps diff --git a/web/src/queries/useUsers.ts b/web/src/queries/useUsers.ts index 1986d59b..f75a0c6a 100644 --- a/web/src/queries/useUsers.ts +++ b/web/src/queries/useUsers.ts @@ -5,6 +5,7 @@ import { realmKeys } from '~/api/realms' import { roleKeys } from '~/api/rbac' import { tokenKeys } from '~/api/tokens' import { + acceptUserInvitation, convertUserToLocal, createUser, deleteUser, @@ -71,6 +72,24 @@ export function useInviteUser() { }) } +/** + * Accept `id`'s invitation for its user with a password the administrator + * sets. The answer replaces the user in the cached list at once (the page + * shows the account active), and the list is re-fetched. + */ +export function useAcceptInvitation() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id, password }) => acceptUserInvitation(id, password), + onSuccess: (view) => { + queryClient.setQueryData(userKeys.list(), (users) => + users?.map((u) => (u.id === view.id ? view : u)), + ) + void queryClient.invalidateQueries({ queryKey: userKeys.list() }) + }, + }) +} + /** Revoke `id`'s invitation; invalidates the list on success. */ export function useRevokeInvitation() { const queryClient = useQueryClient() diff --git a/web/src/routes/Invite.tsx b/web/src/routes/Invite.tsx index 6e9004cd..e3a56281 100644 --- a/web/src/routes/Invite.tsx +++ b/web/src/routes/Invite.tsx @@ -5,6 +5,7 @@ import { createFileRoute, Link, useNavigate, useRouterState } from '@tanstack/re import { acceptInvitation, INVALID_INVITATION, + invitationPasswordProblem, lookupInvitation, MIN_INVITATION_PASSWORD_CHARS, } from '~/api/auth' @@ -16,9 +17,6 @@ export const Route = createFileRoute('/invite')({ component: Invite, }) -/** The most bytes a chosen password has (the server's limit). */ -const MAX_PASSWORD_BYTES = 1024 - /** The server's reason for a refused request, or the generic answer. */ function reason(e: unknown): string { if (e instanceof ApiError) { @@ -62,19 +60,9 @@ function Invite() { function submit(e: React.FormEvent) { e.preventDefault() - if ([...password].length < MIN_INVITATION_PASSWORD_CHARS) { - setProblem(`Choose at least ${MIN_INVITATION_PASSWORD_CHARS} characters.`) - return - } - if (new TextEncoder().encode(password).length > MAX_PASSWORD_BYTES) { - setProblem('That password is too long.') - return - } - if (password !== confirm) { - setProblem('The two passwords differ.') - return - } - setProblem(null) + const why = invitationPasswordProblem(password, confirm) + setProblem(why) + if (why) return accept.mutate(password) } diff --git a/web/src/routes/admin/users/Edit.tsx b/web/src/routes/admin/users/Edit.tsx index 9ec84354..3e93f30e 100644 --- a/web/src/routes/admin/users/Edit.tsx +++ b/web/src/routes/admin/users/Edit.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useId, useState } from 'react' import { createFileRoute, Link } from '@tanstack/react-router' import type { CreatedToken, TokenView } from '~/api/tokens' @@ -17,7 +17,9 @@ import { type IssuedInvitation, type UserView, } from '~/api/users' +import { invitationPasswordProblem, MIN_INVITATION_PASSWORD_CHARS } from '~/api/auth' import { + useAcceptInvitation, useConvertUserToLocal, useInviteUser, useReauthUser, @@ -60,6 +62,7 @@ import { TableHead, TableHeader, TableRow, + TextField, } from '~/components/ui' import { AccessBadge } from '~/components/AccessBadge' import { UserStatusBadge } from './List' @@ -115,7 +118,8 @@ const ADD_STRIP = 'grid gap-4 border-b border-line px-5 py-4' * kept its password hash can be switched to a local account with that * password from here. An invited account (its user has not chosen a password * yet) shows its invitation, which can be resent, copied as a link (shown - * once) or revoked. The account section on top deactivates, reactivates and + * once), revoked, or accepted for the user with a password the + * administrator sets. The account section on top deactivates, reactivates and * signs the user out everywhere. */ function UserPage() { @@ -127,6 +131,9 @@ function UserPage() { const user = users.data?.find((u) => u.id === id) const name = user ? user.username : `User #${id}` const trustedHeader = user ? isTrustedHeaderAccount(user) : false + // The user whose invitation was just accepted here: its panel is gone, + // and this says what happened instead. + const [acceptedFor, setAcceptedFor] = useState(null) if (user && isServiceAccount(user)) { return ( @@ -171,7 +178,13 @@ function UserPage() { {user && } {user && (user.status === 'invited' || user.status === 'invitation_expired') && ( - + setAcceptedFor(user.id)} /> + )} + {user && acceptedFor === user.id && user.status === 'active' && ( + + Invitation accepted: {user.username} signs in with the password you set. Pass it on + through a channel you trust; the invitation link no longer works. + )} {user?.captured_password_at && } @@ -714,11 +727,14 @@ function ScopeEditor({ /** * An invited account's invitation: where it went and until when, sending a * new one (the old link stops working), making a link to copy instead (shown - * once, like a token), and revoking it. + * once, like a token), revoking it, and accepting it for the user with a + * password the administrator sets (#653), for an account handed over ready + * to use. */ -function UserInvitation({ user }: { user: UserView }) { +function UserInvitation({ user, onAccepted }: { user: UserView; onAccepted: () => void }) { const invite = useInviteUser() const revoke = useRevokeInvitation() + const [accepting, setAccepting] = useState(false) // The answer of the last resend or copy: its link is shown once, held // here and never cached. const [issued, setIssued] = useState(null) @@ -757,6 +773,9 @@ function UserInvitation({ user }: { user: UserView }) { Revoke invitation )} + @@ -803,10 +822,90 @@ function UserInvitation({ user }: { user: UserView }) { Resending makes a new link; the one sent before stops working.

)} + {accepting && ( + setAccepting(false)} + onAccepted={() => { + setAccepting(false) + onAccepted() + }} + /> + )} ) } +/** + * **Accept invitation**: the administrator sets the invited account's + * password (twice, by the invitation page's rules) and the account is + * active, its link dead. The only way an administrator sets another + * person's password; the server's refusal (409: the user chose one + * meanwhile) shows here. + */ +function AcceptInvitationDialog({ + user, + onClose, + onAccepted, +}: { + user: UserView + onClose: () => void + onAccepted: () => void +}) { + const accept = useAcceptInvitation() + const formId = useId() + const [password, setPassword] = useState('') + const [confirm, setConfirm] = useState('') + const [problem, setProblem] = useState(null) + + function submit(e: React.FormEvent) { + e.preventDefault() + const why = invitationPasswordProblem(password, confirm) + setProblem(why) + if (why) return + accept.mutate({ id: user.id, password }, { onSuccess: onAccepted }) + } + + return ( + + + + + } + > +
+ setPassword(e.target.value)} + description={`At least ${MIN_INVITATION_PASSWORD_CHARS} characters.`} + /> + setConfirm(e.target.value)} + /> + + {(problem || accept.isError) && ( + {problem ?? (accept.error?.body || accept.error?.message)} + )} +
+ ) +} + type AccountAction = 'deactivate' | 'reactivate' | 'reauth' /** diff --git a/web/src/routes/admin/users/users.test.tsx b/web/src/routes/admin/users/users.test.tsx index 356eddb4..69099c60 100644 --- a/web/src/routes/admin/users/users.test.tsx +++ b/web/src/routes/admin/users/users.test.tsx @@ -25,7 +25,8 @@ import { searchOptions } from '~/lib/search' * an invitation link once (access levels are covered by * `userRoles.test.tsx`); * - the user page of an invited account resends, copies (shown once) and - * revokes its invitation; + * revokes its invitation, and accepts it for the user with a password the + * administrator sets (twice, by the invitation page's rules); * - the user page deactivates, reactivates and signs a user out everywhere, * each after a confirmation saying what happens, and the list and the page * show the new status at once; a refused deactivation (409 for the last @@ -93,6 +94,7 @@ interface ApiHandlers { onConvertUser?: (id: number) => Response onInvite?: (id: number, body: unknown) => Response onRevokeInvitation?: (id: number) => Response + onAcceptInvitation?: (id: number, body: unknown) => Response onSetActive?: (id: number, body: unknown) => Response onReauth?: (id: number) => Response email?: unknown @@ -124,6 +126,16 @@ function mockApi(h: ApiHandlers) { ) } } + const acceptMatch = path.match(/\/admin\/users\/(\d+)\/invitation\/accept$/) + if (acceptMatch && method === 'POST') { + const id = Number(acceptMatch[1]) + const body = init?.body ? JSON.parse(init.body as string) : {} + return Promise.resolve( + h.onAcceptInvitation + ? h.onAcceptInvitation(id, body) + : json(user({ id, status: 'active' })), + ) + } if (path.endsWith('/admin/realms') && method === 'GET') { return Promise.resolve(json(h.realms ?? [])) } @@ -561,6 +573,83 @@ describe('admin users', () => { expect(screen.queryByText('https://forge.example.com/invite#copied')).toBeNull() }) + it("accepts an invited user's invitation with a password the administrator sets", async () => { + const accepted: unknown[] = [] + let users = [ + user({ id: 9, username: 'frank@example.com', status: 'invitation_expired' }), + ] + mockApi({ + get users() { + return users + }, + onAcceptInvitation: (id, body) => { + accepted.push(body) + users = [user({ id, username: 'frank@example.com', status: 'active' })] + return json(users[0]) + }, + }) + + const u = userEvent.setup() + await renderAt('/manage/users/9') + + await u.click(await screen.findByRole('button', { name: 'Accept invitation' })) + const dialog = screen.getByRole('dialog', { + name: 'Accept the invitation for frank@example.com?', + }) + expect(dialog).toHaveTextContent(/the invitation link stops working/) + const setPassword = within(dialog).getByRole('button', { name: 'Set password' }) + + // The invitation page's rules, before anything is sent. + await u.type(within(dialog).getByLabelText('Password'), 'short') + await u.type(within(dialog).getByLabelText('Password again'), 'short') + await u.click(setPassword) + expect(within(dialog).getByText('Choose at least 12 characters.')).toBeInTheDocument() + await u.clear(within(dialog).getByLabelText('Password')) + await u.type(within(dialog).getByLabelText('Password'), 'set-by-the-administrator') + await u.click(setPassword) + expect(within(dialog).getByText('The two passwords differ.')).toBeInTheDocument() + expect(accepted).toEqual([]) + + await u.clear(within(dialog).getByLabelText('Password again')) + await u.type(within(dialog).getByLabelText('Password again'), 'set-by-the-administrator') + await u.click(setPassword) + await waitFor(() => expect(accepted).toEqual([{ password: 'set-by-the-administrator' }])) + + expect( + await screen.findByText(/Invitation accepted: frank@example.com signs in with the password you set/), + ).toBeInTheDocument() + expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.queryByRole('button', { name: 'Accept invitation' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Resend invitation' })).toBeNull() + expect(screen.getByText('active')).toBeInTheDocument() + }) + + it('shows why accepting an invitation was refused and keeps the dialog open', async () => { + const reason = + 'only an active local account whose user has not chosen a password yet can have its invitation accepted' + mockApi({ + users: [user({ id: 9, username: 'frank@example.com', status: 'invited' })], + onAcceptInvitation: () => new Response(reason, { status: 409 }), + }) + + const u = userEvent.setup() + await renderAt('/manage/users/9') + + await u.click(await screen.findByRole('button', { name: 'Accept invitation' })) + const dialog = screen.getByRole('dialog', { + name: 'Accept the invitation for frank@example.com?', + }) + await u.type(within(dialog).getByLabelText('Password'), 'set-by-the-administrator') + await u.type(within(dialog).getByLabelText('Password again'), 'set-by-the-administrator') + await u.click(within(dialog).getByRole('button', { name: 'Set password' })) + + expect(await within(dialog).findByText(reason)).toBeInTheDocument() + expect(screen.queryByText(/Invitation accepted/)).toBeNull() + await u.click(within(dialog).getByRole('button', { name: 'Cancel' })) + expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.getByRole('button', { name: 'Accept invitation' })).toBeInTheDocument() + }) + it('deactivates a user after a confirmation, and both screens show it at once', async () => { const patches: unknown[] = [] let users = [user({ id: 2, username: 'alice' })]