From 5f05d3f8b457b2a66063ee2a3098fbc4c9fb1983 Mon Sep 17 00:00:00 2001 From: stroblme Date: Thu, 15 Oct 2026 11:04:53 +0200 Subject: [PATCH] Link a pending portal member to the local user with their email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx --- backend/fluksio/api/routes/cloud.py | 53 ++++++++++++++ backend/tests/test_cloud.py | 73 +++++++++++++++++++ docs/interface/portal.md | 6 ++ frontend/src/client/schemas.gen.ts | 5 ++ frontend/src/client/sdk.gen.ts | 27 ++++++- frontend/src/client/types.gen.ts | 7 ++ .../src/components/Admin/PortalMembers.tsx | 72 +++++++++++++++--- 7 files changed, 233 insertions(+), 10 deletions(-) diff --git a/backend/fluksio/api/routes/cloud.py b/backend/fluksio/api/routes/cloud.py index 029e342..e8e064b 100644 --- a/backend/fluksio/api/routes/cloud.py +++ b/backend/fluksio/api/routes/cloud.py @@ -167,6 +167,9 @@ class PortalMember(BaseModel): email: str #: Their local user, or None while they have not opened the instance yet. local_user_id: uuid.UUID | None = None + #: A local user has their email, which keeps them out until a superuser + #: links the two. Never true of a superuser's account. + linkable: bool = False @router.get( @@ -208,16 +211,66 @@ def read_members(session: SessionDep) -> Any: user.portal_sub: user.id for user in session.exec(select(User).where(col(User.portal_sub).in_(subs))) } + emails = [str(member["email"]) for member in members] + linkable = { + user.email + for user in session.exec(select(User).where(col(User.email).in_(emails))) + if not user.is_superuser and not user.portal_sub + } return [ PortalMember( user_id=str(member["user_id"]), email=str(member["email"]), local_user_id=local.get(str(member["user_id"])), + linkable=str(member["user_id"]) not in local + and str(member["email"]) in linkable, ) for member in members ] +@router.post( + "/members/{portal_sub}/link", + dependencies=[Depends(get_current_active_superuser)], + response_model=UserPublic, +) +def link_member(session: SessionDep, portal_sub: uuid.UUID) -> Any: + """Let a pending member in as the local user that already has their email. + + Arriving on their own they are refused, because handing an existing + account to whoever holds that address at the portal is not a guess to + make. This is a superuser deciding it, and never for a superuser's account. + """ + if cloud_config.load() is None: + raise HTTPException( + status_code=409, detail="This instance is not connected to a portal" + ) + sub = str(portal_sub) + member = enroll_mod.lookup_member(sub) + if member is None: + raise HTTPException( + status_code=404, detail="The portal did not confirm this member" + ) + if session.exec(select(User).where(User.portal_sub == sub)).first(): + raise HTTPException(status_code=409, detail="That account already has access") + user = session.exec(select(User).where(User.email == str(member["email"]))).first() + if user is None: + raise HTTPException(status_code=404, detail="No local user has this email") + if user.is_superuser: + raise HTTPException( + status_code=403, detail="A superuser cannot be linked to a portal account" + ) + if user.portal_sub: + raise HTTPException( + status_code=409, detail="This user is linked to another portal account" + ) + user.portal_sub = sub + session.add(user) + session.commit() + session.refresh(user) + return user + + @router.delete( "/members/{portal_sub}", dependencies=[Depends(get_current_active_superuser)], diff --git a/backend/tests/test_cloud.py b/backend/tests/test_cloud.py index fd5a9a5..791a9b2 100644 --- a/backend/tests/test_cloud.py +++ b/backend/tests/test_cloud.py @@ -809,6 +809,79 @@ def test_admin_lists_and_revokes_portal_members( db.commit() +def test_a_superuser_links_a_pending_member_to_their_local_user( + client: TestClient, + enrolled: User, # noqa: ARG001 (fixture installs the enrolment) + portal_key: rsa.RSAPrivateKey, + superuser_token_headers: dict[str, str], + normal_user_token_headers: dict[str, str], + db: Session, +) -> None: + """Arriving alone they are refused; linked, they arrive as that user.""" + local = create_random_user(db) + boss = create_random_user(db) + boss.is_superuser = True + db.add(boss) + db.commit() + sub, boss_sub = str(uuid.uuid4()), str(uuid.uuid4()) + + def member(user_id: str, email: str) -> Mock: + return Mock( + status_code=200, + json=Mock(return_value={"user_id": user_id, "email": email}), + ) + + def link(user_id: str, reply: Mock, headers: dict[str, str]) -> httpx.Response: + with patch("fluksio.cloud.enroll.httpx.get", return_value=reply): + return client.post( + f"{settings.API_V1_STR}/cloud/members/{user_id}/link", headers=headers + ) + + # Never adopted on their own: the local row might be anyone's. + with patch("fluksio.cloud.enroll.httpx.get", return_value=member(sub, local.email)): + assert user_from_token(db, portal_token(portal_key, subject=sub)) is None + + listed = Mock( + status_code=200, + json=Mock( + return_value={ + "data": [ + {"user_id": sub, "email": local.email}, + {"user_id": boss_sub, "email": boss.email}, + ] + } + ), + ) + with patch("fluksio.api.routes.cloud.httpx.get", return_value=listed): + members = client.get( + f"{settings.API_V1_STR}/cloud/members", headers=superuser_token_headers + ).json() + assert {row["user_id"]: row["linkable"] for row in members} == { + sub: True, + boss_sub: False, + } + + su = superuser_token_headers + assert link(boss_sub, member(boss_sub, boss.email), su).status_code == 403 + assert link(sub, Mock(status_code=404), su).status_code == 404 + assert link(sub, member(sub, "nobody-here@example.com"), su).status_code == 404 + assert ( + link(sub, member(sub, local.email), normal_user_token_headers).status_code + == 403 + ) + + linked = link(sub, member(sub, local.email), su) + assert linked.status_code == 200, linked.text + assert linked.json()["portal_sub"] == sub + assert link(sub, member(sub, local.email), su).status_code == 409 + db.expire_all() + resolved = user_from_token(db, portal_token(portal_key, subject=sub)) + assert resolved is not None and resolved.id == local.id + db.delete(db.get(User, local.id)) + db.delete(db.get(User, boss.id)) + db.commit() + + def test_the_portal_keys_follow_the_tunnel( enrolled: User, # noqa: ARG001 (fixture installs the enrolment) portal_key: rsa.RSAPrivateKey, diff --git a/docs/interface/portal.md b/docs/interface/portal.md index fa26941..ac0cb26 100644 --- a/docs/interface/portal.md +++ b/docs/interface/portal.md @@ -56,6 +56,12 @@ 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. +If a local user already has their email, they are refused rather than handed +that account. A superuser can press **Link portal account** beside them to let +them in as that user, who keeps their local password. Superuser accounts +cannot be linked. A linked user counts as a portal user from then on, so +removing them at the portal, or disconnecting, deletes them. + ## Hooks and agents through the portal The portal also forwards a flow's hooks and the MCP endpoint, so they work diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 77ec5fa..3b7a8c7 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -3059,6 +3059,11 @@ export const PortalMemberSchema = { } ], title: 'Local User Id' + }, + linkable: { + type: 'boolean', + title: 'Linkable', + default: false } }, type: 'object', diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 20e561b..a5844ec 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, ArtifactsDeleteArtifactData, ArtifactsDeleteArtifactResponse, 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'; +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, ArtifactsDeleteArtifactData, ArtifactsDeleteArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudReadMembersResponse, CloudLinkMemberData, CloudLinkMemberResponse, 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 { /** @@ -438,6 +438,31 @@ export class CloudService { }); } + /** + * Link Member + * Let a pending member in as the local user that already has their email. + * + * Arriving on their own they are refused, because handing an existing + * account to whoever holds that address at the portal is not a guess to + * make. This is a superuser deciding it, and never for a superuser's account. + * @param data The data for the request. + * @param data.portalSub + * @returns UserPublic Successful Response + * @throws ApiError + */ + public static linkMember(data: CloudLinkMemberData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/cloud/members/{portal_sub}/link', + path: { + portal_sub: data.portalSub + }, + errors: { + 422: 'Validation Error' + } + }); + } + /** * Revoke Member * Take a portal membership back. diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index acf17da..1734eb0 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -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); +export type CloudLinkMemberData = { + portalSub: string; +}; + +export type CloudLinkMemberResponse = (UserPublic); + export type CloudRevokeMemberData = { portalSub: string; }; diff --git a/frontend/src/components/Admin/PortalMembers.tsx b/frontend/src/components/Admin/PortalMembers.tsx index ad5dcd4..240ec03 100644 --- a/frontend/src/components/Admin/PortalMembers.tsx +++ b/frontend/src/components/Admin/PortalMembers.tsx @@ -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(null) + const [linking, setLinking] = useState(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" > {member.email} - +
+ {member.linkable && ( + + )} + +
))} @@ -120,6 +147,33 @@ export function PortalMembers() { + + !open && setLinking(null)} + > + + + Link {linking?.email}? + + 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. + + + + + + + + ) }