feat: remove email invite screen, add accept-invite rate limiting, clean up iOS pairing (v0.2.2)
This commit is contained in:
parent
5c856d3de6
commit
26419ce08d
|
|
@ -57,7 +57,7 @@ Closer gives couples a private, shared space for guided connection:
|
|||
- **Question packs** — 22+ curated categories spanning 6,000+ bundled prompts (communication, conflict, trust, intimacy, parenting, marriage, money, stress, date night, and more).
|
||||
- **Answer history** — review past questions and answers, delete controls, and partner reveal support.
|
||||
- **Discussion threads** — question-specific conversation threads and reactions for follow-up.
|
||||
- **Partner pairing** — 6-character invite code, email invite flow, and partner-aware home states.
|
||||
- **Partner pairing** — 6-character invite code with copy and share, and partner-aware home states.
|
||||
- **Spin the wheel** — category-based random questions for date nights, road trips, and low-pressure moments.
|
||||
- **Date tools** — swipe-to-match date ideas, date planning preferences, and a shared bucket list.
|
||||
- **Settings & privacy** — account, notifications, appearance, subscription, relationship, privacy, and account deletion flows.
|
||||
|
|
@ -99,7 +99,7 @@ The Android app is the **reference implementation** — the iOS port is built to
|
|||
All of this is free, forever. No credits, no daily limits that magically shrink after a week.
|
||||
|
||||
- Anonymous onboarding → email or Google sign-up
|
||||
- 6-character invite code pairing (with email invite fallback)
|
||||
- 6-character invite code pairing (copy or share via any app)
|
||||
- Daily question with full answer modes (text, scale, multiple choice, this-or-that)
|
||||
- Private answer reveal flow once both partners have answered
|
||||
- Question packs — 6,000+ bundled prompts across 22 categories (most packs included free)
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ import app.closer.ui.onboarding.CreateProfileScreen
|
|||
import app.closer.ui.onboarding.OnboardingScreen
|
||||
import app.closer.ui.pairing.AcceptInviteScreen
|
||||
import app.closer.ui.pairing.CreateInviteScreen
|
||||
import app.closer.ui.pairing.EmailInviteScreen
|
||||
import app.closer.ui.pairing.InviteConfirmScreen
|
||||
import app.closer.ui.pairing.PairPromptScreen
|
||||
import app.closer.ui.pairing.RecoveryScreen
|
||||
|
|
@ -275,9 +274,6 @@ fun AppNavigation(
|
|||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable(route = AppRoute.EMAIL_INVITE) {
|
||||
EmailInviteScreen(onNavigate = navigateRoute)
|
||||
}
|
||||
composable(route = AppRoute.ACCEPT_INVITE) {
|
||||
AcceptInviteScreen(
|
||||
onNavigate = navigateRoute,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ object AppRoute {
|
|||
const val ANSWER_REVEAL = "answer_reveal/{questionId}"
|
||||
const val ANSWER_HISTORY = "answer_history"
|
||||
const val CREATE_INVITE = "create_invite"
|
||||
const val EMAIL_INVITE = "email_invite"
|
||||
const val ACCEPT_INVITE = "accept_invite"
|
||||
const val INVITE_CONFIRM = "invite_confirm/{inviteCode}"
|
||||
const val CATEGORY_PICKER = "category_picker"
|
||||
|
|
@ -82,7 +81,6 @@ object AppRoute {
|
|||
Definition(ANSWER_REVEAL, "Reveal", "answers"),
|
||||
Definition(ANSWER_HISTORY, "Answer History", "answers"),
|
||||
Definition(CREATE_INVITE, "Create Invite", "pairing"),
|
||||
Definition(EMAIL_INVITE, "Email Invite", "pairing"),
|
||||
Definition(ACCEPT_INVITE, "Accept Invite", "pairing"),
|
||||
Definition(INVITE_CONFIRM, "Invite Confirm", "pairing"),
|
||||
Definition(CATEGORY_PICKER, "Choose A Category", "wheel"),
|
||||
|
|
@ -135,7 +133,6 @@ object AppRoute {
|
|||
|
||||
val modalLikeRoutes = setOf(
|
||||
CREATE_INVITE,
|
||||
EMAIL_INVITE,
|
||||
ACCEPT_INVITE,
|
||||
INVITE_CONFIRM,
|
||||
PAYWALL,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package app.closer.data.remote
|
|||
import app.closer.crypto.RecoveryKeyManager
|
||||
import app.closer.domain.model.Invite
|
||||
import com.google.firebase.firestore.FirebaseFirestore
|
||||
import com.google.firebase.Timestamp
|
||||
import com.google.firebase.functions.FirebaseFunctions
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.tasks.await
|
||||
|
|
@ -24,82 +23,30 @@ class FirestoreInviteDataSource @Inject constructor(
|
|||
.map { CODE_CHARS[Random.nextInt(CODE_CHARS.length)] }
|
||||
.joinToString("")
|
||||
|
||||
/**
|
||||
* Creates an invite server-side via the [createInviteCallable] Cloud Function.
|
||||
*
|
||||
* The client no longer writes invites directly (review2.md Risk #1 fix):
|
||||
* 6-character document IDs are enumerable, and direct client writes expose
|
||||
* them to scanning. This function falls back to a direct Firestore write only
|
||||
* when the callable is unavailable (e.g. not yet deployed), and logs loudly so
|
||||
* the fallback can be removed once all clients are on the new function.
|
||||
*
|
||||
* @return The created invite code and expiry timestamp from the server.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
suspend fun createInvite(
|
||||
code: String,
|
||||
inviterUserId: String,
|
||||
wrappedKey: RecoveryKeyManager.WrappedKey,
|
||||
recoveryPhrase: String
|
||||
): CreateInviteResponse {
|
||||
// Primary path: server-side callable.
|
||||
val callableResult = runCatching {
|
||||
val result = functions.getHttpsCallable("createInviteCallable")
|
||||
.call(
|
||||
mapOf(
|
||||
"wrappedCoupleKey" to wrappedKey.cipherB64,
|
||||
"kdfSalt" to wrappedKey.saltB64,
|
||||
"kdfParams" to wrappedKey.params,
|
||||
"recoveryPhrase" to recoveryPhrase
|
||||
)
|
||||
@Suppress("DEPRECATION")
|
||||
val result = functions.getHttpsCallable("createInviteCallable")
|
||||
.call(
|
||||
mapOf(
|
||||
"wrappedCoupleKey" to wrappedKey.cipherB64,
|
||||
"kdfSalt" to wrappedKey.saltB64,
|
||||
"kdfParams" to wrappedKey.params,
|
||||
"recoveryPhrase" to recoveryPhrase
|
||||
)
|
||||
.await()
|
||||
val data = result.getData() as? Map<*, *>
|
||||
?: throw IllegalStateException("Invalid response from createInviteCallable")
|
||||
val returnedCode = data["code"] as? String
|
||||
?: throw IllegalStateException("Missing code in createInviteCallable response")
|
||||
val expiresAt = data["expiresAt"] as? com.google.firebase.Timestamp
|
||||
?: throw IllegalStateException("Missing expiresAt in createInviteCallable response")
|
||||
CreateInviteResponse(returnedCode, expiresAt)
|
||||
}
|
||||
|
||||
callableResult.onSuccess { return it }
|
||||
|
||||
// Fallback: direct Firestore write when callable is not deployed or unreachable.
|
||||
// TODO(risk-1): remove this fallback once createInviteCallable is deployed to
|
||||
// production and all active Android builds include the functions dependency.
|
||||
android.util.Log.w(
|
||||
"FirestoreInviteDataSource",
|
||||
"createInviteCallable failed (${callableResult.exceptionOrNull()?.message}); falling back to direct Firestore write"
|
||||
)
|
||||
return createInviteDirect(code, inviterUserId, wrappedKey, recoveryPhrase)
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy direct-write path. Kept only as a deployment-transition fallback.
|
||||
* Will be removed once createInviteCallable is universally available.
|
||||
*/
|
||||
private suspend fun createInviteDirect(
|
||||
code: String,
|
||||
inviterUserId: String,
|
||||
wrappedKey: RecoveryKeyManager.WrappedKey,
|
||||
recoveryPhrase: String
|
||||
): CreateInviteResponse {
|
||||
val now = System.currentTimeMillis()
|
||||
inviteRef(code).set(
|
||||
mapOf(
|
||||
"code" to code,
|
||||
"inviterUserId" to inviterUserId,
|
||||
"status" to "pending",
|
||||
"createdAt" to Timestamp.now(),
|
||||
"expiresAt" to Timestamp(now / 1000 + 24 * 60 * 60, 0),
|
||||
"wrappedCoupleKey" to wrappedKey.cipherB64,
|
||||
"kdfSalt" to wrappedKey.saltB64,
|
||||
"kdfParams" to wrappedKey.params,
|
||||
"recoveryPhrase" to recoveryPhrase
|
||||
)
|
||||
).await()
|
||||
return CreateInviteResponse(code, Timestamp(now / 1000 + 24 * 60 * 60, 0))
|
||||
.await()
|
||||
val data = result.getData() as? Map<*, *>
|
||||
?: throw IllegalStateException("Invalid response from createInviteCallable")
|
||||
val returnedCode = data["code"] as? String
|
||||
?: throw IllegalStateException("Missing code in createInviteCallable response")
|
||||
val expiresAt = data["expiresAt"] as? com.google.firebase.Timestamp
|
||||
?: throw IllegalStateException("Missing expiresAt in createInviteCallable response")
|
||||
return CreateInviteResponse(returnedCode, expiresAt)
|
||||
}
|
||||
|
||||
data class CreateInviteResponse(
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
package app.closer.ui.pairing
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import app.closer.core.navigation.AppRoute
|
||||
import app.closer.ui.components.FinishedEmptyStateAction
|
||||
import app.closer.ui.components.FinishedEmptyStateScreen
|
||||
import app.closer.ui.theme.CloserPalette
|
||||
|
||||
@Composable
|
||||
fun EmailInviteScreen(
|
||||
onNavigate: (String) -> Unit = {}
|
||||
) {
|
||||
FinishedEmptyStateScreen(
|
||||
eyebrow = "Pairing",
|
||||
title = "Send an invite that is easy to follow",
|
||||
body = "Create an invite code first, then share it with your partner in the channel you both already use.",
|
||||
glyphCategoryId = "communication",
|
||||
primaryAction = FinishedEmptyStateAction("Create invite", AppRoute.CREATE_INVITE),
|
||||
secondaryAction = FinishedEmptyStateAction("Enter a code", AppRoute.ACCEPT_INVITE),
|
||||
accent = CloserPalette.PurpleDeep,
|
||||
details = listOf(
|
||||
"Use one code instead of a temporary sample invite.",
|
||||
"Keep the message short enough to send anywhere.",
|
||||
"Give your partner a single next step."
|
||||
),
|
||||
onNavigate = onNavigate
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun EmailInviteScreenPreview() {
|
||||
EmailInviteScreen()
|
||||
}
|
||||
|
|
@ -60,6 +60,8 @@ const admin = __importStar(require("firebase-admin"));
|
|||
* 6. Update both user documents with the new coupleId.
|
||||
* 7. Mark the invite as accepted.
|
||||
*/
|
||||
const ACCEPT_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
||||
const ACCEPT_RATE_LIMIT_MAX = 10; // 10 attempts per hour per user
|
||||
exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
|
||||
var _a, _b, _c;
|
||||
const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid;
|
||||
|
|
@ -71,6 +73,22 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
|
|||
throw new functions.https.HttpsError('invalid-argument', 'code is required.');
|
||||
}
|
||||
const db = admin.firestore();
|
||||
// Rate-limit accept attempts per caller to prevent brute-forcing 6-char codes.
|
||||
const windowStart = admin.firestore.Timestamp.fromMillis(admin.firestore.Timestamp.now().toMillis() - ACCEPT_RATE_LIMIT_WINDOW_MS);
|
||||
const recentAttempts = await db
|
||||
.collection('users').doc(callerId)
|
||||
.collection('invite_attempts')
|
||||
.where('attemptedAt', '>=', windowStart)
|
||||
.count()
|
||||
.get();
|
||||
if (recentAttempts.data().count >= ACCEPT_RATE_LIMIT_MAX) {
|
||||
throw new functions.https.HttpsError('resource-exhausted', 'Too many code attempts. Try again later.');
|
||||
}
|
||||
// Record this attempt before doing any work, so failures also count.
|
||||
await db.collection('users').doc(callerId).collection('invite_attempts').add({
|
||||
attemptedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
code,
|
||||
});
|
||||
// Caller must not already be paired.
|
||||
const callerDoc = await db.collection('users').doc(callerId).get();
|
||||
if (callerDoc.exists && ((_b = callerDoc.data()) === null || _b === void 0 ? void 0 : _b.coupleId) != null) {
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
{"version":3,"file":"acceptInviteCallable.js","sourceRoot":"","sources":["../../src/couples/acceptInviteCallable.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"acceptInviteCallable.js","sourceRoot":"","sources":["../../src/couples/acceptInviteCallable.ts"],"names":[],"mappings":"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"}
|
||||
|
|
@ -25,6 +25,9 @@ import * as admin from 'firebase-admin'
|
|||
* 6. Update both user documents with the new coupleId.
|
||||
* 7. Mark the invite as accepted.
|
||||
*/
|
||||
const ACCEPT_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000 // 1 hour
|
||||
const ACCEPT_RATE_LIMIT_MAX = 10 // 10 attempts per hour per user
|
||||
|
||||
export const acceptInviteCallable = functions.https.onCall(async (data: any, context) => {
|
||||
const callerId = context.auth?.uid
|
||||
if (!callerId) {
|
||||
|
|
@ -38,6 +41,25 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con
|
|||
|
||||
const db = admin.firestore()
|
||||
|
||||
// Rate-limit accept attempts per caller to prevent brute-forcing 6-char codes.
|
||||
const windowStart = admin.firestore.Timestamp.fromMillis(
|
||||
admin.firestore.Timestamp.now().toMillis() - ACCEPT_RATE_LIMIT_WINDOW_MS
|
||||
)
|
||||
const recentAttempts = await db
|
||||
.collection('users').doc(callerId)
|
||||
.collection('invite_attempts')
|
||||
.where('attemptedAt', '>=', windowStart)
|
||||
.count()
|
||||
.get()
|
||||
if (recentAttempts.data().count >= ACCEPT_RATE_LIMIT_MAX) {
|
||||
throw new functions.https.HttpsError('resource-exhausted', 'Too many code attempts. Try again later.')
|
||||
}
|
||||
// Record this attempt before doing any work, so failures also count.
|
||||
await db.collection('users').doc(callerId).collection('invite_attempts').add({
|
||||
attemptedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
code,
|
||||
})
|
||||
|
||||
// Caller must not already be paired.
|
||||
const callerDoc = await db.collection('users').doc(callerId).get()
|
||||
if (callerDoc.exists && callerDoc.data()?.coupleId != null) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import SwiftUI
|
|||
struct PairPromptView: View {
|
||||
@State private var showCreateInvite = false
|
||||
@State private var showAcceptInvite = false
|
||||
@State private var showEmailInvite = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
|
|
@ -29,17 +28,12 @@ struct PairPromptView: View {
|
|||
|
||||
VStack(spacing: CloserSpacing.lg) {
|
||||
Button(action: { showCreateInvite = true }) {
|
||||
Label("Create Invite Code", systemImage: "plus.circle.fill")
|
||||
Label("Invite my partner", systemImage: "plus.circle.fill")
|
||||
}
|
||||
.buttonStyle(PrimaryButtonStyle())
|
||||
|
||||
Button(action: { showAcceptInvite = true }) {
|
||||
Label("Enter Partner's Code", systemImage: "key.fill")
|
||||
}
|
||||
.buttonStyle(SecondaryButtonStyle())
|
||||
|
||||
Button(action: { showEmailInvite = true }) {
|
||||
Label("Invite by Email", systemImage: "envelope.fill")
|
||||
Label("Enter a code", systemImage: "key.fill")
|
||||
}
|
||||
.buttonStyle(SecondaryButtonStyle())
|
||||
}
|
||||
|
|
@ -54,9 +48,6 @@ struct PairPromptView: View {
|
|||
.navigationDestination(isPresented: $showAcceptInvite) {
|
||||
AcceptInviteView()
|
||||
}
|
||||
.navigationDestination(isPresented: $showEmailInvite) {
|
||||
EmailInviteView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -252,93 +243,6 @@ struct AcceptInviteView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Email Invite
|
||||
|
||||
struct EmailInviteView: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@State private var email = ""
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var successMessage: String?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: CloserSpacing.xxl) {
|
||||
VStack(spacing: CloserSpacing.sm) {
|
||||
Image(systemName: "envelope.fill")
|
||||
.font(.system(size: 44))
|
||||
.foregroundColor(.closerPrimary)
|
||||
Text("Invite by Email")
|
||||
.font(CloserFont.title1)
|
||||
.foregroundColor(.closerText)
|
||||
Text("Send an invitation to your partner's email")
|
||||
.font(CloserFont.callout)
|
||||
.foregroundColor(.closerTextSecondary)
|
||||
}
|
||||
.padding(.top, CloserSpacing.xxl)
|
||||
|
||||
VStack(spacing: CloserSpacing.md) {
|
||||
TextField("partner@example.com", text: $email)
|
||||
.textContentType(.emailAddress)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.padding()
|
||||
.background(Color.closerSurface)
|
||||
.cornerRadius(CloserRadius.medium)
|
||||
|
||||
if let success = successMessage {
|
||||
Text(success)
|
||||
.font(CloserFont.callout)
|
||||
.foregroundColor(.closerSuccess)
|
||||
}
|
||||
if let error = errorMessage {
|
||||
Text(error)
|
||||
.font(CloserFont.caption)
|
||||
.foregroundColor(.closerDanger)
|
||||
}
|
||||
|
||||
Button(action: sendInvite) {
|
||||
if isLoading {
|
||||
ProgressView().tint(.white)
|
||||
} else {
|
||||
Text("Send Invitation")
|
||||
}
|
||||
}
|
||||
.buttonStyle(PrimaryButtonStyle(isDisabled: isLoading || email.isEmpty))
|
||||
.disabled(isLoading || email.isEmpty)
|
||||
}
|
||||
}
|
||||
.closerPadding()
|
||||
}
|
||||
.background(Color.closerBackground)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private func sendInvite() {
|
||||
// Email invite sends via Cloud Function or mail service
|
||||
// For MVP, generate invite code and share system share sheet
|
||||
guard !email.isEmpty else { return }
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
let (code, _) = try await FirestoreService.shared.createInviteCallable()
|
||||
successMessage = "Invitation sent! Share this code: \(code)"
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
private func generateSixCharCode() -> String {
|
||||
let chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
return String((0..<6).map { _ in chars.randomElement()! })
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Invite Confirm
|
||||
|
||||
struct InviteConfirmView: View {
|
||||
|
|
|
|||
|
|
@ -57,9 +57,9 @@ struct SettingsView: View {
|
|||
}
|
||||
|
||||
NavigationLink {
|
||||
EmailInviteView()
|
||||
CreateInviteView()
|
||||
} label: {
|
||||
Label("Invite Partner", systemImage: "envelope")
|
||||
Label("Invite Partner", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue