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).
|
- **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.
|
- **Answer history** — review past questions and answers, delete controls, and partner reveal support.
|
||||||
- **Discussion threads** — question-specific conversation threads and reactions for follow-up.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
All of this is free, forever. No credits, no daily limits that magically shrink after a week.
|
||||||
|
|
||||||
- Anonymous onboarding → email or Google sign-up
|
- 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)
|
- Daily question with full answer modes (text, scale, multiple choice, this-or-that)
|
||||||
- Private answer reveal flow once both partners have answered
|
- Private answer reveal flow once both partners have answered
|
||||||
- Question packs — 6,000+ bundled prompts across 22 categories (most packs included free)
|
- 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.onboarding.OnboardingScreen
|
||||||
import app.closer.ui.pairing.AcceptInviteScreen
|
import app.closer.ui.pairing.AcceptInviteScreen
|
||||||
import app.closer.ui.pairing.CreateInviteScreen
|
import app.closer.ui.pairing.CreateInviteScreen
|
||||||
import app.closer.ui.pairing.EmailInviteScreen
|
|
||||||
import app.closer.ui.pairing.InviteConfirmScreen
|
import app.closer.ui.pairing.InviteConfirmScreen
|
||||||
import app.closer.ui.pairing.PairPromptScreen
|
import app.closer.ui.pairing.PairPromptScreen
|
||||||
import app.closer.ui.pairing.RecoveryScreen
|
import app.closer.ui.pairing.RecoveryScreen
|
||||||
|
|
@ -275,9 +274,6 @@ fun AppNavigation(
|
||||||
onBack = { navController.popBackStack() }
|
onBack = { navController.popBackStack() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(route = AppRoute.EMAIL_INVITE) {
|
|
||||||
EmailInviteScreen(onNavigate = navigateRoute)
|
|
||||||
}
|
|
||||||
composable(route = AppRoute.ACCEPT_INVITE) {
|
composable(route = AppRoute.ACCEPT_INVITE) {
|
||||||
AcceptInviteScreen(
|
AcceptInviteScreen(
|
||||||
onNavigate = navigateRoute,
|
onNavigate = navigateRoute,
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ object AppRoute {
|
||||||
const val ANSWER_REVEAL = "answer_reveal/{questionId}"
|
const val ANSWER_REVEAL = "answer_reveal/{questionId}"
|
||||||
const val ANSWER_HISTORY = "answer_history"
|
const val ANSWER_HISTORY = "answer_history"
|
||||||
const val CREATE_INVITE = "create_invite"
|
const val CREATE_INVITE = "create_invite"
|
||||||
const val EMAIL_INVITE = "email_invite"
|
|
||||||
const val ACCEPT_INVITE = "accept_invite"
|
const val ACCEPT_INVITE = "accept_invite"
|
||||||
const val INVITE_CONFIRM = "invite_confirm/{inviteCode}"
|
const val INVITE_CONFIRM = "invite_confirm/{inviteCode}"
|
||||||
const val CATEGORY_PICKER = "category_picker"
|
const val CATEGORY_PICKER = "category_picker"
|
||||||
|
|
@ -82,7 +81,6 @@ object AppRoute {
|
||||||
Definition(ANSWER_REVEAL, "Reveal", "answers"),
|
Definition(ANSWER_REVEAL, "Reveal", "answers"),
|
||||||
Definition(ANSWER_HISTORY, "Answer History", "answers"),
|
Definition(ANSWER_HISTORY, "Answer History", "answers"),
|
||||||
Definition(CREATE_INVITE, "Create Invite", "pairing"),
|
Definition(CREATE_INVITE, "Create Invite", "pairing"),
|
||||||
Definition(EMAIL_INVITE, "Email Invite", "pairing"),
|
|
||||||
Definition(ACCEPT_INVITE, "Accept Invite", "pairing"),
|
Definition(ACCEPT_INVITE, "Accept Invite", "pairing"),
|
||||||
Definition(INVITE_CONFIRM, "Invite Confirm", "pairing"),
|
Definition(INVITE_CONFIRM, "Invite Confirm", "pairing"),
|
||||||
Definition(CATEGORY_PICKER, "Choose A Category", "wheel"),
|
Definition(CATEGORY_PICKER, "Choose A Category", "wheel"),
|
||||||
|
|
@ -135,7 +133,6 @@ object AppRoute {
|
||||||
|
|
||||||
val modalLikeRoutes = setOf(
|
val modalLikeRoutes = setOf(
|
||||||
CREATE_INVITE,
|
CREATE_INVITE,
|
||||||
EMAIL_INVITE,
|
|
||||||
ACCEPT_INVITE,
|
ACCEPT_INVITE,
|
||||||
INVITE_CONFIRM,
|
INVITE_CONFIRM,
|
||||||
PAYWALL,
|
PAYWALL,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package app.closer.data.remote
|
||||||
import app.closer.crypto.RecoveryKeyManager
|
import app.closer.crypto.RecoveryKeyManager
|
||||||
import app.closer.domain.model.Invite
|
import app.closer.domain.model.Invite
|
||||||
import com.google.firebase.firestore.FirebaseFirestore
|
import com.google.firebase.firestore.FirebaseFirestore
|
||||||
import com.google.firebase.Timestamp
|
|
||||||
import com.google.firebase.functions.FirebaseFunctions
|
import com.google.firebase.functions.FirebaseFunctions
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import kotlinx.coroutines.tasks.await
|
import kotlinx.coroutines.tasks.await
|
||||||
|
|
@ -24,82 +23,30 @@ class FirestoreInviteDataSource @Inject constructor(
|
||||||
.map { CODE_CHARS[Random.nextInt(CODE_CHARS.length)] }
|
.map { CODE_CHARS[Random.nextInt(CODE_CHARS.length)] }
|
||||||
.joinToString("")
|
.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(
|
suspend fun createInvite(
|
||||||
code: String,
|
code: String,
|
||||||
inviterUserId: String,
|
inviterUserId: String,
|
||||||
wrappedKey: RecoveryKeyManager.WrappedKey,
|
wrappedKey: RecoveryKeyManager.WrappedKey,
|
||||||
recoveryPhrase: String
|
recoveryPhrase: String
|
||||||
): CreateInviteResponse {
|
): CreateInviteResponse {
|
||||||
// Primary path: server-side callable.
|
@Suppress("DEPRECATION")
|
||||||
val callableResult = runCatching {
|
val result = functions.getHttpsCallable("createInviteCallable")
|
||||||
val result = functions.getHttpsCallable("createInviteCallable")
|
.call(
|
||||||
.call(
|
mapOf(
|
||||||
mapOf(
|
"wrappedCoupleKey" to wrappedKey.cipherB64,
|
||||||
"wrappedCoupleKey" to wrappedKey.cipherB64,
|
"kdfSalt" to wrappedKey.saltB64,
|
||||||
"kdfSalt" to wrappedKey.saltB64,
|
"kdfParams" to wrappedKey.params,
|
||||||
"kdfParams" to wrappedKey.params,
|
"recoveryPhrase" to recoveryPhrase
|
||||||
"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()
|
.await()
|
||||||
return CreateInviteResponse(code, Timestamp(now / 1000 + 24 * 60 * 60, 0))
|
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(
|
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.
|
* 6. Update both user documents with the new coupleId.
|
||||||
* 7. Mark the invite as accepted.
|
* 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) => {
|
exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
|
||||||
var _a, _b, _c;
|
var _a, _b, _c;
|
||||||
const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid;
|
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.');
|
throw new functions.https.HttpsError('invalid-argument', 'code is required.');
|
||||||
}
|
}
|
||||||
const db = admin.firestore();
|
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.
|
// Caller must not already be paired.
|
||||||
const callerDoc = await db.collection('users').doc(callerId).get();
|
const callerDoc = await db.collection('users').doc(callerId).get();
|
||||||
if (callerDoc.exists && ((_b = callerDoc.data()) === null || _b === void 0 ? void 0 : _b.coupleId) != null) {
|
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.
|
* 6. Update both user documents with the new coupleId.
|
||||||
* 7. Mark the invite as accepted.
|
* 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) => {
|
export const acceptInviteCallable = functions.https.onCall(async (data: any, context) => {
|
||||||
const callerId = context.auth?.uid
|
const callerId = context.auth?.uid
|
||||||
if (!callerId) {
|
if (!callerId) {
|
||||||
|
|
@ -38,6 +41,25 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con
|
||||||
|
|
||||||
const db = admin.firestore()
|
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.
|
// Caller must not already be paired.
|
||||||
const callerDoc = await db.collection('users').doc(callerId).get()
|
const callerDoc = await db.collection('users').doc(callerId).get()
|
||||||
if (callerDoc.exists && callerDoc.data()?.coupleId != null) {
|
if (callerDoc.exists && callerDoc.data()?.coupleId != null) {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import SwiftUI
|
||||||
struct PairPromptView: View {
|
struct PairPromptView: View {
|
||||||
@State private var showCreateInvite = false
|
@State private var showCreateInvite = false
|
||||||
@State private var showAcceptInvite = false
|
@State private var showAcceptInvite = false
|
||||||
@State private var showEmailInvite = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
|
|
@ -29,17 +28,12 @@ struct PairPromptView: View {
|
||||||
|
|
||||||
VStack(spacing: CloserSpacing.lg) {
|
VStack(spacing: CloserSpacing.lg) {
|
||||||
Button(action: { showCreateInvite = true }) {
|
Button(action: { showCreateInvite = true }) {
|
||||||
Label("Create Invite Code", systemImage: "plus.circle.fill")
|
Label("Invite my partner", systemImage: "plus.circle.fill")
|
||||||
}
|
}
|
||||||
.buttonStyle(PrimaryButtonStyle())
|
.buttonStyle(PrimaryButtonStyle())
|
||||||
|
|
||||||
Button(action: { showAcceptInvite = true }) {
|
Button(action: { showAcceptInvite = true }) {
|
||||||
Label("Enter Partner's Code", systemImage: "key.fill")
|
Label("Enter a code", systemImage: "key.fill")
|
||||||
}
|
|
||||||
.buttonStyle(SecondaryButtonStyle())
|
|
||||||
|
|
||||||
Button(action: { showEmailInvite = true }) {
|
|
||||||
Label("Invite by Email", systemImage: "envelope.fill")
|
|
||||||
}
|
}
|
||||||
.buttonStyle(SecondaryButtonStyle())
|
.buttonStyle(SecondaryButtonStyle())
|
||||||
}
|
}
|
||||||
|
|
@ -54,9 +48,6 @@ struct PairPromptView: View {
|
||||||
.navigationDestination(isPresented: $showAcceptInvite) {
|
.navigationDestination(isPresented: $showAcceptInvite) {
|
||||||
AcceptInviteView()
|
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
|
// MARK: - Invite Confirm
|
||||||
|
|
||||||
struct InviteConfirmView: View {
|
struct InviteConfirmView: View {
|
||||||
|
|
|
||||||
|
|
@ -57,9 +57,9 @@ struct SettingsView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
EmailInviteView()
|
CreateInviteView()
|
||||||
} label: {
|
} label: {
|
||||||
Label("Invite Partner", systemImage: "envelope")
|
Label("Invite Partner", systemImage: "square.and.arrow.up")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue