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:
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user