From e8174309bb0e2479acd276c2d8d400f468c1d1a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 08:38:34 +0000 Subject: [PATCH] =?UTF-8?q?Admin=20=E2=86=92=20Users:=20accept=20an=20invi?= =?UTF-8?q?ted=20user's=20invitation=20with=20a=20password=20the=20adminis?= =?UTF-8?q?trator=20sets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An invited local account signed in only once its user had followed the invitation link and chosen a password, so an administrator who set an account up for someone without email or a mailbox could not hand it over ready to use: it stayed invited, or its invitation expired. POST /admin/users/{id}/invitation/accept with {"password"} now accepts the invitation on the user's behalf, in one transaction: the password becomes the account's, the account is no longer invited (status active), and its invitation, live or expired, is marked accepted, so its link answers as invalid. It works whether a live invitation waits or not. The password follows the invitation page's rules (12 characters to 1024 bytes, 400 otherwise); an account that is not an active invited local one (its user chose a password already, an account of another realm, a service account, a deactivated account) is refused with 409, and no account is 404. The change is in the audit trail with the administrator who made it. Admin → Users → offers Accept invitation on an invited account: a dialog asks for the password twice, checks it by the invitation page's rules (now shared with that page), shows the server's refusal, and says afterwards that the user signs in with the password set. docs/deployment.md (Inviting users) describes it as the only way an administrator sets another person's password. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012e6DJbbDh51swmocfFvXPe --- crates/db/src/invitation_dao.rs | 37 ++++ crates/server/src/admin/mod.rs | 9 +- crates/server/src/admin/users.rs | 79 +++++++++ crates/server/tests/invitations.rs | 202 ++++++++++++++++++++++ docs/changes/2.2.0/653.feature.md | 10 ++ docs/deployment.md | 19 +- web/src/api/auth.ts | 20 +++ web/src/api/users.ts | 15 +- web/src/queries/useUsers.ts | 19 ++ web/src/routes/Invite.tsx | 20 +-- web/src/routes/admin/users/Edit.tsx | 109 +++++++++++- web/src/routes/admin/users/users.test.tsx | 91 +++++++++- 12 files changed, 603 insertions(+), 27 deletions(-) create mode 100644 docs/changes/2.2.0/653.feature.md 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' })]