diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index b02a923..dad9fc0 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -634,6 +634,26 @@ these so a value never appears to come from nowhere — or worse, appears to come from whichever node happens to be drawn as a producer.` } as const; +export const EnrollBodySchema = { + properties: { + portal_url: { + type: 'string', + maxLength: 255, + minLength: 1, + title: 'Portal Url' + }, + claim_code: { + type: 'string', + maxLength: 32, + minLength: 1, + title: 'Claim Code' + } + }, + type: 'object', + required: ['portal_url', 'claim_code'], + title: 'EnrollBody' +} as const; + export const EventRowSchema = { properties: { id: { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 13a368e..6f83928 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 { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, 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, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsReadRunsData, RunsReadRunsResponse, RunsReadRunData, RunsReadRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, 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, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen'; +import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, 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, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsReadRunsData, RunsReadRunsResponse, RunsReadRunData, RunsReadRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, 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, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen'; export class AlertsService { /** @@ -107,6 +107,64 @@ export class ArtifactsService { } } +export class CloudService { + /** + * Read Status + * Whether this installation is enrolled, and whether the link is up. + * + * Readable by any signed-in user: everyone here has a right to know whether + * the machine they are using can be reached from outside. + * @returns unknown Successful Response + * @throws ApiError + */ + public static readStatus(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/cloud/status' + }); + } + + /** + * Enroll + * Redeem a claim code and start dialling the portal. + * + * The account performing this is recorded as the one every portal session + * will act as. There is no way to widen that later from the portal side. + * @param data The data for the request. + * @param data.requestBody + * @returns Message Successful Response + * @throws ApiError + */ + public static enroll(data: CloudEnrollData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/cloud/enroll', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Disconnect + * Sever the connection from this side. + * + * Unilateral and immediate: the config is the only reason portal tokens + * verify here, so deleting it ends remote access whatever the portal still + * has on file. + * @returns Message Successful Response + * @throws ApiError + */ + public static disconnect(): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/cloud' + }); + } +} + export class DashboardsService { /** * Read Dashboards diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index d51278c..ba35a1d 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -268,6 +268,11 @@ export type Endpoint = { requires?: Array<(string)>; }; +export type EnrollBody = { + portal_url: string; + claim_code: string; +}; + export type EventRow = { id: number; ts: string; @@ -1012,6 +1017,18 @@ export type ArtifactsGetArtifactData = { export type ArtifactsGetArtifactResponse = (unknown); +export type CloudReadStatusResponse = ({ + [key: string]: unknown; +}); + +export type CloudEnrollData = { + requestBody: EnrollBody; +}; + +export type CloudEnrollResponse = (Message); + +export type CloudDisconnectResponse = (Message); + export type DashboardsReadDashboardsResponse = (DashboardsPublic); export type DashboardsReadDashboardData = { diff --git a/frontend/src/components/UserSettings/RemoteAccess.tsx b/frontend/src/components/UserSettings/RemoteAccess.tsx new file mode 100644 index 0000000..c3ac58e --- /dev/null +++ b/frontend/src/components/UserSettings/RemoteAccess.tsx @@ -0,0 +1,211 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { useState } from "react" + +import { CloudService } from "@/client" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import useCustomToast from "@/hooks/useCustomToast" +import { handleError } from "@/utils" + +const DEFAULT_PORTAL = "https://hub.fluksio.com" + +type CloudStatus = { + enrolled: boolean + connected: boolean + portal_url: string | null + portal_account: string | null + installation_id: string | null + last_error: string | null + connected_since: number | null +} + +/** + * Connecting this installation to a Fluksio portal, or cutting it loose. + * + * Deliberately blunt about what it grants: a remote session acts as the + * account that enrolled, and this screen says which one. Everything here is + * optional — an installation nobody enrolls never contacts anything. + */ +export function RemoteAccess() { + const queryClient = useQueryClient() + const { showErrorToast, showSuccessToast } = useCustomToast() + const [portalUrl, setPortalUrl] = useState(DEFAULT_PORTAL) + const [code, setCode] = useState("") + const [confirmDisconnect, setConfirmDisconnect] = useState(false) + + const { data: status } = useQuery({ + queryKey: ["cloud", "status"], + // The endpoint returns a plain object; the generated type is `unknown` + // because it has no response model of its own. + queryFn: async () => (await CloudService.readStatus()) as CloudStatus, + // Often enough that "connecting…" resolves while someone is watching it. + refetchInterval: 5000, + }) + + const invalidate = () => + queryClient.invalidateQueries({ queryKey: ["cloud", "status"] }) + + const connect = useMutation({ + mutationFn: () => + CloudService.enroll({ + requestBody: { portal_url: portalUrl.trim(), claim_code: code.trim() }, + }), + onSuccess: () => { + setCode("") + showSuccessToast("Connected to the portal") + invalidate() + }, + onError: handleError.bind(showErrorToast), + }) + + const disconnect = useMutation({ + mutationFn: () => CloudService.disconnect(), + onSuccess: () => { + setConfirmDisconnect(false) + showSuccessToast("Disconnected from the portal") + invalidate() + }, + onError: handleError.bind(showErrorToast), + }) + + if (!status) return null + + return ( +
+
+

Remote access

+

+ Reach this installation from fluksio.com. Entirely optional — without + it, this installation talks to nothing outside your network. +

+
+ + {status.enrolled ? ( +
+
+ + {status.connected + ? "Connected" + : status.last_error + ? `Reconnecting — ${status.last_error}` + : "Reconnecting…"} + + {status.portal_url ?? "—"} + + {status.portal_account ?? "—"} + + Anyone signed in to the portal for this installation gets this + account's rights here. + + + + + {status.installation_id ?? "—"} + + +
+
+ +
+
+ ) : ( +
+
+ + setPortalUrl(event.target.value)} + /> +
+
+ + setCode(event.target.value.toUpperCase())} + /> +

+ Get a code at fluksio.com → Installations → Add installation. +

+
+
+ +
+
+ )} + + + + + Disconnect from the portal? + + Remote access ends immediately and the portal's credentials stop + working here. Nothing on this installation is changed or deleted, + and you can connect again with a new code. + + + + + + + + +
+ ) +} + +function Field({ + label, + children, +}: { + label: string + children: React.ReactNode +}) { + return ( +
+
+ {label} +
+
{children}
+
+ ) +} + +export default RemoteAccess diff --git a/frontend/src/routes/_layout/settings.tsx b/frontend/src/routes/_layout/settings.tsx index fd473fc..6efa62d 100644 --- a/frontend/src/routes/_layout/settings.tsx +++ b/frontend/src/routes/_layout/settings.tsx @@ -3,6 +3,7 @@ import { createFileRoute } from "@tanstack/react-router" import Appearance from "@/components/UserSettings/Appearance" import ChangePassword from "@/components/UserSettings/ChangePassword" import DeleteAccount from "@/components/UserSettings/DeleteAccount" +import RemoteAccess from "@/components/UserSettings/RemoteAccess" import UserInformation from "@/components/UserSettings/UserInformation" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import useAuth from "@/hooks/useAuth" @@ -14,6 +15,12 @@ const tabsConfig = [ { value: "danger-zone", title: "Danger zone", component: DeleteAccount }, ] +// Whether this whole installation can be reached from outside is not a +// personal preference, so the tab only exists for an operator. +const superuserTabs = [ + { value: "remote-access", title: "Remote access", component: RemoteAccess }, +] + export const Route = createFileRoute("/_layout/settings")({ component: UserSettings, head: () => ({ @@ -29,7 +36,10 @@ function UserSettings() { const { user: currentUser } = useAuth() // A superuser deleting its own account would lock everyone out. const finalTabs = currentUser?.is_superuser - ? tabsConfig.filter((tab) => tab.value !== "danger-zone") + ? [ + ...tabsConfig.filter((tab) => tab.value !== "danger-zone"), + ...superuserTabs, + ] : tabsConfig if (!currentUser) {