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