Settings: connect this installation to a portal
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:
@@ -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
|
||||
Reference in New Issue
Block a user