Settings: connect this installation to a portal
Playwright Tests / test-playwright (1, 2) (push) Canceled after 0s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s

A superuser-only tab that redeems a claim code and shows the link's state. It
names the account a remote session acts as, because that is the thing being
granted and the person granting it should see it spelled out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XtBzdDyLsmDaF1W7DLYtYM
This commit is contained in:
2026-08-19 17:39:14 +02:00
co-authored by Claude Opus 5
parent 414cda2798
commit 8d539a4ba8
5 changed files with 318 additions and 2 deletions
+20
View File
@@ -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.` come from whichever node happens to be drawn as a producer.`
} as const; } 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 = { export const EventRowSchema = {
properties: { properties: {
id: { id: {
+59 -1
View File
@@ -3,7 +3,7 @@
import type { CancelablePromise } from './core/CancelablePromise'; import type { CancelablePromise } from './core/CancelablePromise';
import { OpenAPI } from './core/OpenAPI'; import { OpenAPI } from './core/OpenAPI';
import { request as __request } from './core/request'; 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 { 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<CloudReadStatusResponse> {
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<CloudEnrollResponse> {
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<CloudDisconnectResponse> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/api/v1/cloud'
});
}
}
export class DashboardsService { export class DashboardsService {
/** /**
* Read Dashboards * Read Dashboards
+17
View File
@@ -268,6 +268,11 @@ export type Endpoint = {
requires?: Array<(string)>; requires?: Array<(string)>;
}; };
export type EnrollBody = {
portal_url: string;
claim_code: string;
};
export type EventRow = { export type EventRow = {
id: number; id: number;
ts: string; ts: string;
@@ -1012,6 +1017,18 @@ export type ArtifactsGetArtifactData = {
export type ArtifactsGetArtifactResponse = (unknown); 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 DashboardsReadDashboardsResponse = (DashboardsPublic);
export type DashboardsReadDashboardData = { export type DashboardsReadDashboardData = {
@@ -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<CloudStatus>({
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 (
<div className="flex max-w-2xl flex-col gap-6 py-4">
<div>
<h2 className="text-lg font-medium">Remote access</h2>
<p className="text-sm text-muted-foreground">
Reach this installation from fluksio.com. Entirely optional without
it, this installation talks to nothing outside your network.
</p>
</div>
{status.enrolled ? (
<div className="flex flex-col gap-4 rounded-lg border border-border p-4">
<dl className="grid gap-3 sm:grid-cols-2">
<Field label="Status">
{status.connected
? "Connected"
: status.last_error
? `Reconnecting — ${status.last_error}`
: "Reconnecting…"}
</Field>
<Field label="Portal">{status.portal_url ?? "—"}</Field>
<Field label="Acting as">
{status.portal_account ?? "—"}
<span className="mt-1 block text-xs text-muted-foreground">
Anyone signed in to the portal for this installation gets this
account's rights here.
</span>
</Field>
<Field label="Installation">
<span className="font-mono text-xs">
{status.installation_id ?? "—"}
</span>
</Field>
</dl>
<div>
<Button
variant="destructive"
className="rounded-full"
onClick={() => setConfirmDisconnect(true)}
>
Disconnect
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-4 rounded-lg border border-border p-4">
<div className="grid gap-2">
<Label htmlFor="portal-url">Portal</Label>
<Input
id="portal-url"
value={portalUrl}
onChange={(event) => setPortalUrl(event.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="claim-code">Code</Label>
<Input
id="claim-code"
value={code}
placeholder="XXXX-XXXX"
className="font-mono tracking-widest"
onChange={(event) => setCode(event.target.value.toUpperCase())}
/>
<p className="text-xs text-muted-foreground">
Get a code at fluksio.com → Installations → Add installation.
</p>
</div>
<div>
<Button
className="rounded-full bg-[--brand-secondary] text-white hover:opacity-90"
disabled={!code.trim() || !portalUrl.trim() || connect.isPending}
onClick={() => connect.mutate()}
>
{connect.isPending ? "Connecting…" : "Connect"}
</Button>
</div>
</div>
)}
<Dialog open={confirmDisconnect} onOpenChange={setConfirmDisconnect}>
<DialogContent>
<DialogHeader>
<DialogTitle>Disconnect from the portal?</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
className="rounded-full"
onClick={() => setConfirmDisconnect(false)}
>
Cancel
</Button>
<Button
variant="destructive"
className="rounded-full"
disabled={disconnect.isPending}
onClick={() => disconnect.mutate()}
>
Disconnect
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
function Field({
label,
children,
}: {
label: string
children: React.ReactNode
}) {
return (
<div>
<dt className="text-xs uppercase tracking-wide text-muted-foreground">
{label}
</dt>
<dd className="mt-1 text-sm">{children}</dd>
</div>
)
}
export default RemoteAccess
+11 -1
View File
@@ -3,6 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"
import Appearance from "@/components/UserSettings/Appearance" import Appearance from "@/components/UserSettings/Appearance"
import ChangePassword from "@/components/UserSettings/ChangePassword" import ChangePassword from "@/components/UserSettings/ChangePassword"
import DeleteAccount from "@/components/UserSettings/DeleteAccount" import DeleteAccount from "@/components/UserSettings/DeleteAccount"
import RemoteAccess from "@/components/UserSettings/RemoteAccess"
import UserInformation from "@/components/UserSettings/UserInformation" import UserInformation from "@/components/UserSettings/UserInformation"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import useAuth from "@/hooks/useAuth" import useAuth from "@/hooks/useAuth"
@@ -14,6 +15,12 @@ const tabsConfig = [
{ value: "danger-zone", title: "Danger zone", component: DeleteAccount }, { 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")({ export const Route = createFileRoute("/_layout/settings")({
component: UserSettings, component: UserSettings,
head: () => ({ head: () => ({
@@ -29,7 +36,10 @@ function UserSettings() {
const { user: currentUser } = useAuth() const { user: currentUser } = useAuth()
// A superuser deleting its own account would lock everyone out. // A superuser deleting its own account would lock everyone out.
const finalTabs = currentUser?.is_superuser const finalTabs = currentUser?.is_superuser
? tabsConfig.filter((tab) => tab.value !== "danger-zone") ? [
...tabsConfig.filter((tab) => tab.value !== "danger-zone"),
...superuserTabs,
]
: tabsConfig : tabsConfig
if (!currentUser) { if (!currentUser) {