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:
+16
@@ -324,6 +324,22 @@ Shares components with the admin view. See `docs/architecture/structure.canvas`
|
||||
credential is traded for the hub's cookie rather than carried in the URL a
|
||||
year-long token must never sit in. What is waiting on a code is named
|
||||
before anyone approves it
|
||||
- [x] Remote users: a portal account other than the installation's owner reaches
|
||||
it, as a local user of its own. The mirror of the claim code — the person
|
||||
wanting in mints a code on their portal account, and a superuser here
|
||||
redeems it, so admission is a local decision made by someone who already
|
||||
had the right to grant remote access at all. The installation redeems it
|
||||
holding its tunnel credential rather than a portal session, which is what
|
||||
stops an admitted user from admitting anyone else; the account they get is
|
||||
never a superuser, so the same is true from this side. `user.portal_sub`
|
||||
is where a portal identity meets a local account, set for the enrolling
|
||||
superuser at enrolment and for each admitted user after. A proxy token now
|
||||
names a person rather than resolving to whoever enrolled, and an unmapped
|
||||
identity resolves to no user — so deleting the local row is the whole of
|
||||
the revocation, immediate even against a credential already in flight and
|
||||
even when the portal cannot be reached to be told. The hub's `_authorize`
|
||||
grew one member lookup beside the owner comparison, which is the only
|
||||
thing that changed about the tunnel
|
||||
|
||||
## Phase 5 — Website and docs
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Map local accounts to the portal identities they stand for
|
||||
|
||||
A portal session used to act as whoever enrolled this installation. It now
|
||||
names a person, and this column is where that name meets a local account: set
|
||||
for the enrolling superuser at enrolment, and for every remote user a superuser
|
||||
admits afterwards. Unique, because two local accounts standing for the same
|
||||
portal identity would make the lookup a coin toss.
|
||||
|
||||
Revision ID: c4e8b2170f93
|
||||
Revises: a3f1c07b52d9
|
||||
Create Date: 2026-08-21 10:42:07.512004
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel.sql.sqltypes
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'c4e8b2170f93'
|
||||
down_revision = 'a3f1c07b52d9'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
'user',
|
||||
sa.Column('portal_sub', sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True),
|
||||
)
|
||||
op.create_index(op.f('ix_user_portal_sub'), 'user', ['portal_sub'], unique=True)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index(op.f('ix_user_portal_sub'), table_name='user')
|
||||
op.drop_column('user', 'portal_sub')
|
||||
+24
-6
@@ -6,7 +6,7 @@ from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jwt.exceptions import InvalidTokenError
|
||||
from pydantic import ValidationError
|
||||
from sqlmodel import Session
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.cloud import config as cloud_config
|
||||
from app.core import security
|
||||
@@ -98,9 +98,11 @@ def decode_token(token: str, request: Request | None = None) -> dict[str, Any]:
|
||||
|
||||
The last branch is the seam a hosted deployment widens: a portal this
|
||||
installation was enrolled with signs tokens with a key pinned at
|
||||
enrolment, and they resolve to the local account that performed it. With
|
||||
no enrolment the branch raises immediately, so an offline installation
|
||||
pays nothing for the possibility. A screen that paired through that portal
|
||||
enrolment, and they name the portal account holding them, which resolves
|
||||
to whichever local account was mapped to it — the superuser who enrolled,
|
||||
or a remote user one of them admitted since. With no enrolment the branch
|
||||
raises immediately, so an offline installation pays nothing for the
|
||||
possibility. A screen that paired through that portal
|
||||
arrives there too, naming a panel — which is why the gate below is applied
|
||||
to whichever branch produced the claims rather than to one of them: where a
|
||||
panel credential was minted is not what decides what it may read.
|
||||
@@ -142,6 +144,22 @@ def _gate_panel(payload: dict[str, Any], request: Request | None) -> dict[str, A
|
||||
return payload
|
||||
|
||||
|
||||
def _user_for(session: Session, token_data: TokenPayload) -> User | None:
|
||||
"""The local account a payload names, by id or by portal identity.
|
||||
|
||||
A token the portal minted names a person on the portal, not a user here, so
|
||||
the mapping a superuser made when they admitted them is what turns one into
|
||||
the other. No mapping, no user — the caller answers that the same way it
|
||||
answers a token naming a deleted account, which is what makes deleting the
|
||||
local user the whole of the revocation.
|
||||
"""
|
||||
if token_data.portal_sub:
|
||||
return session.exec(
|
||||
select(User).where(User.portal_sub == token_data.portal_sub)
|
||||
).first()
|
||||
return session.get(User, token_data.sub) if token_data.sub else None
|
||||
|
||||
|
||||
def user_from_token(
|
||||
session: Session, token: str, request: Request | None = None
|
||||
) -> User | None:
|
||||
@@ -156,7 +174,7 @@ def user_from_token(
|
||||
token_data = TokenPayload(**decode_token(token, request))
|
||||
except (InvalidTokenError, ValidationError):
|
||||
return None
|
||||
user = session.get(User, token_data.sub)
|
||||
user = _user_for(session, token_data)
|
||||
if user is None or not user.is_active:
|
||||
return None
|
||||
return user
|
||||
@@ -178,7 +196,7 @@ def get_current_user(request: Request, session: SessionDep, token: TokenDep) ->
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
)
|
||||
user = session.get(User, token_data.sub)
|
||||
user = _user_for(session, token_data)
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
||||
@@ -3,22 +3,36 @@
|
||||
Entirely optional, and superuser-only to change: enrolling grants a remote
|
||||
party the rights of the account that performed it, which is not a decision an
|
||||
ordinary user of this installation gets to make on everyone else's behalf.
|
||||
|
||||
Admitting further portal accounts is the same decision made again, so it is
|
||||
guarded the same way. A person let in this way gets an ordinary local account
|
||||
of their own — never a superuser, so they cannot admit anyone else, and the
|
||||
chain stops with whoever a superuser here typed a code for.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlmodel import select
|
||||
|
||||
from app.api.deps import CurrentUser, get_current_active_superuser, get_current_user
|
||||
from app import crud
|
||||
from app.api.deps import (
|
||||
CurrentUser,
|
||||
SessionDep,
|
||||
get_current_active_superuser,
|
||||
get_current_user,
|
||||
)
|
||||
from app.cloud import config as cloud_config
|
||||
from app.models import Message
|
||||
from app.core.security import get_password_hash
|
||||
from app.models import Message, User, UserPublic
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -61,12 +75,14 @@ def read_status(request: Request) -> dict[str, Any]:
|
||||
response_model=Message,
|
||||
)
|
||||
async def enroll(
|
||||
request: Request, current_user: CurrentUser, body: EnrollBody
|
||||
request: Request, session: SessionDep, current_user: CurrentUser, body: EnrollBody
|
||||
) -> Message:
|
||||
"""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.
|
||||
The account performing this is mapped to the portal account that owns the
|
||||
installation, so the owner's portal sessions arrive here as them. Widening
|
||||
that to anyone else is a local decision made one person at a time, below —
|
||||
never something the portal can do from its side.
|
||||
"""
|
||||
if cloud_config.exists():
|
||||
raise HTTPException(
|
||||
@@ -96,6 +112,16 @@ async def enroll(
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
owner_id = data.get("owner_id")
|
||||
if not owner_id:
|
||||
# A portal older than remote users does not say who owns the
|
||||
# installation, and without that the enrolling account cannot be mapped
|
||||
# to anyone — which would leave the portal connected but refused here.
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="That portal is too old for this installation: it did not "
|
||||
"say which account owns the installation",
|
||||
)
|
||||
config = cloud_config.CloudConfig(
|
||||
portal_url=base,
|
||||
ws_url=data["ws_url"],
|
||||
@@ -110,10 +136,129 @@ async def enroll(
|
||||
portal_account=current_user.email,
|
||||
)
|
||||
cloud_config.save(config)
|
||||
|
||||
owner_id = str(owner_id)
|
||||
# Re-enrolling from a different local account moves the mapping rather than
|
||||
# leaving two accounts claiming the same portal identity, which the unique
|
||||
# index would refuse and the lookup could not choose between anyway.
|
||||
for other in session.exec(
|
||||
select(User).where(User.portal_sub == owner_id, User.id != current_user.id)
|
||||
):
|
||||
other.portal_sub = None
|
||||
session.add(other)
|
||||
current_user.portal_sub = owner_id
|
||||
session.add(current_user)
|
||||
session.commit()
|
||||
|
||||
_start_connector(request.app)
|
||||
return Message(message="Connected to the portal")
|
||||
|
||||
|
||||
class RemoteUserBody(BaseModel):
|
||||
#: The code the person wanting in minted on their portal account.
|
||||
code: str = Field(min_length=1, max_length=32)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/users",
|
||||
dependencies=[Depends(get_current_active_superuser)],
|
||||
response_model=UserPublic,
|
||||
)
|
||||
def add_remote_user(session: SessionDep, body: RemoteUserBody) -> Any:
|
||||
"""Admit a portal account, and give it a local user of its own.
|
||||
|
||||
The point of the local user is that it is ordinary: a remote person shows
|
||||
up in Admin → Users like everyone else, holds no superuser flag, and is
|
||||
removed by deleting them there. They have no password because they never
|
||||
sign in here — the portal vouches for them, and this row is what says which
|
||||
of our accounts that vouching amounts to.
|
||||
"""
|
||||
config = cloud_config.load()
|
||||
if config is None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="This installation is not connected to a portal",
|
||||
)
|
||||
try:
|
||||
response = httpx.post(
|
||||
f"{config.portal_url.rstrip('/')}/api/v1/installation-members/",
|
||||
headers={"Authorization": f"Bearer {config.token}"},
|
||||
json={"code": body.code.strip()},
|
||||
timeout=15.0,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502, detail=f"Could not reach the portal: {exc}"
|
||||
) from exc
|
||||
if response.status_code == 404:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="That code is unknown or has expired"
|
||||
)
|
||||
if response.status_code == 409:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="That code belongs to this installation's owner"
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"The portal refused the code ({response.status_code})",
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
portal_sub = str(data["user_id"])
|
||||
email = str(data["email"])
|
||||
existing = session.exec(select(User).where(User.portal_sub == portal_sub)).first()
|
||||
if existing is not None:
|
||||
raise HTTPException(
|
||||
status_code=409, detail=f"{existing.email} already has access"
|
||||
)
|
||||
if crud.get_user_by_email(session=session, email=email) is not None:
|
||||
# Never quietly hand an existing local account — possibly a superuser's
|
||||
# — to whoever holds that address on the portal.
|
||||
raise HTTPException(
|
||||
status_code=409, detail="A local user with this email already exists"
|
||||
)
|
||||
user = User(
|
||||
email=email,
|
||||
# Unusable by construction: this account is reached through the portal
|
||||
# or not at all. Password recovery would issue one, which is a gap
|
||||
# worth closing the day local sign-in for these accounts is wanted.
|
||||
hashed_password=get_password_hash(secrets.token_urlsafe(32)),
|
||||
is_superuser=False,
|
||||
is_active=True,
|
||||
portal_sub=portal_sub,
|
||||
)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def forget_remote_user(portal_sub: str) -> None:
|
||||
"""Tell the portal a remote user is gone from here.
|
||||
|
||||
Best effort on purpose: the local account is what grants access, so it is
|
||||
already over by the time this runs. A portal that cannot be reached keeps a
|
||||
row that opens nothing — the installation refuses the session either way.
|
||||
"""
|
||||
config = cloud_config.load()
|
||||
if config is None:
|
||||
return
|
||||
try:
|
||||
response = httpx.delete(
|
||||
f"{config.portal_url.rstrip('/')}/api/v1/installation-members/{portal_sub}",
|
||||
headers={"Authorization": f"Bearer {config.token}"},
|
||||
timeout=15.0,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("Could not drop %s at the portal: %s", portal_sub, exc)
|
||||
return
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"The portal refused to drop %s (%s)", portal_sub, response.status_code
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"", dependencies=[Depends(get_current_active_superuser)], response_model=Message
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.api.deps import (
|
||||
SessionDep,
|
||||
get_current_active_superuser,
|
||||
)
|
||||
from app.api.routes.cloud import forget_remote_user
|
||||
from app.core.config import settings
|
||||
from app.core.security import get_password_hash, verify_password
|
||||
from app.models import (
|
||||
@@ -136,8 +137,11 @@ def delete_user_me(session: SessionDep, current_user: CurrentUser) -> Any:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Super users are not allowed to delete themselves"
|
||||
)
|
||||
portal_sub = current_user.portal_sub
|
||||
session.delete(current_user)
|
||||
session.commit()
|
||||
if portal_sub:
|
||||
forget_remote_user(portal_sub)
|
||||
return Message(message="User deleted successfully")
|
||||
|
||||
|
||||
@@ -223,6 +227,11 @@ def delete_user(
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Super users are not allowed to delete themselves"
|
||||
)
|
||||
portal_sub = user.portal_sub
|
||||
session.delete(user)
|
||||
session.commit()
|
||||
# The local row is the access; dropping the portal's is tidying up, and it
|
||||
# happens after the thing that actually revokes has already happened.
|
||||
if portal_sub:
|
||||
forget_remote_user(portal_sub)
|
||||
return Message(message="User deleted successfully")
|
||||
|
||||
@@ -46,9 +46,11 @@ class CloudConfig:
|
||||
token: str
|
||||
issuer: str
|
||||
jwks: dict[str, Any]
|
||||
#: The local account every portal session acts as. Recorded at enrolment
|
||||
#: from whoever performed it, so remote access can never exceed the rights
|
||||
#: of the person who granted it.
|
||||
#: The local account this installation acts as on its own behalf: what the
|
||||
#: health summary is collected as, and what a screen paired through the
|
||||
#: portal borrows for want of a person. Recorded at enrolment from whoever
|
||||
#: performed it. Portal *sessions* no longer come through here — they name
|
||||
#: a person, and are resolved to the local account mapped to them.
|
||||
local_user_id: str
|
||||
enrolled_at: str
|
||||
portal_account: str | None = None
|
||||
@@ -145,6 +147,12 @@ def decode_portal_token(token: str) -> dict[str, Any]:
|
||||
return {"sub": config.local_user_id, "panel": panel}
|
||||
if scope != "proxy":
|
||||
raise InvalidTokenError("not a proxy token")
|
||||
# Every portal session acts as the enrolling local user. Who they are on
|
||||
# the portal is kept for the audit trail, not for authorization.
|
||||
return {"sub": config.local_user_id, "portal_sub": claims.get("sub")}
|
||||
# A portal session names the person holding it, and that name is the whole
|
||||
# of their identity here: the caller resolves it to the local account it
|
||||
# was mapped to, and a portal identity nobody mapped resolves to nothing.
|
||||
# Deliberately no local account by default — the failure of a mapping must
|
||||
# be a refusal, not a fallback onto whoever enrolled.
|
||||
portal_sub = str(claims.get("sub") or "")
|
||||
if not portal_sub:
|
||||
raise InvalidTokenError("a proxy token must name its user")
|
||||
return {"portal_sub": portal_sub}
|
||||
|
||||
@@ -50,6 +50,11 @@ class UpdatePassword(SQLModel):
|
||||
class User(UserBase, table=True):
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
hashed_password: str
|
||||
#: The portal account this local user stands for, if any. Set for the
|
||||
#: superuser who enrolled this installation and for every remote user one
|
||||
#: of them admitted; None for a purely local account, which is what an
|
||||
#: installation nobody enrolled has only.
|
||||
portal_sub: str | None = Field(default=None, max_length=64, unique=True, index=True)
|
||||
created_at: datetime | None = Field(
|
||||
default_factory=get_datetime_utc,
|
||||
sa_type=DateTime(timezone=True), # type: ignore
|
||||
@@ -59,6 +64,7 @@ class User(UserBase, table=True):
|
||||
# Properties to return via API, id is always required
|
||||
class UserPublic(UserBase):
|
||||
id: uuid.UUID
|
||||
portal_sub: str | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
@@ -81,6 +87,11 @@ class Token(SQLModel):
|
||||
# Contents of JWT token
|
||||
class TokenPayload(SQLModel):
|
||||
sub: str | None = None
|
||||
#: Set instead of ``sub`` by a token the portal minted for a person: it
|
||||
#: names them on the portal, and the local account it stands for is looked
|
||||
#: up from it. A portal identity nobody here was mapped to resolves to no
|
||||
#: user at all, which is the refusal.
|
||||
portal_sub: str | None = None
|
||||
|
||||
|
||||
class NewPassword(SQLModel):
|
||||
|
||||
@@ -107,6 +107,14 @@ def enrolled(
|
||||
portal_account=settings.FIRST_SUPERUSER,
|
||||
)
|
||||
)
|
||||
# Enrolment also maps the enrolling account to the portal identity that
|
||||
# owns the installation; without it a portal session resolves to nobody.
|
||||
local_user.portal_sub = "portal-user-1"
|
||||
db.add(local_user)
|
||||
db.commit()
|
||||
yield local_user
|
||||
local_user.portal_sub = None
|
||||
db.add(local_user)
|
||||
db.commit()
|
||||
cloud_config.delete()
|
||||
settings.CLOUD_CONFIG_FILE = original
|
||||
|
||||
+116
-7
@@ -1,14 +1,16 @@
|
||||
"""Remote access: off by default, and scoped to the account that enabled it.
|
||||
"""Remote access: off by default, and only ever as much as somebody granted.
|
||||
|
||||
The property worth pinning down is the one everything else rests on — a
|
||||
portal's token is worth nothing here until somebody at this installation
|
||||
enrolled it, and even then it grants exactly the rights of the account that
|
||||
did.
|
||||
enrolled it, and even then it grants exactly the rights of the local account
|
||||
the portal identity holding it was mapped to. An identity nobody mapped gets
|
||||
nothing, which is what makes deleting that local account a revocation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
@@ -37,14 +39,18 @@ def test_portal_token_is_refused_when_not_enrolled(
|
||||
settings.CLOUD_CONFIG_FILE = original
|
||||
|
||||
|
||||
def test_portal_token_resolves_to_the_enrolling_user(
|
||||
def test_portal_token_resolves_by_portal_identity(
|
||||
enrolled: User, portal_key: rsa.RSAPrivateKey, db: Session
|
||||
) -> None:
|
||||
"""The token names a person on the portal; the mapping names them here."""
|
||||
claims = decode_token(portal_token(portal_key))
|
||||
assert claims["sub"] == str(enrolled.id)
|
||||
# Who they are on the portal is carried for the record, not for rights.
|
||||
# No local account of its own: the payload carries who they are on the
|
||||
# portal and nothing else, so an unmapped identity cannot fall back onto
|
||||
# whoever enrolled.
|
||||
assert "sub" not in claims
|
||||
assert claims["portal_sub"] == "portal-user-1"
|
||||
assert user_from_token(db, portal_token(portal_key)) == enrolled
|
||||
assert user_from_token(db, portal_token(portal_key, subject="nobody")) is None
|
||||
|
||||
|
||||
def test_token_for_another_installation_is_refused(
|
||||
@@ -77,7 +83,7 @@ def test_disconnecting_ends_remote_access(
|
||||
enrolled: User, portal_key: rsa.RSAPrivateKey
|
||||
) -> None:
|
||||
"""Deleting the config is the whole of the local revocation."""
|
||||
assert decode_token(portal_token(portal_key))["sub"] == str(enrolled.id)
|
||||
assert decode_token(portal_token(portal_key))["portal_sub"] == enrolled.portal_sub
|
||||
cloud_config.delete()
|
||||
with pytest.raises(jwt.exceptions.InvalidTokenError):
|
||||
decode_token(portal_token(portal_key))
|
||||
@@ -172,3 +178,106 @@ def test_a_panel_token_naming_no_panel_is_refused(
|
||||
"""It would otherwise fall through to the account it borrows."""
|
||||
with pytest.raises(jwt.exceptions.InvalidTokenError):
|
||||
decode_token(portal_token(portal_key, subject="", scope="panel"))
|
||||
|
||||
|
||||
def test_adding_a_remote_user_maps_and_revokes(
|
||||
client: TestClient,
|
||||
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
|
||||
portal_key: rsa.RSAPrivateKey,
|
||||
superuser_token_headers: dict[str, str],
|
||||
db: Session,
|
||||
) -> None:
|
||||
"""Admitting somebody makes an ordinary local user; deleting it ends them."""
|
||||
portal_reply = Mock(
|
||||
status_code=200,
|
||||
json=Mock(
|
||||
return_value={"user_id": "portal-user-9", "email": "remote@example.com"}
|
||||
),
|
||||
)
|
||||
with patch("app.api.routes.cloud.httpx.post", return_value=portal_reply) as post:
|
||||
added = client.post(
|
||||
f"{settings.API_V1_STR}/cloud/users",
|
||||
headers=superuser_token_headers,
|
||||
json={"code": "ABCD-EFGH"},
|
||||
)
|
||||
assert added.status_code == 200, added.text
|
||||
assert post.call_args.kwargs["headers"]["Authorization"].startswith("Bearer ")
|
||||
body = added.json()
|
||||
assert body["email"] == "remote@example.com"
|
||||
assert body["portal_sub"] == "portal-user-9"
|
||||
# Never a superuser: a remote user must not be able to admit anyone else.
|
||||
assert body["is_superuser"] is False
|
||||
|
||||
theirs = portal_token(portal_key, subject="portal-user-9")
|
||||
resolved = user_from_token(db, theirs)
|
||||
assert resolved is not None and resolved.email == "remote@example.com"
|
||||
|
||||
with patch("app.api.routes.cloud.httpx.post", return_value=portal_reply):
|
||||
again = client.post(
|
||||
f"{settings.API_V1_STR}/cloud/users",
|
||||
headers=superuser_token_headers,
|
||||
json={"code": "ABCD-EFGH"},
|
||||
)
|
||||
assert again.status_code == 409
|
||||
|
||||
with patch(
|
||||
"app.api.routes.cloud.httpx.delete", return_value=Mock(status_code=200)
|
||||
) as delete:
|
||||
removed = client.delete(
|
||||
f"{settings.API_V1_STR}/users/{body['id']}", headers=superuser_token_headers
|
||||
)
|
||||
assert removed.status_code == 200, removed.text
|
||||
assert delete.call_args.args[0].endswith("/installation-members/portal-user-9")
|
||||
db.expire_all()
|
||||
assert user_from_token(db, theirs) is None
|
||||
|
||||
|
||||
def test_adding_a_remote_user_needs_a_superuser(
|
||||
client: TestClient,
|
||||
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
|
||||
normal_user_token_headers: dict[str, str],
|
||||
) -> None:
|
||||
"""Widening who can reach this installation stays a superuser's decision."""
|
||||
response = client.post(
|
||||
f"{settings.API_V1_STR}/cloud/users",
|
||||
headers=normal_user_token_headers,
|
||||
json={"code": "ABCD-EFGH"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_enrolling_against_a_portal_without_an_owner_is_refused(
|
||||
client: TestClient,
|
||||
superuser_token_headers: dict[str, str],
|
||||
tmp_path_factory: pytest.TempPathFactory,
|
||||
) -> None:
|
||||
"""A portal too old to name the owner would leave nobody mapped here."""
|
||||
original = settings.CLOUD_CONFIG_FILE
|
||||
settings.CLOUD_CONFIG_FILE = tmp_path_factory.mktemp("old-portal") / "cloud.json"
|
||||
reply = Mock(
|
||||
status_code=200,
|
||||
json=Mock(
|
||||
return_value={
|
||||
"installation_id": str(uuid.uuid4()),
|
||||
"installation_token": "t",
|
||||
"ws_url": f"{ISSUER}/api/v1/tunnel/attach",
|
||||
"issuer": ISSUER,
|
||||
"jwks": {"keys": []},
|
||||
}
|
||||
),
|
||||
)
|
||||
try:
|
||||
with patch("app.api.routes.cloud.httpx.AsyncClient") as client_cls:
|
||||
client_cls.return_value.__aenter__.return_value.post = AsyncMock(
|
||||
return_value=reply
|
||||
)
|
||||
response = client.post(
|
||||
f"{settings.API_V1_STR}/cloud/enroll",
|
||||
headers=superuser_token_headers,
|
||||
json={"portal_url": ISSUER, "claim_code": "ABCD-EFGH"},
|
||||
)
|
||||
assert response.status_code == 502
|
||||
# Nothing was written: an enrolment nobody can act as is not one to keep.
|
||||
assert not settings.CLOUD_CONFIG_FILE.exists()
|
||||
finally:
|
||||
settings.CLOUD_CONFIG_FILE = original
|
||||
|
||||
@@ -2214,6 +2214,20 @@ export const RegisteredClientsSchema = {
|
||||
title: 'RegisteredClients'
|
||||
} as const;
|
||||
|
||||
export const RemoteUserBodySchema = {
|
||||
properties: {
|
||||
code: {
|
||||
type: 'string',
|
||||
maxLength: 32,
|
||||
minLength: 1,
|
||||
title: 'Code'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['code'],
|
||||
title: 'RemoteUserBody'
|
||||
} as const;
|
||||
|
||||
export const RuleSchema = {
|
||||
properties: {
|
||||
events: {
|
||||
@@ -2799,6 +2813,17 @@ export const UserPublicSchema = {
|
||||
format: 'uuid',
|
||||
title: 'Id'
|
||||
},
|
||||
portal_sub: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Portal Sub'
|
||||
},
|
||||
created_at: {
|
||||
anyOf: [
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { CancelablePromise } from './core/CancelablePromise';
|
||||
import { OpenAPI } from './core/OpenAPI';
|
||||
import { request as __request } from './core/request';
|
||||
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, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsReadPanelData, PanelsReadPanelResponse, 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, CloudAddRemoteUserData, CloudAddRemoteUserResponse, 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, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsReadPanelData, PanelsReadPanelResponse, 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 {
|
||||
/**
|
||||
@@ -128,8 +128,10 @@ export class CloudService {
|
||||
* 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.
|
||||
* The account performing this is mapped to the portal account that owns the
|
||||
* installation, so the owner's portal sessions arrive here as them. Widening
|
||||
* that to anyone else is a local decision made one person at a time, below —
|
||||
* never something the portal can do from its side.
|
||||
* @param data The data for the request.
|
||||
* @param data.requestBody
|
||||
* @returns Message Successful Response
|
||||
@@ -147,6 +149,32 @@ export class CloudService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Remote User
|
||||
* Admit a portal account, and give it a local user of its own.
|
||||
*
|
||||
* The point of the local user is that it is ordinary: a remote person shows
|
||||
* up in Admin → Users like everyone else, holds no superuser flag, and is
|
||||
* removed by deleting them there. They have no password because they never
|
||||
* sign in here — the portal vouches for them, and this row is what says which
|
||||
* of our accounts that vouching amounts to.
|
||||
* @param data The data for the request.
|
||||
* @param data.requestBody
|
||||
* @returns UserPublic Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static addRemoteUser(data: CloudAddRemoteUserData): CancelablePromise<CloudAddRemoteUserResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'POST',
|
||||
url: '/api/v1/cloud/users',
|
||||
body: data.requestBody,
|
||||
mediaType: 'application/json',
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect
|
||||
* Sever the connection from this side.
|
||||
|
||||
@@ -801,6 +801,10 @@ export type RegisteredClients = {
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type RemoteUserBody = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Which events go to which channels.
|
||||
*/
|
||||
@@ -964,6 +968,7 @@ export type UserPublic = {
|
||||
is_superuser?: boolean;
|
||||
full_name?: (string | null);
|
||||
id: string;
|
||||
portal_sub?: (string | null);
|
||||
created_at?: (string | null);
|
||||
};
|
||||
|
||||
@@ -1092,6 +1097,12 @@ export type CloudEnrollData = {
|
||||
|
||||
export type CloudEnrollResponse = (Message);
|
||||
|
||||
export type CloudAddRemoteUserData = {
|
||||
requestBody: RemoteUserBody;
|
||||
};
|
||||
|
||||
export type CloudAddRemoteUserResponse = (UserPublic);
|
||||
|
||||
export type CloudDisconnectResponse = (Message);
|
||||
|
||||
export type DashboardsReadDashboardsResponse = (DashboardsPublic);
|
||||
|
||||
@@ -35,7 +35,16 @@ export const columns: ColumnDef<UserTableData>[] = [
|
||||
accessorKey: "email",
|
||||
header: "Email",
|
||||
cell: ({ row }) => (
|
||||
<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,6 +106,7 @@ 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">
|
||||
@@ -103,8 +120,9 @@ export function RemoteAccess() {
|
||||
<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.
|
||||
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">
|
||||
@@ -123,6 +141,45 @@ export function RemoteAccess() {
|
||||
</Button>
|
||||
</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