feat(auth): Authentik OIDC sign-in + Gravatar avatars

Adds optional SSO via Authentik (or any OIDC provider) alongside the
existing password flow, and pulls profile images from the provider's
`picture` claim or Gravatar so the sharing UI stops looking anonymous.
Password login stays available as a recovery path; JIT provisioning and
admin-group mapping are env-configurable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 21:06:32 +02:00
parent 319be20389
commit e8e1adcf37
20 changed files with 852 additions and 60 deletions

View File

@@ -21,6 +21,14 @@ from app.dependencies import (
get_user_folder,
resolve_username,
)
from app.services.gravatar import gravatar_url
def _user_avatar(user: User) -> Optional[str]:
"""OIDC `picture` claim wins, Gravatar fills the gap. Returns None
when neither source can produce a URL so the frontend can fall back
to the initials bubble."""
return user.avatar_url or gravatar_url(user.email)
logger = logging.getLogger(__name__)
@@ -38,6 +46,8 @@ class ShareResponse(BaseModel):
id: str
shared_with_id: str
shared_with_username: str
shared_with_avatar_url: Optional[str] = None
shared_with_display_name: Optional[str] = None
permission: str
status: str # 'pending' | 'accepted'
created_at: str
@@ -51,6 +61,8 @@ class SharedHeapResponse(BaseModel):
share_id: str
name: str
owner_username: str
owner_avatar_url: Optional[str] = None
owner_display_name: Optional[str] = None
permission: str
photo_count: int
@@ -61,6 +73,8 @@ class SharedFolderResponse(BaseModel):
name: str
folder_type: str
owner_username: str
owner_avatar_url: Optional[str] = None
owner_display_name: Optional[str] = None
permission: str
photo_count: int
@@ -72,6 +86,8 @@ class PendingInvite(BaseModel):
target_id: str # heap id or folder id
target_name: str
owner_username: str
owner_avatar_url: Optional[str] = None
owner_display_name: Optional[str] = None
permission: str
created_at: str
@@ -84,6 +100,8 @@ class PendingInvitesResponse(BaseModel):
class ShareableUser(BaseModel):
id: str
username: str
avatar_url: Optional[str] = None
display_name: Optional[str] = None
# ── Shareable users ──────────────────────────────────────────────────────
@@ -104,7 +122,12 @@ async def list_shareable_users(
.order_by(User.username)
)
return [
ShareableUser(id=str(u.id), username=u.username)
ShareableUser(
id=str(u.id),
username=u.username,
avatar_url=_user_avatar(u),
display_name=u.display_name,
)
for u in result.scalars().all()
]
@@ -132,6 +155,8 @@ async def list_pending_invites(
target_id=heap.id,
target_name=heap.name,
owner_username=owner.username,
owner_avatar_url=_user_avatar(owner),
owner_display_name=owner.display_name,
permission=share.permission,
created_at=share.created_at.isoformat() if share.created_at else "",
)
@@ -165,6 +190,8 @@ async def list_pending_invites(
target_id=share.folder_id,
target_name=entity.name,
owner_username=owner.username,
owner_avatar_url=_user_avatar(owner),
owner_display_name=owner.display_name,
permission=share.permission,
created_at=share.created_at.isoformat() if share.created_at else "",
))
@@ -205,6 +232,8 @@ async def list_shared_heaps(
share_id=share.id,
name=heap.name,
owner_username=owner.username,
owner_avatar_url=_user_avatar(owner),
owner_display_name=owner.display_name,
permission=share.permission,
photo_count=count,
))
@@ -230,6 +259,8 @@ async def list_heap_shares(
id=share.id,
shared_with_id=user.id,
shared_with_username=user.username,
shared_with_avatar_url=_user_avatar(user),
shared_with_display_name=user.display_name,
permission=share.permission,
status=share.status,
created_at=share.created_at.isoformat() if share.created_at else "",
@@ -414,6 +445,8 @@ async def list_shared_folders(
name=name,
folder_type=share.folder_type,
owner_username=owner.username,
owner_avatar_url=_user_avatar(owner),
owner_display_name=owner.display_name,
permission=share.permission,
photo_count=count,
))
@@ -454,6 +487,8 @@ async def list_folder_shares(
id=share.id,
shared_with_id=user.id,
shared_with_username=user.username,
shared_with_avatar_url=_user_avatar(user),
shared_with_display_name=user.display_name,
permission=share.permission,
status=share.status,
created_at=share.created_at.isoformat() if share.created_at else "",