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:
@@ -3059,6 +3059,11 @@ export const PortalMemberSchema = {
|
||||
}
|
||||
],
|
||||
title: 'Local User Id'
|
||||
},
|
||||
linkable: {
|
||||
type: 'boolean',
|
||||
title: 'Linkable',
|
||||
default: false
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1049,6 +1049,7 @@ export type PortalMember = {
|
||||
user_id: string;
|
||||
email: string;
|
||||
local_user_id?: (string | null);
|
||||
linkable?: boolean;
|
||||
};
|
||||
|
||||
export type PrivateUserCreate = {
|
||||
@@ -1644,6 +1645,12 @@ export type CloudAddRemoteUserResponse = (UserPublic);
|
||||
|
||||
export type CloudReadMembersResponse = (Array<PortalMember>);
|
||||
|
||||
export type CloudLinkMemberData = {
|
||||
portalSub: string;
|
||||
};
|
||||
|
||||
export type CloudLinkMemberResponse = (UserPublic);
|
||||
|
||||
export type CloudRevokeMemberData = {
|
||||
portalSub: string;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user