Files
bambuddy/backend/app/models/user.py
maziggy df66924066 feat(onboarding): BB welcome modal + 25-step guided tour
Closes the recurring "I added the printer but it isn't connecting" +
  "where is X feature" cluster that drove ~1/3 of invalid-tagged issues.
  New users now see a friendly welcome modal on first load and can take
  a guided walkthrough that highlights the load-bearing UI surface
  step-by-step.

  Backend: two new nullable columns on users (onboarding_status
  VARCHAR(64), onboarding_snoozed_until TIMESTAMP) added via inline
  migration with a one-shot backfill that marks every existing user as
  dismissed_at_migration -- pre-existing installs never see the welcome
  modal. GET + PATCH /api/v1/users/me/onboarding round-trip the state;
  OnboardingUpdate schema rejects dismissed_at_migration from the API
  (migration-internal only), validates the tour_in_progress:<step_id>
  form, and enforces snooze coherence (snoozed_until required only when
  status is snoozed). _users_column_exists helper mirrors
  _api_keys_column_exists and gates the backfill so restarts after
  new-user signups do not clobber the welcome-eligible NULL state.
  SQLite and Postgres both verified end-to-end.

  Frontend architecture: OnboardingProvider wraps the app inside
  AuthProvider; reads from the backend when auth is on, falls back to
  localStorage (bambuddy.onboarding_status + onboarding_snoozed_until)
  when auth is off so no-auth installs still get the welcome
  experience. A loadFailed gate prevents the welcome modal from popping
  over a backend outage -- we cannot distinguish "new user" from "GET
  errored" so we stay silent. OnboardingFlow driver picks between the
  Phase 0 welcome modal, the Phase 0.2 about modal, and the
  step-by-step TourEngine based on persisted status. Mounted inside
  BrowserRouter so the route guard's useLocation has its context (a
  sibling-of-Router placement crashed at runtime).

  Route guard: the overlay never renders on /setup, /login,
  /spoolbuddy/*, /camera/*, /overlay/*, or while requiresSetup is
  true -- fresh installs walk through the existing /setup flow
  uninterrupted, and the SpoolBuddy kiosk / OBS overlay / camera-popout
  windows never get a modal slapped over them.

  Tour engine: 25 steps targeting existing data-tour anchors --
  add-printer -> verify-connection -> printer-card sub-tour x5 (status
  row, AMS row, camera, controls, customize menu) -> add-spool ->
  bambu-cloud-sync -> sidebar overview x6 -> vp -> slicer-api ->
  makerworld -> obico -> integrations -> notifications -> users ->
  groups -> sso -> outro. Per-step route navigation via useNavigate,
  anchor polling at 100ms intervals with a 3-second cap (pages need a
  beat after navigation; never spin forever), box-shadow dimmed-
  spotlight cutout that pointer-events-through so the user can still
  interact, smart modal positioning (sidebar anchors to the right;
  page anchors below or flipped above based on viewport room),
  Back/Next/Skip with Escape as Skip, persistence on every Back/Next
  so mid-tour reloads resume at the same step.

  Conditional skip: each step exposes a skipIf(ctx) evaluated against
  at least one printer exists; verify-connection + the entire card
  sub-tour skip when no printer exists; makerworld skips when the user
  lacks makerworld:view (the sidebar entry is permission-gated and the
  anchor would not resolve); users / groups / sso skip when auth is
  off. Pre-render gate means the user never sees a flash of a step
  that is about to skip.

  Phase 1.1 "Lock the front door" auth step explicitly removed from
  the live tour -- /setup already prompts for the auth choice on fresh
  installs, and users who deliberately chose no-auth must not be
  nudged to enable it. The auth-card anchor stays for any future
  privacy-checkup surface.

  BB mascot: hero pose + 5 named poses (started / walk / almost /
  allset / help) sliced from the character sheet via Pillow into
  public/img/bb_*.webp. MascotIcon component takes a pose prop;
  per-step pose mapping in tourSteps.ts. TourLauncher sits in the
  sidebar footer as a BB icon that relaunches the tour from step 0
  and consumes the [data-tour="help-icon"] selector.

  WikiHelpIcon component lands the per-page question-mark icon on
  Queue / Archives / Stats / Maintenance / Files / Projects /
  Inventory -- opens the matching wiki.bambuddy.cool/{path}/ page in
  a new tab (chose new-tab over iframe because the wiki sets
  X-Frame-Options: DENY).

  27 new data-tour anchors across PrintersPage / SettingsPage /
  ProfilesPage / InventoryPage / Layout. i18n: 138 new keys under a
  new onboarding.* namespace, real translations in every locale
  (de/en/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW) -- no
  IDENTICAL_TO_EN_ALLOWED entries added.

  Backend tests: 12 in TestOnboardingAPI cover the round-trips and the
  validator branches. Frontend tests: 61 across 7 files -- anchor
  presence backstop, Phase 0 modal interaction, tour engine + step
  helpers, provider state machine (auth-on/off split, PATCH error
  fall-through, localStorage round-trip), route guard for every
  suppress path, WikiHelpIcon href/target/aria. Backend pytest
  5723/5723; frontend vitest 2154/2154; ESLint clean; frontend build
  clean; i18n parity clean at 5233 leaves x 11 locales.

  Companion docs: docs/onboarding-tour-plan.md carries the design
  (Phase 0-5 step inventory, anchor selector list, state model, asset
  inventory, resolved decisions, Implementation Status section that
  is authoritative for current shipped state).
2026-06-10 10:06:42 +02:00

141 lines
6.2 KiB
Python

from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
if TYPE_CHECKING:
from backend.app.models.group import Group
from backend.app.models.user_email_pref import UserEmailPreference
class User(Base):
"""User model for authentication and authorization.
Users can belong to multiple groups, and their permissions are additive
across all groups. The legacy 'role' field is kept for backward compatibility
but is_admin property now also considers group membership.
"""
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
email: Mapped[str | None] = mapped_column(String(255), unique=True, index=True, nullable=True)
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
role: Mapped[str] = mapped_column(
String(20), default="user"
) # "admin" or "user" (legacy, kept for backward compat)
auth_source: Mapped[str] = mapped_column(String(20), default="local") # "local", "ldap", or "oidc"
is_active: Mapped[bool] = mapped_column(default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
# Set whenever the local password is changed/reset — used to invalidate JWTs
# issued before the change (M-R7-B). NULL means no password change recorded yet.
password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Per-user Bambu Cloud credentials (when auth is enabled, each user has their own)
cloud_token: Mapped[str | None] = mapped_column(String(500), nullable=True, default=None)
cloud_email: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
# "global" or "china"; NULL treated as "global" for legacy rows.
cloud_region: Mapped[str | None] = mapped_column(String(10), nullable=True, default=None)
# Per-user Orca Cloud credentials. Unlike Bambu Cloud, Orca uses Supabase PKCE
# with short-lived access tokens (1h) and rotating single-use refresh tokens,
# so we store the refresh token + expiry alongside the access token.
orca_cloud_token: Mapped[str | None] = mapped_column(String(2000), nullable=True, default=None)
orca_cloud_refresh_token: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None)
orca_cloud_expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
orca_cloud_email: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
orca_cloud_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None)
# Transient PKCE state held between /orca-cloud/auth/start and /orca-cloud/auth/finish.
# Cleared on successful finish; expires after 10 minutes if the user abandons the flow.
orca_cloud_pending_verifier: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None)
orca_cloud_pending_state: Mapped[str | None] = mapped_column(String(32), nullable=True, default=None)
orca_cloud_pending_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
# Onboarding tour state. See docs/onboarding-tour-plan.md Appendix B for the
# state model — null means the welcome modal has not been shown yet.
onboarding_status: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None)
onboarding_snoozed_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
# Relationship to groups through association table
groups: Mapped[list[Group]] = relationship(
"Group",
secondary="user_groups",
back_populates="users",
lazy="selectin",
)
# Relationship to email notification preferences
email_preferences: Mapped[UserEmailPreference | None] = relationship(
"UserEmailPreference",
back_populates="user",
uselist=False,
cascade="all, delete-orphan",
lazy="select",
)
@property
def is_admin(self) -> bool:
"""Check if user is an admin.
Returns True if:
- User has legacy role='admin', OR
- User belongs to the Administrators group
"""
if self.role == "admin":
return True
return any(g.name == "Administrators" for g in self.groups)
def get_permissions(self) -> set[str]:
"""Get all permissions from all groups the user belongs to.
Returns a set of permission strings. Permissions are additive across groups.
"""
permissions: set[str] = set()
for group in self.groups:
if group.permissions:
permissions.update(group.permissions)
return permissions
def has_permission(self, permission: str) -> bool:
"""Check if user has a specific permission.
Admins have all permissions. For other users, checks if the permission
exists in any of their groups.
"""
if self.is_admin:
return True
return permission in self.get_permissions()
def has_all_permissions(self, *permissions: str) -> bool:
"""Check if user has ALL specified permissions.
Admins have all permissions. For other users, checks if all permissions
exist in their combined group permissions.
"""
if self.is_admin:
return True
user_permissions = self.get_permissions()
return all(p in user_permissions for p in permissions)
def has_any_permission(self, *permissions: str) -> bool:
"""Check if user has ANY of the specified permissions.
Admins have all permissions. For other users, checks if at least one
permission exists in their combined group permissions.
"""
if self.is_admin:
return True
user_permissions = self.get_permissions()
return any(p in user_permissions for p in permissions)
def __repr__(self) -> str:
return f"<User {self.username}>"