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 wrapped = keyManager.wrap(handle, phrase)
|
||||
keyStore.storeInviteKeyset(inviteCode, handle)
|
||||
keyStore.storeInvitePhrase(inviteCode, phrase)
|
||||
SetupResult(handle, wrapped, phrase)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the acceptor during pairing, and on any device during recovery.
|
||||
* 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.
|
||||
*/
|
||||
suspend fun unwrapAndStore(
|
||||
|
|
@ -66,9 +67,12 @@ class CoupleEncryptionManager @Inject constructor(
|
|||
runCatching {
|
||||
val handle = keyManager.unwrap(wrapped, phrase)
|
||||
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.
|
||||
* Handles inviter reconciliation (flow B') transparently.
|
||||
|
|
|
|||
|
|
@ -45,17 +45,34 @@ class CoupleKeyStore @Inject constructor(
|
|||
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? =
|
||||
load(prefKey(coupleId))
|
||||
|
||||
fun loadInviteKeyset(inviteCode: String): KeysetHandle? =
|
||||
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 {
|
||||
val handle = loadInviteKeyset(inviteCode) ?: return false
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -63,6 +80,7 @@ class CoupleKeyStore @Inject constructor(
|
|||
prefs.edit()
|
||||
.remove(prefKey(coupleId))
|
||||
.remove(pendingPhraseKey(coupleId))
|
||||
.remove(recoveryPhraseKey(coupleId))
|
||||
.apply()
|
||||
aeadCache.remove(coupleId)
|
||||
}
|
||||
|
|
@ -88,6 +106,8 @@ class CoupleKeyStore @Inject constructor(
|
|||
|
||||
private fun prefKey(coupleId: String) = "keyset_$coupleId"
|
||||
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 serialize(handle: KeysetHandle): String {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ class FirestoreInviteDataSource @Inject constructor(
|
|||
suspend fun createInvite(
|
||||
code: String,
|
||||
inviterUserId: String,
|
||||
wrappedKey: RecoveryKeyManager.WrappedKey
|
||||
wrappedKey: RecoveryKeyManager.WrappedKey,
|
||||
recoveryPhrase: String
|
||||
): Unit = suspendCancellableCoroutine { cont ->
|
||||
val now = System.currentTimeMillis()
|
||||
inviteRef(code).set(
|
||||
|
|
@ -39,7 +40,8 @@ class FirestoreInviteDataSource @Inject constructor(
|
|||
"expiresAt" to Timestamp(now / 1000 + 24 * 60 * 60, 0),
|
||||
"wrappedCoupleKey" to wrappedKey.cipherB64,
|
||||
"kdfSalt" to wrappedKey.saltB64,
|
||||
"kdfParams" to wrappedKey.params
|
||||
"kdfParams" to wrappedKey.params,
|
||||
"recoveryPhrase" to recoveryPhrase
|
||||
)
|
||||
)
|
||||
.addOnSuccessListener { cont.resume(Unit) }
|
||||
|
|
@ -79,14 +81,13 @@ class FirestoreInviteDataSource @Inject constructor(
|
|||
* Accepts an invite server-side via the [acceptInviteCallable] Cloud Function.
|
||||
*
|
||||
* The client no longer reads the invite document directly (issue #9 fix).
|
||||
* Instead, the function validates the code, creates the couple, updates both
|
||||
* user documents, and returns the inviter UID and wrapped key so the acceptor
|
||||
* can decrypt the couple keyset locally.
|
||||
* The function reads the recovery phrase from the invite doc and returns it,
|
||||
* so the acceptor never needs to type it manually.
|
||||
*/
|
||||
suspend fun acceptInvite(code: String, recoveryPhrase: String): app.closer.domain.repository.AcceptInviteResult {
|
||||
suspend fun acceptInvite(code: String): app.closer.domain.repository.AcceptInviteResult {
|
||||
@Suppress("DEPRECATION")
|
||||
val result = functions.getHttpsCallable("acceptInviteCallable")
|
||||
.call(mapOf("code" to code, "recoveryPhrase" to recoveryPhrase))
|
||||
.call(mapOf("code" to code))
|
||||
.await()
|
||||
val data = result.getData() as? Map<*, *>
|
||||
?: throw IllegalStateException("Invalid response from acceptInviteCallable")
|
||||
|
|
@ -101,6 +102,7 @@ class FirestoreInviteDataSource @Inject constructor(
|
|||
?: throw IllegalStateException("Missing kdfSalt in acceptInvite response")
|
||||
val kdfParams = data["kdfParams"] as? String
|
||||
?: throw IllegalStateException("Missing kdfParams in acceptInvite response")
|
||||
val recoveryPhrase = data["recoveryPhrase"] as? String
|
||||
|
||||
return app.closer.domain.repository.AcceptInviteResult(
|
||||
coupleId = coupleId,
|
||||
|
|
@ -109,7 +111,8 @@ class FirestoreInviteDataSource @Inject constructor(
|
|||
cipherB64 = wrappedCoupleKey,
|
||||
saltB64 = kdfSalt,
|
||||
params = kdfParams
|
||||
)
|
||||
),
|
||||
recoveryPhrase = recoveryPhrase
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ class InviteRepositoryImpl @Inject constructor(
|
|||
override suspend fun createInvite(inviterUserId: String): Result<CreateInviteResult> = runCatching {
|
||||
val code = dataSource.generateCode()
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ class InviteRepositoryImpl @Inject constructor(
|
|||
dataSource.getInviteByCode(code)
|
||||
}
|
||||
|
||||
override suspend fun acceptInvite(code: String, acceptorUserId: String, recoveryPhrase: String): Result<AcceptInviteResult> = runCatching {
|
||||
dataSource.acceptInvite(code, recoveryPhrase)
|
||||
override suspend fun acceptInvite(code: String, acceptorUserId: String): Result<AcceptInviteResult> = runCatching {
|
||||
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 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(
|
||||
val coupleId: String,
|
||||
val inviterUserId: String,
|
||||
val wrappedKey: RecoveryKeyManager.WrappedKey
|
||||
val wrappedKey: RecoveryKeyManager.WrappedKey,
|
||||
val recoveryPhrase: String?
|
||||
)
|
||||
|
||||
interface InviteRepository {
|
||||
suspend fun createInvite(inviterUserId: String): Result<CreateInviteResult>
|
||||
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.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
|
|
@ -43,8 +41,6 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
|
|
@ -146,39 +142,7 @@ fun InviteConfirmScreen(
|
|||
color = SettingsMuted
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.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))
|
||||
}
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
Button(
|
||||
onClick = viewModel::confirmPairing,
|
||||
|
|
|
|||
|
|
@ -20,11 +20,9 @@ import javax.inject.Inject
|
|||
data class InviteConfirmUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val inviterName: String? = null,
|
||||
val recoveryPhrase: String = "",
|
||||
val error: String? = null,
|
||||
val navigateTo: String? = null,
|
||||
val isConfirming: Boolean = false,
|
||||
val isEncryptedInvite: Boolean = false
|
||||
val isConfirming: Boolean = false
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
|
|
@ -43,42 +41,33 @@ class InviteConfirmViewModel @Inject constructor(
|
|||
|
||||
init {
|
||||
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 {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
inviterName = "your partner",
|
||||
isEncryptedInvite = true
|
||||
)
|
||||
it.copy(isLoading = false, inviterName = "your partner")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onPhraseChanged(phrase: String) = _uiState.update { it.copy(recoveryPhrase = phrase, error = null) }
|
||||
|
||||
fun confirmPairing() {
|
||||
val acceptorId = authRepository.currentUserId ?: run {
|
||||
_uiState.update { it.copy(error = "Not signed in.") }
|
||||
return
|
||||
}
|
||||
val phrase = _uiState.value.recoveryPhrase.trim()
|
||||
_uiState.update { it.copy(isConfirming = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
inviteRepository.acceptInvite(inviteCode, acceptorId, phrase)
|
||||
inviteRepository.acceptInvite(inviteCode, acceptorId)
|
||||
.onSuccess { result ->
|
||||
encryptionManager.unwrapAndStore(result.coupleId, result.wrappedKey, phrase)
|
||||
.onSuccess {
|
||||
val inviterName = runCatching { userRepository.getUser(result.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) }
|
||||
}
|
||||
.onFailure { e ->
|
||||
_uiState.update { it.copy(isConfirming = false, error = recoveryErrorMessage(e)) }
|
||||
}
|
||||
val phrase = result.recoveryPhrase
|
||||
if (phrase != null && result.wrappedKey.cipherB64.isNotBlank()) {
|
||||
encryptionManager.unwrapAndStore(result.coupleId, result.wrappedKey, phrase)
|
||||
.onSuccess {
|
||||
navigateHome(result.inviterUserId)
|
||||
}
|
||||
.onFailure { e ->
|
||||
_uiState.update { it.copy(isConfirming = false, error = recoveryErrorMessage(e)) }
|
||||
}
|
||||
} else {
|
||||
navigateHome(result.inviterUserId)
|
||||
}
|
||||
}
|
||||
.onFailure { 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 dismissError() = _uiState.update { it.copy(error = null) }
|
||||
|
||||
private fun recoveryErrorMessage(e: Throwable): String = when {
|
||||
(e.message ?: "").contains("AEADBadTag", 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."
|
||||
}
|
||||
|
||||
|
|
@ -106,7 +108,6 @@ class InviteConfirmViewModel @Inject constructor(
|
|||
else if (msg.contains("already paired", ignoreCase = true)) "You are already paired."
|
||||
else "Couldn't complete pairing. Please try again."
|
||||
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."
|
||||
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.automirrored.filled.ArrowBack
|
||||
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.Key
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
|
|
@ -33,16 +35,23 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import app.closer.core.navigation.AppRoute
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
|
|
@ -50,7 +59,10 @@ fun AccountScreen(
|
|||
onNavigate: (String) -> Unit = {},
|
||||
viewModel: EditProfileViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
val clipboard = LocalClipboardManager.current
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbar) },
|
||||
|
|
@ -90,6 +102,71 @@ fun AccountScreen(
|
|||
|
||||
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
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ package app.closer.ui.settings
|
|||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.closer.crypto.CoupleEncryptionManager
|
||||
import app.closer.data.remote.FirebaseStorageDataSource
|
||||
import app.closer.domain.repository.AuthRepository
|
||||
import app.closer.domain.repository.CoupleRepository
|
||||
import app.closer.domain.repository.UserRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
|
@ -27,14 +29,17 @@ data class EditProfileUiState(
|
|||
val error: String? = null,
|
||||
val success: Boolean = false,
|
||||
val nameError: String? = null,
|
||||
val sexError: String? = null
|
||||
val sexError: String? = null,
|
||||
val recoveryPhrase: String? = null
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class EditProfileViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val userRepository: UserRepository,
|
||||
private val storageDataSource: FirebaseStorageDataSource
|
||||
private val storageDataSource: FirebaseStorageDataSource,
|
||||
private val coupleRepository: CoupleRepository,
|
||||
private val encryptionManager: CoupleEncryptionManager
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(EditProfileUiState())
|
||||
|
|
@ -51,6 +56,8 @@ class EditProfileViewModel @Inject constructor(
|
|||
}
|
||||
viewModelScope.launch {
|
||||
val user = runCatching { userRepository.getUser(uid) }.getOrNull()
|
||||
val coupleId = runCatching { coupleRepository.getCoupleForUser(uid) }.getOrNull()?.id
|
||||
val phrase = coupleId?.let { encryptionManager.recoveryPhrase(it) }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
|
|
@ -59,7 +66,8 @@ class EditProfileViewModel @Inject constructor(
|
|||
photoUrl = user?.photoUrl ?: "",
|
||||
email = authRepository.currentUserEmail ?: user?.email ?: "",
|
||||
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',
|
||||
'wrappedCoupleKey', 'kdfSalt', 'kdfParams'])
|
||||
&& request.resource.data.keys().hasOnly(['inviterUserId', 'code', 'status', 'createdAt', 'expiresAt',
|
||||
'wrappedCoupleKey', 'kdfSalt', 'kdfParams']);
|
||||
'wrappedCoupleKey', 'kdfSalt', 'kdfParams', 'recoveryPhrase']);
|
||||
|
||||
// Update (accept): server-side / Cloud Function only.
|
||||
// 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
|
||||
* 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.
|
||||
* - 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):
|
||||
* 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') {
|
||||
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();
|
||||
// Caller must not already be paired.
|
||||
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 kdfSalt = invite.kdfSalt;
|
||||
const kdfParams = invite.kdfParams;
|
||||
const recoveryPhrase = invite.recoveryPhrase;
|
||||
if (status !== 'pending') {
|
||||
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) {
|
||||
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 coupleRef = db.collection('couples').doc(coupleId);
|
||||
const batch = db.batch();
|
||||
|
|
@ -137,6 +132,7 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
|
|||
wrappedCoupleKey: wrappedCoupleKey !== null && wrappedCoupleKey !== void 0 ? wrappedCoupleKey : null,
|
||||
kdfSalt: kdfSalt !== null && kdfSalt !== void 0 ? kdfSalt : null,
|
||||
kdfParams: kdfParams !== null && kdfParams !== void 0 ? kdfParams : null,
|
||||
recoveryPhrase: recoveryPhrase !== null && recoveryPhrase !== void 0 ? recoveryPhrase : null,
|
||||
};
|
||||
});
|
||||
//# 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
|
||||
* 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.
|
||||
* - 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):
|
||||
* 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.')
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
// 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 kdfSalt = invite.kdfSalt as string | undefined
|
||||
const kdfParams = invite.kdfParams as string | undefined
|
||||
const recoveryPhrase = invite.recoveryPhrase as string | undefined
|
||||
|
||||
if (status !== 'pending') {
|
||||
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.')
|
||||
}
|
||||
|
||||
// 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 coupleRef = db.collection('couples').doc(coupleId)
|
||||
|
||||
|
|
@ -121,5 +114,6 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con
|
|||
wrappedCoupleKey: wrappedCoupleKey ?? null,
|
||||
kdfSalt: kdfSalt ?? null,
|
||||
kdfParams: kdfParams ?? null,
|
||||
recoveryPhrase: recoveryPhrase ?? null,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue