List and revoke portal members under Admin → Users
GET /cloud/members asks the portal who it admits to this instance and
marks those with a local user. DELETE /cloud/members/{portal_sub} takes a
membership back. Admin → Users lists the members who have not arrived
yet, with Revoke, which also surfaces a membership left standing when a
local user was deleted while the portal could not be told.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useState } from "react"
|
||||
|
||||
import { CloudService, type PortalMember } from "@/client"
|
||||
import { InfoTip, PANEL_SECTION } from "@/components/Flow/SidePanel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { handleError } from "@/utils"
|
||||
|
||||
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. */
|
||||
export function PortalMembers() {
|
||||
const { data: status } = useQuery({
|
||||
queryKey: ["cloud", "status"],
|
||||
queryFn: async () =>
|
||||
(await CloudService.readStatus()) as {
|
||||
enrolled: boolean
|
||||
revoked: boolean
|
||||
},
|
||||
})
|
||||
// A revoked link's credential no longer lists anything at the portal.
|
||||
const enrolled = Boolean(status?.enrolled && !status.revoked)
|
||||
const { data, isError } = useQuery({
|
||||
queryKey: membersKey,
|
||||
queryFn: () => CloudService.readMembers(),
|
||||
enabled: enrolled,
|
||||
retry: false,
|
||||
})
|
||||
const queryClient = useQueryClient()
|
||||
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||
const [pending, setPending] = useState<PortalMember | null>(null)
|
||||
|
||||
const revoke = useMutation({
|
||||
mutationFn: (portalSub: string) => CloudService.revokeMember({ portalSub }),
|
||||
onSuccess: (message) => {
|
||||
showSuccessToast(message.message)
|
||||
setPending(null)
|
||||
},
|
||||
onError: handleError.bind(showErrorToast),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: membersKey })
|
||||
},
|
||||
})
|
||||
|
||||
if (!enrolled) return null
|
||||
const waiting = (data ?? []).filter((member) => !member.local_user_id)
|
||||
|
||||
return (
|
||||
<section className="grid gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<h2 className={PANEL_SECTION}>Pending portal members</h2>
|
||||
<InfoTip label="Pending portal members">
|
||||
Admitted at the portal, with no user here until they first open this
|
||||
instance. Revoking takes the invitation back.
|
||||
</InfoTip>
|
||||
</div>
|
||||
{isError ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Could not reach the portal.
|
||||
</p>
|
||||
) : waiting.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Nobody pending.</p>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{waiting.map((member) => (
|
||||
<div
|
||||
key={member.user_id}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-card p-4 shadow-e1"
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={pending !== null}
|
||||
onOpenChange={(open) => !open && setPending(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Revoke {pending?.email}?</DialogTitle>
|
||||
<DialogDescription>
|
||||
They lose access through the portal and need a new invitation to
|
||||
get it back.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setPending(null)}>
|
||||
Keep
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={revoke.isPending}
|
||||
onClick={() => pending && revoke.mutate(pending.user_id)}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default PortalMembers
|
||||
Reference in New Issue
Block a user