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
|
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
|
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
|
@property
|
||||||
def cors_origins(self) -> list[str]:
|
def cors_origins(self) -> list[str]:
|
||||||
"""Parse the ALLOWED_ORIGINS env var into a list. Accepts:
|
"""Parse the ALLOWED_ORIGINS env var into a list. Accepts:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from contextlib import asynccontextmanager
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -80,6 +81,19 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
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)
|
# Mount static files for serving thumbnails (with X-Accel-Redirect support)
|
||||||
if os.path.exists("/data/thumbs"):
|
if os.path.exists("/data/thumbs"):
|
||||||
app.mount("/thumbs", StaticFiles(directory="/data/thumbs"), name="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()))
|
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
username = Column(String(50), unique=True, nullable=False, index=True)
|
username = Column(String(50), unique=True, nullable=False, index=True)
|
||||||
email = Column(String, unique=True, nullable=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'
|
role = Column(String, nullable=False, default='user') # 'admin' | 'user'
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
# Absolute path to this user's photo directory (e.g., "/photos/daniel")
|
# Absolute path to this user's photo directory (e.g., "/photos/daniel")
|
||||||
media_path = Column(String, nullable=False)
|
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 os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
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 pydantic import BaseModel
|
||||||
from sqlalchemy import select, func as sa_func
|
from sqlalchemy import select, func as sa_func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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 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.database import get_db
|
||||||
from app.dependencies import get_current_user
|
from app.dependencies import get_current_user
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.folders import SourceRoot
|
from app.models.folders import SourceRoot
|
||||||
|
from app.services.gravatar import gravatar_url
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -45,6 +52,8 @@ class UserResponse(BaseModel):
|
|||||||
role: str
|
role: str
|
||||||
is_active: bool
|
is_active: bool
|
||||||
created_at: Optional[str]
|
created_at: Optional[str]
|
||||||
|
avatar_url: Optional[str] = None
|
||||||
|
display_name: Optional[str] = None
|
||||||
|
|
||||||
class SetupRequest(BaseModel):
|
class SetupRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
@@ -55,10 +64,62 @@ class ChangePasswordRequest(BaseModel):
|
|||||||
new_password: str
|
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
|
# 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)
|
@router.post("/login", response_model=TokenResponse)
|
||||||
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||||
"""Authenticate with username + password, receive JWT tokens."""
|
"""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()
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid username or password",
|
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)
|
@router.get("/me", response_model=UserResponse)
|
||||||
async def get_me(current_user: User = Depends(get_current_user)):
|
async def get_me(current_user: User = Depends(get_current_user)):
|
||||||
"""Return the authenticated user's profile."""
|
"""Return the authenticated user's profile."""
|
||||||
return UserResponse(
|
return serialize_user(current_user)
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/change-password")
|
@router.post("/change-password")
|
||||||
@@ -132,7 +193,9 @@ async def change_password(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Change the authenticated user's password."""
|
"""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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Current password is incorrect",
|
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()
|
count = (await db.execute(select(sa_func.count(User.id)))).scalar()
|
||||||
return {"setup_completed": count > 0}
|
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,
|
get_user_folder,
|
||||||
resolve_username,
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -38,6 +46,8 @@ class ShareResponse(BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
shared_with_id: str
|
shared_with_id: str
|
||||||
shared_with_username: str
|
shared_with_username: str
|
||||||
|
shared_with_avatar_url: Optional[str] = None
|
||||||
|
shared_with_display_name: Optional[str] = None
|
||||||
permission: str
|
permission: str
|
||||||
status: str # 'pending' | 'accepted'
|
status: str # 'pending' | 'accepted'
|
||||||
created_at: str
|
created_at: str
|
||||||
@@ -51,6 +61,8 @@ class SharedHeapResponse(BaseModel):
|
|||||||
share_id: str
|
share_id: str
|
||||||
name: str
|
name: str
|
||||||
owner_username: str
|
owner_username: str
|
||||||
|
owner_avatar_url: Optional[str] = None
|
||||||
|
owner_display_name: Optional[str] = None
|
||||||
permission: str
|
permission: str
|
||||||
photo_count: int
|
photo_count: int
|
||||||
|
|
||||||
@@ -61,6 +73,8 @@ class SharedFolderResponse(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
folder_type: str
|
folder_type: str
|
||||||
owner_username: str
|
owner_username: str
|
||||||
|
owner_avatar_url: Optional[str] = None
|
||||||
|
owner_display_name: Optional[str] = None
|
||||||
permission: str
|
permission: str
|
||||||
photo_count: int
|
photo_count: int
|
||||||
|
|
||||||
@@ -72,6 +86,8 @@ class PendingInvite(BaseModel):
|
|||||||
target_id: str # heap id or folder id
|
target_id: str # heap id or folder id
|
||||||
target_name: str
|
target_name: str
|
||||||
owner_username: str
|
owner_username: str
|
||||||
|
owner_avatar_url: Optional[str] = None
|
||||||
|
owner_display_name: Optional[str] = None
|
||||||
permission: str
|
permission: str
|
||||||
created_at: str
|
created_at: str
|
||||||
|
|
||||||
@@ -84,6 +100,8 @@ class PendingInvitesResponse(BaseModel):
|
|||||||
class ShareableUser(BaseModel):
|
class ShareableUser(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
username: str
|
username: str
|
||||||
|
avatar_url: Optional[str] = None
|
||||||
|
display_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
# ── Shareable users ──────────────────────────────────────────────────────
|
# ── Shareable users ──────────────────────────────────────────────────────
|
||||||
@@ -104,7 +122,12 @@ async def list_shareable_users(
|
|||||||
.order_by(User.username)
|
.order_by(User.username)
|
||||||
)
|
)
|
||||||
return [
|
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()
|
for u in result.scalars().all()
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -132,6 +155,8 @@ async def list_pending_invites(
|
|||||||
target_id=heap.id,
|
target_id=heap.id,
|
||||||
target_name=heap.name,
|
target_name=heap.name,
|
||||||
owner_username=owner.username,
|
owner_username=owner.username,
|
||||||
|
owner_avatar_url=_user_avatar(owner),
|
||||||
|
owner_display_name=owner.display_name,
|
||||||
permission=share.permission,
|
permission=share.permission,
|
||||||
created_at=share.created_at.isoformat() if share.created_at else "",
|
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_id=share.folder_id,
|
||||||
target_name=entity.name,
|
target_name=entity.name,
|
||||||
owner_username=owner.username,
|
owner_username=owner.username,
|
||||||
|
owner_avatar_url=_user_avatar(owner),
|
||||||
|
owner_display_name=owner.display_name,
|
||||||
permission=share.permission,
|
permission=share.permission,
|
||||||
created_at=share.created_at.isoformat() if share.created_at else "",
|
created_at=share.created_at.isoformat() if share.created_at else "",
|
||||||
))
|
))
|
||||||
@@ -205,6 +232,8 @@ async def list_shared_heaps(
|
|||||||
share_id=share.id,
|
share_id=share.id,
|
||||||
name=heap.name,
|
name=heap.name,
|
||||||
owner_username=owner.username,
|
owner_username=owner.username,
|
||||||
|
owner_avatar_url=_user_avatar(owner),
|
||||||
|
owner_display_name=owner.display_name,
|
||||||
permission=share.permission,
|
permission=share.permission,
|
||||||
photo_count=count,
|
photo_count=count,
|
||||||
))
|
))
|
||||||
@@ -230,6 +259,8 @@ async def list_heap_shares(
|
|||||||
id=share.id,
|
id=share.id,
|
||||||
shared_with_id=user.id,
|
shared_with_id=user.id,
|
||||||
shared_with_username=user.username,
|
shared_with_username=user.username,
|
||||||
|
shared_with_avatar_url=_user_avatar(user),
|
||||||
|
shared_with_display_name=user.display_name,
|
||||||
permission=share.permission,
|
permission=share.permission,
|
||||||
status=share.status,
|
status=share.status,
|
||||||
created_at=share.created_at.isoformat() if share.created_at else "",
|
created_at=share.created_at.isoformat() if share.created_at else "",
|
||||||
@@ -414,6 +445,8 @@ async def list_shared_folders(
|
|||||||
name=name,
|
name=name,
|
||||||
folder_type=share.folder_type,
|
folder_type=share.folder_type,
|
||||||
owner_username=owner.username,
|
owner_username=owner.username,
|
||||||
|
owner_avatar_url=_user_avatar(owner),
|
||||||
|
owner_display_name=owner.display_name,
|
||||||
permission=share.permission,
|
permission=share.permission,
|
||||||
photo_count=count,
|
photo_count=count,
|
||||||
))
|
))
|
||||||
@@ -454,6 +487,8 @@ async def list_folder_shares(
|
|||||||
id=share.id,
|
id=share.id,
|
||||||
shared_with_id=user.id,
|
shared_with_id=user.id,
|
||||||
shared_with_username=user.username,
|
shared_with_username=user.username,
|
||||||
|
shared_with_avatar_url=_user_avatar(user),
|
||||||
|
shared_with_display_name=user.display_name,
|
||||||
permission=share.permission,
|
permission=share.permission,
|
||||||
status=share.status,
|
status=share.status,
|
||||||
created_at=share.created_at.isoformat() if share.created_at else "",
|
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
|
python-jose[cryptography]==3.3.0
|
||||||
passlib[bcrypt]==1.7.4
|
passlib[bcrypt]==1.7.4
|
||||||
bcrypt==4.0.1
|
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
|
# Development
|
||||||
pytest==7.4.4
|
pytest==7.4.4
|
||||||
|
|||||||
@@ -53,6 +53,23 @@ services:
|
|||||||
- SECRET_KEY=${SECRET_KEY:-mulita-dev-secret-change-me}
|
- SECRET_KEY=${SECRET_KEY:-mulita-dev-secret-change-me}
|
||||||
- ACCESS_TOKEN_EXPIRE_MINUTES=${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
|
- ACCESS_TOKEN_EXPIRE_MINUTES=${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
|
||||||
- REFRESH_TOKEN_EXPIRE_DAYS=${REFRESH_TOKEN_EXPIRE_DAYS:-30}
|
- 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}
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||||
- TZ=${TZ:-UTC}
|
- TZ=${TZ:-UTC}
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { usePhotosQuery } from './hooks/usePhotosQuery'
|
|||||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||||
import { LoginPage } from './components/auth/LoginPage'
|
import { LoginPage } from './components/auth/LoginPage'
|
||||||
import { SetupPage } from './components/auth/SetupPage'
|
import { SetupPage } from './components/auth/SetupPage'
|
||||||
|
import { OidcCallback } from './components/auth/OidcCallback'
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
|
|
||||||
function MainApp() {
|
function MainApp() {
|
||||||
@@ -185,6 +186,13 @@ function App() {
|
|||||||
function AuthGate() {
|
function AuthGate() {
|
||||||
const { user, isLoading, needsSetup } = useAuth()
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-bg">
|
<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 { useAuth } from '../../contexts/AuthContext'
|
||||||
|
import api from '../../services/api'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
|
||||||
|
interface OidcConfig {
|
||||||
|
enabled: boolean
|
||||||
|
label: string
|
||||||
|
login_url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthConfig {
|
||||||
|
oidc: OidcConfig | null
|
||||||
|
}
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { login } = useAuth()
|
const { login } = useAuth()
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
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) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -29,10 +59,7 @@ export function LoginPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
|
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
|
||||||
<form
|
<div className="w-full max-w-sm space-y-5 rounded-lg border border-border bg-surface p-8 shadow-xl">
|
||||||
onSubmit={handleSubmit}
|
|
||||||
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">
|
<h1 className="text-center text-xl font-semibold text-text">
|
||||||
Sign in to Mulita
|
Sign in to Mulita
|
||||||
</h1>
|
</h1>
|
||||||
@@ -43,6 +70,23 @@ export function LoginPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="login-user">Username</Label>
|
<Label htmlFor="login-user">Username</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -67,9 +111,10 @@ export function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" disabled={loading} className="w-full">
|
<Button type="submit" disabled={loading} className="w-full">
|
||||||
{loading ? 'Signing in\u2026' : 'Sign In'}
|
{loading ? 'Signing in…' : 'Sign In'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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 })
|
navigateToSection(`heap-${sh.id}`, { heapId: sh.id })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Avatar name={sh.owner_username} size="xs" />
|
<Avatar
|
||||||
<span className="truncate" title={`${sh.name} (shared by ${sh.owner_username})`}>
|
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}
|
{sh.name}
|
||||||
</span>
|
</span>
|
||||||
<PermissionIcon
|
<PermissionIcon
|
||||||
|
|||||||
@@ -899,8 +899,12 @@ export function LeftSidebar() {
|
|||||||
navigateToSection(`folder-${sf.id}`, { folderId: sf.id })
|
navigateToSection(`folder-${sf.id}`, { folderId: sf.id })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Avatar name={sf.owner_username} size="xs" />
|
<Avatar
|
||||||
<span className="truncate" title={`${sf.name} (shared by ${sf.owner_username})`}>
|
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}
|
{sf.name}
|
||||||
</span>
|
</span>
|
||||||
<PermissionIcon
|
<PermissionIcon
|
||||||
@@ -955,8 +959,19 @@ export function LeftSidebar() {
|
|||||||
<div className="border-t border-border p-1.5 space-y-0.5">
|
<div className="border-t border-border p-1.5 space-y-0.5">
|
||||||
{/* User row */}
|
{/* User row */}
|
||||||
<div className="flex items-center gap-2 rounded px-2 py-1 text-[12px] text-text-muted">
|
<div className="flex items-center gap-2 rounded px-2 py-1 text-[12px] text-text-muted">
|
||||||
|
{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" />
|
<UserIcon className="h-3.5 w-3.5 flex-shrink-0" />
|
||||||
<span className="flex-1 truncate text-text">{user?.username}</span>
|
)}
|
||||||
|
<span className="flex-1 truncate text-text">
|
||||||
|
{user?.display_name || user?.username}
|
||||||
|
</span>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<span className="rounded bg-accent/20 px-1 py-px text-[10px] leading-none text-accent flex-shrink-0">
|
<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" />
|
<Shield className="inline h-2.5 w-2.5" />
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
/** Hash-tinted initial bubble used across every sharing surface
|
/** Hash-tinted initial bubble used across every sharing surface
|
||||||
* (ShareDialog, NotificationBell, sidebar shared rows). The tint
|
* (ShareDialog, NotificationBell, sidebar shared rows). The tint
|
||||||
* isn't meaningful — it's just an identity cue so a list of names
|
* isn't meaningful — it's just an identity cue so a list of names
|
||||||
* feels less anonymous. Using the same hash across components means
|
* 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 = [
|
const PALETTE = [
|
||||||
'bg-primary/25 text-primary',
|
'bg-primary/25 text-primary',
|
||||||
'bg-pick/25 text-pick',
|
'bg-pick/25 text-pick',
|
||||||
@@ -23,10 +29,12 @@ export function avatarColor(name: string): string {
|
|||||||
|
|
||||||
export function Avatar({
|
export function Avatar({
|
||||||
name,
|
name,
|
||||||
|
imageUrl,
|
||||||
size = 'md',
|
size = 'md',
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
name: string
|
name: string
|
||||||
|
imageUrl?: string | null
|
||||||
size?: 'xs' | 'sm' | 'md'
|
size?: 'xs' | 'sm' | 'md'
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
@@ -38,17 +46,38 @@ export function Avatar({
|
|||||||
: size === 'sm'
|
: size === 'sm'
|
||||||
? 'h-5 w-5 text-[9px]'
|
? 'h-5 w-5 text-[9px]'
|
||||||
: 'h-8 w-8 text-[11px]'
|
: '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 (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
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,
|
dims,
|
||||||
tint,
|
tint,
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
aria-hidden
|
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>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,10 +90,16 @@ export function NotificationBell() {
|
|||||||
className="flex flex-col gap-2 px-3 py-2.5"
|
className="flex flex-col gap-2 px-3 py-2.5"
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-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="min-w-0 flex-1">
|
||||||
<div className="text-sm leading-tight text-text">
|
<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="text-text-muted"> shared </span>
|
||||||
<span className="inline-flex items-center gap-1 align-baseline">
|
<span className="inline-flex items-center gap-1 align-baseline">
|
||||||
<TypeIcon className="inline h-3 w-3 text-text-muted" />
|
<TypeIcon className="inline h-3 w-3 text-text-muted" />
|
||||||
|
|||||||
@@ -180,8 +180,8 @@ export function ShareDialog({
|
|||||||
{availableUsers.map((u) => (
|
{availableUsers.map((u) => (
|
||||||
<SelectItem key={u.id} value={u.username}>
|
<SelectItem key={u.id} value={u.username}>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Avatar name={u.username} size="sm" />
|
<Avatar name={u.username} imageUrl={u.avatar_url} size="sm" />
|
||||||
{u.username}
|
{u.display_name || u.username}
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
@@ -259,11 +259,14 @@ export function ShareDialog({
|
|||||||
key={share.id}
|
key={share.id}
|
||||||
className="group flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-surface-2"
|
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="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="truncate text-sm font-medium text-text">
|
<span className="truncate text-sm font-medium text-text">
|
||||||
{share.shared_with_username}
|
{share.shared_with_display_name || share.shared_with_username}
|
||||||
</span>
|
</span>
|
||||||
{share.status === 'pending' && (
|
{share.status === 'pending' && (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface AuthUser {
|
|||||||
email: string | null
|
email: string | null
|
||||||
role: 'admin' | 'user'
|
role: 'admin' | 'user'
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
|
avatar_url: string | null
|
||||||
|
display_name: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
@@ -26,6 +28,9 @@ interface AuthContextValue {
|
|||||||
logout: () => void
|
logout: () => void
|
||||||
/** Called after the setup endpoint creates the first admin. */
|
/** Called after the setup endpoint creates the first admin. */
|
||||||
onSetupComplete: (accessToken: string, refreshToken: string) => Promise<void>
|
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)
|
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||||
@@ -131,6 +136,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
[fetchMe],
|
[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
|
// Axios interceptor: on 401, try to refresh once using the stored
|
||||||
// refresh token. If that fails, sign out.
|
// refresh token. If that fails, sign out.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -168,7 +182,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider
|
<AuthContext.Provider
|
||||||
value={{ user, isAdmin, isLoading, needsSetup, login, logout, onSetupComplete }}
|
value={{ user, isAdmin, isLoading, needsSetup, login, logout, onSetupComplete, onOidcTokens }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
|
|||||||
@@ -790,6 +790,8 @@ export interface SharedHeap {
|
|||||||
share_id: string
|
share_id: string
|
||||||
name: string
|
name: string
|
||||||
owner_username: string
|
owner_username: string
|
||||||
|
owner_avatar_url: string | null
|
||||||
|
owner_display_name: string | null
|
||||||
permission: 'read' | 'write'
|
permission: 'read' | 'write'
|
||||||
photo_count: number
|
photo_count: number
|
||||||
}
|
}
|
||||||
@@ -800,6 +802,8 @@ export interface SharedFolder {
|
|||||||
name: string
|
name: string
|
||||||
folder_type: 'folder' | 'source_root'
|
folder_type: 'folder' | 'source_root'
|
||||||
owner_username: string
|
owner_username: string
|
||||||
|
owner_avatar_url: string | null
|
||||||
|
owner_display_name: string | null
|
||||||
permission: 'read' | 'write'
|
permission: 'read' | 'write'
|
||||||
photo_count: number
|
photo_count: number
|
||||||
}
|
}
|
||||||
@@ -808,6 +812,8 @@ export interface ShareInfo {
|
|||||||
id: string
|
id: string
|
||||||
shared_with_id: string
|
shared_with_id: string
|
||||||
shared_with_username: string
|
shared_with_username: string
|
||||||
|
shared_with_avatar_url: string | null
|
||||||
|
shared_with_display_name: string | null
|
||||||
permission: string
|
permission: string
|
||||||
/** 'pending' until the recipient accepts in the notification bell,
|
/** 'pending' until the recipient accepts in the notification bell,
|
||||||
* then 'accepted'. Decline deletes the row outright. */
|
* then 'accepted'. Decline deletes the row outright. */
|
||||||
@@ -818,6 +824,8 @@ export interface ShareInfo {
|
|||||||
export interface ShareableUser {
|
export interface ShareableUser {
|
||||||
id: string
|
id: string
|
||||||
username: string
|
username: string
|
||||||
|
avatar_url: string | null
|
||||||
|
display_name: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One entry in the combined pending-shares response. `target_id` is
|
/** One entry in the combined pending-shares response. `target_id` is
|
||||||
@@ -828,6 +836,8 @@ export interface PendingShare {
|
|||||||
target_id: string
|
target_id: string
|
||||||
target_name: string
|
target_name: string
|
||||||
owner_username: string
|
owner_username: string
|
||||||
|
owner_avatar_url: string | null
|
||||||
|
owner_display_name: string | null
|
||||||
permission: 'read' | 'write'
|
permission: 'read' | 'write'
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user