mirror of
https://github.com/xpipe-io/xpipe.git
synced 2026-08-22 09:16:29 +00:00
Vault fixes
This commit is contained in:
@@ -40,7 +40,11 @@ public class ModalButton {
|
||||
}
|
||||
|
||||
public static ModalButton cancel() {
|
||||
return new ModalButton("cancel", null, true, false);
|
||||
return cancel(null);
|
||||
}
|
||||
|
||||
public static ModalButton cancel(Runnable action) {
|
||||
return new ModalButton("cancel", action, true, false);
|
||||
}
|
||||
|
||||
public static ModalButton skip() {
|
||||
|
||||
@@ -250,7 +250,8 @@ public abstract class OperationMode {
|
||||
var loc = AppProperties.get().isDevelopmentEnvironment()
|
||||
? XPipeInstallation.getLocalDefaultInstallationBasePath()
|
||||
: XPipeInstallation.getCurrentInstallationBasePath().toString();
|
||||
var exec = XPipeInstallation.createExternalAsyncLaunchCommand(loc, XPipeDaemonMode.GUI, "", true);
|
||||
var dataDir = AppProperties.get().getDataDir();
|
||||
var exec = XPipeInstallation.createExternalAsyncLaunchCommand(loc, XPipeDaemonMode.GUI, "-Dio.xpipe.app.acceptEula=true -Dio.xpipe.app.dataDir=\"" + dataDir + "\"", true);
|
||||
LocalShell.getShell().executeSimpleCommand(exec);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package io.xpipe.app.prefs;
|
||||
|
||||
import atlantafx.base.layout.ModalBox;
|
||||
import io.xpipe.app.comp.Comp;
|
||||
import io.xpipe.app.comp.base.ButtonComp;
|
||||
import io.xpipe.app.comp.base.ModalButton;
|
||||
import io.xpipe.app.comp.base.ModalOverlay;
|
||||
import io.xpipe.app.core.AppI18n;
|
||||
import io.xpipe.app.core.window.AppDialog;
|
||||
import io.xpipe.app.core.window.AppWindowHelper;
|
||||
import io.xpipe.app.storage.DataStorage;
|
||||
import io.xpipe.app.storage.DataStorageSyncHandler;
|
||||
@@ -30,36 +34,43 @@ public class VaultCategory extends AppPrefsCategory {
|
||||
|
||||
var encryptVault = new SimpleBooleanProperty(prefs.encryptAllVaultData().get());
|
||||
encryptVault.addListener((observable, oldValue, newValue) -> {
|
||||
if (!newValue
|
||||
&& !AppWindowHelper.showConfirmationAlert(
|
||||
"confirmVaultUnencryptTitle",
|
||||
"confirmVaultUnencryptHeader",
|
||||
"confirmVaultUnencryptContent")) {
|
||||
Platform.runLater(() -> {
|
||||
encryptVault.set(true);
|
||||
});
|
||||
return;
|
||||
if (!newValue) {
|
||||
var modal = ModalOverlay.of("confirmVaultUnencryptTitle", AppDialog.dialogTextKey("confirmVaultUnencryptContent"));
|
||||
modal.addButton(ModalButton.cancel(() -> {
|
||||
Platform.runLater(() -> {
|
||||
encryptVault.set(true);
|
||||
});
|
||||
}));
|
||||
modal.addButton(ModalButton.ok(() -> {
|
||||
prefs.encryptAllVaultData.setValue(false);
|
||||
}));
|
||||
modal.showAndWait();
|
||||
} else {
|
||||
prefs.encryptAllVaultData.setValue(true);
|
||||
}
|
||||
|
||||
prefs.encryptAllVaultData.setValue(newValue);
|
||||
});
|
||||
|
||||
var uh = DataStorageUserHandler.getInstance();
|
||||
var vaultTypeKey = uh.getUserCount() == 0 ? "vaultTypeDefault" : uh.getUserCount() == 1 ? (uh.getActiveUser() != null && uh.getActiveUser().equals("legacy") ?
|
||||
"vaultTypeLegacy" : "vaultTypePersonal") : "vaultTypeTeam";
|
||||
|
||||
builder.addTitle("vaultUsers")
|
||||
.sub(new OptionsBuilder()
|
||||
.nameAndDescription("personalVault")
|
||||
.name("vaultType")
|
||||
.description(vaultTypeKey)
|
||||
.addComp(Comp.empty())
|
||||
.hide(new SimpleBooleanProperty(DataStorageUserHandler.getInstance().getUserCount() > 1))
|
||||
.hide(new SimpleBooleanProperty(uh.getUserCount() > 1))
|
||||
.name("userManagement")
|
||||
.description(
|
||||
DataStorageUserHandler.getInstance().getActiveUser() != null
|
||||
uh.getActiveUser() != null
|
||||
? "userManagementDescription"
|
||||
: "userManagementDescriptionEmpty")
|
||||
.addComp(DataStorageUserHandler.getInstance().createOverview())
|
||||
.addComp(uh.createOverview())
|
||||
.nameAndDescription("teamVaults")
|
||||
.addComp(Comp.empty())
|
||||
.licenseRequirement("team")
|
||||
.disable(!LicenseProvider.get().getFeature("team").isSupported())
|
||||
.hide(new SimpleBooleanProperty(DataStorageUserHandler.getInstance().getUserCount() > 1))
|
||||
.hide(new SimpleBooleanProperty(uh.getUserCount() > 1))
|
||||
.nameAndDescription("syncTeamVaults")
|
||||
.addComp(new ButtonComp(AppI18n.observable("enableGitSync"), () -> AppPrefs.get()
|
||||
.selectCategory("sync")))
|
||||
@@ -73,7 +84,6 @@ public class VaultCategory extends AppPrefsCategory {
|
||||
.addToggle(prefs.lockVaultOnHibernation)
|
||||
.pref(prefs.encryptAllVaultData)
|
||||
.addToggle(encryptVault)
|
||||
.disable(DataStorageUserHandler.getInstance().getUserCount() > 1)
|
||||
);
|
||||
builder.addTitle("vault")
|
||||
.sub(new OptionsBuilder()
|
||||
|
||||
@@ -307,8 +307,8 @@ public abstract class DataStorage {
|
||||
|
||||
DataStoreEntry c = entry;
|
||||
do {
|
||||
// We can't check for sharing of invalid entries
|
||||
if (!c.getValidity().isUsable()) {
|
||||
// We can't check for sharing of failed entries
|
||||
if (c.getValidity() == DataStoreEntry.Validity.LOAD_FAILED) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -316,7 +316,11 @@ public abstract class DataStorage {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!c.getProvider().isShareable(c)) {
|
||||
try {
|
||||
if (!c.getProvider().isShareable(c)) {
|
||||
return false;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
} while ((c = DataStorage.get().getDefaultDisplayParent(c).orElse(null)) != null);
|
||||
@@ -398,6 +402,8 @@ public abstract class DataStorage {
|
||||
DataStorage.get().saveAsync();
|
||||
}
|
||||
|
||||
public abstract boolean isOtherUserEntry(UUID uuid);
|
||||
|
||||
public void moveEntryToCategory(DataStoreEntry entry, DataStoreCategory newCategory) {
|
||||
if (newCategory.getUuid().equals(entry.getCategoryUuid())) {
|
||||
return;
|
||||
|
||||
@@ -23,16 +23,45 @@ import java.io.IOException;
|
||||
@Value
|
||||
public class DataStorageNode {
|
||||
|
||||
public static DataStorageNode ofNewStore(DataStore store) {
|
||||
private static boolean encryptPerUser(DataStore store) {
|
||||
if (DataStorageUserHandler.getInstance().getActiveUser() == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var perUser = false;
|
||||
try {
|
||||
perUser = store instanceof UserScopeStore s && s.isPerUser();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
var encrypted = perUser
|
||||
|| (AppPrefs.get() != null
|
||||
&& AppPrefs.get().encryptAllVaultData().get());
|
||||
return new DataStorageNode(JacksonMapper.getDefault().valueToTree(store), perUser, true, encrypted);
|
||||
|
||||
if (perUser) {
|
||||
return true;
|
||||
}
|
||||
|
||||
var all = AppPrefs.get() != null && AppPrefs.get().encryptAllVaultData().get();
|
||||
var useUserKey = DataStorageUserHandler.getInstance().getUserCount() == 1 && DataStorageUserHandler.getInstance().getActiveUser() != null;
|
||||
return all && useUserKey;
|
||||
}
|
||||
|
||||
private static boolean encrypt(DataStore store) {
|
||||
if (AppPrefs.get() != null && AppPrefs.get().encryptAllVaultData().get()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (DataStorageUserHandler.getInstance().getActiveUser() == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var perUser = false;
|
||||
try {
|
||||
perUser = store instanceof UserScopeStore s && s.isPerUser();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return perUser;
|
||||
}
|
||||
|
||||
public static DataStorageNode ofNewStore(DataStore store) {
|
||||
return new DataStorageNode(JacksonMapper.getDefault().valueToTree(store), encryptPerUser(store), true, encrypt(store));
|
||||
}
|
||||
|
||||
public static DataStorageNode fail() {
|
||||
@@ -74,10 +103,7 @@ public class DataStorageNode {
|
||||
}
|
||||
|
||||
public static JsonNode encryptNodeIfNeeded(DataStorageNode node) {
|
||||
var encrypt =
|
||||
(AppPrefs.get() != null && AppPrefs.get().encryptAllVaultData().get())
|
||||
|| (node.isPerUser() && node.hasAccess());
|
||||
if (!encrypt) {
|
||||
if (!node.isEncrypted()) {
|
||||
return node.getContentNode();
|
||||
}
|
||||
|
||||
@@ -108,21 +134,6 @@ public class DataStorageNode {
|
||||
return !perUser || availableForUser;
|
||||
}
|
||||
|
||||
public DataStorageNode withStore(DataStore store) {
|
||||
if (store == null) {
|
||||
return fail();
|
||||
}
|
||||
|
||||
try {
|
||||
var perUser = store instanceof UserScopeStore s && s.isPerUser();
|
||||
return new DataStorageNode(
|
||||
JacksonMapper.getDefault().valueToTree(store), perUser, availableForUser, encrypted);
|
||||
} catch (Exception e) {
|
||||
// The per user check might fail for incomplete stores
|
||||
return new DataStorageNode(JacksonMapper.getDefault().valueToTree(store), false, true, encrypted);
|
||||
}
|
||||
}
|
||||
|
||||
JsonNode contentNode;
|
||||
boolean perUser;
|
||||
boolean availableForUser;
|
||||
|
||||
@@ -5,6 +5,7 @@ import io.xpipe.app.util.EncryptionToken;
|
||||
import io.xpipe.app.util.PasswordLockSecretValue;
|
||||
import io.xpipe.app.util.VaultKeySecretValue;
|
||||
import io.xpipe.core.util.EncryptedSecretValue;
|
||||
import io.xpipe.core.util.InPlaceSecretValue;
|
||||
import io.xpipe.core.util.JacksonMapper;
|
||||
import io.xpipe.core.util.SecretValue;
|
||||
|
||||
@@ -53,7 +54,7 @@ public class DataStorageSecret {
|
||||
token = userToken ? EncryptionToken.ofUser() : EncryptionToken.ofVaultKey();
|
||||
}
|
||||
|
||||
return new DataStorageSecret(token, secretTree, secret);
|
||||
return new DataStorageSecret(token, secretTree, secret.inPlace());
|
||||
}
|
||||
|
||||
public static DataStorageSecret ofCurrentSecret(SecretValue internalSecret) {
|
||||
@@ -61,22 +62,22 @@ public class DataStorageSecret {
|
||||
return new DataStorageSecret(
|
||||
handler.getActiveUser() != null ? EncryptionToken.ofUser() : EncryptionToken.ofVaultKey(),
|
||||
null,
|
||||
internalSecret);
|
||||
internalSecret.inPlace());
|
||||
}
|
||||
|
||||
public static DataStorageSecret ofSecret(SecretValue internalSecret, EncryptionToken token) {
|
||||
return new DataStorageSecret(token, null, internalSecret);
|
||||
return new DataStorageSecret(token, null, internalSecret.inPlace());
|
||||
}
|
||||
|
||||
@NonFinal
|
||||
JsonNode originalNode;
|
||||
|
||||
SecretValue internalSecret;
|
||||
InPlaceSecretValue internalSecret;
|
||||
|
||||
@NonFinal
|
||||
EncryptionToken encryptedToken;
|
||||
|
||||
public DataStorageSecret(EncryptionToken encryptedToken, JsonNode originalNode, SecretValue internalSecret) {
|
||||
public DataStorageSecret(EncryptionToken encryptedToken, JsonNode originalNode, InPlaceSecretValue internalSecret) {
|
||||
this.encryptedToken = encryptedToken;
|
||||
this.originalNode = originalNode;
|
||||
this.internalSecret = internalSecret;
|
||||
@@ -89,16 +90,14 @@ public class DataStorageSecret {
|
||||
|
||||
// User key must have changed
|
||||
if (!isUser && !isVault) {
|
||||
// There must be a key mismatch
|
||||
// We have loaded a secret with a user key that does no longer exist
|
||||
// This means that the user was deleted in this session
|
||||
// Replace it with a vault key
|
||||
if (userHandler.getActiveUser() == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We don't want to use the new user key
|
||||
if (!allowUserSecretKey) {
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Password was changed
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -115,19 +114,18 @@ public class DataStorageSecret {
|
||||
return false;
|
||||
}
|
||||
|
||||
public JsonNode rewrite(boolean allowUserSecretKey) {
|
||||
private void rewrite(boolean allowUserSecretKey) {
|
||||
var handler = DataStorageUserHandler.getInstance();
|
||||
if (handler != null && handler.getActiveUser() != null && allowUserSecretKey) {
|
||||
var val = new PasswordLockSecretValue(getSecret());
|
||||
originalNode = JacksonMapper.getDefault().valueToTree(val);
|
||||
encryptedToken = EncryptionToken.ofUser();
|
||||
return originalNode;
|
||||
return;
|
||||
}
|
||||
|
||||
var val = new VaultKeySecretValue(getSecret());
|
||||
originalNode = JacksonMapper.getDefault().valueToTree(val);
|
||||
encryptedToken = EncryptionToken.ofVaultKey();
|
||||
return originalNode;
|
||||
}
|
||||
|
||||
public JsonNode serialize(boolean allowUserSecretKey) {
|
||||
@@ -137,17 +135,18 @@ public class DataStorageSecret {
|
||||
|
||||
var mapper = JacksonMapper.getDefault();
|
||||
var tree = JsonNodeFactory.instance.objectNode();
|
||||
tree.set("encryptedToken", mapper.valueToTree(getEncryptedToken()));
|
||||
|
||||
// Preserve same output if not changed
|
||||
if (getOriginalNode() != null && !requiresRewrite(allowUserSecretKey)) {
|
||||
tree.set("secret", getOriginalNode());
|
||||
tree.set("encryptedToken", mapper.valueToTree(getEncryptedToken()));
|
||||
return tree;
|
||||
}
|
||||
|
||||
// Reencrypt
|
||||
var val = rewrite(allowUserSecretKey);
|
||||
tree.set("secret", val);
|
||||
rewrite(allowUserSecretKey);
|
||||
tree.set("secret", getOriginalNode());
|
||||
tree.set("encryptedToken", mapper.valueToTree(getEncryptedToken()));
|
||||
return tree;
|
||||
}
|
||||
|
||||
|
||||
@@ -523,7 +523,7 @@ public class DataStoreEntry extends StorageElement {
|
||||
}
|
||||
|
||||
this.store = store;
|
||||
this.storeNode = this.storeNode.withStore(store);
|
||||
this.storeNode = DataStorageNode.ofNewStore(store);
|
||||
this.provider = DataStoreProviders.byStore(store);
|
||||
if (updateTime) {
|
||||
lastModified = Instant.now();
|
||||
@@ -533,7 +533,7 @@ public class DataStoreEntry extends StorageElement {
|
||||
}
|
||||
|
||||
public void reassignStoreNode() {
|
||||
this.storeNode = this.storeNode.withStore(store);
|
||||
this.storeNode = DataStorageNode.ofNewStore(store);
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import io.xpipe.app.ext.LocalStore;
|
||||
import io.xpipe.app.util.EncryptionKey;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import javax.crypto.SecretKey;
|
||||
|
||||
public class ImpersistentStorage extends DataStorage {
|
||||
@@ -63,4 +64,9 @@ public class ImpersistentStorage extends DataStorage {
|
||||
public boolean supportsSharing() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isOtherUserEntry(UUID uuid) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,13 +17,11 @@ import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
import javax.crypto.SecretKey;
|
||||
|
||||
@@ -43,6 +41,7 @@ public class StandardStorage extends DataStorage {
|
||||
private boolean disposed;
|
||||
private boolean saveQueued;
|
||||
private final ReentrantLock busyIo = new ReentrantLock();
|
||||
private final Set<UUID> inaccessibleEntries = new HashSet<>();
|
||||
|
||||
|
||||
StandardStorage() {
|
||||
@@ -294,6 +293,7 @@ public class StandardStorage extends DataStorage {
|
||||
.map(dataStoreEntry -> dataStoreEntry.getDirectory())
|
||||
.toList());
|
||||
toRemove.forEach(storeEntries::remove);
|
||||
inaccessibleEntries.addAll(toRemove.stream().map(dataStoreEntry -> dataStoreEntry.getUuid()).collect(Collectors.toSet()));
|
||||
}
|
||||
|
||||
private boolean shouldRemoveOtherUserEntry(DataStoreEntry entry) {
|
||||
@@ -435,6 +435,11 @@ public class StandardStorage extends DataStorage {
|
||||
return dataStorageSyncHandler.supportsSync();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isOtherUserEntry(UUID uuid) {
|
||||
return inaccessibleEntries.contains(uuid);
|
||||
}
|
||||
|
||||
private void deleteLeftovers() {
|
||||
var storesDir = getStoresDir();
|
||||
var categoriesDir = getCategoriesDir();
|
||||
|
||||
@@ -22,10 +22,6 @@ public class EncryptionToken {
|
||||
|
||||
private static EncryptionToken createUserToken() {
|
||||
var userHandler = DataStorageUserHandler.getInstance();
|
||||
if (userHandler.getActiveUser() == null) {
|
||||
throw new IllegalStateException("No active user available");
|
||||
}
|
||||
|
||||
var userSecretValue =
|
||||
new PasswordLockSecretValue(userHandler.getActiveUser().toCharArray()) {
|
||||
@Override
|
||||
@@ -44,10 +40,15 @@ public class EncryptionToken {
|
||||
}
|
||||
|
||||
public static EncryptionToken ofInvalid() {
|
||||
return EncryptionToken.builder().token("").isUser(false).isVault(false).build();
|
||||
return EncryptionToken.builder().token("").isVault(false).build();
|
||||
}
|
||||
|
||||
public static EncryptionToken ofUser() {
|
||||
var userHandler = DataStorageUserHandler.getInstance();
|
||||
if (userHandler.getActiveUser() == null) {
|
||||
throw new IllegalStateException("No active user available");
|
||||
}
|
||||
|
||||
if (userToken == null) {
|
||||
userToken = createUserToken();
|
||||
}
|
||||
@@ -63,9 +64,6 @@ public class EncryptionToken {
|
||||
|
||||
private final String token;
|
||||
|
||||
@JsonIgnore
|
||||
private Boolean isUser;
|
||||
|
||||
@JsonIgnore
|
||||
private Boolean isVault;
|
||||
|
||||
@@ -84,16 +82,12 @@ public class EncryptionToken {
|
||||
}
|
||||
|
||||
public boolean isUser() {
|
||||
if (isUser != null) {
|
||||
return isUser;
|
||||
}
|
||||
|
||||
var userHandler = DataStorageUserHandler.getInstance();
|
||||
if (userHandler.getActiveUser() == null) {
|
||||
return (isUser = false);
|
||||
return false;
|
||||
}
|
||||
|
||||
return (isUser = userHandler.getActiveUser().equals(decode(userHandler.getEncryptionKey())));
|
||||
return userHandler.getActiveUser().equals(decode(userHandler.getEncryptionKey()));
|
||||
}
|
||||
|
||||
public boolean isVault() {
|
||||
|
||||
@@ -487,8 +487,7 @@ xshellSetup=Xshell setup
|
||||
termiusSetup=Termius setup
|
||||
tryPtbDescription=Try out new features early in XPipe developer builds
|
||||
confirmVaultUnencryptTitle=Confirm vault unencryption
|
||||
confirmVaultUnencryptHeader=Do you really want to disable advanced vault encryption?
|
||||
confirmVaultUnencryptContent=This will remove the additional encryption for stored data and will overwrite existing data.
|
||||
confirmVaultUnencryptContent=Do you really want to disable advanced vault encryption? This will remove the additional encryption for stored data and will overwrite existing data.
|
||||
enableHttpApi=Enable HTTP API
|
||||
enableHttpApiDescription=Enables the API, allowing external programs to call the XPipe daemon to perform actions with your managed connections.
|
||||
chooseCustomIcon=Choose custom icon
|
||||
@@ -559,12 +558,16 @@ userName=Username
|
||||
team=Team
|
||||
teamSettings=Team settings
|
||||
teamVaults=Team vaults
|
||||
personalVault=Personal vault
|
||||
personalVaultDescription=You are currently using a personal vault, which can have up to one user. Creating a vault user for yourself allows you to encrypt vault secrets with your own personal passphrase that you have to input on each login to unlock the vault.
|
||||
vaultType=Vault type
|
||||
teamVaultsDescription=Team vaults allow multiple users to have secure access to a shared vault. You can configure connections and identities to either be shared for all users or only have them available for your personal user by encrypting them with your personal key. Other vault users can't access your personal connections and identities.
|
||||
vaultTypeDefault=You are currently using a default vault with no user and custom passphrase set. Secrets are encrypted with the local vault key. You can upgrade to a personal vault by creating a vault user account. This allows you to encrypt vault secrets with your own personal passphrase that you have to input on each login to unlock the vault.
|
||||
vaultTypeLegacy=You are currently using a legacy personal vault for your user. Secrets are encrypted with your personal passphrase. This legacy compatibility has limited features and can't be upgraded to a team vault in-place.
|
||||
vaultTypePersonal=You are currently using a personal vault for your user. Secrets are encrypted with your personal passphrase. You can upgrade to a team vault by adding additional vault users.
|
||||
vaultTypeTeam=You are currently using a team vault, which allows multiple users to have secure access to a shared vault. You can configure connections and identities to either be shared for all users or only have them available for your personal user by encrypting them with your personal key. Other vault users can't access your personal connections and identities.
|
||||
userManagement=User management
|
||||
userManagementDescription=Manage existing vault team members or create new ones.
|
||||
userManagementDescriptionEmpty=Manage existing vault team members or create new ones. Create a user for yourself to be able to encrypt connections and identities with your personal key.\n\nA single user account is supported in the community edition. Multiple user accounts for a team are supported in the professional plan.
|
||||
#force
|
||||
userManagementDescription=Manage existing vault users or create new ones.
|
||||
userManagementDescriptionEmpty=Manage existing vault users or create new ones. Create a user for yourself to be able to encrypt connections and identities with your personal key.\n\nA single user account is supported in the community edition. Multiple user accounts for a team are supported in the professional plan.
|
||||
userIntroHeader=User management
|
||||
userIntroContent=Create the first user account for yourself to get started. This allows you to lock this workspace with a password.
|
||||
addReusableIdentity=Add reusable identity
|
||||
|
||||
Reference in New Issue
Block a user