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:
83
backend/alembic/versions/0015_oidc_and_avatar.py
Normal file
83
backend/alembic/versions/0015_oidc_and_avatar.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""OIDC identity + avatar / display_name on users
|
||||
|
||||
Revision ID: 0015_oidc_and_avatar
|
||||
Revises: 0014_share_status
|
||||
Create Date: 2026-04-22
|
||||
|
||||
Lets users sign in via an OIDC provider (Authentik) and carry a profile
|
||||
image / display name from the provider. Password-only users are
|
||||
unaffected.
|
||||
|
||||
1. Add users.oidc_issuer, users.oidc_sub (identity pair from the IdP).
|
||||
2. Add users.avatar_url, users.display_name (profile bits from claims
|
||||
or manually set).
|
||||
3. Make users.hashed_password nullable — OIDC-only users have no local
|
||||
password. Existing rows all have hashes so the NULLability change
|
||||
is backwards-compatible.
|
||||
4. Partial unique index on (oidc_issuer, oidc_sub) WHERE oidc_sub IS
|
||||
NOT NULL so multiple password-only users (both NULL) don't collide.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0015_oidc_and_avatar"
|
||||
down_revision: Union[str, None] = "0014_share_status"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# 1 + 2. Add new columns (idempotent — create_all on fresh installs
|
||||
# already built them from the model).
|
||||
for col_def in (
|
||||
"oidc_issuer VARCHAR",
|
||||
"oidc_sub VARCHAR",
|
||||
"avatar_url VARCHAR",
|
||||
"display_name VARCHAR",
|
||||
):
|
||||
conn.execute(sa.text(f"ALTER TABLE users ADD COLUMN IF NOT EXISTS {col_def}"))
|
||||
|
||||
# 3. Drop NOT NULL on hashed_password. Postgres only — SQLite can't
|
||||
# alter column nullability in place, but the SQLite escape hatch is
|
||||
# used for fresh local dev where create_all already wrote the new
|
||||
# nullable definition.
|
||||
if conn.dialect.name == "postgresql":
|
||||
conn.execute(sa.text(
|
||||
"ALTER TABLE users ALTER COLUMN hashed_password DROP NOT NULL"
|
||||
))
|
||||
|
||||
# 4. Partial unique index — Postgres supports the WHERE clause so
|
||||
# NULLs don't collide; SQLite treats NULLs as distinct in unique
|
||||
# indexes already, so a plain unique index is safe there too.
|
||||
if conn.dialect.name == "postgresql":
|
||||
conn.execute(sa.text(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ix_users_oidc_identity "
|
||||
"ON users (oidc_issuer, oidc_sub) WHERE oidc_sub IS NOT NULL"
|
||||
))
|
||||
else:
|
||||
conn.execute(sa.text(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ix_users_oidc_identity "
|
||||
"ON users (oidc_issuer, oidc_sub)"
|
||||
))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
conn.execute(sa.text("DROP INDEX IF EXISTS ix_users_oidc_identity"))
|
||||
|
||||
if conn.dialect.name == "postgresql":
|
||||
# Can't re-apply NOT NULL if any OIDC-only user has NULL — so
|
||||
# only do it when safe.
|
||||
conn.execute(sa.text(
|
||||
"UPDATE users SET hashed_password = '' WHERE hashed_password IS NULL"
|
||||
))
|
||||
conn.execute(sa.text(
|
||||
"ALTER TABLE users ALTER COLUMN hashed_password SET NOT NULL"
|
||||
))
|
||||
|
||||
for col in ("display_name", "avatar_url", "oidc_sub", "oidc_issuer"):
|
||||
conn.execute(sa.text(f"ALTER TABLE users DROP COLUMN IF EXISTS {col}"))
|
||||
77
backend/app/auth_oidc.py
Normal file
77
backend/app/auth_oidc.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""OIDC (OpenID Connect) client setup — used for Authentik SSO today,
|
||||
generic enough to register other providers later.
|
||||
|
||||
Authlib handles the Authorization Code + PKCE flow, including discovery
|
||||
via the provider's `.well-known/openid-configuration` document. We keep
|
||||
a single registered client named "authentik" regardless of label, so the
|
||||
router code always knows where to find it.
|
||||
|
||||
If OIDC isn't fully configured the module stays inert — `is_enabled()`
|
||||
returns False and the registry has no client. Callers must guard.
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
PROVIDER_NAME = "authentik"
|
||||
|
||||
|
||||
def is_configured() -> bool:
|
||||
"""True when every required OIDC setting is present."""
|
||||
return bool(
|
||||
settings.oidc_issuer
|
||||
and settings.oidc_client_id
|
||||
and settings.oidc_client_secret
|
||||
and settings.oidc_redirect_uri
|
||||
)
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
"""True when OIDC is both configured and the admin flipped it on."""
|
||||
return bool(settings.oidc_enabled) and is_configured()
|
||||
|
||||
|
||||
def provider_label() -> str:
|
||||
return settings.oidc_provider_label or "Authentik"
|
||||
|
||||
|
||||
def _build_oauth() -> OAuth:
|
||||
"""Build the Authlib OAuth registry. Always safe to call; only
|
||||
registers the provider when credentials are present so importing
|
||||
this module never fails on a fresh install.
|
||||
"""
|
||||
registry = OAuth()
|
||||
if not is_configured():
|
||||
return registry
|
||||
|
||||
# Authentik exposes discovery at `{issuer}/.well-known/openid-configuration`.
|
||||
# Trailing slash handling varies by Authentik version, so normalise.
|
||||
issuer = settings.oidc_issuer.rstrip("/")
|
||||
discovery_url = f"{issuer}/.well-known/openid-configuration"
|
||||
|
||||
registry.register(
|
||||
name=PROVIDER_NAME,
|
||||
client_id=settings.oidc_client_id,
|
||||
client_secret=settings.oidc_client_secret,
|
||||
server_metadata_url=discovery_url,
|
||||
client_kwargs={
|
||||
"scope": settings.oidc_scopes,
|
||||
# Force PKCE — cheap win for public clients, harmless for
|
||||
# confidential ones.
|
||||
"code_challenge_method": "S256",
|
||||
},
|
||||
)
|
||||
return registry
|
||||
|
||||
|
||||
oauth: OAuth = _build_oauth()
|
||||
|
||||
|
||||
def get_client() -> Optional[object]:
|
||||
"""Return the registered provider client, or None when not configured."""
|
||||
if not is_configured():
|
||||
return None
|
||||
return oauth.create_client(PROVIDER_NAME)
|
||||
@@ -109,6 +109,41 @@ class Settings(BaseSettings):
|
||||
access_token_expire_minutes: int = Field(default=525600, env="ACCESS_TOKEN_EXPIRE_MINUTES") # 1 year
|
||||
refresh_token_expire_days: int = Field(default=3650, env="REFRESH_TOKEN_EXPIRE_DAYS") # 10 years
|
||||
|
||||
# ── OIDC / Authentik single sign-on ────────────────────────────────
|
||||
# Disabled by default; enable by setting OIDC_ENABLED=true and the
|
||||
# issuer + client credentials. When enabled the login page shows a
|
||||
# "Sign in with {label}" button alongside the username/password form.
|
||||
oidc_enabled: bool = Field(default=False, env="OIDC_ENABLED")
|
||||
oidc_issuer: Optional[str] = Field(default=None, env="OIDC_ISSUER")
|
||||
oidc_client_id: Optional[str] = Field(default=None, env="OIDC_CLIENT_ID")
|
||||
oidc_client_secret: Optional[str] = Field(default=None, env="OIDC_CLIENT_SECRET")
|
||||
# Absolute URL the IdP redirects back to. Must match the Redirect URI
|
||||
# configured on the Authentik side exactly.
|
||||
oidc_redirect_uri: Optional[str] = Field(default=None, env="OIDC_REDIRECT_URI")
|
||||
oidc_scopes: str = Field(default="openid profile email", env="OIDC_SCOPES")
|
||||
oidc_provider_label: str = Field(default="Authentik", env="OIDC_PROVIDER_LABEL")
|
||||
# When true, a successful OIDC login for a subject we've never seen
|
||||
# auto-creates a local user + their /photos/{username} folder. When
|
||||
# false, unknown subjects get 403 and must be pre-provisioned.
|
||||
oidc_allow_signup: bool = Field(default=True, env="OIDC_ALLOW_SIGNUP")
|
||||
# Comma-separated Authentik group names. Any group-claim match
|
||||
# promotes the user to role=admin; otherwise role=user. Role is
|
||||
# refreshed on every sign-in so removals demote automatically.
|
||||
oidc_admin_groups: str = Field(default="", env="OIDC_ADMIN_GROUPS")
|
||||
# Starlette session cookie secret — only used to hold PKCE/state
|
||||
# during the brief OIDC round-trip. Falls back to secret_key when
|
||||
# unset.
|
||||
session_secret: Optional[str] = Field(default=None, env="SESSION_SECRET")
|
||||
|
||||
@property
|
||||
def oidc_admin_group_list(self) -> list[str]:
|
||||
raw = (self.oidc_admin_groups or "").strip()
|
||||
return [g.strip() for g in raw.split(",") if g.strip()]
|
||||
|
||||
@property
|
||||
def effective_session_secret(self) -> str:
|
||||
return self.session_secret or self.secret_key
|
||||
|
||||
@property
|
||||
def cors_origins(self) -> list[str]:
|
||||
"""Parse the ALLOWED_ORIGINS env var into a list. Accepts:
|
||||
|
||||
@@ -6,6 +6,7 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
import logging
|
||||
import os
|
||||
|
||||
@@ -80,6 +81,19 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Session middleware — only used by Authlib to hold PKCE state during
|
||||
# the OIDC round-trip. max_age is short because the cookie is only
|
||||
# meaningful between /auth/oidc/login and /auth/oidc/callback; the app
|
||||
# itself still runs on JWTs.
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=settings.effective_session_secret,
|
||||
session_cookie="mulita_oidc",
|
||||
max_age=600,
|
||||
same_site="lax",
|
||||
https_only=False,
|
||||
)
|
||||
|
||||
# Mount static files for serving thumbnails (with X-Accel-Redirect support)
|
||||
if os.path.exists("/data/thumbs"):
|
||||
app.mount("/thumbs", StaticFiles(directory="/data/thumbs"), name="thumbs")
|
||||
|
||||
@@ -14,10 +14,24 @@ class User(Base):
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
username = Column(String(50), unique=True, nullable=False, index=True)
|
||||
email = Column(String, unique=True, nullable=True)
|
||||
hashed_password = Column(String, nullable=False)
|
||||
# Nullable: OIDC-only users have no local password. Local accounts
|
||||
# still always have one.
|
||||
hashed_password = Column(String, nullable=True)
|
||||
role = Column(String, nullable=False, default='user') # 'admin' | 'user'
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
# Absolute path to this user's photo directory (e.g., "/photos/daniel")
|
||||
media_path = Column(String, nullable=False)
|
||||
|
||||
# OIDC identity — populated when a user signs in via Authentik (or any
|
||||
# other OIDC provider later). `oidc_sub` is stable per provider, so
|
||||
# lookups key on (oidc_issuer, oidc_sub). NULL for password-only users.
|
||||
oidc_issuer = Column(String, nullable=True)
|
||||
oidc_sub = Column(String, nullable=True)
|
||||
|
||||
# Profile bits that can come from OIDC claims or be filled in later.
|
||||
# avatar_url wins over Gravatar when set; the /auth/me response
|
||||
# computes the final avatar URL for the frontend.
|
||||
avatar_url = Column(String, nullable=True)
|
||||
display_name = Column(String, nullable=True)
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
"""
|
||||
Authentication router — login, token refresh, profile, first-run setup.
|
||||
Authentication router — login, token refresh, profile, first-run setup,
|
||||
and optional OIDC (Authentik) sign-in.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import logging
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, func as sa_func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import hash_password, verify_password, create_access_token, create_refresh_token, decode_token
|
||||
from app.auth_oidc import get_client as get_oidc_client, is_enabled as oidc_is_enabled, provider_label, PROVIDER_NAME
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.folders import SourceRoot
|
||||
from app.services.gravatar import gravatar_url
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -45,6 +52,8 @@ class UserResponse(BaseModel):
|
||||
role: str
|
||||
is_active: bool
|
||||
created_at: Optional[str]
|
||||
avatar_url: Optional[str] = None
|
||||
display_name: Optional[str] = None
|
||||
|
||||
class SetupRequest(BaseModel):
|
||||
username: str
|
||||
@@ -55,10 +64,62 @@ class ChangePasswordRequest(BaseModel):
|
||||
new_password: str
|
||||
|
||||
|
||||
class OidcConfig(BaseModel):
|
||||
enabled: bool
|
||||
label: str
|
||||
login_url: str
|
||||
|
||||
|
||||
class AuthConfigResponse(BaseModel):
|
||||
# None when OIDC is disabled / not configured — the frontend uses
|
||||
# that to hide the SSO button.
|
||||
oidc: Optional[OidcConfig] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def serialize_user(user: User) -> UserResponse:
|
||||
"""Build the user-facing payload, computing the avatar URL with the
|
||||
`provider picture > Gravatar > None` fallback chain."""
|
||||
avatar = user.avatar_url or gravatar_url(user.email)
|
||||
return UserResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
created_at=user.created_at.isoformat() if user.created_at else None,
|
||||
avatar_url=avatar,
|
||||
display_name=user.display_name,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/config", response_model=AuthConfigResponse)
|
||||
async def auth_config():
|
||||
"""Public — tells the frontend which login options to render.
|
||||
|
||||
Returns `oidc: null` when OIDC is disabled or not fully configured,
|
||||
so the login page can hide the SSO button without a round-trip to
|
||||
the IdP. The `login_url` is browser-navigable (full redirect); it
|
||||
starts the Authlib flow that sets the PKCE cookie.
|
||||
"""
|
||||
if not oidc_is_enabled():
|
||||
return AuthConfigResponse(oidc=None)
|
||||
return AuthConfigResponse(
|
||||
oidc=OidcConfig(
|
||||
enabled=True,
|
||||
label=provider_label(),
|
||||
login_url="/api/v1/auth/oidc/login",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Authenticate with username + password, receive JWT tokens."""
|
||||
@@ -67,7 +128,14 @@ async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None or not verify_password(body.password, user.hashed_password):
|
||||
# OIDC-only users (hashed_password IS NULL) can't sign in via this
|
||||
# endpoint; they must go through the SSO flow. Treat as auth failure
|
||||
# so we don't leak account existence.
|
||||
if (
|
||||
user is None
|
||||
or not user.hashed_password
|
||||
or not verify_password(body.password, user.hashed_password)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid username or password",
|
||||
@@ -115,14 +183,7 @@ async def refresh_token(body: RefreshRequest, db: AsyncSession = Depends(get_db)
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_me(current_user: User = Depends(get_current_user)):
|
||||
"""Return the authenticated user's profile."""
|
||||
return UserResponse(
|
||||
id=current_user.id,
|
||||
username=current_user.username,
|
||||
email=current_user.email,
|
||||
role=current_user.role,
|
||||
is_active=current_user.is_active,
|
||||
created_at=current_user.created_at.isoformat() if current_user.created_at else None,
|
||||
)
|
||||
return serialize_user(current_user)
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
@@ -132,7 +193,9 @@ async def change_password(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Change the authenticated user's password."""
|
||||
if not verify_password(body.current_password, current_user.hashed_password):
|
||||
if not current_user.hashed_password or not verify_password(
|
||||
body.current_password, current_user.hashed_password
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Current password is incorrect",
|
||||
@@ -200,3 +263,206 @@ async def auth_status(db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
count = (await db.execute(select(sa_func.count(User.id)))).scalar()
|
||||
return {"setup_completed": count > 0}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OIDC (Authentik) sign-in
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_USERNAME_SANITIZER = re.compile(r"[^a-zA-Z0-9_.-]+")
|
||||
|
||||
|
||||
def _derive_username(claims: dict, existing_usernames: set[str]) -> str:
|
||||
"""Pick a local username from OIDC claims.
|
||||
|
||||
Order of preference:
|
||||
1. `preferred_username` claim (Authentik's usual choice)
|
||||
2. local-part of `email`
|
||||
3. `sub` claim (always present)
|
||||
|
||||
Strips characters the rest of the app doesn't like in paths/URLs,
|
||||
trims to 50 chars (User.username column limit), and appends a short
|
||||
suffix on collision so two Authentik users can't land on the same
|
||||
local row.
|
||||
"""
|
||||
raw = (
|
||||
claims.get("preferred_username")
|
||||
or (claims.get("email") or "").split("@", 1)[0]
|
||||
or claims.get("sub")
|
||||
or "user"
|
||||
)
|
||||
base = _USERNAME_SANITIZER.sub("", str(raw)).strip("._-") or "user"
|
||||
base = base[:40]
|
||||
candidate = base
|
||||
suffix = 0
|
||||
while candidate in existing_usernames:
|
||||
suffix += 1
|
||||
candidate = f"{base}-{suffix}"[:50]
|
||||
return candidate
|
||||
|
||||
|
||||
def _frontend_origin(request: Request) -> str:
|
||||
"""Best guess at where the SPA lives so the callback redirect lands
|
||||
back on the app origin. Uses the configured redirect URI's scheme +
|
||||
host (strips /api/... path) when available, falling back to the
|
||||
request's own origin."""
|
||||
if settings.oidc_redirect_uri:
|
||||
parsed = urlparse(settings.oidc_redirect_uri)
|
||||
return f"{parsed.scheme}://{parsed.netloc}"
|
||||
return f"{request.url.scheme}://{request.url.netloc}"
|
||||
|
||||
|
||||
@router.get("/oidc/login")
|
||||
async def oidc_login(request: Request):
|
||||
"""Start the OIDC flow — redirect to Authentik's authorization URL."""
|
||||
if not oidc_is_enabled():
|
||||
raise HTTPException(status_code=404, detail="OIDC login is not enabled")
|
||||
client = get_oidc_client()
|
||||
if client is None:
|
||||
raise HTTPException(status_code=500, detail="OIDC client not configured")
|
||||
|
||||
redirect_uri = settings.oidc_redirect_uri
|
||||
return await client.authorize_redirect(request, redirect_uri)
|
||||
|
||||
|
||||
@router.get("/oidc/callback")
|
||||
async def oidc_callback(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
"""Handle the OIDC redirect — exchange code, provision/link user,
|
||||
issue our own JWTs, bounce back to the SPA."""
|
||||
if not oidc_is_enabled():
|
||||
raise HTTPException(status_code=404, detail="OIDC login is not enabled")
|
||||
client = get_oidc_client()
|
||||
if client is None:
|
||||
raise HTTPException(status_code=500, detail="OIDC client not configured")
|
||||
|
||||
try:
|
||||
token = await client.authorize_access_token(request)
|
||||
except Exception as exc:
|
||||
logger.warning("OIDC callback: authorize_access_token failed: %s", exc)
|
||||
return _oidc_error_redirect(request, "oidc_exchange_failed")
|
||||
|
||||
# `parse_id_token` verifies signature + nonce; `userinfo` fills in
|
||||
# claims some IdPs don't put in the ID token (e.g. picture). We
|
||||
# merge both, preferring userinfo when both are present.
|
||||
claims = dict(token.get("userinfo") or {})
|
||||
if not claims:
|
||||
try:
|
||||
claims = dict(await client.userinfo(token=token))
|
||||
except Exception:
|
||||
claims = {}
|
||||
id_token_claims = token.get("id_token_claims") or {}
|
||||
for k, v in id_token_claims.items():
|
||||
claims.setdefault(k, v)
|
||||
|
||||
sub = claims.get("sub")
|
||||
if not sub:
|
||||
logger.warning("OIDC callback: claims missing `sub` — %r", claims)
|
||||
return _oidc_error_redirect(request, "oidc_missing_sub")
|
||||
|
||||
issuer = claims.get("iss") or (settings.oidc_issuer or "").rstrip("/")
|
||||
email = claims.get("email")
|
||||
display_name = claims.get("name") or claims.get("preferred_username")
|
||||
picture = claims.get("picture")
|
||||
groups = claims.get("groups") or []
|
||||
if isinstance(groups, str):
|
||||
groups = [groups]
|
||||
|
||||
admin_groups = set(settings.oidc_admin_group_list)
|
||||
role = "admin" if admin_groups and admin_groups.intersection(groups) else "user"
|
||||
|
||||
# 1. Match by (issuer, sub) first — stable identity key.
|
||||
user = (await db.execute(
|
||||
select(User).where(
|
||||
User.oidc_issuer == issuer,
|
||||
User.oidc_sub == sub,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
# 2. Fall back to email so a pre-existing local account can be
|
||||
# linked on first SSO login (homelab admin keeps their row).
|
||||
if user is None and email:
|
||||
user = (await db.execute(
|
||||
select(User).where(User.email == email)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
if not settings.oidc_allow_signup:
|
||||
logger.info("OIDC signup disabled — rejecting unknown sub=%s email=%s", sub, email)
|
||||
return _oidc_error_redirect(request, "oidc_signup_disabled")
|
||||
|
||||
# JIT provision.
|
||||
existing = {
|
||||
u for (u,) in (await db.execute(select(User.username))).all()
|
||||
}
|
||||
username = _derive_username(claims, existing)
|
||||
media_path = os.path.join(settings.photo_dirs, username)
|
||||
os.makedirs(media_path, exist_ok=True)
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
email=email,
|
||||
hashed_password=None,
|
||||
role=role,
|
||||
is_active=True,
|
||||
media_path=media_path,
|
||||
oidc_issuer=issuer,
|
||||
oidc_sub=sub,
|
||||
avatar_url=picture,
|
||||
display_name=display_name,
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
db.add(SourceRoot(
|
||||
name=f"{user.username}'s Library",
|
||||
path=media_path,
|
||||
user_id=user.id,
|
||||
))
|
||||
await db.commit()
|
||||
logger.info("OIDC JIT-created user %s (role=%s)", user.username, role)
|
||||
else:
|
||||
# Refresh profile bits + link identity if needed. We do *not*
|
||||
# demote admins created locally; only touch role when admin
|
||||
# group mapping is configured.
|
||||
changed = False
|
||||
if user.oidc_sub != sub or user.oidc_issuer != issuer:
|
||||
user.oidc_issuer = issuer
|
||||
user.oidc_sub = sub
|
||||
changed = True
|
||||
if email and user.email != email:
|
||||
user.email = email
|
||||
changed = True
|
||||
if display_name and user.display_name != display_name:
|
||||
user.display_name = display_name
|
||||
changed = True
|
||||
if picture and user.avatar_url != picture:
|
||||
user.avatar_url = picture
|
||||
changed = True
|
||||
if admin_groups:
|
||||
new_role = "admin" if admin_groups.intersection(groups) else "user"
|
||||
if user.role != new_role:
|
||||
user.role = new_role
|
||||
changed = True
|
||||
if not user.is_active:
|
||||
# Don't resurrect a deactivated account — surface an error.
|
||||
logger.info("OIDC login rejected — user %s is deactivated", user.username)
|
||||
return _oidc_error_redirect(request, "oidc_deactivated")
|
||||
if changed:
|
||||
await db.commit()
|
||||
|
||||
# Mint our own JWTs and bounce back to the SPA. Tokens ride in the
|
||||
# URL fragment-free for simplicity; the frontend callback page
|
||||
# strips them from the location bar immediately.
|
||||
access = create_access_token(user.id, user.role)
|
||||
refresh = create_refresh_token(user.id)
|
||||
|
||||
params = urlencode({"access_token": access, "refresh_token": refresh})
|
||||
target = f"{_frontend_origin(request)}/auth/callback?{params}"
|
||||
return RedirectResponse(url=target, status_code=302)
|
||||
|
||||
|
||||
def _oidc_error_redirect(request: Request, code: str) -> RedirectResponse:
|
||||
"""Bounce back to the SPA with an `error=` query so the login page
|
||||
can render something meaningful instead of a stack trace."""
|
||||
target = f"{_frontend_origin(request)}/auth/callback?error={code}"
|
||||
return RedirectResponse(url=target, status_code=302)
|
||||
|
||||
@@ -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 "",
|
||||
|
||||
28
backend/app/services/gravatar.py
Normal file
28
backend/app/services/gravatar.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Gravatar URL helper.
|
||||
|
||||
Pure function — no HTTP calls. The browser does the actual image
|
||||
fetch. We just build the deterministic URL from the user's email and
|
||||
let Gravatar serve an identicon when no account exists for that hash,
|
||||
so the avatar is never a broken image.
|
||||
|
||||
Current Gravatar guidance is SHA-256 of the trimmed, lower-cased email.
|
||||
MD5 still works but is deprecated, so we prefer SHA-256.
|
||||
"""
|
||||
import hashlib
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def gravatar_url(email: Optional[str], size: int = 240) -> Optional[str]:
|
||||
"""Return a Gravatar image URL for `email`, or None when email is empty.
|
||||
|
||||
The `d=identicon` fallback guarantees a deterministic placeholder when
|
||||
the address has no Gravatar account, so callers can treat the result
|
||||
as a valid image URL.
|
||||
"""
|
||||
if not email:
|
||||
return None
|
||||
normalized = email.strip().lower()
|
||||
if not normalized:
|
||||
return None
|
||||
digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||
return f"https://gravatar.com/avatar/{digest}?d=identicon&s={size}"
|
||||
@@ -55,6 +55,11 @@ aiofiles==23.2.1
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.0.1
|
||||
# OIDC single sign-on (Authentik, etc.). Authlib drives the Auth Code +
|
||||
# PKCE flow; itsdangerous signs the short-lived Starlette session cookie
|
||||
# that holds the PKCE state during the IdP round-trip.
|
||||
authlib==1.3.1
|
||||
itsdangerous==2.1.2
|
||||
|
||||
# Development
|
||||
pytest==7.4.4
|
||||
|
||||
@@ -53,6 +53,23 @@ services:
|
||||
- SECRET_KEY=${SECRET_KEY:-mulita-dev-secret-change-me}
|
||||
- ACCESS_TOKEN_EXPIRE_MINUTES=${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
|
||||
- REFRESH_TOKEN_EXPIRE_DAYS=${REFRESH_TOKEN_EXPIRE_DAYS:-30}
|
||||
# Authentik / OIDC single sign-on. Leave OIDC_ENABLED=false to
|
||||
# hide the SSO button and stick with username/password. When
|
||||
# enabled, set OIDC_ISSUER to the Authentik provider URL (the one
|
||||
# that serves /.well-known/openid-configuration), and paste the
|
||||
# client id/secret from the Authentik application. OIDC_REDIRECT_URI
|
||||
# must match the one registered on the Authentik side exactly —
|
||||
# e.g. https://photovault.example.com/api/v1/auth/oidc/callback.
|
||||
- OIDC_ENABLED=${OIDC_ENABLED:-false}
|
||||
- OIDC_ISSUER=${OIDC_ISSUER:-}
|
||||
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-}
|
||||
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-}
|
||||
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-}
|
||||
- OIDC_SCOPES=${OIDC_SCOPES:-openid profile email}
|
||||
- OIDC_PROVIDER_LABEL=${OIDC_PROVIDER_LABEL:-Authentik}
|
||||
- OIDC_ALLOW_SIGNUP=${OIDC_ALLOW_SIGNUP:-true}
|
||||
- OIDC_ADMIN_GROUPS=${OIDC_ADMIN_GROUPS:-}
|
||||
- SESSION_SECRET=${SESSION_SECRET:-}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
- TZ=${TZ:-UTC}
|
||||
depends_on:
|
||||
|
||||
@@ -24,6 +24,7 @@ import { usePhotosQuery } from './hooks/usePhotosQuery'
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
import { LoginPage } from './components/auth/LoginPage'
|
||||
import { SetupPage } from './components/auth/SetupPage'
|
||||
import { OidcCallback } from './components/auth/OidcCallback'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
function MainApp() {
|
||||
@@ -185,6 +186,13 @@ function App() {
|
||||
function AuthGate() {
|
||||
const { user, isLoading, needsSetup } = useAuth()
|
||||
|
||||
// OIDC callback lands on /auth/callback — handle it even while
|
||||
// isLoading, so the callback page can adopt tokens and transition
|
||||
// straight to MainApp without flashing the login screen.
|
||||
if (window.location.pathname.startsWith('/auth/callback')) {
|
||||
return <OidcCallback />
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-bg">
|
||||
|
||||
@@ -1,16 +1,46 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import api from '../../services/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
|
||||
interface OidcConfig {
|
||||
enabled: boolean
|
||||
label: string
|
||||
login_url: string
|
||||
}
|
||||
|
||||
interface AuthConfig {
|
||||
oidc: OidcConfig | null
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const { login } = useAuth()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [oidc, setOidc] = useState<OidcConfig | null>(null)
|
||||
|
||||
// Ask the backend which login methods to show. Failure is silent —
|
||||
// worst case the SSO button just doesn't appear and the user falls
|
||||
// back to username/password.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await api.get<AuthConfig>('/auth/config')
|
||||
if (!cancelled) setOidc(res.data.oidc)
|
||||
} catch {
|
||||
/* ignore — SSO button stays hidden */
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -29,10 +59,7 @@ export function LoginPage() {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="w-full max-w-sm space-y-5 rounded-lg border border-border bg-surface p-8 shadow-xl"
|
||||
>
|
||||
<div className="w-full max-w-sm space-y-5 rounded-lg border border-border bg-surface p-8 shadow-xl">
|
||||
<h1 className="text-center text-xl font-semibold text-text">
|
||||
Sign in to Mulita
|
||||
</h1>
|
||||
@@ -43,33 +70,51 @@ export function LoginPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="login-user">Username</Label>
|
||||
<Input
|
||||
id="login-user"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{oidc?.enabled && (
|
||||
<>
|
||||
{/* Full-page navigation (not a fetch) — Authlib sets a
|
||||
* signed session cookie in the /oidc/login response, so
|
||||
* the browser needs to follow the redirect chain itself. */}
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<a href={oidc.login_url}>Sign in with {oidc.label}</a>
|
||||
</Button>
|
||||
<div className="flex items-center gap-3 text-[11px] uppercase tracking-wide text-text-muted">
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
or continue with password
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="login-pass">Password</Label>
|
||||
<Input
|
||||
id="login-pass"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="login-user">Username</Label>
|
||||
<Input
|
||||
id="login-user"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? 'Signing in\u2026' : 'Sign In'}
|
||||
</Button>
|
||||
</form>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="login-pass">Password</Label>
|
||||
<Input
|
||||
id="login-pass"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? 'Signing in…' : 'Sign In'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
84
frontend/src/components/auth/OidcCallback.tsx
Normal file
84
frontend/src/components/auth/OidcCallback.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
/** Landing page for the OIDC redirect.
|
||||
*
|
||||
* Authentik bounces the browser to /auth/callback?access_token=...&refresh_token=...
|
||||
* (or ?error=oidc_xxx when something went wrong). We pull those out of
|
||||
* the URL, hand them to AuthContext, then replace history so the
|
||||
* tokens don't linger in the location bar, the back button, or
|
||||
* whatever screen-recording the user has going.
|
||||
*
|
||||
* Tokens in the query string are an accepted trade-off here: they're
|
||||
* short-lived, they never leave the app origin, and the alternative
|
||||
* (HTTP-only cookies) would be a much larger rework of an otherwise
|
||||
* JWT-in-localStorage codebase.
|
||||
*/
|
||||
const ERROR_MESSAGES: Record<string, string> = {
|
||||
oidc_exchange_failed: 'Could not complete sign-in with the identity provider.',
|
||||
oidc_missing_sub: 'Identity provider did not return a user identifier.',
|
||||
oidc_signup_disabled: 'Your identity provider account has not been authorized for this instance.',
|
||||
oidc_deactivated: 'This account has been deactivated.',
|
||||
}
|
||||
|
||||
export function OidcCallback() {
|
||||
const { onOidcTokens } = useAuth()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const accessToken = params.get('access_token')
|
||||
const refreshToken = params.get('refresh_token')
|
||||
const errCode = params.get('error')
|
||||
|
||||
// Drop everything after the origin + root path, including the
|
||||
// tokens, so refreshing or sharing the URL doesn't leak them.
|
||||
const clean = () => window.history.replaceState({}, '', '/')
|
||||
|
||||
if (errCode) {
|
||||
setError(ERROR_MESSAGES[errCode] || 'Sign-in failed. Please try again.')
|
||||
clean()
|
||||
return
|
||||
}
|
||||
|
||||
if (!accessToken || !refreshToken) {
|
||||
setError('The identity provider did not return the expected tokens.')
|
||||
clean()
|
||||
return
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
await onOidcTokens(accessToken, refreshToken)
|
||||
} catch {
|
||||
setError('Could not complete sign-in. Please try again.')
|
||||
} finally {
|
||||
clean()
|
||||
}
|
||||
})()
|
||||
}, [onOidcTokens])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
|
||||
<div className="w-full max-w-sm space-y-4 rounded-lg border border-border bg-surface p-8 shadow-xl">
|
||||
<h1 className="text-center text-lg font-semibold text-text">Sign-in failed</h1>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
<Button className="w-full" onClick={() => (window.location.href = '/')}>
|
||||
Back to sign in
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-bg">
|
||||
<div className="text-text-muted">Signing you in…</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -521,8 +521,12 @@ export function HeapsPanel() {
|
||||
navigateToSection(`heap-${sh.id}`, { heapId: sh.id })
|
||||
}
|
||||
>
|
||||
<Avatar name={sh.owner_username} size="xs" />
|
||||
<span className="truncate" title={`${sh.name} (shared by ${sh.owner_username})`}>
|
||||
<Avatar
|
||||
name={sh.owner_username}
|
||||
imageUrl={sh.owner_avatar_url}
|
||||
size="xs"
|
||||
/>
|
||||
<span className="truncate" title={`${sh.name} (shared by ${sh.owner_display_name || sh.owner_username})`}>
|
||||
{sh.name}
|
||||
</span>
|
||||
<PermissionIcon
|
||||
|
||||
@@ -899,8 +899,12 @@ export function LeftSidebar() {
|
||||
navigateToSection(`folder-${sf.id}`, { folderId: sf.id })
|
||||
}
|
||||
>
|
||||
<Avatar name={sf.owner_username} size="xs" />
|
||||
<span className="truncate" title={`${sf.name} (shared by ${sf.owner_username})`}>
|
||||
<Avatar
|
||||
name={sf.owner_username}
|
||||
imageUrl={sf.owner_avatar_url}
|
||||
size="xs"
|
||||
/>
|
||||
<span className="truncate" title={`${sf.name} (shared by ${sf.owner_display_name || sf.owner_username})`}>
|
||||
{sf.name}
|
||||
</span>
|
||||
<PermissionIcon
|
||||
@@ -955,8 +959,19 @@ export function LeftSidebar() {
|
||||
<div className="border-t border-border p-1.5 space-y-0.5">
|
||||
{/* User row */}
|
||||
<div className="flex items-center gap-2 rounded px-2 py-1 text-[12px] text-text-muted">
|
||||
<UserIcon className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span className="flex-1 truncate text-text">{user?.username}</span>
|
||||
{user ? (
|
||||
<Avatar
|
||||
name={user.username}
|
||||
imageUrl={user.avatar_url}
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<UserIcon className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 truncate text-text">
|
||||
{user?.display_name || user?.username}
|
||||
</span>
|
||||
{isAdmin && (
|
||||
<span className="rounded bg-accent/20 px-1 py-px text-[10px] leading-none text-accent flex-shrink-0">
|
||||
<Shield className="inline h-2.5 w-2.5" />
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** Hash-tinted initial bubble used across every sharing surface
|
||||
* (ShareDialog, NotificationBell, sidebar shared rows). The tint
|
||||
* isn't meaningful — it's just an identity cue so a list of names
|
||||
* feels less anonymous. Using the same hash across components means
|
||||
* a given user's bubble stays the same colour everywhere. */
|
||||
* a given user's bubble stays the same colour everywhere.
|
||||
*
|
||||
* When `imageUrl` is present (Authentik `picture` claim or a Gravatar
|
||||
* URL), an <img> sits on top of the tinted bubble; if the image fails
|
||||
* to load we collapse back to initials, so a broken avatar never
|
||||
* shows up as a white square. */
|
||||
const PALETTE = [
|
||||
'bg-primary/25 text-primary',
|
||||
'bg-pick/25 text-pick',
|
||||
@@ -23,10 +29,12 @@ export function avatarColor(name: string): string {
|
||||
|
||||
export function Avatar({
|
||||
name,
|
||||
imageUrl,
|
||||
size = 'md',
|
||||
className,
|
||||
}: {
|
||||
name: string
|
||||
imageUrl?: string | null
|
||||
size?: 'xs' | 'sm' | 'md'
|
||||
className?: string
|
||||
}) {
|
||||
@@ -38,17 +46,38 @@ export function Avatar({
|
||||
: size === 'sm'
|
||||
? 'h-5 w-5 text-[9px]'
|
||||
: 'h-8 w-8 text-[11px]'
|
||||
|
||||
// Reset the broken-image flag when the URL changes, e.g. after the
|
||||
// user updates their Gravatar or logs in via a different provider.
|
||||
const [broken, setBroken] = useState(false)
|
||||
useEffect(() => {
|
||||
setBroken(false)
|
||||
}, [imageUrl])
|
||||
|
||||
const showImage = Boolean(imageUrl) && !broken
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center justify-center rounded-full font-semibold uppercase tracking-wide',
|
||||
'relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full font-semibold uppercase tracking-wide',
|
||||
dims,
|
||||
tint,
|
||||
className,
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{initials}
|
||||
{showImage ? (
|
||||
<img
|
||||
src={imageUrl!}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => setBroken(true)}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
initials
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -90,10 +90,16 @@ export function NotificationBell() {
|
||||
className="flex flex-col gap-2 px-3 py-2.5"
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Avatar name={invite.owner_username} size="sm" />
|
||||
<Avatar
|
||||
name={invite.owner_username}
|
||||
imageUrl={invite.owner_avatar_url}
|
||||
size="sm"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm leading-tight text-text">
|
||||
<span className="font-medium">{invite.owner_username}</span>
|
||||
<span className="font-medium">
|
||||
{invite.owner_display_name || invite.owner_username}
|
||||
</span>
|
||||
<span className="text-text-muted"> shared </span>
|
||||
<span className="inline-flex items-center gap-1 align-baseline">
|
||||
<TypeIcon className="inline h-3 w-3 text-text-muted" />
|
||||
|
||||
@@ -180,8 +180,8 @@ export function ShareDialog({
|
||||
{availableUsers.map((u) => (
|
||||
<SelectItem key={u.id} value={u.username}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Avatar name={u.username} size="sm" />
|
||||
{u.username}
|
||||
<Avatar name={u.username} imageUrl={u.avatar_url} size="sm" />
|
||||
{u.display_name || u.username}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -259,11 +259,14 @@ export function ShareDialog({
|
||||
key={share.id}
|
||||
className="group flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-surface-2"
|
||||
>
|
||||
<Avatar name={share.shared_with_username} />
|
||||
<Avatar
|
||||
name={share.shared_with_username}
|
||||
imageUrl={share.shared_with_avatar_url}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-sm font-medium text-text">
|
||||
{share.shared_with_username}
|
||||
{share.shared_with_display_name || share.shared_with_username}
|
||||
</span>
|
||||
{share.status === 'pending' && (
|
||||
<span
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface AuthUser {
|
||||
email: string | null
|
||||
role: 'admin' | 'user'
|
||||
is_active: boolean
|
||||
avatar_url: string | null
|
||||
display_name: string | null
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
@@ -26,6 +28,9 @@ interface AuthContextValue {
|
||||
logout: () => void
|
||||
/** Called after the setup endpoint creates the first admin. */
|
||||
onSetupComplete: (accessToken: string, refreshToken: string) => Promise<void>
|
||||
/** Adopt tokens received from the OIDC callback. Same effect as
|
||||
* onSetupComplete but semantically distinct. */
|
||||
onOidcTokens: (accessToken: string, refreshToken: string) => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
@@ -131,6 +136,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
[fetchMe],
|
||||
)
|
||||
|
||||
const onOidcTokens = useCallback(
|
||||
async (accessToken: string, refreshToken: string) => {
|
||||
storeToken(accessToken)
|
||||
storeRefreshToken(refreshToken)
|
||||
await fetchMe()
|
||||
},
|
||||
[fetchMe],
|
||||
)
|
||||
|
||||
// Axios interceptor: on 401, try to refresh once using the stored
|
||||
// refresh token. If that fails, sign out.
|
||||
useEffect(() => {
|
||||
@@ -168,7 +182,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{ user, isAdmin, isLoading, needsSetup, login, logout, onSetupComplete }}
|
||||
value={{ user, isAdmin, isLoading, needsSetup, login, logout, onSetupComplete, onOidcTokens }}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
|
||||
@@ -790,6 +790,8 @@ export interface SharedHeap {
|
||||
share_id: string
|
||||
name: string
|
||||
owner_username: string
|
||||
owner_avatar_url: string | null
|
||||
owner_display_name: string | null
|
||||
permission: 'read' | 'write'
|
||||
photo_count: number
|
||||
}
|
||||
@@ -800,6 +802,8 @@ export interface SharedFolder {
|
||||
name: string
|
||||
folder_type: 'folder' | 'source_root'
|
||||
owner_username: string
|
||||
owner_avatar_url: string | null
|
||||
owner_display_name: string | null
|
||||
permission: 'read' | 'write'
|
||||
photo_count: number
|
||||
}
|
||||
@@ -808,6 +812,8 @@ export interface ShareInfo {
|
||||
id: string
|
||||
shared_with_id: string
|
||||
shared_with_username: string
|
||||
shared_with_avatar_url: string | null
|
||||
shared_with_display_name: string | null
|
||||
permission: string
|
||||
/** 'pending' until the recipient accepts in the notification bell,
|
||||
* then 'accepted'. Decline deletes the row outright. */
|
||||
@@ -818,6 +824,8 @@ export interface ShareInfo {
|
||||
export interface ShareableUser {
|
||||
id: string
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
display_name: string | null
|
||||
}
|
||||
|
||||
/** One entry in the combined pending-shares response. `target_id` is
|
||||
@@ -828,6 +836,8 @@ export interface PendingShare {
|
||||
target_id: string
|
||||
target_name: string
|
||||
owner_username: string
|
||||
owner_avatar_url: string | null
|
||||
owner_display_name: string | null
|
||||
permission: 'read' | 'write'
|
||||
created_at: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user