15 KiB
Plan: Evotor User Lifecycle + RBAC + Admin Panel
Context
Evotor (Russian POS ecosystem) sends webhook calls to register and verify users who buy the EvoSync app on their marketplace. Currently the web app has no webhook receivers for user lifecycle events, no role-based access control, and no admin panel. We need to:
- Receive Evotor user webhooks (
/user/create,/user/verify,/user/token) - Create users in
pendingstatus, link them to a local account, send an invite to set password - Abstract email/SMS providers (no concrete provider chosen yet — console output for now)
- Add role-based access control (system / admin / user) with a full roles+permissions table structure
- Build an admin panel for user management
- Extend the user profile card with role/status/Evotor metadata
Target: Python/FastAPI on current HEAD (15a362c). The v3 scaffold is minimal — main.py is a bare FastAPI app, web/models/ is empty, web/tasks/celery_app.py is a stub.
Reference: The Node.js commit 854c912 has all existing features (auth, profile, catalog, sync) — use it as the schema and UI reference for porting.
Architecture Decisions
- Session: Starlette
SessionMiddleware(already a FastAPI dep) —request.session["user_id"]cookie, mirroring Node.js pattern. No extra package. - RBAC:
roleenum directly onuserstable (fast path in middleware) plusroles/permissions/user_roles/role_permissionstables for fine-grained, admin-configurable permissions. - Evotor app token: Store a random UUID token in
evotor_connections.api_token(new column). Noitsdangerousdependency needed. Return this token in webhook responses. password_hashnullable: Users arriving via/user/createhave no password yet; they set it via invite link. Migration mustALTER COLUMNto allow NULL.- Notifications: Celery tasks on a new
notificationsqueue → abstractEmailProvider/SMSProvider→ConsoleEmailProvider/ConsoleSMSProviderby default. - Migrations: Three incremental migrations. 0002 creates the full schema (conditionally, since live DB may have Node.js tables). 0003 creates RBAC tables + seeds default roles/permissions. 0004 is optional seed for a system user.
File Structure
web/
├── main.py — add SessionMiddleware, Jinja2, static, router includes
├── config.py — add INVITE_EXPIRE_HOURS, EMAIL_PROVIDER, SMS_PROVIDER
├── database.py — add get_db() dependency (sync Session)
├── templates_env.py — NEW: Jinja2Templates singleton with datefmt/price filters
│
├── models/
│ ├── __init__.py — import all models (for Alembic autogenerate)
│ ├── user.py — NEW: User, UserRoleEnum, UserStatusEnum
│ ├── rbac.py — NEW: Role, Permission, role_permissions, UserRole
│ └── connections.py — NEW: EvotorConnection, VkConnection, SyncConfig, etc.
│
├── auth/
│ ├── __init__.py
│ ├── session.py — NEW: get_current_user() helper, session utils
│ ├── password.py — NEW: hash_password(), verify_password() via passlib
│ └── rbac.py — NEW: require_role(), require_permission() FastAPI deps
│
├── notifications/
│ ├── __init__.py
│ ├── base.py — NEW: abstract EmailProvider, SMSProvider
│ ├── console.py — NEW: ConsoleEmailProvider, ConsoleSMSProvider
│ ├── registry.py — NEW: get_email_provider(), get_sms_provider() factories
│ └── tasks.py — NEW: send_email_task, send_sms_task Celery tasks
│
├── routes/
│ ├── __init__.py
│ ├── auth.py — NEW: register, login, logout, confirm-email
│ ├── reset.py — NEW: forgot-password, reset-password
│ ├── invite.py — NEW: GET/POST /invite (Evotor invite flow)
│ ├── profile.py — NEW: /profile, /profile/edit, change-password, delete
│ ├── connections.py — NEW: /connections (port from Node.js)
│ ├── evotor.py — NEW: /evotor UI + /evotor/token (port from Node.js)
│ ├── evotor_webhooks.py — NEW: POST /user/create, /user/verify, /user/token
│ ├── vk.py — NEW: /vk + /vk/callback (port from Node.js)
│ ├── catalog.py — NEW: /catalog (port from Node.js)
│ ├── sync.py — NEW: /sync (port from Node.js)
│ └── admin.py — NEW: /admin/* panel
│
├── tasks/
│ ├── celery_app.py — add notifications queue + route
│ ├── sync.py — NEW: sync Celery tasks
│ └── health.py — NEW: health-check tasks
│
├── templates/
│ ├── base.html — port base.njk; nav shows Admin link if role=admin/system
│ ├── login.html, register.html, confirm_email.html, email_confirmed.html
│ ├── forgot_password.html, reset_password.html
│ ├── invite.html — NEW: set-password form for invite flow
│ ├── message.html, profile_view.html, profile_edit.html
│ ├── profile_change_password.html, profile_delete.html
│ ├── connections.html, connections_add.html
│ ├── evotor.html, vk.html, vk_callback.html
│ ├── catalog_stores.html, catalog_groups.html, catalog_products.html
│ ├── sync.html
│ └── admin/
│ ├── users.html — paginated user list with filters
│ ├── user_detail.html — user card + Evotor meta JSON + action buttons
│ └── roles.html — roles and permission assignment
│
├── static/
│ └── style.css — port from 854c912:web/static/style.css
│
└── migrations/versions/
├── 0001_initial.py — exists (empty placeholder)
├── 0002_users_and_connections.py — NEW: full schema + new user fields
├── 0003_rbac_tables.py — NEW: RBAC tables + seed roles/permissions
└── 0004_seed_system_user.py — NEW (optional): seed system user from env vars
DB Schema Additions
web/models/user.py
class UserRoleEnum(str, enum.Enum):
system = "system"
admin = "admin"
user = "user"
class UserStatusEnum(str, enum.Enum):
pending = "pending"
active = "active"
suspended = "suspended"
class User(Base):
__tablename__ = "users"
# existing fields (port from 854c912 schema.ts)
id, first_name, last_name, email, phone
password_hash = Column(String(255), nullable=True) # nullable: invite flow
is_email_confirmed, email_confirm_token
password_reset_token, password_reset_expires
created_at, updated_at
# new fields
role = Column(Enum(UserRoleEnum), default=UserRoleEnum.user, index=True)
status = Column(Enum(UserStatusEnum), default=UserStatusEnum.pending, index=True)
evotor_user_id = Column(String(255), unique=True, nullable=True)
evotor_meta = Column(JSON, nullable=True)
invite_token = Column(String(255), nullable=True)
invite_expires = Column(DateTime, nullable=True)
phone_otp = Column(String(10), nullable=True)
phone_otp_expires = Column(DateTime, nullable=True)
web/models/connections.py
Port all tables from 854c912:web/src/db/schema.ts verbatim:
evotor_connections (add api_token varchar(255) column for our app token), vk_connections, sync_configs, sync_filters, cached_stores, cached_groups, cached_products.
web/models/rbac.py
class Role(Base): # name: system/admin/user
class Permission(Base): # name: e.g. "admin.users.edit"
role_permissions = Table(...) # join table Role ↔ Permission
class UserRole(Base): # join table User ↔ Role (fine-grained assignment)
Route List
Evotor Webhooks (web/routes/evotor_webhooks.py)
| Method | Path | Auth |
|---|---|---|
| POST | /user/create |
Bearer EVOTOR_WEBHOOK_SECRET |
| POST | /user/verify |
Bearer EVOTOR_WEBHOOK_SECRET |
| POST | /user/token |
Bearer EVOTOR_WEBHOOK_SECRET |
/user/create flow:
- Verify Bearer token (401 if wrong, skip check if secret is unset — dev mode)
- Parse
{userId, customField}— attempt JSON parse ofcustomField; extractemail,phone,first_name,last_name - Try to find existing user by email → phone →
evotor_user_id - If found: set
evotor_user_id, updateevotor_meta, setstatus=activeif was pending - If not found: create
User(status=pending, role=user, evotor_user_id=..., password_hash=None) - Generate
invite_token = secrets.token_urlsafe(32),invite_expires = now + 48h - Dispatch
send_email_task.delay(email, "Приглашение в ЭвоСинк", html)via Celery - Generate a random
api_token = secrets.token_urlsafe(32), upsert intoevotor_connections - Return
{"userId": payload.userId, "token": api_token}
/user/verify flow:
- Verify Bearer; parse
{userId, username, password} - Find user by email OR phone (username field)
- Check:
password_hashnot None, status not suspended,verify_password(password, hash) - Return
{"userId": user.evotor_user_id, "token": evotor_connection.api_token}
/user/token flow:
- Verify Bearer; parse
{userId, token} - Find user by
evotor_user_id; upsertevotor_connections.access_token = token - Return HTTP 200
{}
Admin Panel (web/routes/admin.py) — all require Depends(require_role("admin", "system"))
| Method | Path |
|---|---|
| GET | /admin/users — paginated list with role/status/search filters |
| GET | /admin/users/{id} — user card, evotor_meta JSON display |
| POST | /admin/users/{id}/activate — set status=active |
| POST | /admin/users/{id}/suspend — set status=suspended |
| POST | /admin/users/{id}/reset-password — generate token, dispatch email task |
| POST | /admin/users/{id}/send-invite — regenerate invite_token, dispatch email task |
| POST | /admin/users/{id}/edit — update name/email/phone/role |
| POST | /admin/users/{id}/delete — hard delete (system only) |
| GET | /admin/roles — role + permission list (system only) |
| POST | /admin/roles/{id}/permissions — set permissions for role |
User Profile (web/routes/profile.py)
Extend existing profile card with: role, status, Evotor connection info, "Confirm email" action (if is_email_confirmed=False).
Auth/Invite (new)
| Method | Path |
|---|---|
| GET/POST | /invite?token=... — Evotor-invited user sets password + activates account |
Notifications Layer
# base.py
class EmailProvider(ABC):
@abstractmethod
def send(self, to: str, subject: str, html_body: str) -> None: ...
class SMSProvider(ABC):
@abstractmethod
def send(self, to: str, text: str) -> None: ...
# console.py — prints formatted block to stdout (like Node.js "="*40 pattern)
# registry.py — factory: EMAIL_PROVIDER env var selects implementation
# tasks.py
@celery_app.task(queue="notifications")
def send_email_task(to, subject, html_body): get_email_provider().send(...)
@celery_app.task(queue="notifications")
def send_sms_task(to, text): get_sms_provider().send(...)
Add to docker-compose worker command: --queues=default,sync,health,notifications
RBAC Middleware
# auth/rbac.py
def require_role(*roles):
def dep(request, db=Depends(get_db)):
user = get_current_user(request, db)
if user.role.value not in roles: raise HTTPException(403)
return user
return Depends(dep)
def require_permission(permission_name):
def dep(request, db=Depends(get_db)):
user = get_current_user(request, db)
if user.role == UserRoleEnum.system: return user # bypass
# walk user_roles → role_permissions → permissions
...
return Depends(dep)
Migration Strategy
0002: Full schema (conditionally — IF NOT EXISTS / ADD COLUMN IF NOT EXISTS for live DBs with Node.js tables). Includes ALTER COLUMN password_hash to nullable. Adds api_token to evotor_connections. Adds role, status, evotor_user_id, evotor_meta, invite_token, invite_expires, phone_otp, phone_otp_expires to users.
0003: RBAC tables (roles, permissions, role_permissions, user_roles). Seeds three default Role rows and baseline permission set (admin.users.view, admin.users.edit, admin.users.delete, admin.roles.manage).
0004 (optional): Seeds a system user from SYSTEM_USER_EMAIL / SYSTEM_USER_PASSWORD env vars.
Order of Implementation
- Models —
user.py,connections.py,rbac.py,__init__.py - Migrations — 0002, 0003 (run
alembic upgrade headto verify) - Auth foundation —
auth/password.py,auth/session.py,templates_env.py, updatemain.py - Core auth routes —
routes/auth.py,routes/reset.py+ templates (base.html, login, register, etc.) - Notifications —
notifications/package +tasks.py+ updatecelery_app.py - Invite flow —
routes/invite.py+invite.html - Evotor webhooks —
routes/evotor_webhooks.py(the most novel piece) - Profile + Connections — port from Node.js
854c912 - RBAC middleware —
auth/rbac.py - Admin panel —
routes/admin.py+ admin templates - Catalog + Sync — port remaining routes and Celery tasks from Node.js
Verification
alembic upgrade head— all migrations run clean on a fresh DBPOST /user/createwithcurl+ Bearer token → check user created in DB, invite email printed to consoleGET /invite?token=<token>→ set password → checkstatus=active,is_email_confirmed=TruePOST /loginwith the set password → session created, redirect to/profilePOST /user/verifywith username+password → returns{userId, token}POST /user/tokenwith{userId, token}→ 200, evotor_connection updated- Login as admin user, visit
/admin/users— user list renders - Admin activates/suspends a user — status changes in DB
POST /loginas suspended user → rejecteduvicorn web.main:app --reload— no import errors, health check returns 200