Multi-user via PocketID: account linking, group gating, admin user management
PocketID OIDC already auto-provisioned users keyed by pocketid_sub, and the data layer was already fully user-scoped. This adds the missing pieces for running real multi-user: - auth.py callback: link by email to an existing un-linked account (so the admin keeps their data when first signing in by passkey), collision-safe username generation, and request the `groups` scope. - Group gating: optional pocketid_allowed_group (admin-config or POCKETID_ALLOWED_GROUP env); users lacking the group are rejected at the callback and redirected to /login?auth_error=not_authorized. - New admin users API (app/api/users.py): list users, promote/demote admin (guards against demoting/locking out the last admin or yourself), and delete a user with ordered bulk deletes of all their data + on-disk files. - ProfilePage: allowed-group field; LoginPage: rejected-login message; Layout: admin-only Users nav; new UsersPage. Resync milevault_export to current source (it had drifted many features behind — missing garmin_sync, npm-ci Dockerfile and @polyline-codec that broke its own CI) and add POCKETID_ALLOWED_GROUP to .env.example. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+51
-3
@@ -24,6 +24,27 @@ async def _get_pocketid_config(db: AsyncSession):
|
||||
return issuer, client_id, client_secret
|
||||
|
||||
|
||||
async def _get_allowed_group(db: AsyncSession):
|
||||
"""Group a PocketID user must belong to in order to sign in (None = allow all)."""
|
||||
result = await db.execute(select(User).where(User.is_admin == True).limit(1))
|
||||
admin = result.scalar_one_or_none()
|
||||
group = (admin and admin.pocketid_allowed_group) or settings.pocketid_allowed_group
|
||||
return (group or "").strip() or None
|
||||
|
||||
|
||||
async def _unique_username(db: AsyncSession, base: str) -> str:
|
||||
"""Return `base`, or `base-2`, `base-3`, … until it is not already taken."""
|
||||
base = (base or "user").strip() or "user"
|
||||
candidate = base
|
||||
n = 1
|
||||
while True:
|
||||
existing = await db.execute(select(User).where(User.username == candidate))
|
||||
if existing.scalar_one_or_none() is None:
|
||||
return candidate
|
||||
n += 1
|
||||
candidate = f"{base}-{n}"
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
@@ -79,7 +100,7 @@ async def pocketid_login_url(db: AsyncSession = Depends(get_db)):
|
||||
"client_id": client_id,
|
||||
"redirect_uri": f"{settings.base_url}/api/auth/pocketid/callback",
|
||||
"response_type": "code",
|
||||
"scope": "openid profile email",
|
||||
"scope": "openid profile email groups",
|
||||
}
|
||||
return {"url": f"{issuer}/authorize?{urlencode(params)}"}
|
||||
|
||||
@@ -106,17 +127,44 @@ async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)):
|
||||
)
|
||||
userinfo = userinfo_resp.json()
|
||||
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
sub = userinfo.get("sub")
|
||||
email = userinfo.get("email")
|
||||
preferred_username = userinfo.get("preferred_username") or email
|
||||
|
||||
# Group gating: if an allowed group is configured, the user must be in it.
|
||||
allowed_group = await _get_allowed_group(db)
|
||||
if allowed_group:
|
||||
groups = userinfo.get("groups") or []
|
||||
if allowed_group not in groups:
|
||||
return RedirectResponse(url="/login?auth_error=not_authorized")
|
||||
|
||||
# 1) Existing passkey identity → use it.
|
||||
result = await db.execute(select(User).where(User.pocketid_sub == sub))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
# 2) No passkey identity yet, but an account with this email exists and is
|
||||
# not already linked to a different passkey → link them (preserves data).
|
||||
if not user and email:
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing and existing.pocketid_sub is None:
|
||||
existing.pocketid_sub = sub
|
||||
user = existing
|
||||
|
||||
# 3) Otherwise provision a new account with a collision-safe username.
|
||||
if not user:
|
||||
user = User(username=preferred_username, email=email, pocketid_sub=sub)
|
||||
base = preferred_username or (email.split("@")[0] if email else "user")
|
||||
username = await _unique_username(db, base)
|
||||
# Only set email if no other account already claims it (unique column).
|
||||
email_taken = False
|
||||
if email:
|
||||
dup = await db.execute(select(User).where(User.email == email))
|
||||
email_taken = dup.scalar_one_or_none() is not None
|
||||
user = User(username=username, email=None if email_taken else email, pocketid_sub=sub)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
token = create_access_token({"sub": str(user.id)})
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url=f"/?token={token}")
|
||||
|
||||
Reference in New Issue
Block a user