Link a pending portal member to the local user with their email

A portal identity whose email a local user already has is still refused
on arrival. POST /cloud/members/{portal_sub}/link lets a superuser map it
onto that user, never a superuser's, after the portal confirms the
membership. GET /cloud/members marks such members linkable, and Admin →
Users offers "Link portal account" for them, saying that a linked user is
deleted with a removal at the portal or a Disconnect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
This commit is contained in:
2026-10-15 11:04:53 +02:00
co-authored by Claude Opus 5.5
parent 579c0c22b2
commit 5f05d3f8b4
7 changed files with 233 additions and 10 deletions
@@ -20,7 +20,9 @@ const membersKey = ["cloud", "members"]
/** Portal members who have no user here yet, and the way to take one back.
* A share link admits somebody at the portal before this instance hears of
* them, so without this list nobody here could see who may still arrive.
* Those who have arrived are users above, deleted there. */
* Those who have arrived are users above, deleted there. A member whose email
* a local user already has is refused on arrival until linked to that user
* here. */
export function PortalMembers() {
const { data: status } = useQuery({
queryKey: ["cloud", "status"],
@@ -41,6 +43,7 @@ export function PortalMembers() {
const queryClient = useQueryClient()
const { showSuccessToast, showErrorToast } = useCustomToast()
const [pending, setPending] = useState<PortalMember | null>(null)
const [linking, setLinking] = useState<PortalMember | null>(null)
const revoke = useMutation({
mutationFn: (portalSub: string) => CloudService.revokeMember({ portalSub }),
@@ -54,6 +57,19 @@ export function PortalMembers() {
},
})
const link = useMutation({
mutationFn: (portalSub: string) => CloudService.linkMember({ portalSub }),
onSuccess: (user) => {
showSuccessToast(`Linked ${user.email}`)
setLinking(null)
queryClient.invalidateQueries({ queryKey: ["users"] })
},
onError: handleError.bind(showErrorToast),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: membersKey })
},
})
if (!enrolled) return null
const waiting = (data ?? []).filter((member) => !member.local_user_id)
@@ -81,14 +97,25 @@ export function PortalMembers() {
data-testid="portal-member"
>
<span className="text-sm">{member.email}</span>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setPending(member)}
>
Revoke
</Button>
<div className="flex gap-1">
{member.linkable && (
<Button
variant="ghost"
size="sm"
onClick={() => setLinking(member)}
>
Link portal account
</Button>
)}
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setPending(member)}
>
Revoke
</Button>
</div>
</div>
))}
</div>
@@ -120,6 +147,33 @@ export function PortalMembers() {
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={linking !== null}
onOpenChange={(open) => !open && setLinking(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Link {linking?.email}?</DialogTitle>
<DialogDescription>
Through the portal they open this instance as the local user with
this email, who keeps their password. Removing them at the portal,
or disconnecting it, then deletes that user.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setLinking(null)}>
Cancel
</Button>
<Button
disabled={link.isPending}
onClick={() => linking && link.mutate(linking.user_id)}
>
Link
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
)
}