Files
app/frontend/src/components/UserSettings/RemoteAccess.tsx
T
stroblmeandClaude Opus 5 d01a8dad37 Rename Installation to Instance
Follows the portal: the noun is "instance" everywhere the app says it —
UI strings, CLI output, error details, docs and comments. The wire keys
(`instance_id`, `instance_token`) and the hub route this calls move with it.

An existing cloud.json is adopted rather than refused: without the key
alias the dataclass fails to parse, which the caller swallows and reads as
"never enrolled" instead of "reconnect".

`instance_key` on a node type becomes `target_key`. It means the outside
thing a node points at, which is a different sense of the word, and keeping
both would put two meanings of "instance" in one codebase.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YrQnKV3bnQd4K342y8tKj
2026-08-31 10:12:01 +02:00

283 lines
9.3 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useState } from "react"
import { CloudService } from "@/client"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
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
instance_id: string | null
last_error: string | null
connected_since: number | null
}
/**
* Connecting this instance to a Fluksio portal, or cutting it loose, and
* admitting other portal accounts to it.
*
* Deliberately blunt about what it grants: the account that enrolled is what
* the portal owner's sessions act as, and this screen says which one. Anyone
* else gets in only by being added here, as a local user of their own.
* Everything here is optional — an instance 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 [joinCode, setJoinCode] = 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 addRemoteUser = useMutation({
mutationFn: () =>
CloudService.addRemoteUser({ requestBody: { code: joinCode.trim() } }),
onSuccess: (user) => {
setJoinCode("")
showSuccessToast(`Added ${user.email}`)
// They are an ordinary user from here on, and the Admin page lists them.
queryClient.invalidateQueries({ queryKey: ["users"] })
},
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 (
<Card>
<CardHeader>
<CardTitle>Remote access</CardTitle>
<CardDescription>
Reach this instance from fluksio.com. Entirely optional without it,
this instance talks to nothing outside your network.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-6">
{status.enrolled ? (
<>
<div className="flex flex-col gap-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">
Portal sessions of this account get this account's rights
here. Anyone else gets in only once added below, as their
own user.
</span>
</Field>
<Field label="Instance">
<span className="font-mono text-xs">
{status.instance_id ?? "—"}
</span>
</Field>
</dl>
<div>
<Button
variant="destructive"
className="rounded-full"
onClick={() => setConfirmDisconnect(true)}
>
Disconnect
</Button>
</div>
</div>
<Separator />
<div className="flex flex-col gap-4">
<div>
<h3 className="font-medium">Remote users</h3>
<p className="text-sm text-muted-foreground">
Let someone else reach this instance through the portal. They
get a user of their own here — not yours, and never a
superuser, so they cannot pass access on.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="join-code">Code</Label>
<Input
id="join-code"
value={joinCode}
placeholder="XXXX-XXXX"
className="font-mono tracking-widest"
onChange={(event) =>
setJoinCode(event.target.value.toUpperCase())
}
/>
<p className="text-xs text-muted-foreground">
They get a code at fluksio.com → Instances → Join an instance.
Added users appear under Admin → Users; deleting them there
ends their access.
</p>
</div>
<div>
<Button
className="rounded-full"
variant="outline"
disabled={!joinCode.trim() || addRemoteUser.isPending}
onClick={() => addRemoteUser.mutate()}
>
{addRemoteUser.isPending ? "Adding…" : "Add remote user"}
</Button>
</div>
</div>
</>
) : (
<div className="flex flex-col gap-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 → Instances → Add instance.
</p>
</div>
<div>
<Button
variant="brand"
className="rounded-full"
disabled={
!code.trim() || !portalUrl.trim() || connect.isPending
}
onClick={() => connect.mutate()}
>
{connect.isPending ? "Connecting…" : "Connect"}
</Button>
</div>
</div>
)}
</CardContent>
<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 instance 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>
</Card>
)
}
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