-
Notifications
You must be signed in to change notification settings - Fork 1
Phone number bottom sheet #48
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9b14874
7cf650f
d018d8f
ed1d7a2
13b45b4
3fa63db
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| import hashlib | ||
| import logging | ||
| import secrets | ||
| from datetime import datetime, timezone | ||
|
|
||
| import httpx | ||
| from fastapi import APIRouter, Depends, HTTPException | ||
| from pydantic import BaseModel, Field | ||
|
|
||
| from config import settings | ||
| from services.supabase_client import get_supabase_client | ||
| from utils.auth import get_current_user | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| router = APIRouter(prefix="/user/phone", tags=["phone"]) | ||
|
|
||
|
|
||
| class StartPhoneOtpRequest(BaseModel): | ||
| """Request payload for starting a phone-number OTP verification flow.""" | ||
|
|
||
| phone_number: str = Field(..., description="E.164 phone number (e.g. +15551234567).") | ||
|
|
||
|
|
||
| class ResendPhoneOtpRequest(BaseModel): | ||
| """Request payload for resending a phone-number OTP.""" | ||
|
|
||
| phone_number: str | None = Field( | ||
| default=None, | ||
| description="Optional E.164 phone number. If omitted, the stored pending phone number is used.", | ||
| ) | ||
|
|
||
|
|
||
| def _generate_6_digit_otp() -> str: | ||
| """ | ||
| Generate a 6-digit numeric OTP as a string. | ||
|
|
||
| Returns: | ||
| str: A zero-padded 6-digit OTP (e.g. "042381"). | ||
| """ | ||
| return str(secrets.randbelow(1_000_000)).zfill(6) | ||
|
|
||
|
|
||
| def _sha256_hex(value: str) -> str: | ||
| """ | ||
| Compute the SHA-256 hex digest for an input string. | ||
|
|
||
| Parameters: | ||
| value (str): Input string to hash. | ||
|
|
||
| Returns: | ||
| str: Lowercase hex SHA-256 digest. | ||
| """ | ||
| return hashlib.sha256(value.encode("utf-8")).hexdigest() | ||
|
|
||
|
|
||
| async def _send_sms_otp(phone_number: str, otp: str) -> None: | ||
| """ | ||
| Send an OTP SMS to a phone number using Twilio's REST API. | ||
|
|
||
| Parameters: | ||
| phone_number (str): Destination phone number in E.164 format. | ||
| otp (str): 6-digit OTP to send. | ||
|
|
||
| Raises: | ||
| HTTPException: If Twilio credentials are missing or the API call fails. | ||
| """ | ||
| if not settings.TWILIO_ACCOUNT_SID or not settings.TWILIO_AUTH_TOKEN or not settings.TWILIO_FROM_NUMBER: | ||
| raise HTTPException(status_code=500, detail="Twilio is not configured on the backend") | ||
|
|
||
| url = ( | ||
| f"https://api.twilio.com/2010-04-01/Accounts/{settings.TWILIO_ACCOUNT_SID}/Messages.json" | ||
| ) | ||
| body = f"Your Keepsafe verification code is: {otp}" | ||
|
|
||
| async with httpx.AsyncClient(timeout=10) as client: | ||
| response = await client.post( | ||
| url, | ||
| data={"To": phone_number, "From": settings.TWILIO_FROM_NUMBER, "Body": body}, | ||
| auth=(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN), | ||
| ) | ||
|
|
||
| if response.status_code not in (200, 201): | ||
| logger.error("Twilio send failed", extra={"status": response.status_code, "body": response.text}) | ||
| raise HTTPException(status_code=502, detail="Failed to send OTP SMS") | ||
|
|
||
|
|
||
| @router.post("/otp/start") | ||
| async def start_phone_otp( | ||
| payload: StartPhoneOtpRequest, | ||
| current_user=Depends(get_current_user), | ||
| supabase=Depends(get_supabase_client), | ||
| ): | ||
| """ | ||
| Create or replace a pending `phone_number_updates` row and send an OTP SMS. | ||
|
|
||
| The row is upserted on `user_id` so each user has at most one pending verification record. | ||
| """ | ||
| user_id = current_user.user.id | ||
| otp = _generate_6_digit_otp() | ||
| otp_hash = _sha256_hex(otp) | ||
|
|
||
| # Upsert the verification record | ||
| now_iso = datetime.now(timezone.utc).isoformat() | ||
| try: | ||
| supabase.table("phone_number_updates").upsert( | ||
| { | ||
| "user_id": user_id, | ||
| "phone_number": payload.phone_number, | ||
| "otp_hash": otp_hash, | ||
| "created_at": now_iso, | ||
| }, | ||
| on_conflict="user_id", | ||
| ).execute() | ||
| except Exception as e: | ||
| logger.exception("Failed to upsert phone_number_updates row") | ||
| raise HTTPException(status_code=500, detail="Failed to create phone verification record") from e | ||
|
|
||
| await _send_sms_otp(payload.phone_number, otp) | ||
|
|
||
| return {"message": "OTP sent"} | ||
|
|
||
|
|
||
| @router.post("/otp/resend") | ||
| async def resend_phone_otp( | ||
| payload: ResendPhoneOtpRequest, | ||
| current_user=Depends(get_current_user), | ||
| supabase=Depends(get_supabase_client), | ||
| ): | ||
| """ | ||
| Resend a phone-number OTP by regenerating the OTP hash and sending a new SMS. | ||
|
|
||
| If `phone_number` is not provided in the request body, the stored pending phone number is used. | ||
| """ | ||
| user_id = current_user.user.id | ||
|
|
||
| phone_number = payload.phone_number | ||
| if not phone_number: | ||
| try: | ||
| res = ( | ||
| supabase.table("phone_number_updates") | ||
| .select("phone_number") | ||
| .eq("user_id", user_id) | ||
| .execute() | ||
| ) | ||
| except Exception as e: | ||
| logger.exception("Failed to fetch existing phone_number_updates row") | ||
| raise HTTPException(status_code=500, detail="Failed to fetch pending phone verification record") from e | ||
|
|
||
| if not res.data: | ||
| raise HTTPException(status_code=404, detail="No pending phone verification record found") | ||
|
|
||
| phone_number = res.data[0].get("phone_number") | ||
|
|
||
| otp = _generate_6_digit_otp() | ||
| otp_hash = _sha256_hex(otp) | ||
| now_iso = datetime.now(timezone.utc).isoformat() | ||
|
|
||
| try: | ||
| supabase.table("phone_number_updates").upsert( | ||
| { | ||
| "user_id": user_id, | ||
| "phone_number": phone_number, | ||
| "otp_hash": otp_hash, | ||
| "created_at": now_iso, | ||
| }, | ||
| on_conflict="user_id", | ||
| ).execute() | ||
| except Exception as e: | ||
| logger.exception("Failed to upsert phone_number_updates row for resend") | ||
| raise HTTPException(status_code=500, detail="Failed to recreate phone verification record") from e | ||
|
|
||
| await _send_sms_otp(phone_number, otp) | ||
| return {"message": "OTP resent"} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| /** | ||
| * ESLint configuration for the Expo/React Native app. | ||
| */ | ||
| module.exports = { | ||
| root: true, | ||
| extends: ['expo'], | ||
| }; | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,6 +23,9 @@ import { Colors } from '@/lib/constants'; | |
| import AudioWaveVisualier from '@/components/audio/audio-wave-visualier'; | ||
| import { useResponsive } from '@/hooks/use-responsive'; | ||
| import { logger } from '@/lib/logger'; | ||
| import PhoneNumberBottomSheet from '@/components/phone-number-bottom-sheet'; | ||
| import { supabase } from '@/lib/supabase'; | ||
| import { getPhonePromptState } from '@/services/phone-number-prompt-service'; | ||
|
|
||
| const { height } = Dimensions.get('window'); | ||
|
|
||
|
|
@@ -46,7 +49,8 @@ export default function CaptureScreen() { | |
| const [isCameraReady, setIsCameraReady] = useState(false); | ||
| const [cameraMode, setCameraMode] = useState<'picture' | 'video'>('picture'); | ||
|
|
||
| const { profile } = useAuthContext(); | ||
| const { profile, user } = useAuthContext(); | ||
| const [showPhoneSheet, setShowPhoneSheet] = useState(false); | ||
|
|
||
| const { | ||
| isCapturing, | ||
|
|
@@ -122,6 +126,43 @@ export default function CaptureScreen() { | |
| }; | ||
| }, []); | ||
|
|
||
| // Show the phone-number prompt bottom sheet when entering `/capture` if needed. | ||
| useEffect(() => { | ||
| let cancelled = false; | ||
|
|
||
| const checkShouldShowPhonePrompt = async () => { | ||
| if (!user?.id) return; | ||
| if (profile?.phone_number) { | ||
| if (!cancelled) setShowPhoneSheet(false); | ||
| return; | ||
| } | ||
|
|
||
| // If the user already has a pending OTP record, always show the sheet. | ||
| const { data: pendingRecord } = await supabase | ||
| .from('phone_number_updates') | ||
| .select('id') | ||
| .eq('user_id', user.id) | ||
| .maybeSingle(); | ||
|
|
||
| if (pendingRecord?.id) { | ||
| if (!cancelled) setShowPhoneSheet(true); | ||
| return; | ||
| } | ||
|
|
||
| const state = await getPhonePromptState(user.id); | ||
| const now = Date.now(); | ||
| const shouldShow = | ||
| !state.dontAskAgain && (!state.nextPromptAtMs || now >= state.nextPromptAtMs); | ||
|
|
||
| if (!cancelled) setShowPhoneSheet(shouldShow); | ||
| }; | ||
|
|
||
| checkShouldShowPhonePrompt().catch(() => {}); | ||
|
Comment on lines
+140
to
+160
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # Find schema/migration files and phone_number_updates references
rg -l "phone_number_updates" --type ts --type tsx --type sql --type js --type jsxRepository: fortune710/keepsafe Length of output: 90 🏁 Script executed: # Search for schema or migrations directory
fd -t d "schema|migration|supabase" | head -20Repository: fortune710/keepsafe Length of output: 121 🏁 Script executed: # Look for phone_number_updates table definition
rg "phone_number_updates" -A 5 -B 2Repository: fortune710/keepsafe Length of output: 11654 Improve error handling for pending OTP record query. The Supabase query uses Consider destructuring and logging errors for observability: Suggested error handling- const { data: pendingRecord } = await supabase
+ const { data: pendingRecord, error: pendingError } = await supabase
.from('phone_number_updates')
.select('id')
.eq('user_id', user.id)
.maybeSingle();
+ if (pendingError) {
+ logger.warn('Failed to check pending phone updates', pendingError);
+ }- checkShouldShowPhonePrompt().catch(() => {});
+ checkShouldShowPhonePrompt().catch((error) => {
+ logger.warn('Failed to determine phone prompt visibility', error);
+ });🤖 Prompt for AI Agents |
||
| return () => { | ||
| cancelled = true; | ||
| }; | ||
| }, [profile?.phone_number, user?.id]); | ||
|
|
||
| // Cleanup audio recording when component unmounts (navigating away) | ||
| useEffect(() => { | ||
| return () => { | ||
|
|
@@ -614,6 +655,11 @@ export default function CaptureScreen() { | |
| </ScrollView> | ||
| </GestureDetector> | ||
| </SafeAreaView> | ||
|
|
||
| <PhoneNumberBottomSheet | ||
| isVisible={showPhoneSheet} | ||
| onClose={() => setShowPhoneSheet(false)} | ||
| /> | ||
| </Animated.View> | ||
| ); | ||
| } | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.