feat(users): support creating app users with a Firebase Auth account

New users can now be created with a password, which creates a real
Firebase Auth account (so they can log into the mobile app immediately)
alongside the Firestore profile document. UserCreate is now
UserProfile + password (request-only, never persisted or echoed back);
deleting a user also removes their Auth account.

- backend/users: split UserCreate into UserProfile (persisted shape)
  and UserCreate (adds password), wire firebase_auth create/delete
- CreateUserModal: new lightweight modal for creating a user from
  other flows (e.g. device onboarding) without leaving the page
- UserForm: adds the password field for new users; also fixes
  useToast() being used undestructured (toast.success(...) was being
  called on the hook's return value instead of its .toast method)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 18:31:37 +03:00
co-authored by Claude Sonnet 5
parent 6e0a291228
commit 0d79a9f72c
4 changed files with 147 additions and 10 deletions
+6 -2
View File
@@ -4,7 +4,7 @@ from typing import List, Optional
# --- Request / Response schemas --- # --- Request / Response schemas ---
class UserCreate(BaseModel): class UserProfile(BaseModel):
email: str = "" email: str = ""
display_name: str = "" display_name: str = ""
photo_url: str = "" photo_url: str = ""
@@ -17,6 +17,10 @@ class UserCreate(BaseModel):
quickSettingsPIN: str = "" quickSettingsPIN: str = ""
class UserCreate(UserProfile):
password: str = "" # request-only — used to create the Firebase Auth account, never persisted to Firestore or echoed back
class UserUpdate(BaseModel): class UserUpdate(BaseModel):
email: Optional[str] = None email: Optional[str] = None
display_name: Optional[str] = None display_name: Optional[str] = None
@@ -29,7 +33,7 @@ class UserUpdate(BaseModel):
quickSettingsPIN: Optional[str] = None quickSettingsPIN: Optional[str] = None
class UserInDB(UserCreate): class UserInDB(UserProfile):
id: str id: str
created_time: str = "" created_time: str = ""
lastActive: str = "" lastActive: str = ""
+26 -3
View File
@@ -84,12 +84,28 @@ def get_user(user_doc_id: str) -> UserInDB:
def create_user(data: UserCreate) -> UserInDB: def create_user(data: UserCreate) -> UserInDB:
"""Create a new user document in Firestore.""" """Create a new user: a Firebase Auth account (so they can log into the app
immediately) plus the matching Firestore profile document."""
if not data.password or len(data.password) < 6:
raise ValidationError("Password must be at least 6 characters.")
if not data.email:
raise ValidationError("Email is required.")
db = get_db() db = get_db()
doc_data = data.model_dump() doc_data = data.model_dump(exclude={"password"})
doc_data["friendsList"] = [] doc_data["friendsList"] = []
doc_data["friendsInvited"] = [] doc_data["friendsInvited"] = []
try:
firebase_user = firebase_auth.create_user(
email=data.email,
password=data.password,
display_name=data.display_name or None,
)
except firebase_auth.EmailAlreadyExistsError:
raise ValidationError(f"A user with email {data.email} already exists.")
doc_data["uid"] = firebase_user.uid
_, doc_ref = db.collection(COLLECTION).add(doc_data) _, doc_ref = db.collection(COLLECTION).add(doc_data)
return UserInDB(id=doc_ref.id, **doc_data) return UserInDB(id=doc_ref.id, **doc_data)
@@ -111,13 +127,20 @@ def update_user(user_doc_id: str, data: UserUpdate) -> UserInDB:
def delete_user(user_doc_id: str) -> None: def delete_user(user_doc_id: str) -> None:
"""Delete a user document from Firestore.""" """Delete a user document from Firestore, plus their Firebase Auth account if one exists."""
db = get_db() db = get_db()
doc_ref = db.collection(COLLECTION).document(user_doc_id) doc_ref = db.collection(COLLECTION).document(user_doc_id)
doc = doc_ref.get() doc = doc_ref.get()
if not doc.exists: if not doc.exists:
raise NotFoundError("User") raise NotFoundError("User")
uid = doc.to_dict().get("uid", "")
if uid:
try:
firebase_auth.delete_user(uid)
except firebase_auth.UserNotFoundError:
pass
doc_ref.delete() doc_ref.delete()
@@ -0,0 +1,97 @@
// frontend/src/modals/bellcloud/users/CreateUserModal.jsx
// Create a new app user (Firebase Auth account + Firestore profile) from anywhere in the console.
import { useState, useEffect } from 'react'
import api from '@/lib/api'
import Modal from '@/components/ui/Modal'
import Button from '@/components/ui/Button'
import FormField from '@/components/ui/FormField'
export default function CreateUserModal({ open, onClose, onCreated }) {
const [email, setEmail] = useState('')
const [displayName, setDisplayName] = useState('')
const [password, setPassword] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (!open) return
setEmail(''); setDisplayName(''); setPassword(''); setError('')
}, [open])
const handleCreate = async () => {
setError('')
if (!email || !displayName || !password) {
setError('Email, display name, and password are required.')
return
}
if (password.length < 6) {
setError('Password must be at least 6 characters.')
return
}
setSaving(true)
try {
const created = await api.post('/users', {
email,
display_name: displayName,
password,
status: 'active',
})
onCreated(created)
} catch (err) {
setError(err.message || 'Failed to create user.')
} finally {
setSaving(false)
}
}
return (
<Modal
open={open}
onClose={onClose}
title="Create App User"
size="sm"
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-3)' }}>
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={handleCreate} loading={saving}>Create User</Button>
</div>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
<FormField
label="Email"
name="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
placeholder="user@example.com"
autoFocus
/>
<FormField
label="Display Name"
name="display_name"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
required
placeholder="e.g. Jane Smith"
/>
<FormField
label="Password"
name="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
placeholder="Min. 6 characters"
hint="Used to sign into the mobile app immediately."
/>
{error && (
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-danger)' }}>{error}</p>
)}
</div>
</Modal>
)
}
@@ -71,7 +71,7 @@ function StatusToggle({ value, onChange }) {
function PhotoField({ value, onChange, userId }) { function PhotoField({ value, onChange, userId }) {
const fileInputRef = useRef(null) const fileInputRef = useRef(null)
const toast = useToast() const { toast } = useToast()
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [hovered, setHovered] = useState(false) const [hovered, setHovered] = useState(false)
@@ -192,7 +192,7 @@ function EyeToggle({ show, onToggle }) {
} }
function PasswordCard({ userId, hasUid }) { function PasswordCard({ userId, hasUid }) {
const toast = useToast() const { toast } = useToast()
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('') const [confirm, setConfirm] = useState('')
const [showPass, setShowPass] = useState(false) const [showPass, setShowPass] = useState(false)
@@ -302,9 +302,9 @@ function PasswordCard({ userId, hasUid }) {
// ─── Main ──────────────────────────────────────────────────────────────────── // ─── Main ────────────────────────────────────────────────────────────────────
export default function UserForm() { export default function UserForm() {
const { id } = useParams() const { id } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const toast = useToast() const { toast } = useToast()
const isEdit = Boolean(id) const isEdit = Boolean(id)
const [form, setForm] = useState({ const [form, setForm] = useState({
@@ -317,6 +317,7 @@ export default function UserForm() {
userTitle: '', userTitle: '',
settingsPIN: '', settingsPIN: '',
quickSettingsPIN: '', quickSettingsPIN: '',
password: '',
}) })
const [uid, setUid] = useState('') const [uid, setUid] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -435,6 +436,18 @@ export default function UserForm() {
placeholder="+1 234 567 8900" placeholder="+1 234 567 8900"
/> />
</div> </div>
{!isEdit && (
<FormField
label="Password"
name="password"
type="password"
value={form.password}
onChange={set('password')}
required
placeholder="Min. 6 characters"
hint="Used to sign into the mobile app immediately — this creates the Firebase Auth account."
/>
)}
<StatusToggle value={form.status} onChange={setVal('status')} /> <StatusToggle value={form.status} onChange={setVal('status')} />
</div> </div>
</Card> </Card>