fix(users): console-created users use the Firebase uid as doc ID

create_user wrote the profile with .add() (random doc ID). On first login the
FlutterFlow app looks for users/{uid}, doesn't find it, and creates a second,
bare doc - so every console-created user ended up duplicated, and devices
assigned in the console pointed at the doc the app never reads.

Now the profile is written to users/{uid} with created_time set, and the email
is lowercased to match what Firebase Auth stores. If the Firestore write
fails, the just-created Auth account is deleted so no orphan is left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 15:31:34 +03:00
co-authored by Claude Opus 5.5
parent 3f3d2cc63c
commit c1df3b5aa3
2 changed files with 25 additions and 8 deletions
+22 -7
View File
@@ -1,4 +1,4 @@
from datetime import datetime from datetime import datetime, timezone
from google.cloud.firestore_v1 import DocumentReference, ArrayUnion, ArrayRemove from google.cloud.firestore_v1 import DocumentReference, ArrayUnion, ArrayRemove
@@ -116,30 +116,45 @@ def get_user(user_doc_id: str) -> UserInDB:
def create_user(data: UserCreate) -> UserInDB: def create_user(data: UserCreate) -> UserInDB:
"""Create a new user: a Firebase Auth account (so they can log into the app """Create a new user: a Firebase Auth account (so they can log into the app
immediately) plus the matching Firestore profile document.""" immediately) plus the matching Firestore profile document.
The profile doc ID MUST be the Firebase Auth uid. The FlutterFlow app looks
up `users/{uid}` on login and creates a bare doc there if it's missing, and
MQTT auth (mqtt/app_users.py) + device `user_list` paths assume the same.
"""
if not data.password or len(data.password) < 6: if not data.password or len(data.password) < 6:
raise ValidationError("Password must be at least 6 characters.") raise ValidationError("Password must be at least 6 characters.")
if not data.email: # Firebase Auth lowercases emails; store the same form so the profile
# matches what the app sees for the signed-in user.
email = (data.email or "").strip().lower()
if not email:
raise ValidationError("Email is required.") raise ValidationError("Email is required.")
db = get_db() db = get_db()
doc_data = data.model_dump(exclude={"password"}) doc_data = data.model_dump(exclude={"password"})
doc_data["email"] = email
doc_data["friendsList"] = [] doc_data["friendsList"] = []
doc_data["friendsInvited"] = [] doc_data["friendsInvited"] = []
doc_data["created_time"] = datetime.now(timezone.utc)
try: try:
firebase_user = firebase_auth.create_user( firebase_user = firebase_auth.create_user(
email=data.email, email=email,
password=data.password, password=data.password,
display_name=data.display_name or None, display_name=data.display_name or None,
) )
except firebase_auth.EmailAlreadyExistsError: except firebase_auth.EmailAlreadyExistsError:
raise ValidationError(f"A user with email {data.email} already exists.") raise ValidationError(f"A user with email {email} already exists.")
doc_data["uid"] = firebase_user.uid doc_data["uid"] = firebase_user.uid
_, doc_ref = db.collection(COLLECTION).add(doc_data) try:
db.collection(COLLECTION).document(firebase_user.uid).set(doc_data)
except Exception:
# Don't leave an Auth account behind with no profile doc.
firebase_auth.delete_user(firebase_user.uid)
raise
return UserInDB(id=doc_ref.id, **doc_data) return UserInDB(id=firebase_user.uid, **_sanitize_dict(doc_data))
def update_user(user_doc_id: str, data: UserUpdate) -> UserInDB: def update_user(user_doc_id: str, data: UserUpdate) -> UserInDB:
+3 -1
View File
@@ -64,7 +64,9 @@ flashed into NVS and the firmware uses it as its MQTT id. Legacy docs only have
- `PUT /api/devices/{id}` when the body contains `user_list` - `PUT /api/devices/{id}` when the body contains `user_list`
(`devices.service.update_device`) (`devices.service.update_device`)
- Users are resolved by the **`uid` field** (a query), **never by doc ID**. Console- - Users are resolved by the **`uid` field** (a query), **never by doc ID**. Console-
created users get random doc IDs (`.add()`); FlutterFlow uses the uid as the doc ID. created users used to get random doc IDs (`.add()`) — fixed 2026-09-30, the Console
now writes `users/{uid}` like FlutterFlow does — but older docs may still have a
random ID, so keep resolving by the `uid` field.
- A user is refused when `status == "blocked"`. - A user is refused when `status == "blocked"`.
- Lookups are cached in-process for 60 s (`mqtt/app_users.py`). Assign/unassign, - Lookups are cached in-process for 60 s (`mqtt/app_users.py`). Assign/unassign,
update, block/unblock and delete call `invalidate(uid)`. If you add a new code path update, block/unblock and delete call `invalidate(uid)`. If you add a new code path