mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 07:05:35 +03:00
[PM-6702] Add AppLink support to open links sent to email to complete account registration
This commit is contained in:
parent
56e68361a6
commit
b867079ced
18 changed files with 127 additions and 112 deletions
|
@ -55,6 +55,17 @@
|
|||
<data android:mimeType="video/*" />
|
||||
<data android:mimeType="text/*" />
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="https" />
|
||||
<data android:host="bitwarden.com" />
|
||||
<data android:host="bitwarden.pw" />
|
||||
<data android:host="bitwarden.eu" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
|
|
|
@ -23,6 +23,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
import android.net.Uri
|
||||
|
||||
/**
|
||||
* Primary entry point for the application.
|
||||
|
@ -74,6 +75,15 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
val appLinkData: Uri? = intent.data
|
||||
if (appLinkData != null && appLinkData.isHierarchical) {
|
||||
mainViewModel.trySendAction(
|
||||
action = MainAction.ReceiveNewIntent(
|
||||
intent = intent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
|
|
|
@ -5,7 +5,9 @@ import android.os.Parcelable
|
|||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.vault.CipherView
|
||||
import com.google.firebase.Timestamp
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.util.getCompleteRegistrationDataIntentOrNull
|
||||
import com.x8bit.bitwarden.data.auth.util.getPasswordlessRequestDataIntentOrNull
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.util.getFido2CredentialRequestOrNull
|
||||
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
|
||||
|
@ -174,6 +176,7 @@ class MainViewModel @Inject constructor(
|
|||
val hasGeneratorShortcut = intent.isPasswordGeneratorShortcut
|
||||
val hasVaultShortcut = intent.isMyVaultShortcut
|
||||
val fido2CredentialRequestData = intent.getFido2CredentialRequestOrNull()
|
||||
val completeRegistrationData = intent.getCompleteRegistrationDataIntentOrNull()
|
||||
when {
|
||||
passwordlessRequestData != null -> {
|
||||
specialCircumstanceManager.specialCircumstance =
|
||||
|
@ -185,6 +188,14 @@ class MainViewModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
completeRegistrationData != null -> {
|
||||
specialCircumstanceManager.specialCircumstance =
|
||||
SpecialCircumstance.CompleteRegistration(
|
||||
completeRegistrationData = completeRegistrationData,
|
||||
timestamp = Timestamp.now()
|
||||
)
|
||||
}
|
||||
|
||||
autofillSaveItem != null -> {
|
||||
specialCircumstanceManager.specialCircumstance =
|
||||
SpecialCircumstance.AutofillSave(
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
package com.x8bit.bitwarden.data.auth.util
|
||||
|
||||
import android.content.Intent
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
|
||||
|
||||
/**
|
||||
* Checks if the given [Intent] contains data to complete registration.
|
||||
* The [CompleteRegistrationData] will be returned when present.
|
||||
*/
|
||||
fun Intent.getCompleteRegistrationDataIntentOrNull(): CompleteRegistrationData? {
|
||||
val uri = data ?: return null
|
||||
val email = uri?.getQueryParameter("email") ?: return null
|
||||
val verificationToken = uri.getQueryParameter("verificationtoken") ?: return null
|
||||
return CompleteRegistrationData(
|
||||
email = email,
|
||||
verificationToken = verificationToken
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.x8bit.bitwarden.data.platform.manager.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/**
|
||||
* Required data to complete ongoing registration process.
|
||||
*
|
||||
* @property email The email of the user creating the account.
|
||||
* @property verificationToken The token required to finish the registration process.
|
||||
*/
|
||||
@Parcelize
|
||||
data class CompleteRegistrationData(
|
||||
val email: String,
|
||||
val verificationToken: String,
|
||||
) : Parcelable
|
|
@ -1,6 +1,7 @@
|
|||
package com.x8bit.bitwarden.data.platform.manager.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.google.firebase.Timestamp
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
|
||||
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
|
||||
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
|
||||
|
@ -48,6 +49,15 @@ sealed class SpecialCircumstance : Parcelable {
|
|||
val shouldFinishWhenComplete: Boolean,
|
||||
) : SpecialCircumstance()
|
||||
|
||||
/**
|
||||
* The app was launched via AppLink in order to allow the user complete an ongoing registration.
|
||||
*/
|
||||
@Parcelize
|
||||
data class CompleteRegistration(
|
||||
val completeRegistrationData: CompleteRegistrationData,
|
||||
val timestamp: Timestamp
|
||||
) : SpecialCircumstance()
|
||||
|
||||
/**
|
||||
* The app was launched via the credential manager framework in order to allow the user to
|
||||
* manually save a passkey to their vault.
|
||||
|
|
|
@ -17,6 +17,7 @@ fun SpecialCircumstance.toAutofillSaveItemOrNull(): AutofillSaveItem? =
|
|||
SpecialCircumstance.GeneratorShortcut -> null
|
||||
SpecialCircumstance.VaultShortcut -> null
|
||||
is SpecialCircumstance.Fido2Save -> null
|
||||
is SpecialCircumstance.CompleteRegistration -> null
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -31,6 +32,7 @@ fun SpecialCircumstance.toAutofillSelectionDataOrNull(): AutofillSelectionData?
|
|||
SpecialCircumstance.GeneratorShortcut -> null
|
||||
SpecialCircumstance.VaultShortcut -> null
|
||||
is SpecialCircumstance.Fido2Save -> null
|
||||
is SpecialCircumstance.CompleteRegistration -> null
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -18,6 +18,7 @@ private const val CHECK_EMAIL_ROUTE: String = "check_email/{$EMAIL_ADDRESS}"
|
|||
fun NavController.navigateToCheckEmail(emailAddress: String, navOptions: NavOptions? = null) {
|
||||
this.navigate("check_email/$emailAddress", navOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Class to retrieve check email arguments from the [SavedStateHandle].
|
||||
*/
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
package com.x8bit.bitwarden.ui.auth.feature.completeregistration
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
|
||||
|
||||
private const val COMPLETE_REGISTRATION_ROUTE = "complete_registration"
|
||||
|
|
|
@ -12,6 +12,8 @@ import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
|
|||
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
|
||||
import com.x8bit.bitwarden.data.platform.manager.util.toFido2RequestOrNull
|
||||
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CheckDataBreachesToggle
|
||||
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ConfirmPasswordInputChange
|
||||
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ContinueWithBreachedPasswordClick
|
||||
|
@ -43,7 +45,7 @@ private const val MIN_PASSWORD_LENGTH = 12
|
|||
@HiltViewModel
|
||||
class CompleteRegistrationViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val authRepository: AuthRepository,
|
||||
private val authRepository: AuthRepository
|
||||
) : BaseViewModel<CompleteRegistrationState, CompleteRegistrationEvent, CompleteRegistrationAction>(
|
||||
initialState = savedStateHandle[KEY_STATE]
|
||||
?: CompleteRegistrationState(
|
||||
|
|
|
@ -1,106 +0,0 @@
|
|||
package com.x8bit.bitwarden.ui.auth.feature.createaccount
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialColors
|
||||
|
||||
/**
|
||||
* Draws a password indicator that displays password strength based on the given [state].
|
||||
*/
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber")
|
||||
@Composable
|
||||
fun PasswordStrengthIndicator(
|
||||
modifier: Modifier = Modifier,
|
||||
state: PasswordStrengthState,
|
||||
) {
|
||||
val widthPercent by animateFloatAsState(
|
||||
targetValue = when (state) {
|
||||
PasswordStrengthState.NONE -> 0f
|
||||
PasswordStrengthState.WEAK_1 -> .25f
|
||||
PasswordStrengthState.WEAK_2 -> .5f
|
||||
PasswordStrengthState.WEAK_3 -> .66f
|
||||
PasswordStrengthState.GOOD -> .82f
|
||||
PasswordStrengthState.STRONG -> 1f
|
||||
},
|
||||
label = "Width Percent State",
|
||||
)
|
||||
val indicatorColor = when (state) {
|
||||
PasswordStrengthState.NONE -> MaterialTheme.colorScheme.error
|
||||
PasswordStrengthState.WEAK_1 -> MaterialTheme.colorScheme.error
|
||||
PasswordStrengthState.WEAK_2 -> MaterialTheme.colorScheme.error
|
||||
PasswordStrengthState.WEAK_3 -> LocalNonMaterialColors.current.passwordWeak
|
||||
PasswordStrengthState.GOOD -> MaterialTheme.colorScheme.primary
|
||||
PasswordStrengthState.STRONG -> LocalNonMaterialColors.current.passwordStrong
|
||||
}
|
||||
val animatedIndicatorColor by animateColorAsState(
|
||||
targetValue = indicatorColor,
|
||||
label = "Indicator Color State",
|
||||
)
|
||||
val label = when (state) {
|
||||
PasswordStrengthState.NONE -> "".asText()
|
||||
PasswordStrengthState.WEAK_1 -> R.string.weak.asText()
|
||||
PasswordStrengthState.WEAK_2 -> R.string.weak.asText()
|
||||
PasswordStrengthState.WEAK_3 -> R.string.weak.asText()
|
||||
PasswordStrengthState.GOOD -> R.string.good.asText()
|
||||
PasswordStrengthState.STRONG -> R.string.strong.asText()
|
||||
}
|
||||
Column(
|
||||
modifier = modifier,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(4.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer {
|
||||
transformOrigin = TransformOrigin(pivotFractionX = 0f, pivotFractionY = 0f)
|
||||
scaleX = widthPercent
|
||||
}
|
||||
.drawBehind {
|
||||
drawRect(animatedIndicatorColor)
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = label(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = indicatorColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models various levels of password strength that can be displayed by [PasswordStrengthIndicator].
|
||||
*/
|
||||
enum class PasswordStrengthState {
|
||||
NONE,
|
||||
WEAK_1,
|
||||
WEAK_2,
|
||||
WEAK_3,
|
||||
GOOD,
|
||||
STRONG,
|
||||
}
|
|
@ -306,7 +306,7 @@ private fun TermsAndPrivacyText(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.semantics(mergeDescendants = true) {
|
||||
testTag = "AcceptPoliciesText"
|
||||
testTag = "DisclaimerText"
|
||||
}
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
|
|
|
@ -18,6 +18,7 @@ import androidx.navigation.navOptions
|
|||
import com.x8bit.bitwarden.ui.auth.feature.auth.AUTH_GRAPH_ROUTE
|
||||
import com.x8bit.bitwarden.ui.auth.feature.auth.authGraph
|
||||
import com.x8bit.bitwarden.ui.auth.feature.auth.navigateToAuthGraph
|
||||
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.navigateToCompleteRegistration
|
||||
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.RESET_PASSWORD_ROUTE
|
||||
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.navigateToResetPasswordGraph
|
||||
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.resetPasswordDestination
|
||||
|
@ -95,7 +96,8 @@ fun RootNavScreen(
|
|||
}
|
||||
|
||||
val targetRoute = when (state) {
|
||||
RootNavState.Auth -> AUTH_GRAPH_ROUTE
|
||||
RootNavState.Auth,
|
||||
is RootNavState.CompleteOngoingRegistration-> AUTH_GRAPH_ROUTE
|
||||
RootNavState.ResetPassword -> RESET_PASSWORD_ROUTE
|
||||
RootNavState.SetPassword -> SET_PASSWORD_ROUTE
|
||||
RootNavState.Splash -> SPLASH_ROUTE
|
||||
|
@ -192,6 +194,11 @@ fun RootNavScreen(
|
|||
navOptions = rootNavOptions,
|
||||
)
|
||||
}
|
||||
|
||||
is RootNavState.CompleteOngoingRegistration -> {
|
||||
navController.navigateToAuthGraph(rootNavOptions)
|
||||
navController.navigateToCompleteRegistration()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.platform.feature.rootnav
|
|||
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.firebase.Timestamp
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
|
||||
|
@ -60,6 +61,7 @@ class RootNavViewModel @Inject constructor(
|
|||
action: RootNavAction.Internal.UserStateUpdateReceive,
|
||||
) {
|
||||
val userState = action.userState
|
||||
val specialCircumstance = action.specialCircumstance
|
||||
val updatedRootNavState = when {
|
||||
userState?.activeAccount?.trustedDevice?.isDeviceTrusted == false &&
|
||||
!userState.activeAccount.isVaultUnlocked &&
|
||||
|
@ -69,12 +71,21 @@ class RootNavViewModel @Inject constructor(
|
|||
|
||||
userState?.activeAccount?.needsPasswordReset == true -> RootNavState.ResetPassword
|
||||
|
||||
specialCircumstance is SpecialCircumstance.CompleteRegistration -> {
|
||||
RootNavState.CompleteOngoingRegistration(
|
||||
email = specialCircumstance.completeRegistrationData.email,
|
||||
verificationToken = specialCircumstance.completeRegistrationData.verificationToken,
|
||||
timestamp = specialCircumstance.timestamp
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
userState == null ||
|
||||
!userState.activeAccount.isLoggedIn ||
|
||||
userState.hasPendingAccountAddition -> RootNavState.Auth
|
||||
|
||||
userState.activeAccount.isVaultUnlocked -> {
|
||||
when (val specialCircumstance = action.specialCircumstance) {
|
||||
when (specialCircumstance) {
|
||||
is SpecialCircumstance.AutofillSave -> {
|
||||
RootNavState.VaultUnlockedForAutofillSave(
|
||||
autofillSaveItem = specialCircumstance.autofillSaveItem,
|
||||
|
@ -105,6 +116,13 @@ class RootNavViewModel @Inject constructor(
|
|||
SpecialCircumstance.VaultShortcut,
|
||||
null,
|
||||
-> RootNavState.VaultUnlocked(activeUserId = userState.activeAccount.userId)
|
||||
|
||||
is SpecialCircumstance.CompleteRegistration ->
|
||||
RootNavState.CompleteOngoingRegistration (
|
||||
email = specialCircumstance.completeRegistrationData.email,
|
||||
verificationToken = specialCircumstance.completeRegistrationData.verificationToken,
|
||||
timestamp = specialCircumstance.timestamp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -200,6 +218,16 @@ sealed class RootNavState : Parcelable {
|
|||
@Parcelize
|
||||
data object VaultUnlockedForNewSend : RootNavState()
|
||||
|
||||
/**
|
||||
* App should show the screen to complete an ongoing registration process.
|
||||
*/
|
||||
@Parcelize
|
||||
data class CompleteOngoingRegistration (
|
||||
val email: String,
|
||||
val verificationToken: String,
|
||||
val timestamp: Timestamp
|
||||
) : RootNavState()
|
||||
|
||||
/**
|
||||
* App should show the auth confirmation screen for an unlocked user.
|
||||
*/
|
||||
|
|
|
@ -17,6 +17,7 @@ import androidx.compose.ui.test.performScrollTo
|
|||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.core.net.toUri
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
||||
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState
|
||||
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.AcceptPoliciesToggle
|
||||
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CheckDataBreachesToggle
|
||||
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CloseClick
|
||||
|
|
|
@ -14,6 +14,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
|||
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
|
||||
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState
|
||||
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.AcceptPoliciesToggle
|
||||
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CloseClick
|
||||
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ConfirmPasswordInputChange
|
||||
|
|
|
@ -15,7 +15,7 @@ import androidx.compose.ui.test.performClick
|
|||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
||||
import com.x8bit.bitwarden.ui.auth.feature.createaccount.PasswordStrengthState
|
||||
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat
|
||||
|
|
|
@ -19,7 +19,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockPolicy
|
|||
import com.x8bit.bitwarden.data.vault.manager.FileManager
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult
|
||||
import com.x8bit.bitwarden.ui.auth.feature.createaccount.PasswordStrengthState
|
||||
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat
|
||||
|
|
Loading…
Reference in a new issue