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.
This commit is contained in:
2026-10-15 10:31:25 +02:00
parent 8b47b32d3f
commit 1d66b30d0c
8 changed files with 369 additions and 2 deletions
+28
View File
@@ -3038,6 +3038,34 @@ export const PlacementSchema = {
description: "Where a widget sits in its section's grid, in grid units."
} as const;
export const PortalMemberSchema = {
properties: {
user_id: {
type: 'string',
title: 'User Id'
},
email: {
type: 'string',
title: 'Email'
},
local_user_id: {
anyOf: [
{
type: 'string',
format: 'uuid'
},
{
type: 'null'
}
],
title: 'Local User Id'
}
},
type: 'object',
required: ['user_id', 'email'],
title: 'PortalMember'
} as const;
export const PrivateUserCreateSchema = {
properties: {
email: {
File diff suppressed because one or more lines are too long
+14
View File
@@ -1045,6 +1045,12 @@ export type Placement = {
h?: number;
};
export type PortalMember = {
user_id: string;
email: string;
local_user_id?: (string | null);
};
export type PrivateUserCreate = {
email: string;
password: string;
@@ -1630,6 +1636,14 @@ export type CloudAddRemoteUserData = {
export type CloudAddRemoteUserResponse = (UserPublic);
export type CloudReadMembersResponse = (Array<PortalMember>);
export type CloudRevokeMemberData = {
portalSub: string;
};
export type CloudRevokeMemberResponse = (Message);
export type CloudDisconnectResponse = (Message);
export type ConnectorsReadConnectorStateData = {
@@ -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
+2
View File
@@ -6,6 +6,7 @@ import { type UserPublic, UsersService } from "@/client"
import AddUser from "@/components/Admin/AddUser"
import { columns, type UserTableData } from "@/components/Admin/columns"
import OAuthClients from "@/components/Admin/OAuthClients"
import PortalMembers from "@/components/Admin/PortalMembers"
import { DataTable } from "@/components/Common/DataTable"
import PendingUsers from "@/components/Pending/PendingUsers"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
@@ -77,6 +78,7 @@ function Admin() {
<AddUser />
</div>
<UsersTable />
<PortalMembers />
</TabsContent>
<TabsContent value="agents">
<OAuthClients />