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
)}
+ setAccepting(true)}>
+ Accept invitation
+
make(false)}>
Copy invitation link
@@ -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 (
+
+ Cancel
+
+ {accept.isPending ? 'Setting…' : 'Set password'}
+
+ >
+ }
+ >
+
+ {(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' })]