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:
@@ -13,12 +13,14 @@ chain stops with whoever a superuser here typed a code for.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlmodel import col, select
|
||||
|
||||
from fluksio.api.deps import (
|
||||
CurrentUser,
|
||||
@@ -28,7 +30,7 @@ from fluksio.api.deps import (
|
||||
)
|
||||
from fluksio.cloud import config as cloud_config
|
||||
from fluksio.cloud import enroll as enroll_mod
|
||||
from fluksio.models import Message, UserPublic
|
||||
from fluksio.models import Message, User, UserPublic
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -159,6 +161,98 @@ def add_remote_user(session: SessionDep, body: RemoteUserBody) -> Any:
|
||||
raise HTTPException(status_code=exc.status, detail=exc.detail) from exc
|
||||
|
||||
|
||||
class PortalMember(BaseModel):
|
||||
#: Their portal account, which is what ``portal_sub`` holds here.
|
||||
user_id: str
|
||||
email: str
|
||||
#: Their local user, or None while they have not opened the instance yet.
|
||||
local_user_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/members",
|
||||
dependencies=[Depends(get_current_active_superuser)],
|
||||
response_model=list[PortalMember],
|
||||
)
|
||||
def read_members(session: SessionDep) -> Any:
|
||||
"""Everyone the portal admits to this instance, its owner aside.
|
||||
|
||||
A share link admits somebody at the portal before this instance has heard
|
||||
of them, so this is the one place a superuser here can see who may still
|
||||
arrive. It also shows a membership left standing when a local user was
|
||||
deleted while the portal could not be told.
|
||||
"""
|
||||
config = cloud_config.load()
|
||||
if config is None:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="This instance is not connected to a portal"
|
||||
)
|
||||
try:
|
||||
response = httpx.get(
|
||||
f"{config.portal_url.rstrip('/')}/api/v1/instance-members/",
|
||||
headers={"Authorization": f"Bearer {config.token}"},
|
||||
timeout=15.0,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502, detail=f"Could not reach the portal: {exc}"
|
||||
) from exc
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"The portal did not list members ({response.status_code})",
|
||||
)
|
||||
members = response.json()["data"]
|
||||
subs = [str(member["user_id"]) for member in members]
|
||||
local = {
|
||||
user.portal_sub: user.id
|
||||
for user in session.exec(select(User).where(col(User.portal_sub).in_(subs)))
|
||||
}
|
||||
return [
|
||||
PortalMember(
|
||||
user_id=str(member["user_id"]),
|
||||
email=str(member["email"]),
|
||||
local_user_id=local.get(str(member["user_id"])),
|
||||
)
|
||||
for member in members
|
||||
]
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/members/{portal_sub}",
|
||||
dependencies=[Depends(get_current_active_superuser)],
|
||||
response_model=Message,
|
||||
)
|
||||
def revoke_member(portal_sub: uuid.UUID) -> Message:
|
||||
"""Take a portal membership back.
|
||||
|
||||
For somebody who has not arrived, this is the whole of their access.
|
||||
Somebody who has is deleted under Admin → Users instead, which also does
|
||||
this.
|
||||
"""
|
||||
config = cloud_config.load()
|
||||
if config is None:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="This instance is not connected to a portal"
|
||||
)
|
||||
try:
|
||||
response = httpx.delete(
|
||||
f"{config.portal_url.rstrip('/')}/api/v1/instance-members/{portal_sub}",
|
||||
headers={"Authorization": f"Bearer {config.token}"},
|
||||
timeout=15.0,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502, detail=f"Could not reach the portal: {exc}"
|
||||
) from exc
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"The portal refused the revocation ({response.status_code})",
|
||||
)
|
||||
return Message(message="Membership revoked")
|
||||
|
||||
|
||||
def forget_remote_user(portal_sub: str) -> None:
|
||||
"""Tell the portal a remote user is gone from here.
|
||||
|
||||
|
||||
@@ -717,3 +717,58 @@ def test_a_member_removed_at_the_portal_loses_their_user(db: Session) -> None:
|
||||
assert db.get(User, boss_id) is not None
|
||||
db.delete(boss)
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_admin_lists_and_revokes_portal_members(
|
||||
client: TestClient,
|
||||
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
|
||||
superuser_token_headers: dict[str, str],
|
||||
normal_user_token_headers: dict[str, str],
|
||||
db: Session,
|
||||
) -> None:
|
||||
"""Who the portal admits, and which of them have arrived here."""
|
||||
arrived = create_random_user(db)
|
||||
arrived.portal_sub = str(uuid.uuid4())
|
||||
db.add(arrived)
|
||||
db.commit()
|
||||
waiting = str(uuid.uuid4())
|
||||
listed = Mock(
|
||||
status_code=200,
|
||||
json=Mock(
|
||||
return_value={
|
||||
"data": [
|
||||
{"user_id": arrived.portal_sub, "email": arrived.email},
|
||||
{"user_id": waiting, "email": "invited@example.com"},
|
||||
]
|
||||
}
|
||||
),
|
||||
)
|
||||
with patch("fluksio.api.routes.cloud.httpx.get", return_value=listed) as get:
|
||||
members = client.get(
|
||||
f"{settings.API_V1_STR}/cloud/members", headers=superuser_token_headers
|
||||
)
|
||||
assert members.status_code == 200, members.text
|
||||
assert get.call_args.args[0].endswith("/instance-members/")
|
||||
assert {row["user_id"]: row["local_user_id"] for row in members.json()} == {
|
||||
arrived.portal_sub: str(arrived.id),
|
||||
waiting: None,
|
||||
}
|
||||
|
||||
with patch(
|
||||
"fluksio.api.routes.cloud.httpx.delete", return_value=Mock(status_code=200)
|
||||
) as delete:
|
||||
revoked = client.delete(
|
||||
f"{settings.API_V1_STR}/cloud/members/{waiting}",
|
||||
headers=superuser_token_headers,
|
||||
)
|
||||
assert revoked.status_code == 200, revoked.text
|
||||
assert delete.call_args.args[0].endswith(f"/instance-members/{waiting}")
|
||||
|
||||
assert (
|
||||
client.get(
|
||||
f"{settings.API_V1_STR}/cloud/members", headers=normal_user_token_headers
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
db.delete(arrived)
|
||||
db.commit()
|
||||
|
||||
@@ -52,6 +52,10 @@ Their account appears under **Admin → Users** with a *Portal* badge. They
|
||||
sign in through the portal rather than with a local password. Invitations are
|
||||
time-limited; generate a fresh one if it expires.
|
||||
|
||||
Somebody admitted by a link gets their local account the first time they open
|
||||
the instance. Until then they are listed under **Admin → Users → Pending portal
|
||||
members**, where you can revoke the invitation.
|
||||
|
||||
## Manage or end remote access
|
||||
|
||||
**Settings → Remote access** shows the connection status and the latest
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user