A portal session names a person, not whoever enrolled
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

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:
2026-08-21 11:34:01 +02:00
co-authored by Claude Opus 5
parent 4e253f41ae
commit 81dad0a449
14 changed files with 553 additions and 64 deletions
+10 -1
View File
@@ -35,7 +35,16 @@ export const columns: ColumnDef<UserTableData>[] = [
accessorKey: "email",
header: "Email",
cell: ({ row }) => (
<span className="text-muted-foreground">{row.original.email}</span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">{row.original.email}</span>
{/* Signs in through the portal, never here — worth saying, because
deleting this row is what ends their remote access. */}
{row.original.portal_sub && (
<Badge variant="outline" className="text-xs">
Portal
</Badge>
)}
</div>
),
},
{
@@ -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">