Add OIDC/SSO login, reminder fixes, user notification settings
- Add generic OIDC/SSO support (configurable via env vars) - Admin can enable SSO-only mode (disables password login) - SSO callback auto-creates and verifies users - Login page shows SSO button when configured, hides password form in SSO-only mode - Fix reminders: skip course quizzes and deleted quizzes - Don't create reminders for course quiz attempts - Add user reminder opt-out toggle in Settings > Notifications - Scheduler checks user opt-out before sending emails Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
695392f022
commit
699cbabfcb
12 changed files with 298 additions and 27 deletions
|
|
@ -44,5 +44,12 @@ class Settings(BaseSettings):
|
||||||
BBB_SERVER_URL: str = "" # BigBlueButton server URL (e.g. https://bbb.example.com/bigbluebutton)
|
BBB_SERVER_URL: str = "" # BigBlueButton server URL (e.g. https://bbb.example.com/bigbluebutton)
|
||||||
BBB_SECRET: str = "" # BigBlueButton shared secret
|
BBB_SECRET: str = "" # BigBlueButton shared secret
|
||||||
|
|
||||||
|
# OIDC / SSO — leave blank to disable
|
||||||
|
OIDC_PROVIDER_URL: str = "" # e.g. https://accounts.google.com, https://login.microsoftonline.com/{tenant}/v2.0
|
||||||
|
OIDC_CLIENT_ID: str = ""
|
||||||
|
OIDC_CLIENT_SECRET: str = ""
|
||||||
|
OIDC_SCOPES: str = "openid email profile" # space-separated
|
||||||
|
OIDC_PROVIDER_NAME: str = "SSO" # Display name on login button
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
|
||||||
|
|
@ -505,6 +505,10 @@ app.add_middleware(
|
||||||
expose_headers=["X-New-Token"], # Allow frontend to read this header
|
expose_headers=["X-New-Token"], # Allow frontend to read this header
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Session middleware for OIDC state (authlib needs it)
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY)
|
||||||
|
|
||||||
# Add token refresh middleware AFTER CORS (middleware applies in reverse)
|
# Add token refresh middleware AFTER CORS (middleware applies in reverse)
|
||||||
from app.utils.auth import TokenRefreshMiddleware
|
from app.utils.auth import TokenRefreshMiddleware
|
||||||
app.add_middleware(TokenRefreshMiddleware)
|
app.add_middleware(TokenRefreshMiddleware)
|
||||||
|
|
|
||||||
|
|
@ -401,16 +401,23 @@ def get_settings(admin: User = Depends(require_admin)):
|
||||||
registration_enabled = r.get("settings:registration_enabled")
|
registration_enabled = r.get("settings:registration_enabled")
|
||||||
embedding_model = r.get("settings:embedding_model")
|
embedding_model = r.get("settings:embedding_model")
|
||||||
polly_enabled = r.get("settings:polly_enabled")
|
polly_enabled = r.get("settings:polly_enabled")
|
||||||
|
sso_only = r.get("settings:sso_only")
|
||||||
return {
|
return {
|
||||||
"registration_enabled": registration_enabled != "false",
|
"registration_enabled": registration_enabled != "false",
|
||||||
"embedding_model": embedding_model or settings.LITELLM_EMBEDDING_MODEL or "",
|
"embedding_model": embedding_model or settings.LITELLM_EMBEDDING_MODEL or "",
|
||||||
"polly_enabled": polly_enabled != "false",
|
"polly_enabled": polly_enabled != "false",
|
||||||
|
"sso_only": sso_only == "true",
|
||||||
|
"sso_configured": bool(settings.OIDC_PROVIDER_URL and settings.OIDC_CLIENT_ID),
|
||||||
|
"sso_provider_name": settings.OIDC_PROVIDER_NAME,
|
||||||
}
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
return {
|
return {
|
||||||
"registration_enabled": True,
|
"registration_enabled": True,
|
||||||
"embedding_model": settings.LITELLM_EMBEDDING_MODEL or "",
|
"embedding_model": settings.LITELLM_EMBEDDING_MODEL or "",
|
||||||
"polly_enabled": True,
|
"polly_enabled": True,
|
||||||
|
"sso_only": False,
|
||||||
|
"sso_configured": bool(settings.OIDC_PROVIDER_URL and settings.OIDC_CLIENT_ID),
|
||||||
|
"sso_provider_name": settings.OIDC_PROVIDER_NAME,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -435,6 +442,10 @@ def update_settings(
|
||||||
value = "true" if settings_data["polly_enabled"] else "false"
|
value = "true" if settings_data["polly_enabled"] else "false"
|
||||||
r.set("settings:polly_enabled", value)
|
r.set("settings:polly_enabled", value)
|
||||||
|
|
||||||
|
if "sso_only" in settings_data:
|
||||||
|
value = "true" if settings_data["sso_only"] else "false"
|
||||||
|
r.set("settings:sso_only", value)
|
||||||
|
|
||||||
return {"success": True, "message": "Settings updated"}
|
return {"success": True, "message": "Settings updated"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to update settings: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to update settings: {str(e)}")
|
||||||
|
|
|
||||||
|
|
@ -167,12 +167,13 @@ def submit_attempt(
|
||||||
|
|
||||||
percentage = (score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0
|
percentage = (score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0
|
||||||
|
|
||||||
# Update reminder schedule
|
# Update reminder schedule (skip course quizzes)
|
||||||
try:
|
if not is_course_quiz:
|
||||||
from app.services.reminder_service import update_reminder_schedule
|
try:
|
||||||
update_reminder_schedule(db, current_user.id, attempt.quiz_id, percentage)
|
from app.services.reminder_service import update_reminder_schedule
|
||||||
except Exception:
|
update_reminder_schedule(db, current_user.id, attempt.quiz_id, percentage)
|
||||||
pass # Don't fail submission if reminder update fails
|
except Exception:
|
||||||
|
pass # Don't fail submission if reminder update fails
|
||||||
|
|
||||||
return AttemptDetail(
|
return AttemptDetail(
|
||||||
id=attempt.id,
|
id=attempt.id,
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,11 @@ async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db:
|
||||||
|
|
||||||
@router.post("/login", response_model=Token)
|
@router.post("/login", response_model=Token)
|
||||||
async def login(login_data: LoginRequest, db: Session = Depends(get_db), request: Request = None):
|
async def login(login_data: LoginRequest, db: Session = Depends(get_db), request: Request = None):
|
||||||
|
# Block password login if SSO-only mode
|
||||||
|
sso_settings = _get_sso_settings()
|
||||||
|
if sso_settings["sso_only"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Password login is disabled. Please use SSO.")
|
||||||
|
|
||||||
# Verify Turnstile if configured
|
# Verify Turnstile if configured
|
||||||
from app.config import settings as cfg
|
from app.config import settings as cfg
|
||||||
if cfg.TURNSTILE_SECRET_KEY:
|
if cfg.TURNSTILE_SECRET_KEY:
|
||||||
|
|
@ -314,3 +319,111 @@ def update_me(data: UserUpdateMe, db: Session = Depends(get_db), current_user: U
|
||||||
"id": current_user.id, "email": current_user.email,
|
"id": current_user.id, "email": current_user.email,
|
||||||
"name": current_user.name, "role": current_user.role,
|
"name": current_user.name, "role": current_user.role,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── SSO / OIDC ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_sso_settings():
|
||||||
|
"""Return SSO admin settings from Redis."""
|
||||||
|
try:
|
||||||
|
import redis as redis_lib
|
||||||
|
from app.config import settings as cfg
|
||||||
|
r = redis_lib.from_url(cfg.REDIS_URL, decode_responses=True, socket_connect_timeout=1)
|
||||||
|
return {
|
||||||
|
"sso_only": r.get("settings:sso_only") == "true",
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return {"sso_only": False}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sso/config")
|
||||||
|
def sso_config():
|
||||||
|
"""Public endpoint — tells frontend whether SSO is available and login mode."""
|
||||||
|
from app.config import settings as cfg
|
||||||
|
sso_enabled = bool(cfg.OIDC_PROVIDER_URL and cfg.OIDC_CLIENT_ID)
|
||||||
|
sso_settings = _get_sso_settings()
|
||||||
|
return {
|
||||||
|
"sso_enabled": sso_enabled,
|
||||||
|
"sso_only": sso_settings["sso_only"],
|
||||||
|
"provider_name": cfg.OIDC_PROVIDER_NAME if sso_enabled else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sso/login")
|
||||||
|
def sso_login(request: Request):
|
||||||
|
"""Redirect user to the OIDC provider for login."""
|
||||||
|
from authlib.integrations.starlette_client import OAuth
|
||||||
|
from starlette.responses import RedirectResponse
|
||||||
|
from app.config import settings as cfg
|
||||||
|
|
||||||
|
if not cfg.OIDC_PROVIDER_URL or not cfg.OIDC_CLIENT_ID:
|
||||||
|
raise HTTPException(status_code=400, detail="SSO is not configured")
|
||||||
|
|
||||||
|
oauth = OAuth()
|
||||||
|
oauth.register(
|
||||||
|
name="oidc",
|
||||||
|
server_metadata_url=f"{cfg.OIDC_PROVIDER_URL.rstrip('/')}/.well-known/openid-configuration",
|
||||||
|
client_id=cfg.OIDC_CLIENT_ID,
|
||||||
|
client_secret=cfg.OIDC_CLIENT_SECRET,
|
||||||
|
client_kwargs={"scope": cfg.OIDC_SCOPES},
|
||||||
|
)
|
||||||
|
|
||||||
|
redirect_uri = f"{cfg.APP_URL}/api/auth/sso/callback"
|
||||||
|
return oauth.oidc.authorize_redirect(request, redirect_uri)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sso/callback")
|
||||||
|
async def sso_callback(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""Handle OIDC provider callback — create or login user."""
|
||||||
|
from authlib.integrations.starlette_client import OAuth
|
||||||
|
from starlette.responses import RedirectResponse
|
||||||
|
from app.config import settings as cfg
|
||||||
|
|
||||||
|
if not cfg.OIDC_PROVIDER_URL or not cfg.OIDC_CLIENT_ID:
|
||||||
|
raise HTTPException(status_code=400, detail="SSO is not configured")
|
||||||
|
|
||||||
|
oauth = OAuth()
|
||||||
|
oauth.register(
|
||||||
|
name="oidc",
|
||||||
|
server_metadata_url=f"{cfg.OIDC_PROVIDER_URL.rstrip('/')}/.well-known/openid-configuration",
|
||||||
|
client_id=cfg.OIDC_CLIENT_ID,
|
||||||
|
client_secret=cfg.OIDC_CLIENT_SECRET,
|
||||||
|
client_kwargs={"scope": cfg.OIDC_SCOPES},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = await oauth.oidc.authorize_access_token(request)
|
||||||
|
except Exception:
|
||||||
|
return RedirectResponse(url=f"{cfg.APP_URL}/login?error=sso_failed")
|
||||||
|
|
||||||
|
userinfo = token.get("userinfo") or {}
|
||||||
|
email = userinfo.get("email", "").lower().strip()
|
||||||
|
name = userinfo.get("name") or userinfo.get("preferred_username") or email.split("@")[0]
|
||||||
|
|
||||||
|
if not email:
|
||||||
|
return RedirectResponse(url=f"{cfg.APP_URL}/login?error=no_email")
|
||||||
|
|
||||||
|
# Find or create user
|
||||||
|
user = db.query(User).filter(User.email == email).first()
|
||||||
|
if not user:
|
||||||
|
user = User(
|
||||||
|
email=email,
|
||||||
|
hashed_password=get_password_hash(secrets.token_urlsafe(32)), # random password — SSO users don't use it
|
||||||
|
name=name,
|
||||||
|
role="user",
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
# Auto-verify SSO users
|
||||||
|
verification = EmailVerification(
|
||||||
|
user_id=user.id,
|
||||||
|
token=secrets.token_urlsafe(32),
|
||||||
|
expires_at=datetime.utcnow() + timedelta(hours=1),
|
||||||
|
verified_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(verification)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
|
||||||
|
access_token = create_access_token(data={"sub": user.email})
|
||||||
|
return RedirectResponse(url=f"{cfg.APP_URL}/sso-callback?token={access_token}")
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,21 @@ def check_and_send_reminders():
|
||||||
user = db.query(User).filter(User.id == reminder.user_id).first()
|
user = db.query(User).filter(User.id == reminder.user_id).first()
|
||||||
quiz = db.query(Quiz).filter(Quiz.id == reminder.quiz_id).first()
|
quiz = db.query(Quiz).filter(Quiz.id == reminder.quiz_id).first()
|
||||||
|
|
||||||
if not user or not quiz:
|
if not user or not quiz or quiz.deleted_at or quiz.course_id:
|
||||||
reminder.is_active = False
|
reminder.is_active = False
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Check if user has opted out of reminders
|
||||||
|
try:
|
||||||
|
import redis as redis_lib, json as _json
|
||||||
|
from app.config import settings as cfg
|
||||||
|
r = redis_lib.from_url(cfg.REDIS_URL, decode_responses=True, socket_connect_timeout=1)
|
||||||
|
user_settings = _json.loads(r.get(f"user_settings:{user.id}") or "{}")
|
||||||
|
if user_settings.get("reminders_disabled"):
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Send email asynchronously
|
# Send email asynchronously
|
||||||
try:
|
try:
|
||||||
from app.services.email_service import send_reminder_email
|
from app.services.email_service import send_reminder_email
|
||||||
|
|
|
||||||
|
|
@ -21,3 +21,5 @@ httpx==0.27.0
|
||||||
boto3==1.34.69
|
boto3==1.34.69
|
||||||
pgvector==0.3.6
|
pgvector==0.3.6
|
||||||
openpyxl==3.1.2
|
openpyxl==3.1.2
|
||||||
|
authlib==1.3.0
|
||||||
|
itsdangerous==2.1.2
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import FlashcardStudyPage from './pages/FlashcardStudyPage'
|
||||||
import CoursesPage from './pages/CoursesPage'
|
import CoursesPage from './pages/CoursesPage'
|
||||||
import CourseDetailPage from './pages/CourseDetailPage'
|
import CourseDetailPage from './pages/CourseDetailPage'
|
||||||
import CourseEditorPage from './pages/CourseEditorPage'
|
import CourseEditorPage from './pages/CourseEditorPage'
|
||||||
|
import SsoCallbackPage from './pages/SsoCallbackPage'
|
||||||
|
|
||||||
// Layout wrapper for authenticated app pages (Navbar + container + footer)
|
// Layout wrapper for authenticated app pages (Navbar + container + footer)
|
||||||
function AppLayout() {
|
function AppLayout() {
|
||||||
|
|
@ -65,6 +66,7 @@ function AppRoutes() {
|
||||||
<Route path="/verify-email" element={<VerifyEmailPage />} />
|
<Route path="/verify-email" element={<VerifyEmailPage />} />
|
||||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||||
|
<Route path="/sso-callback" element={<SsoCallbackPage />} />
|
||||||
|
|
||||||
{/* Authenticated app — wrapped in AppLayout */}
|
{/* Authenticated app — wrapped in AppLayout */}
|
||||||
<Route element={<RequireAuth />}>
|
<Route element={<RequireAuth />}>
|
||||||
|
|
|
||||||
|
|
@ -710,6 +710,39 @@ export default function AdminPage() {
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* SSO Only */}
|
||||||
|
{settings.sso_configured && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 0', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<div>
|
||||||
|
<strong style={{ display: 'block', marginBottom: 4 }}>SSO-Only Login ({settings.sso_provider_name})</strong>
|
||||||
|
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
||||||
|
Disable password login — users must sign in via SSO. Admins can still use CLI to reset passwords.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.sso_only || false}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const enabled = e.target.checked
|
||||||
|
setSettings(s => ({ ...s, sso_only: enabled }))
|
||||||
|
try {
|
||||||
|
await api.put('/admin/settings', { sso_only: enabled })
|
||||||
|
setSuccess(`SSO-only mode ${enabled ? 'enabled' : 'disabled'}`)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.detail || 'Failed to update setting')
|
||||||
|
setSettings(s => ({ ...s, sso_only: !enabled }))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ width: 'auto', accentColor: 'var(--primary)' }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '0.9rem', fontWeight: 600 }}>
|
||||||
|
{settings.sso_only ? 'Enabled' : 'Disabled'}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Embedding model change warning */}
|
{/* Embedding model change warning */}
|
||||||
{embedModelChanged && (
|
{embedModelChanged && (
|
||||||
<div style={{ padding: '12px 16px', background: '#fef3c7', border: '1px solid #f59e0b', borderRadius: 8, marginBottom: 0, marginTop: 8 }}>
|
<div style={{ padding: '12px 16px', background: '#fef3c7', border: '1px solid #f59e0b', borderRadius: 8, marginBottom: 0, marginTop: 8 }}>
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,15 @@ export default function LoginPage() {
|
||||||
const [resendSent, setResendSent] = useState(false)
|
const [resendSent, setResendSent] = useState(false)
|
||||||
const [resending, setResending] = useState(false)
|
const [resending, setResending] = useState(false)
|
||||||
const [turnstileToken, setTurnstileToken] = useState('')
|
const [turnstileToken, setTurnstileToken] = useState('')
|
||||||
|
const [ssoConfig, setSsoConfig] = useState(null)
|
||||||
const { login } = useAuth()
|
const { login } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const searchParams = new URLSearchParams(window.location.search)
|
||||||
|
const ssoError = searchParams.get('error')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/auth/sso/config').then(r => setSsoConfig(r.data)).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
@ -98,27 +105,41 @@ export default function LoginPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <div className="alert alert-error">{error}</div>}
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
{ssoError && <div className="alert alert-error">SSO login failed. Please try again.</div>}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
{ssoConfig?.sso_enabled && (
|
||||||
<div className="form-group">
|
<>
|
||||||
<label>Email</label>
|
<a href="/api/auth/sso/login" className="btn btn-secondary" style={{ width: '100%', display: 'block', textAlign: 'center', textDecoration: 'none', marginBottom: 16 }}>
|
||||||
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required />
|
Sign in with {ssoConfig.provider_name}
|
||||||
</div>
|
</a>
|
||||||
<div className="form-group">
|
{!ssoConfig.sso_only && <div style={{ textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.82rem', margin: '12px 0' }}>or sign in with email</div>}
|
||||||
<label>Password</label>
|
</>
|
||||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
|
)}
|
||||||
</div>
|
|
||||||
<TurnstileWidget onVerify={setTurnstileToken} />
|
{!ssoConfig?.sso_only && (
|
||||||
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading || (TURNSTILE_SITE_KEY && !turnstileToken)}>
|
<>
|
||||||
{loading ? 'Signing in...' : 'Sign In'}
|
<form onSubmit={handleSubmit}>
|
||||||
</button>
|
<div className="form-group">
|
||||||
</form>
|
<label>Email</label>
|
||||||
<div className="auth-link">
|
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required />
|
||||||
<Link to="/forgot-password" style={{ color: '#64748b', fontSize: '0.85rem' }}>Forgot password?</Link>
|
</div>
|
||||||
</div>
|
<div className="form-group">
|
||||||
<div className="auth-link">
|
<label>Password</label>
|
||||||
Don't have an account? <Link to="/register">Sign up</Link>
|
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
|
||||||
</div>
|
</div>
|
||||||
|
<TurnstileWidget onVerify={setTurnstileToken} />
|
||||||
|
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading || (TURNSTILE_SITE_KEY && !turnstileToken)}>
|
||||||
|
{loading ? 'Signing in...' : 'Sign In'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div className="auth-link">
|
||||||
|
<Link to="/forgot-password" style={{ color: '#64748b', fontSize: '0.85rem' }}>Forgot password?</Link>
|
||||||
|
</div>
|
||||||
|
<div className="auth-link">
|
||||||
|
Don't have an account? <Link to="/register">Sign up</Link>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,49 @@ function ProfileSection({ user }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NotificationsSection() {
|
||||||
|
const [remindersDisabled, setRemindersDisabled] = useState(false)
|
||||||
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/auth/me/settings').then(res => {
|
||||||
|
setRemindersDisabled(!!res.data.reminders_disabled)
|
||||||
|
}).catch(() => {}).finally(() => setLoaded(true))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggle = async (disabled) => {
|
||||||
|
setRemindersDisabled(disabled)
|
||||||
|
try {
|
||||||
|
const current = await api.get('/auth/me/settings')
|
||||||
|
await api.put('/auth/me/settings', { ...current.data, reminders_disabled: disabled })
|
||||||
|
} catch { setRemindersDisabled(!disabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loaded) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section title="Notifications">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div>
|
||||||
|
<strong style={{ display: 'block', marginBottom: 4, fontSize: '0.9rem' }}>Quiz Reminders</strong>
|
||||||
|
<span style={{ fontSize: '0.82rem', color: 'var(--text-muted)' }}>
|
||||||
|
Receive email reminders to review quizzes based on your performance.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!remindersDisabled}
|
||||||
|
onChange={e => toggle(!e.target.checked)}
|
||||||
|
style={{ width: 'auto', accentColor: 'var(--primary)' }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '0.85rem', fontWeight: 600 }}>{remindersDisabled ? 'Off' : 'On'}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function AppearanceSection() {
|
function AppearanceSection() {
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
const themes = [
|
const themes = [
|
||||||
|
|
@ -275,6 +318,7 @@ export default function SettingsPage() {
|
||||||
<h1 style={{ fontSize: '1.4rem', fontWeight: 700 }}>Settings</h1>
|
<h1 style={{ fontSize: '1.4rem', fontWeight: 700 }}>Settings</h1>
|
||||||
</div>
|
</div>
|
||||||
<ProfileSection user={user} />
|
<ProfileSection user={user} />
|
||||||
|
<NotificationsSection />
|
||||||
<AppearanceSection />
|
<AppearanceSection />
|
||||||
{isModerator && <NextcloudSection />}
|
{isModerator && <NextcloudSection />}
|
||||||
{isModerator && <DocumentsSection />}
|
{isModerator && <DocumentsSection />}
|
||||||
|
|
|
||||||
22
frontend/src/pages/SsoCallbackPage.jsx
Normal file
22
frontend/src/pages/SsoCallbackPage.jsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useSearchParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
|
export default function SsoCallbackPage() {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { loginWithToken } = useAuth()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = searchParams.get('token')
|
||||||
|
if (token) {
|
||||||
|
loginWithToken(token).then(() => navigate('/')).catch(() => navigate('/login?error=sso_failed'))
|
||||||
|
} else {
|
||||||
|
navigate('/login?error=sso_failed')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="loading"><div className="spinner"></div> Signing in...</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue