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
)}
+ {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 (
+
+ )
+}
+
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' })]