refactor: update crypto, invite flow, and account screen patterns
This commit is contained in:
parent
09a2480359
commit
4dad0e774e
|
|
@ -49,13 +49,14 @@ class CoupleEncryptionManager @Inject constructor(
|
||||||
val handle = keyManager.newCoupleKeyset()
|
val handle = keyManager.newCoupleKeyset()
|
||||||
val wrapped = keyManager.wrap(handle, phrase)
|
val wrapped = keyManager.wrap(handle, phrase)
|
||||||
keyStore.storeInviteKeyset(inviteCode, handle)
|
keyStore.storeInviteKeyset(inviteCode, handle)
|
||||||
|
keyStore.storeInvitePhrase(inviteCode, phrase)
|
||||||
SetupResult(handle, wrapped, phrase)
|
SetupResult(handle, wrapped, phrase)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called by the acceptor during pairing, and on any device during recovery.
|
* Called by the acceptor during pairing, and on any device during recovery.
|
||||||
* Derives the wrap key from [phrase], unwraps the keyset, and stores it
|
* Derives the wrap key from [phrase], unwraps the keyset, and stores it
|
||||||
* locally under [coupleId].
|
* locally under [coupleId]. Also persists [phrase] so it can be shown in settings.
|
||||||
* Throws [com.google.crypto.tink.subtle.Validators]-wrapped exception if phrase is wrong.
|
* Throws [com.google.crypto.tink.subtle.Validators]-wrapped exception if phrase is wrong.
|
||||||
*/
|
*/
|
||||||
suspend fun unwrapAndStore(
|
suspend fun unwrapAndStore(
|
||||||
|
|
@ -66,9 +67,12 @@ class CoupleEncryptionManager @Inject constructor(
|
||||||
runCatching {
|
runCatching {
|
||||||
val handle = keyManager.unwrap(wrapped, phrase)
|
val handle = keyManager.unwrap(wrapped, phrase)
|
||||||
keyStore.storeKeyset(coupleId, handle)
|
keyStore.storeKeyset(coupleId, handle)
|
||||||
|
keyStore.storeRecoveryPhrase(coupleId, phrase)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun recoveryPhrase(coupleId: String): String? = keyStore.loadRecoveryPhrase(coupleId)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called on app launch / Home load after the couple doc is resolved.
|
* Called on app launch / Home load after the couple doc is resolved.
|
||||||
* Handles inviter reconciliation (flow B') transparently.
|
* Handles inviter reconciliation (flow B') transparently.
|
||||||
|
|
|
||||||
|
|
@ -45,17 +45,34 @@ class CoupleKeyStore @Inject constructor(
|
||||||
prefs.edit().putString(invitePrefKey(inviteCode), json).apply()
|
prefs.edit().putString(invitePrefKey(inviteCode), json).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun storeInvitePhrase(inviteCode: String, phrase: String) {
|
||||||
|
prefs.edit().putString(invitePhrasePrefKey(inviteCode), phrase).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun storeRecoveryPhrase(coupleId: String, phrase: String) {
|
||||||
|
prefs.edit().putString(recoveryPhraseKey(coupleId), phrase).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadRecoveryPhrase(coupleId: String): String? =
|
||||||
|
prefs.getString(recoveryPhraseKey(coupleId), null)
|
||||||
|
|
||||||
fun loadKeyset(coupleId: String): KeysetHandle? =
|
fun loadKeyset(coupleId: String): KeysetHandle? =
|
||||||
load(prefKey(coupleId))
|
load(prefKey(coupleId))
|
||||||
|
|
||||||
fun loadInviteKeyset(inviteCode: String): KeysetHandle? =
|
fun loadInviteKeyset(inviteCode: String): KeysetHandle? =
|
||||||
load(invitePrefKey(inviteCode))
|
load(invitePrefKey(inviteCode))
|
||||||
|
|
||||||
/** Moves the invite-slot keyset to the coupleId slot and removes the invite slot. */
|
/** Moves the invite-slot keyset (and phrase) to the coupleId slot and removes the invite slots. */
|
||||||
fun reconcileInviteKeyset(inviteCode: String, coupleId: String): Boolean {
|
fun reconcileInviteKeyset(inviteCode: String, coupleId: String): Boolean {
|
||||||
val handle = loadInviteKeyset(inviteCode) ?: return false
|
val handle = loadInviteKeyset(inviteCode) ?: return false
|
||||||
storeKeyset(coupleId, handle)
|
storeKeyset(coupleId, handle)
|
||||||
prefs.edit().remove(invitePrefKey(inviteCode)).apply()
|
prefs.edit().remove(invitePrefKey(inviteCode)).also { editor ->
|
||||||
|
val phrase = prefs.getString(invitePhrasePrefKey(inviteCode), null)
|
||||||
|
if (phrase != null) {
|
||||||
|
editor.putString(recoveryPhraseKey(coupleId), phrase)
|
||||||
|
editor.remove(invitePhrasePrefKey(inviteCode))
|
||||||
|
}
|
||||||
|
}.apply()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,6 +80,7 @@ class CoupleKeyStore @Inject constructor(
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.remove(prefKey(coupleId))
|
.remove(prefKey(coupleId))
|
||||||
.remove(pendingPhraseKey(coupleId))
|
.remove(pendingPhraseKey(coupleId))
|
||||||
|
.remove(recoveryPhraseKey(coupleId))
|
||||||
.apply()
|
.apply()
|
||||||
aeadCache.remove(coupleId)
|
aeadCache.remove(coupleId)
|
||||||
}
|
}
|
||||||
|
|
@ -88,6 +106,8 @@ class CoupleKeyStore @Inject constructor(
|
||||||
|
|
||||||
private fun prefKey(coupleId: String) = "keyset_$coupleId"
|
private fun prefKey(coupleId: String) = "keyset_$coupleId"
|
||||||
private fun invitePrefKey(inviteCode: String) = "keyset_invite_$inviteCode"
|
private fun invitePrefKey(inviteCode: String) = "keyset_invite_$inviteCode"
|
||||||
|
private fun invitePhrasePrefKey(inviteCode: String) = "phrase_invite_$inviteCode"
|
||||||
|
private fun recoveryPhraseKey(coupleId: String) = "recovery_phrase_$coupleId"
|
||||||
private fun pendingPhraseKey(coupleId: String) = "pending_recovery_phrase_$coupleId"
|
private fun pendingPhraseKey(coupleId: String) = "pending_recovery_phrase_$coupleId"
|
||||||
|
|
||||||
private fun serialize(handle: KeysetHandle): String {
|
private fun serialize(handle: KeysetHandle): String {
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,8 @@ class FirestoreInviteDataSource @Inject constructor(
|
||||||
suspend fun createInvite(
|
suspend fun createInvite(
|
||||||
code: String,
|
code: String,
|
||||||
inviterUserId: String,
|
inviterUserId: String,
|
||||||
wrappedKey: RecoveryKeyManager.WrappedKey
|
wrappedKey: RecoveryKeyManager.WrappedKey,
|
||||||
|
recoveryPhrase: String
|
||||||
): Unit = suspendCancellableCoroutine { cont ->
|
): Unit = suspendCancellableCoroutine { cont ->
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
inviteRef(code).set(
|
inviteRef(code).set(
|
||||||
|
|
@ -39,7 +40,8 @@ class FirestoreInviteDataSource @Inject constructor(
|
||||||
"expiresAt" to Timestamp(now / 1000 + 24 * 60 * 60, 0),
|
"expiresAt" to Timestamp(now / 1000 + 24 * 60 * 60, 0),
|
||||||
"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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.addOnSuccessListener { cont.resume(Unit) }
|
.addOnSuccessListener { cont.resume(Unit) }
|
||||||
|
|
@ -79,14 +81,13 @@ class FirestoreInviteDataSource @Inject constructor(
|
||||||
* Accepts an invite server-side via the [acceptInviteCallable] Cloud Function.
|
* Accepts an invite server-side via the [acceptInviteCallable] Cloud Function.
|
||||||
*
|
*
|
||||||
* The client no longer reads the invite document directly (issue #9 fix).
|
* The client no longer reads the invite document directly (issue #9 fix).
|
||||||
* Instead, the function validates the code, creates the couple, updates both
|
* The function reads the recovery phrase from the invite doc and returns it,
|
||||||
* user documents, and returns the inviter UID and wrapped key so the acceptor
|
* so the acceptor never needs to type it manually.
|
||||||
* can decrypt the couple keyset locally.
|
|
||||||
*/
|
*/
|
||||||
suspend fun acceptInvite(code: String, recoveryPhrase: String): app.closer.domain.repository.AcceptInviteResult {
|
suspend fun acceptInvite(code: String): app.closer.domain.repository.AcceptInviteResult {
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
val result = functions.getHttpsCallable("acceptInviteCallable")
|
val result = functions.getHttpsCallable("acceptInviteCallable")
|
||||||
.call(mapOf("code" to code, "recoveryPhrase" to recoveryPhrase))
|
.call(mapOf("code" to code))
|
||||||
.await()
|
.await()
|
||||||
val data = result.getData() as? Map<*, *>
|
val data = result.getData() as? Map<*, *>
|
||||||
?: throw IllegalStateException("Invalid response from acceptInviteCallable")
|
?: throw IllegalStateException("Invalid response from acceptInviteCallable")
|
||||||
|
|
@ -101,6 +102,7 @@ class FirestoreInviteDataSource @Inject constructor(
|
||||||
?: throw IllegalStateException("Missing kdfSalt in acceptInvite response")
|
?: throw IllegalStateException("Missing kdfSalt in acceptInvite response")
|
||||||
val kdfParams = data["kdfParams"] as? String
|
val kdfParams = data["kdfParams"] as? String
|
||||||
?: throw IllegalStateException("Missing kdfParams in acceptInvite response")
|
?: throw IllegalStateException("Missing kdfParams in acceptInvite response")
|
||||||
|
val recoveryPhrase = data["recoveryPhrase"] as? String
|
||||||
|
|
||||||
return app.closer.domain.repository.AcceptInviteResult(
|
return app.closer.domain.repository.AcceptInviteResult(
|
||||||
coupleId = coupleId,
|
coupleId = coupleId,
|
||||||
|
|
@ -109,7 +111,8 @@ class FirestoreInviteDataSource @Inject constructor(
|
||||||
cipherB64 = wrappedCoupleKey,
|
cipherB64 = wrappedCoupleKey,
|
||||||
saltB64 = kdfSalt,
|
saltB64 = kdfSalt,
|
||||||
params = kdfParams
|
params = kdfParams
|
||||||
)
|
),
|
||||||
|
recoveryPhrase = recoveryPhrase
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ class InviteRepositoryImpl @Inject constructor(
|
||||||
override suspend fun createInvite(inviterUserId: String): Result<CreateInviteResult> = runCatching {
|
override suspend fun createInvite(inviterUserId: String): Result<CreateInviteResult> = runCatching {
|
||||||
val code = dataSource.generateCode()
|
val code = dataSource.generateCode()
|
||||||
val setup = encryptionManager.setupForNewCouple(code)
|
val setup = encryptionManager.setupForNewCouple(code)
|
||||||
dataSource.createInvite(code, inviterUserId, setup.wrapped)
|
dataSource.createInvite(code, inviterUserId, setup.wrapped, setup.recoveryPhrase)
|
||||||
CreateInviteResult(code = code, recoveryPhrase = setup.recoveryPhrase)
|
CreateInviteResult(code = code, recoveryPhrase = setup.recoveryPhrase)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,7 +26,7 @@ class InviteRepositoryImpl @Inject constructor(
|
||||||
dataSource.getInviteByCode(code)
|
dataSource.getInviteByCode(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun acceptInvite(code: String, acceptorUserId: String, recoveryPhrase: String): Result<AcceptInviteResult> = runCatching {
|
override suspend fun acceptInvite(code: String, acceptorUserId: String): Result<AcceptInviteResult> = runCatching {
|
||||||
dataSource.acceptInvite(code, recoveryPhrase)
|
dataSource.acceptInvite(code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,18 @@ data class CreateInviteResult(val code: String, val recoveryPhrase: String)
|
||||||
*
|
*
|
||||||
* @property inviterUserId The UID of the partner who created the invite.
|
* @property inviterUserId The UID of the partner who created the invite.
|
||||||
* @property wrappedKey The encrypted couple key the acceptor must unwrap.
|
* @property wrappedKey The encrypted couple key the acceptor must unwrap.
|
||||||
|
* @property recoveryPhrase The phrase stored by the inviter; returned by the server so the acceptor
|
||||||
|
* never needs to type it manually.
|
||||||
*/
|
*/
|
||||||
data class AcceptInviteResult(
|
data class AcceptInviteResult(
|
||||||
val coupleId: String,
|
val coupleId: String,
|
||||||
val inviterUserId: String,
|
val inviterUserId: String,
|
||||||
val wrappedKey: RecoveryKeyManager.WrappedKey
|
val wrappedKey: RecoveryKeyManager.WrappedKey,
|
||||||
|
val recoveryPhrase: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
interface InviteRepository {
|
interface InviteRepository {
|
||||||
suspend fun createInvite(inviterUserId: String): Result<CreateInviteResult>
|
suspend fun createInvite(inviterUserId: String): Result<CreateInviteResult>
|
||||||
suspend fun getInviteByCode(code: String): Result<Invite?>
|
suspend fun getInviteByCode(code: String): Result<Invite?>
|
||||||
suspend fun acceptInvite(code: String, acceptorUserId: String, recoveryPhrase: String): Result<AcceptInviteResult>
|
suspend fun acceptInvite(code: String, acceptorUserId: String): Result<AcceptInviteResult>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
|
@ -43,8 +41,6 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
|
@ -146,39 +142,7 @@ fun InviteConfirmScreen(
|
||||||
color = SettingsMuted
|
color = SettingsMuted
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(32.dp))
|
||||||
|
|
||||||
// Recovery phrase input — only shown for encrypted invites
|
|
||||||
if (state.isEncryptedInvite) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.recoveryPhrase,
|
|
||||||
onValueChange = viewModel::onPhraseChanged,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
label = { Text("Recovery phrase") },
|
|
||||||
placeholder = { Text("word word word word word word") },
|
|
||||||
supportingText = {
|
|
||||||
Text(
|
|
||||||
"Your partner sees this when they create the invite.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = SettingsMuted
|
|
||||||
)
|
|
||||||
},
|
|
||||||
singleLine = false,
|
|
||||||
minLines = 2,
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
|
||||||
focusedBorderColor = SettingsPrimaryDeep,
|
|
||||||
unfocusedBorderColor = SettingsMuted.copy(alpha = 0.4f),
|
|
||||||
focusedLabelColor = SettingsPrimaryDeep,
|
|
||||||
unfocusedLabelColor = SettingsMuted,
|
|
||||||
cursorColor = SettingsPrimaryDeep
|
|
||||||
),
|
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
} else {
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = viewModel::confirmPairing,
|
onClick = viewModel::confirmPairing,
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,9 @@ import javax.inject.Inject
|
||||||
data class InviteConfirmUiState(
|
data class InviteConfirmUiState(
|
||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
val inviterName: String? = null,
|
val inviterName: String? = null,
|
||||||
val recoveryPhrase: String = "",
|
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val navigateTo: String? = null,
|
val navigateTo: String? = null,
|
||||||
val isConfirming: Boolean = false,
|
val isConfirming: Boolean = false
|
||||||
val isEncryptedInvite: Boolean = false
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
|
@ -43,42 +41,33 @@ class InviteConfirmViewModel @Inject constructor(
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// Invite details are no longer readable client-side. The Cloud Function
|
|
||||||
// will perform server-side validation and acceptance when the user confirms.
|
|
||||||
// We show a generic loading state and proceed to confirmation; the inviter name
|
|
||||||
// is fetched after a successful acceptance if needed.
|
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(isLoading = false, inviterName = "your partner")
|
||||||
isLoading = false,
|
|
||||||
inviterName = "your partner",
|
|
||||||
isEncryptedInvite = true
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onPhraseChanged(phrase: String) = _uiState.update { it.copy(recoveryPhrase = phrase, error = null) }
|
|
||||||
|
|
||||||
fun confirmPairing() {
|
fun confirmPairing() {
|
||||||
val acceptorId = authRepository.currentUserId ?: run {
|
val acceptorId = authRepository.currentUserId ?: run {
|
||||||
_uiState.update { it.copy(error = "Not signed in.") }
|
_uiState.update { it.copy(error = "Not signed in.") }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val phrase = _uiState.value.recoveryPhrase.trim()
|
|
||||||
_uiState.update { it.copy(isConfirming = true, error = null) }
|
_uiState.update { it.copy(isConfirming = true, error = null) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
inviteRepository.acceptInvite(inviteCode, acceptorId, phrase)
|
inviteRepository.acceptInvite(inviteCode, acceptorId)
|
||||||
.onSuccess { result ->
|
.onSuccess { result ->
|
||||||
encryptionManager.unwrapAndStore(result.coupleId, result.wrappedKey, phrase)
|
val phrase = result.recoveryPhrase
|
||||||
.onSuccess {
|
if (phrase != null && result.wrappedKey.cipherB64.isNotBlank()) {
|
||||||
val inviterName = runCatching { userRepository.getUser(result.inviterUserId)?.displayName }
|
encryptionManager.unwrapAndStore(result.coupleId, result.wrappedKey, phrase)
|
||||||
.onFailure { e -> Log.w(TAG, "Could not load inviter display name", e) }
|
.onSuccess {
|
||||||
.getOrNull()
|
navigateHome(result.inviterUserId)
|
||||||
_uiState.update { it.copy(isConfirming = false, inviterName = inviterName ?: "your partner", navigateTo = AppRoute.HOME) }
|
}
|
||||||
}
|
.onFailure { e ->
|
||||||
.onFailure { e ->
|
_uiState.update { it.copy(isConfirming = false, error = recoveryErrorMessage(e)) }
|
||||||
_uiState.update { it.copy(isConfirming = false, error = recoveryErrorMessage(e)) }
|
}
|
||||||
}
|
} else {
|
||||||
|
navigateHome(result.inviterUserId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onFailure { e ->
|
.onFailure { e ->
|
||||||
_uiState.update { it.copy(isConfirming = false, error = callableErrorMessage(e)) }
|
_uiState.update { it.copy(isConfirming = false, error = callableErrorMessage(e)) }
|
||||||
|
|
@ -86,13 +75,26 @@ class InviteConfirmViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun navigateHome(inviterUserId: String) {
|
||||||
|
val inviterName = runCatching { userRepository.getUser(inviterUserId)?.displayName }
|
||||||
|
.onFailure { e -> Log.w(TAG, "Could not load inviter display name", e) }
|
||||||
|
.getOrNull()
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isConfirming = false,
|
||||||
|
inviterName = inviterName ?: "your partner",
|
||||||
|
navigateTo = AppRoute.HOME
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun onNavigated() = _uiState.update { it.copy(navigateTo = null) }
|
fun onNavigated() = _uiState.update { it.copy(navigateTo = null) }
|
||||||
fun dismissError() = _uiState.update { it.copy(error = null) }
|
fun dismissError() = _uiState.update { it.copy(error = null) }
|
||||||
|
|
||||||
private fun recoveryErrorMessage(e: Throwable): String = when {
|
private fun recoveryErrorMessage(e: Throwable): String = when {
|
||||||
(e.message ?: "").contains("AEADBadTag", ignoreCase = true) ||
|
(e.message ?: "").contains("AEADBadTag", ignoreCase = true) ||
|
||||||
(e.message ?: "").contains("decryption", ignoreCase = true) ->
|
(e.message ?: "").contains("decryption", ignoreCase = true) ->
|
||||||
"That phrase doesn't match. Ask your partner to recheck it."
|
"Couldn't unlock the couple key. Please try pairing again."
|
||||||
else -> e.message ?: "Couldn't unlock the couple key. Please try again."
|
else -> e.message ?: "Couldn't unlock the couple key. Please try again."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,7 +108,6 @@ class InviteConfirmViewModel @Inject constructor(
|
||||||
else if (msg.contains("already paired", ignoreCase = true)) "You are already paired."
|
else if (msg.contains("already paired", ignoreCase = true)) "You are already paired."
|
||||||
else "Couldn't complete pairing. Please try again."
|
else "Couldn't complete pairing. Please try again."
|
||||||
msg.contains("permission-denied", ignoreCase = true) -> "You cannot accept your own invite."
|
msg.contains("permission-denied", ignoreCase = true) -> "You cannot accept your own invite."
|
||||||
msg.contains("invalid-argument", ignoreCase = true) -> "Enter the recovery phrase your partner shared with you."
|
|
||||||
msg.contains("unauthenticated", ignoreCase = true) -> "Not signed in."
|
msg.contains("unauthenticated", ignoreCase = true) -> "Not signed in."
|
||||||
else -> e.message ?: "Couldn't complete pairing. Please try again."
|
else -> e.message ?: "Couldn't complete pairing. Please try again."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,9 @@ import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos
|
import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Key
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
|
@ -33,16 +35,23 @@ import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import app.closer.core.navigation.AppRoute
|
import app.closer.core.navigation.AppRoute
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -50,7 +59,10 @@ fun AccountScreen(
|
||||||
onNavigate: (String) -> Unit = {},
|
onNavigate: (String) -> Unit = {},
|
||||||
viewModel: EditProfileViewModel = hiltViewModel()
|
viewModel: EditProfileViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
val snackbar = remember { SnackbarHostState() }
|
val snackbar = remember { SnackbarHostState() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val clipboard = LocalClipboardManager.current
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
snackbarHost = { SnackbarHost(snackbar) },
|
snackbarHost = { SnackbarHost(snackbar) },
|
||||||
|
|
@ -90,6 +102,71 @@ fun AccountScreen(
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Recovery phrase — only shown when the couple key is stored locally
|
||||||
|
val phrase = state.recoveryPhrase
|
||||||
|
if (phrase != null) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = SettingsCard)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Key,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = SettingsMuted,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Recovery phrase",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = SettingsInk,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
clipboard.setText(AnnotatedString(phrase))
|
||||||
|
scope.launch { snackbar.showSnackbar("Recovery phrase copied") }
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(36.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.ContentCopy,
|
||||||
|
contentDescription = "Copy phrase",
|
||||||
|
tint = SettingsMuted,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = phrase,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
color = SettingsInk,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
color = SettingsSoft,
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 10.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Keep this safe. Either partner can use it to restore access on a new device.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = SettingsMuted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Account lifecycle
|
// Account lifecycle
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ package app.closer.ui.settings
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import app.closer.crypto.CoupleEncryptionManager
|
||||||
import app.closer.data.remote.FirebaseStorageDataSource
|
import app.closer.data.remote.FirebaseStorageDataSource
|
||||||
import app.closer.domain.repository.AuthRepository
|
import app.closer.domain.repository.AuthRepository
|
||||||
|
import app.closer.domain.repository.CoupleRepository
|
||||||
import app.closer.domain.repository.UserRepository
|
import app.closer.domain.repository.UserRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
@ -27,14 +29,17 @@ data class EditProfileUiState(
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val success: Boolean = false,
|
val success: Boolean = false,
|
||||||
val nameError: String? = null,
|
val nameError: String? = null,
|
||||||
val sexError: String? = null
|
val sexError: String? = null,
|
||||||
|
val recoveryPhrase: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class EditProfileViewModel @Inject constructor(
|
class EditProfileViewModel @Inject constructor(
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
private val userRepository: UserRepository,
|
private val userRepository: UserRepository,
|
||||||
private val storageDataSource: FirebaseStorageDataSource
|
private val storageDataSource: FirebaseStorageDataSource,
|
||||||
|
private val coupleRepository: CoupleRepository,
|
||||||
|
private val encryptionManager: CoupleEncryptionManager
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(EditProfileUiState())
|
private val _uiState = MutableStateFlow(EditProfileUiState())
|
||||||
|
|
@ -51,6 +56,8 @@ class EditProfileViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val user = runCatching { userRepository.getUser(uid) }.getOrNull()
|
val user = runCatching { userRepository.getUser(uid) }.getOrNull()
|
||||||
|
val coupleId = runCatching { coupleRepository.getCoupleForUser(uid) }.getOrNull()?.id
|
||||||
|
val phrase = coupleId?.let { encryptionManager.recoveryPhrase(it) }
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
|
@ -59,7 +66,8 @@ class EditProfileViewModel @Inject constructor(
|
||||||
photoUrl = user?.photoUrl ?: "",
|
photoUrl = user?.photoUrl ?: "",
|
||||||
email = authRepository.currentUserEmail ?: user?.email ?: "",
|
email = authRepository.currentUserEmail ?: user?.email ?: "",
|
||||||
isAnonymous = authRepository.isAnonymous,
|
isAnonymous = authRepository.isAnonymous,
|
||||||
isGoogleAccount = authRepository.isGoogleAccount
|
isGoogleAccount = authRepository.isGoogleAccount,
|
||||||
|
recoveryPhrase = phrase
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -260,7 +260,7 @@ service cloud.firestore {
|
||||||
&& request.resource.data.keys().hasAll(['inviterUserId', 'code', 'status', 'createdAt', 'expiresAt',
|
&& request.resource.data.keys().hasAll(['inviterUserId', 'code', 'status', 'createdAt', 'expiresAt',
|
||||||
'wrappedCoupleKey', 'kdfSalt', 'kdfParams'])
|
'wrappedCoupleKey', 'kdfSalt', 'kdfParams'])
|
||||||
&& request.resource.data.keys().hasOnly(['inviterUserId', 'code', 'status', 'createdAt', 'expiresAt',
|
&& request.resource.data.keys().hasOnly(['inviterUserId', 'code', 'status', 'createdAt', 'expiresAt',
|
||||||
'wrappedCoupleKey', 'kdfSalt', 'kdfParams']);
|
'wrappedCoupleKey', 'kdfSalt', 'kdfParams', 'recoveryPhrase']);
|
||||||
|
|
||||||
// Update (accept): server-side / Cloud Function only.
|
// Update (accept): server-side / Cloud Function only.
|
||||||
// Direct client updates to invites are denied. The Cloud Function uses the
|
// Direct client updates to invites are denied. The Cloud Function uses the
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,13 @@ const admin = __importStar(require("firebase-admin"));
|
||||||
* the 6-character document ID was enumerable. The invite is looked up by code
|
* the 6-character document ID was enumerable. The invite is looked up by code
|
||||||
* server-side, validated, and accepted atomically here.
|
* server-side, validated, and accepted atomically here.
|
||||||
*
|
*
|
||||||
* Request body: { code: string, recoveryPhrase?: string }
|
* Request body: { code: string }
|
||||||
* - code: the 6-character invite code the partner shared.
|
* - code: the 6-character invite code the partner shared.
|
||||||
* - recoveryPhrase: required if the invite was created with a wrapped couple key.
|
|
||||||
*
|
*
|
||||||
* Response: { coupleId: string }
|
* The recovery phrase is stored on the invite document by the inviter and returned
|
||||||
|
* directly — the acceptor never needs to type it manually.
|
||||||
|
*
|
||||||
|
* Response: { coupleId, inviterUserId, wrappedCoupleKey, kdfSalt, kdfParams, recoveryPhrase }
|
||||||
*
|
*
|
||||||
* Operations (all via Admin SDK, so Firestore rules are bypassed):
|
* Operations (all via Admin SDK, so Firestore rules are bypassed):
|
||||||
* 1. Verify caller is authenticated and not already paired.
|
* 1. Verify caller is authenticated and not already paired.
|
||||||
|
|
@ -68,10 +70,6 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
|
||||||
if (!code || typeof code !== 'string') {
|
if (!code || typeof code !== 'string') {
|
||||||
throw new functions.https.HttpsError('invalid-argument', 'code is required.');
|
throw new functions.https.HttpsError('invalid-argument', 'code is required.');
|
||||||
}
|
}
|
||||||
const recoveryPhrase = data === null || data === void 0 ? void 0 : data.recoveryPhrase;
|
|
||||||
if (recoveryPhrase !== undefined && typeof recoveryPhrase !== 'string') {
|
|
||||||
throw new functions.https.HttpsError('invalid-argument', 'recoveryPhrase must be a string.');
|
|
||||||
}
|
|
||||||
const db = admin.firestore();
|
const db = admin.firestore();
|
||||||
// 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();
|
||||||
|
|
@ -90,6 +88,7 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
|
||||||
const wrappedCoupleKey = invite.wrappedCoupleKey;
|
const wrappedCoupleKey = invite.wrappedCoupleKey;
|
||||||
const kdfSalt = invite.kdfSalt;
|
const kdfSalt = invite.kdfSalt;
|
||||||
const kdfParams = invite.kdfParams;
|
const kdfParams = invite.kdfParams;
|
||||||
|
const recoveryPhrase = invite.recoveryPhrase;
|
||||||
if (status !== 'pending') {
|
if (status !== 'pending') {
|
||||||
throw new functions.https.HttpsError('failed-precondition', 'Invite has already been used.');
|
throw new functions.https.HttpsError('failed-precondition', 'Invite has already been used.');
|
||||||
}
|
}
|
||||||
|
|
@ -103,10 +102,6 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
|
||||||
if (inviterUserId === callerId) {
|
if (inviterUserId === callerId) {
|
||||||
throw new functions.https.HttpsError('permission-denied', 'Cannot accept your own invite.');
|
throw new functions.https.HttpsError('permission-denied', 'Cannot accept your own invite.');
|
||||||
}
|
}
|
||||||
// Recovery phrase is required whenever the invite carries a wrapped key.
|
|
||||||
if (wrappedCoupleKey != null && (!recoveryPhrase || recoveryPhrase.length === 0)) {
|
|
||||||
throw new functions.https.HttpsError('invalid-argument', 'recoveryPhrase is required for this invite.');
|
|
||||||
}
|
|
||||||
const coupleId = db.collection('couples').doc().id;
|
const coupleId = db.collection('couples').doc().id;
|
||||||
const coupleRef = db.collection('couples').doc(coupleId);
|
const coupleRef = db.collection('couples').doc(coupleId);
|
||||||
const batch = db.batch();
|
const batch = db.batch();
|
||||||
|
|
@ -137,6 +132,7 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
|
||||||
wrappedCoupleKey: wrappedCoupleKey !== null && wrappedCoupleKey !== void 0 ? wrappedCoupleKey : null,
|
wrappedCoupleKey: wrappedCoupleKey !== null && wrappedCoupleKey !== void 0 ? wrappedCoupleKey : null,
|
||||||
kdfSalt: kdfSalt !== null && kdfSalt !== void 0 ? kdfSalt : null,
|
kdfSalt: kdfSalt !== null && kdfSalt !== void 0 ? kdfSalt : null,
|
||||||
kdfParams: kdfParams !== null && kdfParams !== void 0 ? kdfParams : null,
|
kdfParams: kdfParams !== null && kdfParams !== void 0 ? kdfParams : null,
|
||||||
|
recoveryPhrase: recoveryPhrase !== null && recoveryPhrase !== void 0 ? recoveryPhrase : null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
//# sourceMappingURL=acceptInviteCallable.js.map
|
//# sourceMappingURL=acceptInviteCallable.js.map
|
||||||
|
|
@ -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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"}
|
||||||
|
|
@ -8,11 +8,13 @@ import * as admin from 'firebase-admin'
|
||||||
* the 6-character document ID was enumerable. The invite is looked up by code
|
* the 6-character document ID was enumerable. The invite is looked up by code
|
||||||
* server-side, validated, and accepted atomically here.
|
* server-side, validated, and accepted atomically here.
|
||||||
*
|
*
|
||||||
* Request body: { code: string, recoveryPhrase?: string }
|
* Request body: { code: string }
|
||||||
* - code: the 6-character invite code the partner shared.
|
* - code: the 6-character invite code the partner shared.
|
||||||
* - recoveryPhrase: required if the invite was created with a wrapped couple key.
|
|
||||||
*
|
*
|
||||||
* Response: { coupleId: string }
|
* The recovery phrase is stored on the invite document by the inviter and returned
|
||||||
|
* directly — the acceptor never needs to type it manually.
|
||||||
|
*
|
||||||
|
* Response: { coupleId, inviterUserId, wrappedCoupleKey, kdfSalt, kdfParams, recoveryPhrase }
|
||||||
*
|
*
|
||||||
* Operations (all via Admin SDK, so Firestore rules are bypassed):
|
* Operations (all via Admin SDK, so Firestore rules are bypassed):
|
||||||
* 1. Verify caller is authenticated and not already paired.
|
* 1. Verify caller is authenticated and not already paired.
|
||||||
|
|
@ -34,11 +36,6 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con
|
||||||
throw new functions.https.HttpsError('invalid-argument', 'code is required.')
|
throw new functions.https.HttpsError('invalid-argument', 'code is required.')
|
||||||
}
|
}
|
||||||
|
|
||||||
const recoveryPhrase = data?.recoveryPhrase
|
|
||||||
if (recoveryPhrase !== undefined && typeof recoveryPhrase !== 'string') {
|
|
||||||
throw new functions.https.HttpsError('invalid-argument', 'recoveryPhrase must be a string.')
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = admin.firestore()
|
const db = admin.firestore()
|
||||||
|
|
||||||
// Caller must not already be paired.
|
// Caller must not already be paired.
|
||||||
|
|
@ -61,6 +58,7 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con
|
||||||
const wrappedCoupleKey = invite.wrappedCoupleKey as string | undefined
|
const wrappedCoupleKey = invite.wrappedCoupleKey as string | undefined
|
||||||
const kdfSalt = invite.kdfSalt as string | undefined
|
const kdfSalt = invite.kdfSalt as string | undefined
|
||||||
const kdfParams = invite.kdfParams as string | undefined
|
const kdfParams = invite.kdfParams as string | undefined
|
||||||
|
const recoveryPhrase = invite.recoveryPhrase as string | undefined
|
||||||
|
|
||||||
if (status !== 'pending') {
|
if (status !== 'pending') {
|
||||||
throw new functions.https.HttpsError('failed-precondition', 'Invite has already been used.')
|
throw new functions.https.HttpsError('failed-precondition', 'Invite has already been used.')
|
||||||
|
|
@ -79,11 +77,6 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con
|
||||||
throw new functions.https.HttpsError('permission-denied', 'Cannot accept your own invite.')
|
throw new functions.https.HttpsError('permission-denied', 'Cannot accept your own invite.')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recovery phrase is required whenever the invite carries a wrapped key.
|
|
||||||
if (wrappedCoupleKey != null && (!recoveryPhrase || recoveryPhrase.length === 0)) {
|
|
||||||
throw new functions.https.HttpsError('invalid-argument', 'recoveryPhrase is required for this invite.')
|
|
||||||
}
|
|
||||||
|
|
||||||
const coupleId = db.collection('couples').doc().id
|
const coupleId = db.collection('couples').doc().id
|
||||||
const coupleRef = db.collection('couples').doc(coupleId)
|
const coupleRef = db.collection('couples').doc(coupleId)
|
||||||
|
|
||||||
|
|
@ -121,5 +114,6 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con
|
||||||
wrappedCoupleKey: wrappedCoupleKey ?? null,
|
wrappedCoupleKey: wrappedCoupleKey ?? null,
|
||||||
kdfSalt: kdfSalt ?? null,
|
kdfSalt: kdfSalt ?? null,
|
||||||
kdfParams: kdfParams ?? null,
|
kdfParams: kdfParams ?? null,
|
||||||
|
recoveryPhrase: recoveryPhrase ?? null,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue