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:
@@ -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 "",
|
||||
|
||||
Reference in New Issue
Block a user