users.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581
  1. from datetime import datetime, timezone
  2. from typing import Annotated
  3. import jwt as _jwt
  4. from fastapi import APIRouter, Depends, HTTPException, Query, status
  5. from fastapi.security import HTTPAuthorizationCredentials
  6. from sqlalchemy import delete, func, select
  7. from sqlalchemy.ext.asyncio import AsyncSession
  8. from sqlalchemy.orm import selectinload
  9. from backend.app.api.routes.settings import get_external_login_url
  10. from backend.app.core.auth import (
  11. ALGORITHM,
  12. SECRET_KEY,
  13. RequirePermissionIfAuthEnabled,
  14. get_current_user_optional,
  15. get_password_hash,
  16. revoke_jti,
  17. security,
  18. verify_password,
  19. )
  20. from backend.app.core.database import get_db
  21. from backend.app.core.permissions import Permission
  22. from backend.app.models.api_key import APIKey
  23. from backend.app.models.archive import PrintArchive
  24. from backend.app.models.group import Group
  25. from backend.app.models.library import LibraryFile
  26. from backend.app.models.long_lived_token import LongLivedToken
  27. from backend.app.models.oidc_provider import UserOIDCLink
  28. from backend.app.models.print_batch import PrintBatch
  29. from backend.app.models.print_queue import PrintQueueItem
  30. from backend.app.models.settings import Settings
  31. from backend.app.models.user import User
  32. from backend.app.models.user_otp_code import UserOTPCode
  33. from backend.app.models.user_totp import UserTOTP
  34. from backend.app.schemas.auth import ChangePasswordRequest, GroupBrief, UserCreate, UserResponse, UserUpdate
  35. from backend.app.schemas.onboarding import OnboardingResponse, OnboardingUpdate
  36. from backend.app.services.email_service import (
  37. create_welcome_email_from_template,
  38. generate_secure_password,
  39. get_smtp_settings,
  40. send_email,
  41. )
  42. router = APIRouter(prefix="/users", tags=["users"])
  43. def _user_to_response(user: User) -> UserResponse:
  44. """Convert a User model to UserResponse schema."""
  45. return UserResponse(
  46. id=user.id,
  47. username=user.username,
  48. email=user.email,
  49. role=user.role,
  50. is_active=user.is_active,
  51. is_admin=user.is_admin,
  52. auth_source=getattr(user, "auth_source", "local"),
  53. groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups],
  54. permissions=sorted(user.get_permissions()),
  55. created_at=user.created_at.isoformat(),
  56. )
  57. @router.get("", response_model=list[UserResponse])
  58. @router.get("/", response_model=list[UserResponse])
  59. async def list_users(
  60. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
  61. db: AsyncSession = Depends(get_db),
  62. ):
  63. """List all users."""
  64. result = await db.execute(select(User).options(selectinload(User.groups)).order_by(User.created_at))
  65. users = result.scalars().all()
  66. return [_user_to_response(user) for user in users]
  67. @router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
  68. @router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
  69. async def create_user(
  70. user_data: UserCreate,
  71. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
  72. db: AsyncSession = Depends(get_db),
  73. ):
  74. """Create a new user.
  75. When advanced authentication is enabled:
  76. - Email is required
  77. - Password is auto-generated and emailed to user
  78. - Admin cannot set or see the password
  79. """
  80. import logging
  81. logger = logging.getLogger(__name__)
  82. # Check if advanced auth is enabled
  83. result = await db.execute(select(Settings).where(Settings.key == "advanced_auth_enabled"))
  84. advanced_auth_setting = result.scalar_one_or_none()
  85. advanced_auth_enabled = advanced_auth_setting and advanced_auth_setting.value.lower() == "true"
  86. # Check if username already exists (case-insensitive)
  87. existing_user = await db.execute(select(User).where(func.lower(User.username) == func.lower(user_data.username)))
  88. if existing_user.scalar_one_or_none():
  89. raise HTTPException(
  90. status_code=status.HTTP_400_BAD_REQUEST,
  91. detail="Username already exists",
  92. )
  93. # Validate role
  94. if user_data.role not in ["admin", "user"]:
  95. raise HTTPException(
  96. status_code=status.HTTP_400_BAD_REQUEST,
  97. detail="Role must be 'admin' or 'user'",
  98. )
  99. # Advanced auth validation
  100. if advanced_auth_enabled:
  101. if not user_data.email:
  102. raise HTTPException(
  103. status_code=status.HTTP_400_BAD_REQUEST,
  104. detail="Email is required when advanced authentication is enabled",
  105. )
  106. # Check if email already exists (case-insensitive)
  107. existing_email = await db.execute(select(User).where(func.lower(User.email) == func.lower(user_data.email)))
  108. if existing_email.scalar_one_or_none():
  109. raise HTTPException(
  110. status_code=status.HTTP_400_BAD_REQUEST,
  111. detail="Email already exists",
  112. )
  113. # Generate password if advanced auth enabled, otherwise require password
  114. if advanced_auth_enabled:
  115. password = generate_secure_password()
  116. else:
  117. if not user_data.password:
  118. raise HTTPException(
  119. status_code=status.HTTP_400_BAD_REQUEST,
  120. detail="Password is required when advanced authentication is disabled",
  121. )
  122. password = user_data.password
  123. new_user = User(
  124. username=user_data.username,
  125. email=user_data.email,
  126. password_hash=get_password_hash(password),
  127. role=user_data.role,
  128. is_active=True,
  129. )
  130. # Handle group assignments
  131. if user_data.group_ids:
  132. groups_result = await db.execute(select(Group).where(Group.id.in_(user_data.group_ids)))
  133. groups = groups_result.scalars().all()
  134. if len(groups) != len(user_data.group_ids):
  135. raise HTTPException(
  136. status_code=status.HTTP_400_BAD_REQUEST,
  137. detail="One or more group IDs are invalid",
  138. )
  139. new_user.groups = list(groups)
  140. db.add(new_user)
  141. await db.commit()
  142. await db.refresh(new_user)
  143. # Send welcome email if advanced auth enabled
  144. if advanced_auth_enabled and new_user.email:
  145. try:
  146. smtp_settings = await get_smtp_settings(db)
  147. if smtp_settings:
  148. login_url = await get_external_login_url(db)
  149. subject, text_body, html_body = await create_welcome_email_from_template(
  150. db, new_user.username, password, login_url
  151. )
  152. send_email(smtp_settings, new_user.email, subject, text_body, html_body)
  153. logger.info(f"Welcome email sent to {new_user.email}")
  154. else:
  155. logger.warning(f"SMTP not configured, could not send welcome email to {new_user.email}")
  156. except Exception as e:
  157. logger.error(f"Failed to send welcome email: {e}")
  158. # Don't fail user creation if email fails
  159. return _user_to_response(new_user)
  160. @router.get("/{user_id}", response_model=UserResponse)
  161. async def get_user(
  162. user_id: int,
  163. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
  164. db: AsyncSession = Depends(get_db),
  165. ):
  166. """Get a user by ID."""
  167. result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
  168. user = result.scalar_one_or_none()
  169. if not user:
  170. raise HTTPException(
  171. status_code=status.HTTP_404_NOT_FOUND,
  172. detail="User not found",
  173. )
  174. return _user_to_response(user)
  175. @router.patch("/{user_id}", response_model=UserResponse)
  176. async def update_user(
  177. user_id: int,
  178. user_data: UserUpdate,
  179. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_UPDATE),
  180. db: AsyncSession = Depends(get_db),
  181. ):
  182. """Update a user."""
  183. result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
  184. user = result.scalar_one_or_none()
  185. if not user:
  186. raise HTTPException(
  187. status_code=status.HTTP_404_NOT_FOUND,
  188. detail="User not found",
  189. )
  190. # Prevent deactivating the last admin
  191. if user_data.is_active is False and user.is_admin:
  192. # Count admins by role or Administrators group membership
  193. admin_count_result = await db.execute(select(User).where(User.role == "admin", User.is_active.is_(True)))
  194. role_admins = admin_count_result.scalars().all()
  195. # Also check for users in Administrators group
  196. admin_group_result = await db.execute(
  197. select(Group).where(Group.name == "Administrators").options(selectinload(Group.users))
  198. )
  199. admin_group = admin_group_result.scalar_one_or_none()
  200. group_admins = [u for u in (admin_group.users if admin_group else []) if u.is_active]
  201. # Combine unique admins
  202. all_admins = {u.id for u in role_admins} | {u.id for u in group_admins}
  203. if len(all_admins) <= 1 and user.id in all_admins:
  204. raise HTTPException(
  205. status_code=status.HTTP_400_BAD_REQUEST,
  206. detail="Cannot deactivate the last admin user",
  207. )
  208. # Prevent changing role of last admin
  209. if user_data.role and user_data.role != "admin" and user.role == "admin":
  210. admin_count_result = await db.execute(select(User).where(User.role == "admin", User.is_active.is_(True)))
  211. admin_count = len(admin_count_result.scalars().all())
  212. if admin_count <= 1:
  213. raise HTTPException(
  214. status_code=status.HTTP_400_BAD_REQUEST,
  215. detail="Cannot change role of the last admin user",
  216. )
  217. if user_data.username is not None:
  218. # Check if new username already exists (case-insensitive)
  219. existing_user = await db.execute(
  220. select(User).where(func.lower(User.username) == func.lower(user_data.username), User.id != user_id)
  221. )
  222. if existing_user.scalar_one_or_none():
  223. raise HTTPException(
  224. status_code=status.HTTP_400_BAD_REQUEST,
  225. detail="Username already exists",
  226. )
  227. user.username = user_data.username
  228. if user_data.email is not None:
  229. # Check if new email already exists (case-insensitive)
  230. existing_email = await db.execute(
  231. select(User).where(func.lower(User.email) == func.lower(user_data.email), User.id != user_id)
  232. )
  233. if existing_email.scalar_one_or_none():
  234. raise HTTPException(
  235. status_code=status.HTTP_400_BAD_REQUEST,
  236. detail="Email already exists",
  237. )
  238. user.email = user_data.email
  239. if user_data.password is not None:
  240. if getattr(user, "auth_source", "local") == "ldap":
  241. raise HTTPException(
  242. status_code=status.HTTP_400_BAD_REQUEST,
  243. detail="Cannot set password for LDAP users",
  244. )
  245. user.password_hash = get_password_hash(user_data.password)
  246. if user_data.role is not None:
  247. if user_data.role not in ["admin", "user"]:
  248. raise HTTPException(
  249. status_code=status.HTTP_400_BAD_REQUEST,
  250. detail="Role must be 'admin' or 'user'",
  251. )
  252. user.role = user_data.role
  253. if user_data.is_active is not None:
  254. user.is_active = user_data.is_active
  255. # Handle group assignments
  256. if user_data.group_ids is not None:
  257. groups_result = await db.execute(select(Group).where(Group.id.in_(user_data.group_ids)))
  258. groups = groups_result.scalars().all()
  259. if len(groups) != len(user_data.group_ids):
  260. raise HTTPException(
  261. status_code=status.HTTP_400_BAD_REQUEST,
  262. detail="One or more group IDs are invalid",
  263. )
  264. user.groups = list(groups)
  265. await db.commit()
  266. result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
  267. user = result.scalar_one()
  268. return _user_to_response(user)
  269. @router.get("/{user_id}/items-count")
  270. async def get_user_items_count(
  271. user_id: int,
  272. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
  273. db: AsyncSession = Depends(get_db),
  274. ):
  275. """Get count of items created by this user."""
  276. # Verify user exists
  277. result = await db.execute(select(User).where(User.id == user_id))
  278. if not result.scalar_one_or_none():
  279. raise HTTPException(
  280. status_code=status.HTTP_404_NOT_FOUND,
  281. detail="User not found",
  282. )
  283. # Count archives
  284. archives_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.created_by_id == user_id))
  285. archives_count = archives_result.scalar() or 0
  286. # Count queue items
  287. queue_result = await db.execute(
  288. select(func.count(PrintQueueItem.id)).where(PrintQueueItem.created_by_id == user_id)
  289. )
  290. queue_items_count = queue_result.scalar() or 0
  291. # Count library files
  292. library_result = await db.execute(
  293. select(func.count(LibraryFile.id)).where(
  294. LibraryFile.created_by_id == user_id,
  295. LibraryFile.deleted_at.is_(None),
  296. )
  297. )
  298. library_files_count = library_result.scalar() or 0
  299. return {
  300. "archives": archives_count,
  301. "queue_items": queue_items_count,
  302. "library_files": library_files_count,
  303. }
  304. @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
  305. async def delete_user(
  306. user_id: int,
  307. delete_items: bool = Query(False, description="Delete all items created by this user"),
  308. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_DELETE),
  309. db: AsyncSession = Depends(get_db),
  310. ):
  311. """Delete a user.
  312. If delete_items=True, all archives, queue items, and library files created by
  313. this user will also be deleted. Otherwise, these items will become "ownerless"
  314. (created_by_id set to NULL by the foreign key constraint).
  315. """
  316. result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
  317. user = result.scalar_one_or_none()
  318. if not user:
  319. raise HTTPException(
  320. status_code=status.HTTP_404_NOT_FOUND,
  321. detail="User not found",
  322. )
  323. # Prevent deleting the last admin
  324. if user.is_admin:
  325. # Count admins by role or Administrators group membership
  326. admin_count_result = await db.execute(select(User).where(User.role == "admin", User.id != user_id))
  327. other_role_admins = admin_count_result.scalars().all()
  328. # Also check for users in Administrators group
  329. admin_group_result = await db.execute(
  330. select(Group).where(Group.name == "Administrators").options(selectinload(Group.users))
  331. )
  332. admin_group = admin_group_result.scalar_one_or_none()
  333. other_group_admins = [u for u in (admin_group.users if admin_group else []) if u.id != user_id and u.is_active]
  334. # Combine unique admins
  335. all_other_admins = {u.id for u in other_role_admins} | {u.id for u in other_group_admins}
  336. if len(all_other_admins) == 0:
  337. raise HTTPException(
  338. status_code=status.HTTP_400_BAD_REQUEST,
  339. detail="Cannot delete the last admin user",
  340. )
  341. # Prevent deleting yourself (only if auth is enabled and we have a current user)
  342. if current_user and user.id == current_user.id:
  343. raise HTTPException(
  344. status_code=status.HTTP_400_BAD_REQUEST,
  345. detail="Cannot delete your own account",
  346. )
  347. if delete_items:
  348. # Delete all items created by this user
  349. await db.execute(delete(PrintArchive).where(PrintArchive.created_by_id == user_id))
  350. await db.execute(delete(PrintQueueItem).where(PrintQueueItem.created_by_id == user_id))
  351. await db.execute(delete(LibraryFile).where(LibraryFile.created_by_id == user_id))
  352. await db.execute(delete(PrintBatch).where(PrintBatch.created_by_id == user_id))
  353. else:
  354. # Explicitly set created_by_id to NULL for all items (ensures consistent behavior
  355. # across different database backends, including SQLite without foreign key support).
  356. # PrintBatch carries the same created_by_id FK with ondelete=SET NULL — admin-deleted
  357. # users would otherwise leave dangling created_by_id on SQLite (#1295 review nit).
  358. from sqlalchemy import update
  359. await db.execute(update(PrintArchive).where(PrintArchive.created_by_id == user_id).values(created_by_id=None))
  360. await db.execute(
  361. update(PrintQueueItem).where(PrintQueueItem.created_by_id == user_id).values(created_by_id=None)
  362. )
  363. await db.execute(update(LibraryFile).where(LibraryFile.created_by_id == user_id).values(created_by_id=None))
  364. await db.execute(update(PrintBatch).where(PrintBatch.created_by_id == user_id).values(created_by_id=None))
  365. # Drop API keys owned by this user. The model declares ON DELETE CASCADE
  366. # so Postgres handles this automatically, but SQLite ships with FK
  367. # enforcement off (the project's existing pattern — same reason the
  368. # blocks above set created_by_id = NULL by hand). Without an explicit
  369. # DELETE here, deleting a user on SQLite would leave their API keys
  370. # with a dangling user_id and ``_user_from_api_key`` would return None,
  371. # silently degrading the keys to anonymous (and locking them out of
  372. # /cloud/* — but the rest of the API would still accept them, which is
  373. # exactly the orphan-key state the CASCADE was meant to prevent).
  374. await db.execute(delete(APIKey).where(APIKey.user_id == user_id))
  375. # Drop OIDC links, MFA state, and long-lived camera-stream tokens
  376. # owned by this user. Same SQLite/FK pattern as APIKey above. Without
  377. # these, deleting a user on SQLite leaves:
  378. # - UserOIDCLink: the OIDC callback finds the orphan link, fails to
  379. # resolve the (now missing) user, and falls through to
  380. # "account_inactive" instead of triggering auto_create (#1285).
  381. # - UserTOTP: MFA secrets persist in the DB after the owning user.
  382. # - UserOTPCode: pending email OTP codes linger.
  383. # - LongLivedToken: per-user camera-stream tokens whose secret_hash
  384. # is still valid — verify() would happily match them by lookup
  385. # prefix even though the user is gone.
  386. await db.execute(delete(UserOIDCLink).where(UserOIDCLink.user_id == user_id))
  387. await db.execute(delete(UserTOTP).where(UserTOTP.user_id == user_id))
  388. await db.execute(delete(UserOTPCode).where(UserOTPCode.user_id == user_id))
  389. await db.execute(delete(LongLivedToken).where(LongLivedToken.user_id == user_id))
  390. await db.delete(user)
  391. await db.commit()
  392. @router.post("/me/change-password", response_model=dict)
  393. async def change_own_password(
  394. password_data: ChangePasswordRequest,
  395. credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
  396. current_user: User | None = Depends(get_current_user_optional),
  397. db: AsyncSession = Depends(get_db),
  398. ):
  399. """Change the current user's password. Requires current password verification."""
  400. if not current_user:
  401. raise HTTPException(
  402. status_code=status.HTTP_401_UNAUTHORIZED,
  403. detail="Authentication required to change password",
  404. )
  405. # Block password change for LDAP users
  406. if getattr(current_user, "auth_source", "local") == "ldap":
  407. raise HTTPException(
  408. status_code=status.HTTP_400_BAD_REQUEST,
  409. detail="Cannot change password for LDAP users — passwords are managed by the LDAP server",
  410. )
  411. # Verify current password
  412. if not current_user.password_hash:
  413. raise HTTPException(
  414. status_code=status.HTTP_400_BAD_REQUEST,
  415. detail="Account has no local password set",
  416. )
  417. # Rate-limit failed password-change attempts (H-R5-A)
  418. from backend.app.api.routes.mfa import MAX_2FA_ATTEMPTS, check_rate_limit, record_failed_attempt
  419. await check_rate_limit(db, current_user.username, event_type="password_change", max_attempts=MAX_2FA_ATTEMPTS)
  420. if not verify_password(password_data.current_password, current_user.password_hash):
  421. await record_failed_attempt(db, current_user.username, event_type="password_change")
  422. raise HTTPException(
  423. status_code=status.HTTP_400_BAD_REQUEST,
  424. detail="Current password is incorrect",
  425. )
  426. # Fetch user from this session to ensure changes are persisted
  427. result = await db.execute(select(User).where(User.id == current_user.id))
  428. user = result.scalar_one_or_none()
  429. if not user:
  430. raise HTTPException(
  431. status_code=status.HTTP_404_NOT_FOUND,
  432. detail="User not found",
  433. )
  434. # Update password
  435. user.password_hash = get_password_hash(password_data.new_password)
  436. user.password_changed_at = datetime.now(timezone.utc) # M-R7-B: invalidate all prior JWTs
  437. await db.commit()
  438. # L-R6-A: Password verified successfully — reset the failure counter
  439. from backend.app.api.routes.mfa import clear_failed_attempts
  440. await clear_failed_attempts(db, user.username, event_type="password_change")
  441. # Revoke the current session token so the caller must re-authenticate (M-R5-A)
  442. if credentials is not None:
  443. try:
  444. payload = _jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
  445. jti = payload.get("jti")
  446. exp = payload.get("exp")
  447. if jti and exp:
  448. try:
  449. await revoke_jti(jti, datetime.fromtimestamp(exp, tz=timezone.utc), user.username)
  450. except Exception as exc:
  451. # B4: log so operators know revocation is broken; password was
  452. # already changed so the token will fail freshness checks anyway.
  453. import logging
  454. logging.getLogger(__name__).error(
  455. "Failed to revoke JTI after password change for user %s: %s", user.username, exc
  456. )
  457. except Exception:
  458. pass # Decode failure is harmless — token is already invalidated by password_changed_at
  459. return {"message": "Password changed successfully"}
  460. @router.get("/me/onboarding", response_model=OnboardingResponse)
  461. async def get_own_onboarding(
  462. current_user: User | None = Depends(get_current_user_optional),
  463. ):
  464. """Return the current user's onboarding tour state.
  465. Frontend polls this on app boot to decide whether to show the welcome
  466. modal, snooze it, or skip straight to the dashboard. See
  467. docs/onboarding-tour-plan.md Appendix B for the state model.
  468. """
  469. if not current_user:
  470. raise HTTPException(
  471. status_code=status.HTTP_401_UNAUTHORIZED,
  472. detail="Authentication required",
  473. )
  474. return OnboardingResponse(
  475. status=current_user.onboarding_status,
  476. snoozed_until=current_user.onboarding_snoozed_until,
  477. )
  478. @router.patch("/me/onboarding", response_model=OnboardingResponse)
  479. async def update_own_onboarding(
  480. update: OnboardingUpdate,
  481. current_user: User | None = Depends(get_current_user_optional),
  482. db: AsyncSession = Depends(get_db),
  483. ):
  484. """Update the current user's onboarding tour state."""
  485. if not current_user:
  486. raise HTTPException(
  487. status_code=status.HTTP_401_UNAUTHORIZED,
  488. detail="Authentication required",
  489. )
  490. result = await db.execute(select(User).where(User.id == current_user.id))
  491. user = result.scalar_one_or_none()
  492. if not user:
  493. raise HTTPException(
  494. status_code=status.HTTP_404_NOT_FOUND,
  495. detail="User not found",
  496. )
  497. user.onboarding_status = update.status
  498. user.onboarding_snoozed_until = update.snoozed_until
  499. await db.commit()
  500. return OnboardingResponse(
  501. status=user.onboarding_status,
  502. snoozed_until=user.onboarding_snoozed_until,
  503. )