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:
@@ -4,7 +4,7 @@ from typing import List, Optional
|
||||
|
||||
# --- Request / Response schemas ---
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
class UserProfile(BaseModel):
|
||||
email: str = ""
|
||||
display_name: str = ""
|
||||
photo_url: str = ""
|
||||
@@ -17,6 +17,10 @@ class UserCreate(BaseModel):
|
||||
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):
|
||||
email: Optional[str] = None
|
||||
display_name: Optional[str] = None
|
||||
@@ -29,7 +33,7 @@ class UserUpdate(BaseModel):
|
||||
quickSettingsPIN: Optional[str] = None
|
||||
|
||||
|
||||
class UserInDB(UserCreate):
|
||||
class UserInDB(UserProfile):
|
||||
id: str
|
||||
created_time: str = ""
|
||||
lastActive: str = ""
|
||||
|
||||
@@ -84,12 +84,28 @@ def get_user(user_doc_id: str) -> 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()
|
||||
doc_data = data.model_dump()
|
||||
doc_data = data.model_dump(exclude={"password"})
|
||||
doc_data["friendsList"] = []
|
||||
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)
|
||||
|
||||
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:
|
||||
"""Delete a user document from Firestore."""
|
||||
"""Delete a user document from Firestore, plus their Firebase Auth account if one exists."""
|
||||
db = get_db()
|
||||
doc_ref = db.collection(COLLECTION).document(user_doc_id)
|
||||
doc = doc_ref.get()
|
||||
if not doc.exists:
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@@ -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 }) {
|
||||
const fileInputRef = useRef(null)
|
||||
const toast = useToast()
|
||||
const { toast } = useToast()
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
@@ -192,7 +192,7 @@ function EyeToggle({ show, onToggle }) {
|
||||
}
|
||||
|
||||
function PasswordCard({ userId, hasUid }) {
|
||||
const toast = useToast()
|
||||
const { toast } = useToast()
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [showPass, setShowPass] = useState(false)
|
||||
@@ -304,7 +304,7 @@ function PasswordCard({ userId, hasUid }) {
|
||||
export default function UserForm() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const { toast } = useToast()
|
||||
const isEdit = Boolean(id)
|
||||
|
||||
const [form, setForm] = useState({
|
||||
@@ -317,6 +317,7 @@ export default function UserForm() {
|
||||
userTitle: '',
|
||||
settingsPIN: '',
|
||||
quickSettingsPIN: '',
|
||||
password: '',
|
||||
})
|
||||
const [uid, setUid] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -435,6 +436,18 @@ export default function UserForm() {
|
||||
placeholder="+1 234 567 8900"
|
||||
/>
|
||||
</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')} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user