A portal session names a person, not whoever enrolled
Remote access used to collapse every portal session onto the account that performed the enrolment. That was the only thing it could do while nothing here knew who was at the other end, and it is why letting a second person in meant handing them the first one's account. `user.portal_sub` is where a portal identity meets a local one: set for the enrolling superuser at enrolment, and for each person a superuser admits afterwards through Settings -> Remote access -> Add remote user. The code they type comes from the newcomer's own portal account, and it is redeemed against the hub with this installation's tunnel credential rather than with a portal session, so being let in is not itself the power to let others in. The account created is never a superuser, which closes the same door from this side. A proxy token now resolves through that mapping and nowhere else. An identity nobody mapped resolves to no user rather than falling back on the enroller, so deleting the local row under Admin -> Users is the whole of the revocation: it bites on a credential already in flight, and it does not wait on the portal being reachable to be told. Telling the portal is best effort for exactly that reason. The cost is stated where it lands, in DEPLOY.md: an installation enrolled before this has no mapping, so its owner reconnects once with a fresh code. Panels and the health summary still act as the enrolling account - neither of them is a person, and neither gained a way to name one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,17 +29,21 @@ type CloudStatus = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Connecting this installation to a Fluksio portal, or cutting it loose.
|
||||
* Connecting this installation to a Fluksio portal, or cutting it loose, and
|
||||
* admitting other portal accounts to it.
|
||||
*
|
||||
* 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.
|
||||
* 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 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 [joinCode, setJoinCode] = useState("")
|
||||
const [confirmDisconnect, setConfirmDisconnect] = useState(false)
|
||||
|
||||
const { data: status } = useQuery<CloudStatus>({
|
||||
@@ -67,6 +71,18 @@ export function RemoteAccess() {
|
||||
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: () => {
|
||||
@@ -90,39 +106,80 @@ export function RemoteAccess() {
|
||||
</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 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">
|
||||
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="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>
|
||||
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-border p-4">
|
||||
<div>
|
||||
<h3 className="font-medium">Remote users</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Let someone else reach this installation 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 → Installations → Join an
|
||||
installation. 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 rounded-lg border border-border p-4">
|
||||
<div className="grid gap-2">
|
||||
|
||||
Reference in New Issue
Block a user