From 1d66b30d0c95c6ee573905c3384cac2fc23fbbdb Mon Sep 17 00:00:00 2001 From: stroblme Date: Thu, 15 Oct 2026 10:31:25 +0200 Subject: [PATCH] =?UTF-8?q?List=20and=20revoke=20portal=20members=20under?= =?UTF-8?q?=20Admin=20=E2=86=92=20Users?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/fluksio/api/routes/cloud.py | 96 ++++++++++++- backend/tests/test_cloud.py | 55 ++++++++ docs/interface/portal.md | 4 + frontend/src/client/schemas.gen.ts | 28 ++++ frontend/src/client/sdk.gen.ts | 45 ++++++- frontend/src/client/types.gen.ts | 14 ++ .../src/components/Admin/PortalMembers.tsx | 127 ++++++++++++++++++ frontend/src/routes/_layout/admin.tsx | 2 + 8 files changed, 369 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/Admin/PortalMembers.tsx diff --git a/backend/fluksio/api/routes/cloud.py b/backend/fluksio/api/routes/cloud.py index 4a52caf..b8f1fb2 100644 --- a/backend/fluksio/api/routes/cloud.py +++ b/backend/fluksio/api/routes/cloud.py @@ -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. diff --git a/backend/tests/test_cloud.py b/backend/tests/test_cloud.py index a51ced1..2e9384a 100644 --- a/backend/tests/test_cloud.py +++ b/backend/tests/test_cloud.py @@ -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() diff --git a/docs/interface/portal.md b/docs/interface/portal.md index f5221af..b4896c0 100644 --- a/docs/interface/portal.md +++ b/docs/interface/portal.md @@ -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 diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 1c3611c..77ec5fa 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -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: { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 18235f7..8da7e17 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { AiReadAiConfigResponse, AiUpdateAiConfigData, AiUpdateAiConfigResponse, AiDeleteAiConfigResponse, AiListAiModelsData, AiListAiModelsResponse, AiReadAiStatusResponse, AiReadControlNodesResponse, AiReadChatResponse, AiChatData, AiChatResponse, AiClearChatResponse, AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsReadWebpushKeyResponse, AlertsAddWebpushSubscriptionData, AlertsAddWebpushSubscriptionResponse, AlertsRemoveWebpushSubscriptionData, AlertsRemoveWebpushSubscriptionResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudDisconnectResponse, ConnectorsReadConnectorStateData, ConnectorsReadConnectorStateResponse, ConnectorsClearConnectorStateData, ConnectorsClearConnectorStateResponse, ConnectorsDeleteConnectorStateData, ConnectorsDeleteConnectorStateResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsGenerateResultsDashboardData, DashboardsGenerateResultsDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlavorsReadFlavorsResponse, FlavorsCreateFlavorData, FlavorsCreateFlavorResponse, FlavorsUpdateFlavorData, FlavorsUpdateFlavorResponse, FlavorsDeleteFlavorData, FlavorsDeleteFlavorResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsAcknowledgeNodeErrorData, FlowsAcknowledgeNodeErrorResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, FlowsIssueSocketTicketResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, ModulesRefreshModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, ObservabilityFlushMetricsResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsUnpairPanelData, PanelsUnpairPanelResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsImportRunsData, RunsImportRunsResponse, RunsReadRunsData, RunsReadRunsResponse, RunsDeleteRunsData, RunsDeleteRunsResponse, RunsReadOverviewResponse, RunsExportMetricsData, RunsExportMetricsResponse, RunsExportRunsData, RunsExportRunsResponse, RunsReadMetricNamesData, RunsReadMetricNamesResponse, RunsReadRunData, RunsReadRunResponse, RunsDeleteRunData, RunsDeleteRunResponse, RunsReadNodeOutputsData, RunsReadNodeOutputsResponse, RunsCancelRunData, RunsCancelRunResponse, RunsRetryRunData, RunsRetryRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SearchReadSearchIndexResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsReadConfigResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersReadResourcesResponse, WorkersReadTokensResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersRevokeTokenData, WorkersRevokeTokenResponse, WorkersReadRuntimeResponse } from './types.gen'; +import type { AiReadAiConfigResponse, AiUpdateAiConfigData, AiUpdateAiConfigResponse, AiDeleteAiConfigResponse, AiListAiModelsData, AiListAiModelsResponse, AiReadAiStatusResponse, AiReadControlNodesResponse, AiReadChatResponse, AiChatData, AiChatResponse, AiClearChatResponse, AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsReadWebpushKeyResponse, AlertsAddWebpushSubscriptionData, AlertsAddWebpushSubscriptionResponse, AlertsRemoveWebpushSubscriptionData, AlertsRemoveWebpushSubscriptionResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudReadMembersResponse, CloudRevokeMemberData, CloudRevokeMemberResponse, CloudDisconnectResponse, ConnectorsReadConnectorStateData, ConnectorsReadConnectorStateResponse, ConnectorsClearConnectorStateData, ConnectorsClearConnectorStateResponse, ConnectorsDeleteConnectorStateData, ConnectorsDeleteConnectorStateResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsGenerateResultsDashboardData, DashboardsGenerateResultsDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlavorsReadFlavorsResponse, FlavorsCreateFlavorData, FlavorsCreateFlavorResponse, FlavorsUpdateFlavorData, FlavorsUpdateFlavorResponse, FlavorsDeleteFlavorData, FlavorsDeleteFlavorResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsAcknowledgeNodeErrorData, FlowsAcknowledgeNodeErrorResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, FlowsIssueSocketTicketResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, ModulesRefreshModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, ObservabilityFlushMetricsResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsUnpairPanelData, PanelsUnpairPanelResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsImportRunsData, RunsImportRunsResponse, RunsReadRunsData, RunsReadRunsResponse, RunsDeleteRunsData, RunsDeleteRunsResponse, RunsReadOverviewResponse, RunsExportMetricsData, RunsExportMetricsResponse, RunsExportRunsData, RunsExportRunsResponse, RunsReadMetricNamesData, RunsReadMetricNamesResponse, RunsReadRunData, RunsReadRunResponse, RunsDeleteRunData, RunsDeleteRunResponse, RunsReadNodeOutputsData, RunsReadNodeOutputsResponse, RunsCancelRunData, RunsCancelRunResponse, RunsRetryRunData, RunsRetryRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SearchReadSearchIndexResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsReadConfigResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersReadResourcesResponse, WorkersReadTokensResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersRevokeTokenData, WorkersRevokeTokenResponse, WorkersReadRuntimeResponse } from './types.gen'; export class AiService { /** @@ -393,6 +393,49 @@ export class CloudService { }); } + /** + * Read Members + * 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. + * @returns PortalMember Successful Response + * @throws ApiError + */ + public static readMembers(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/cloud/members' + }); + } + + /** + * Revoke Member + * 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. + * @param data The data for the request. + * @param data.portalSub + * @returns Message Successful Response + * @throws ApiError + */ + public static revokeMember(data: CloudRevokeMemberData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/cloud/members/{portal_sub}', + path: { + portal_sub: data.portalSub + }, + errors: { + 422: 'Validation Error' + } + }); + } + /** * Disconnect * Sever the connection from this side. diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 5983255..4cea20e 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -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); + +export type CloudRevokeMemberData = { + portalSub: string; +}; + +export type CloudRevokeMemberResponse = (Message); + export type CloudDisconnectResponse = (Message); export type ConnectorsReadConnectorStateData = { diff --git a/frontend/src/components/Admin/PortalMembers.tsx b/frontend/src/components/Admin/PortalMembers.tsx new file mode 100644 index 0000000..ad5dcd4 --- /dev/null +++ b/frontend/src/components/Admin/PortalMembers.tsx @@ -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(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 ( +
+
+

Pending portal members

+ + Admitted at the portal, with no user here until they first open this + instance. Revoking takes the invitation back. + +
+ {isError ? ( +

+ Could not reach the portal. +

+ ) : waiting.length === 0 ? ( +

Nobody pending.

+ ) : ( +
+ {waiting.map((member) => ( +
+ {member.email} + +
+ ))} +
+ )} + + !open && setPending(null)} + > + + + Revoke {pending?.email}? + + They lose access through the portal and need a new invitation to + get it back. + + + + + + + + +
+ ) +} + +export default PortalMembers diff --git a/frontend/src/routes/_layout/admin.tsx b/frontend/src/routes/_layout/admin.tsx index 69c34b5..fedb3e0 100644 --- a/frontend/src/routes/_layout/admin.tsx +++ b/frontend/src/routes/_layout/admin.tsx @@ -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() { +