GUACAMOLE-926: Add user-selectable options for connection batch import to allow connection / permission replacement.

This commit is contained in:
James Muehlner
2023-04-17 18:24:56 +00:00
parent d0876cdc71
commit 896cd48eca
14 changed files with 808 additions and 273 deletions

View File

@@ -90,6 +90,7 @@ angular.module('import').controller('importConnectionsController', ['$scope', '$
const userGroupService = $injector.get('userGroupService'); const userGroupService = $injector.get('userGroupService');
// Required types // Required types
const ConnectionImportConfig = $injector.get('ConnectionImportConfig');
const DirectoryPatch = $injector.get('DirectoryPatch'); const DirectoryPatch = $injector.get('DirectoryPatch');
const Error = $injector.get('Error'); const Error = $injector.get('Error');
const ParseError = $injector.get('ParseError'); const ParseError = $injector.get('ParseError');
@@ -137,6 +138,14 @@ angular.module('import').controller('importConnectionsController', ['$scope', '$
*/ */
$scope.mimeType = null; $scope.mimeType = null;
/**
* The name of the file that's currently being uploaded, or has yet to
* be imported, if any.
*
* @type {String}
*/
$scope.fileName = null;
/** /**
* The raw string contents of the uploaded file, if any. * The raw string contents of the uploaded file, if any.
* *
@@ -152,6 +161,13 @@ angular.module('import').controller('importConnectionsController', ['$scope', '$
*/ */
$scope.fileReader = null; $scope.fileReader = null;
/**
* The configuration options for this import, to be chosen by the user.
*
* @type {ConnectionImportConfig}
*/
$scope.importConfig = new ConnectionImportConfig();
/** /**
* Clear all file upload state. * Clear all file upload state.
*/ */
@@ -178,9 +194,8 @@ angular.module('import').controller('importConnectionsController', ['$scope', '$
/** /**
* Create all users and user groups mentioned in the import file that don't * Create all users and user groups mentioned in the import file that don't
* already exist in the current data source. If either creation fails, any * already exist in the current data source. Return an object describing the
* already-created entities will be cleaned up, and the returned promise * result of the creation requests.
* will be rejected.
* *
* @param {ParseResult} parseResult * @param {ParseResult} parseResult
* The result of parsing the user-supplied import file. * The result of parsing the user-supplied import file.
@@ -222,30 +237,11 @@ angular.module('import').controller('importConnectionsController', ['$scope', '$
value: new UserGroup({ identifier }) value: new UserGroup({ identifier })
})); }));
// First, create any required users and groups, automatically cleaning // Create all the users and groups
// up any created already-created entities if a call fails. return $q.all({
// NOTE: Generally we'd want to do these calls in parallel, using userResponse: userService.patchUsers(dataSource, userPatches),
// `$q.all()`. However, `$q.all()` rejects immediately if any of the userGroupResponse: userGroupService.patchUserGroups(
// wrapped promises reject, so the users may not be ready for cleanup dataSource, groupPatches)
// at the time that the group promise rejects, or vice versa. While
// it would be possible to juggle promises and still do these calls
// in parallel, the code gets pretty complex, so for readability and
// simplicity, they are executed serially. The performance cost of
// doing so should be low.
return userService.patchUsers(dataSource, userPatches).then(userResponse => {
// Then, if that succeeds, create any required groups
return userGroupService.patchUserGroups(dataSource, groupPatches).then(
// If user group creation succeeds, resolve the returned promise
userGroupResponse => ({ userResponse, userGroupResponse}))
// If the group creation request fails, clean up any created users
.catch(groupFailure => {
cleanUpUsers(userResponse);
return groupFailure;
});
}); });
}); });
@@ -322,52 +318,6 @@ angular.module('import').controller('importConnectionsController', ['$scope', '$
return $q.all({ ...userRequests, ...groupRequests }); return $q.all({ ...userRequests, ...groupRequests });
} }
// Given a PATCH API response, create an array of patches to delete every
// entity created in the original request that generated this response
const createDeletionPatches = creationResponse =>
creationResponse.patches.map(patch =>
// Add one deletion patch per original creation patch
new DirectoryPatch({
op: 'remove',
path: '/' + patch.identifier
}));
/**
* Given a successful response to a connection PATCH request, make another
* request to delete every created connection in the provided request, i.e.
* clean up every connection that was created.
*
* @param {DirectoryPatchResponse} creationResponse
* The response to the connection PATCH request.
*
* @returns {DirectoryPatchResponse}
* The response to the PATCH deletion request.
*/
function cleanUpConnections(creationResponse) {
return connectionService.patchConnections(
$routeParams.dataSource, createDeletionPatches(creationResponse));
}
/**
* Given a successful response to a user PATCH request, make another
* request to delete every created user in the provided request.
*
* @param {DirectoryPatchResponse} creationResponse
* The response to the user PATCH request.
*
* @returns {DirectoryPatchResponse}
* The response to the PATCH deletion request.
*/
function cleanUpUsers(creationResponse) {
return userService.patchUsers(
$routeParams.dataSource, createDeletionPatches(creationResponse));
}
/** /**
* Process a successfully parsed import file, creating any specified * Process a successfully parsed import file, creating any specified
* connections, creating and granting permissions to any specified users * connections, creating and granting permissions to any specified users
@@ -398,6 +348,9 @@ angular.module('import').controller('importConnectionsController', ['$scope', '$
// If connection creation is successful, create users and groups // If connection creation is successful, create users and groups
createUsersAndGroups(parseResult).then(() => createUsersAndGroups(parseResult).then(() =>
// Grant any new permissions to users and groups. NOTE: Any
// existing permissions for updated connections will NOT be
// removed - only new permissions will be added.
grantConnectionPermissions(parseResult, connectionResponse) grantConnectionPermissions(parseResult, connectionResponse)
.then(() => { .then(() => {
@@ -409,7 +362,7 @@ angular.module('import').controller('importConnectionsController', ['$scope', '$
title : 'IMPORT.DIALOG_HEADER_SUCCESS', title : 'IMPORT.DIALOG_HEADER_SUCCESS',
text : { text : {
key: 'IMPORT.INFO_CONNECTIONS_IMPORTED_SUCCESS', key: 'IMPORT.INFO_CONNECTIONS_IMPORTED_SUCCESS',
variables: { NUMBER: parseResult.patches.length } variables: { NUMBER: parseResult.connectionCount }
}, },
// Add a button to acknowledge and redirect to // Add a button to acknowledge and redirect to
@@ -428,14 +381,10 @@ angular.module('import').controller('importConnectionsController', ['$scope', '$
}); });
})) }))
// If an error occurs while trying to users or groups, or while trying // If an error occurs while trying to create users or groups,
// to assign permissions to users or groups, clean up the already-created // display the error to the user.
// connections, displaying an error to the user along with a blank slate .catch(handleError)
// so they can fix their problems and try again. )
.catch(error => {
cleanUpConnections(connectionResponse);
handleError(error);
}))
// If an error occurred when the call to create the connections was made, // If an error occurred when the call to create the connections was made,
// skip any further processing - the user will have a chance to fix the // skip any further processing - the user will have a chance to fix the
@@ -531,7 +480,7 @@ angular.module('import').controller('importConnectionsController', ['$scope', '$
}; };
// Make the call to process the data into a series of patches // Make the call to process the data into a series of patches
processDataCallback(data) processDataCallback($scope.importConfig, data)
// Send the data off to be imported if parsing is successful // Send the data off to be imported if parsing is successful
.then(handleParseSuccess) .then(handleParseSuccess)
@@ -688,9 +637,42 @@ angular.module('import').controller('importConnectionsController', ['$scope', '$
}; };
/** /**
* The name of the file that's currently being uploaded, or has yet to * Display a modal with the given title and text keys.
* be imported, if any. *
* @param {String} titleKey
* The translation key to use for the title of the modal.
*
* @param {String} contentKey
* The translation key to use for the text contents of the modal.
*/ */
$scope.fileName = null; const showModal = (titleKey, contentKey) => guacNotification.showStatus({
// The provided modal contents
title: titleKey,
text: { key: contentKey },
// Add a button to hide the modal
actions : [{
name : 'IMPORT.ACTION_ACKNOWLEDGE',
callback : () => guacNotification.showStatus(false)
}]
});
/**
* Display a modal with information about the existing connection
* replacement option.
*/
$scope.showConnectionReplaceHelp = () => showModal(
'IMPORT.HELP_REPLACE_CONNECTION_TITLE',
'IMPORT.HELP_REPLACE_CONNECTION_CONTENT');
/**
* Display a modal with information about the existing connection permission
* replacement option.
*/
$scope.showPermissionReplaceHelp = () => showModal(
'IMPORT.HELP_REPLACE_PERMISSION_TITLE',
'IMPORT.HELP_REPLACE_PERMISSION_CONTENT');
}]); }]);

View File

@@ -59,6 +59,7 @@ angular.module('import').directive('connectionImportErrors', [
function connectionImportErrorsController($scope, $injector) { function connectionImportErrorsController($scope, $injector) {
// Required types // Required types
const DirectoryPatch = $injector.get('DirectoryPatch');
const DisplayErrorList = $injector.get('DisplayErrorList'); const DisplayErrorList = $injector.get('DisplayErrorList');
const ImportConnectionError = $injector.get('ImportConnectionError'); const ImportConnectionError = $injector.get('ImportConnectionError');
const ParseError = $injector.get('ParseError'); const ParseError = $injector.get('ParseError');
@@ -93,6 +94,7 @@ angular.module('import').directive('connectionImportErrors', [
$scope.errorOrder = new SortOrder([ $scope.errorOrder = new SortOrder([
'rowNumber', 'rowNumber',
'name', 'name',
'group',
'protocol', 'protocol',
'errors', 'errors',
]); ]);
@@ -105,6 +107,7 @@ angular.module('import').directive('connectionImportErrors', [
$scope.filteredErrorProperties = [ $scope.filteredErrorProperties = [
'rowNumber', 'rowNumber',
'name', 'name',
'group',
'protocol', 'protocol',
'errors', 'errors',
]; ];
@@ -117,13 +120,18 @@ angular.module('import').directive('connectionImportErrors', [
* The result of parsing the connection import file. * The result of parsing the connection import file.
* *
* @param {Integer} index * @param {Integer} index
* The current row within the import file, 0-indexed. * The current row within the patches array, 0-indexed.
*
* @param {Integer} row
* The current row within the original connection, 0-indexed.
* If any REMOVE patches are present, this may be greater than
* the index.
* *
* @returns {ImportConnectionError} * @returns {ImportConnectionError}
* The connection error object associated with the given row in the * The connection error object associated with the given row in the
* given parse result. * given parse result.
*/ */
const generateConnectionError = (parseResult, index) => { const generateConnectionError = (parseResult, index, row) => {
// Get the patch associated with the current row // Get the patch associated with the current row
const patch = parseResult.patches[index]; const patch = parseResult.patches[index];
@@ -133,11 +141,12 @@ angular.module('import').directive('connectionImportErrors', [
return new ImportConnectionError({ return new ImportConnectionError({
// Add 1 to the index to get the position in the file // Add 1 to the provided row to get the position in the file
rowNumber: index + 1, rowNumber: row + 1,
// Basic connection information - name and protocol. // Basic connection information - name, group, and protocol.
name: connection.name, name: connection.name,
group: parseResult.groupPaths[index],
protocol: connection.protocol, protocol: connection.protocol,
// The human-readable error messages // The human-readable error messages
@@ -159,28 +168,67 @@ angular.module('import').directive('connectionImportErrors', [
// updated until all translations are ready. // updated until all translations are ready.
const translationPromises = []; const translationPromises = [];
// Any error returned from the API specifically associated with the
// preceding REMOVE patch
let removeError = null;
// Fetch the API error, if any, of the patch at the given index
const getAPIError = index =>
_.get(patchFailure, ['patches', index, 'error']);
// The row number for display. Unlike the index, this number will
// skip any REMOVE patches. In other words, this is the index of
// connections within the original import file.
let row = 0;
// Set up the list of connection errors based on the existing parse // Set up the list of connection errors based on the existing parse
// result, with error messages fetched from the patch failure // result, with error messages fetched from the patch failure
const connectionErrors = parseResult.patches.map( const connectionErrors = parseResult.patches.reduce(
(patch, index) => { (errors, patch, index) => {
// Do not process display REMOVE patches - they are always
// followed by ADD patches containing the actual content
// (and errors, if any)
if (patch.op === DirectoryPatch.Operation.REMOVE) {
// Save the API error, if any, so it can be displayed
// alongside the connection information associated with the
// following ADD patch
removeError = getAPIError(index);
// Do not add an entry for this remove patch - it should
// always be followed by a corresponding CREATE patch
// containing the relevant connection information
return errors;
}
// Generate a connection error for display // Generate a connection error for display
const connectionError = generateConnectionError(parseResult, index); const connectionError = generateConnectionError(
parseResult, index, row++);
// Set the error from the PATCH request, if there is one // Add the error associated with the previous REMOVE patch, if
const error = _.get(patchFailure, ['patches', index, 'error']); // any, to the error associated with the current patch, if any
if (error) const apiErrors = [ removeError, getAPIError(index) ];
// Fetch the translation and update it when it's ready // Clear the previous REMOVE patch error after consuming it
translationPromises.push($translate( removeError = null;
// Go through each potential API error
apiErrors.forEach(error =>
// If the API error exists, fetch the translation and
// update it when it's ready
error && translationPromises.push($translate(
error.key, error.variables) error.key, error.variables)
.then(translatedError => .then(translatedError =>
connectionError.errors.getArray().push(translatedError) connectionError.errors.getArray().push(translatedError)
)); )));
return connectionError; errors.push(connectionError);
return errors;
}); }, []);
// Once all the translations have been completed, update the // Once all the translations have been completed, update the
// connectionErrors all in one go, to ensure no excessive reloading // connectionErrors all in one go, to ensure no excessive reloading
@@ -206,13 +254,25 @@ angular.module('import').directive('connectionImportErrors', [
// entirely - if set, they will be from the previous file and no // entirely - if set, they will be from the previous file and no
// longer relevant. // longer relevant.
// The row number for display. Unlike the index, this number will
// skip any REMOVE patches. In other words, this is the index of
// connections within the original import file.
let row = 0;
// Set up the list of connection errors based on the updated parse // Set up the list of connection errors based on the updated parse
// result // result
const connectionErrors = parseResult.patches.map( const connectionErrors = parseResult.patches.reduce(
(patch, index) => { (errors, patch, index) => {
// Do not process display REMOVE patches - they are always
// followed by ADD patches containing the actual content
// (and errors, if any)
if (patch.op === DirectoryPatch.Operation.REMOVE)
return errors;
// Generate a connection error for display // Generate a connection error for display
const connectionError = generateConnectionError(parseResult, index); const connectionError = generateConnectionError(
parseResult, index, row++);
// Go through the errors and check if any are translateable // Go through the errors and check if any are translateable
connectionError.errors.getArray().forEach( connectionError.errors.getArray().forEach(
@@ -231,12 +291,19 @@ angular.module('import').directive('connectionImportErrors', [
connectionError.errors.getArray()[errorIndex] = translatedError; connectionError.errors.getArray()[errorIndex] = translatedError;
})); }));
}); // If the error is not a known translatable type, add the
// message directly to the error array
return connectionError; else
connectionError.errors.getArray()[errorIndex] = (
error.message ? error.message : error);
}); });
errors.push(connectionError);
return errors;
}, []);
// Once all the translations have been completed, update the // Once all the translations have been completed, update the
// connectionErrors all in one go, to ensure no excessive reloading // connectionErrors all in one go, to ensure no excessive reloading
$q.all(translationPromises).then(() => { $q.all(translationPromises).then(() => {

View File

@@ -42,7 +42,9 @@ angular.module('import').factory('connectionParseService',
// Required types // Required types
const Connection = $injector.get('Connection'); const Connection = $injector.get('Connection');
const ConnectionImportConfig = $injector.get('ConnectionImportConfig');
const DirectoryPatch = $injector.get('DirectoryPatch'); const DirectoryPatch = $injector.get('DirectoryPatch');
const ImportConnection = $injector.get('ImportConnection');
const ParseError = $injector.get('ParseError'); const ParseError = $injector.get('ParseError');
const ParseResult = $injector.get('ParseResult'); const ParseResult = $injector.get('ParseResult');
const TranslatableMessage = $injector.get('TranslatableMessage'); const TranslatableMessage = $injector.get('TranslatableMessage');
@@ -92,42 +94,69 @@ angular.module('import').factory('connectionParseService',
} }
/** /**
* Returns a promise that resolves to an object containing both a map of * A collection of connection-group-tree-derived maps that are useful for
* connection group paths to group identifiers and a set of all known group * processing connections.
* identifiers.
* *
* The resolved object will contain a "groupLookups" key with a map of group * @constructor
* paths to group identifier, as well as a "identifierSet" key containing a * @param {TreeLookups|{}} template
* set of all known group identifiers. * The object whose properties should be copied within the new
* * ConnectionImportConfig.
* The idea is that a user-provided import file might directly specify a
* parentIdentifier, or it might specify a named group path like "ROOT",
* "ROOT/parent", or "ROOT/parent/child". The resolved "groupLookups" field
* will map all of the above to the identifier of the appropriate group, if
* defined. The "identifierSet" field can be used to check if a given group
* identifier is known.
*
* @returns {Promise.<Object>}
* A promise that resolves to an object containing a map of group paths
* to group identifiers, as well as set of all known group identifiers.
*/ */
function getGroupLookups() { const TreeLookups = template => ({
/**
* A map of all known group paths to the corresponding identifier for
* that group. The is that a user-provided import file might directly
* specify a named group path like "ROOT", "ROOT/parent", or
* "ROOT/parent/child". This field field will map all of the above to
* the identifier of the appropriate group, if defined.
*
* @type Object.<String, String>
*/
groupPathsByIdentifier: template.groupPathsByIdentifier || {},
/**
* A map of all known group identifiers to the path of the corresponding
* group. These paths are all of the form "ROOT/parent/child".
*
* @type Object.<String, String>
*/
groupIdentifiersByPath: template.groupIdentifiersByPath || {},
/**
* A map of group identifier, to connection name, to connection
* identifier. These paths are all of the form "ROOT/parent/child". The
* idea is that existing connections can be found by checking if a
* connection already exists with the same parent group, and with the
* same name as an user-supplied import connection.
*
* @type Object.<String, String>
*/
connectionIdsByGroupAndName : template.connectionIdsByGroupAndName || {}
});
/**
* Returns a promise that resolves to a TreeLookups object containing maps
* useful for processing user-supplied connections to be imported, derived
* from the current connection group tree, starting at the ROOT group.
*
* @returns {Promise.<TreeLookups>}
* A promise that resolves to a TreeLookups object containing maps
* useful for processing connections.
*/
function getTreeLookups() {
// The current data source - defines all the groups that the connections // The current data source - defines all the groups that the connections
// might be imported into // might be imported into
const dataSource = $routeParams.dataSource; const dataSource = $routeParams.dataSource;
const deferredGroupLookups = $q.defer(); const deferredTreeLookups = $q.defer();
connectionGroupService.getConnectionGroupTree(dataSource).then( connectionGroupService.getConnectionGroupTree(dataSource).then(
rootGroup => { rootGroup => {
// An object mapping group paths to group identifiers const lookups = new TreeLookups({});
const groupLookups = {};
// An object mapping group identifiers to the boolean value true,
// i.e. a set of all known group identifiers
const identifierSet = {};
// Add the specified group to the lookup, appending all specified // Add the specified group to the lookup, appending all specified
// prefixes, and then recursively call saveLookups for all children // prefixes, and then recursively call saveLookups for all children
@@ -137,11 +166,18 @@ angular.module('import').factory('connectionParseService',
// To get the path for the current group, add the name // To get the path for the current group, add the name
const currentPath = prefix + group.name; const currentPath = prefix + group.name;
// Add the current path to the lookup // Add the current path to the identifier map
groupLookups[currentPath] = group.identifier; lookups.groupPathsByIdentifier[currentPath] = group.identifier;
// Add this group identifier to the set // Add the current identifier to the path map
identifierSet[group.identifier] = true; lookups.groupIdentifiersByPath[group.identifier] = currentPath;
// Add each connection to the connection map
_.forEach(group.childConnections,
connection => _.setWith(
lookups.connectionIdsByGroupAndName,
[group.identifier, connection.name],
connection.identifier, Object));
// Add each child group to the lookup // Add each child group to the lookup
const nextPrefix = currentPath + "/"; const nextPrefix = currentPath + "/";
@@ -154,68 +190,93 @@ angular.module('import').factory('connectionParseService',
saveLookups("", rootGroup); saveLookups("", rootGroup);
// Resolve with the now fully-populated lookups // Resolve with the now fully-populated lookups
deferredGroupLookups.resolve({ groupLookups, identifierSet }); deferredTreeLookups.resolve(lookups);
}); });
return deferredGroupLookups.promise; return deferredTreeLookups.promise;
} }
/** /**
* Returns a promise that will resolve to a transformer function that will * Returns a promise that will resolve to a transformer function that will
* take an object that may contain a "group" field, replacing it if present * perform various checks and transforms relating to the connection group
* with a "parentIdentifier". If both a "group" and "parentIdentifier" field * tree heirarchy. It will:
* are present on the provided object, or if no group exists at the specified * - Ensure that a connection specifies either a valid group path (no path
* path, the function will throw a ParseError describing the failure. * defaults to ROOT), or a valid parent group identifier, but not both
* - Ensure that this connection does not duplicate another connection
* earlier in the import file
* - Handle import connections that match existing connections connections
* based on the provided import config.
* *
* The group may begin with the root identifier, a leading slash, or may omit * The group set on the connection may begin with the root identifier, a
* the root identifier entirely. Additionally, the group may optionally end * leading slash, or may omit the root identifier entirely. The group may
* with a trailing slash. * optionally end with a trailing slash.
* *
* @returns {Promise.<Function<Object, Object>>} * @param {ConnectionImportConfig} importConfig
* A promise that will resolve to a function that will transform a * The configuration options selected by the user prior to import.
* "group" field into a "parentIdentifier" field if possible. *
* @returns {Promise.<Function<ImportConnection, ImportConnection>>}
* A promise that will resolve to a function that will apply various
* connection tree based checks and transforms to this connection.
*/ */
function getGroupTransformer() { function getTreeTransformer(importConfig) {
return getGroupLookups().then(({groupLookups, identifierSet}) =>
connection => {
const parentIdentifier = connection.parentIdentifier; // A map of group path with connection name, to connection object, used
// for detecting duplicate connections within the import file itself
const connectionsInFile = {};
// If there's no group path defined for this connection return getTreeLookups().then(treeLookups => connection => {
if (!connection.group) {
// If the specified parentIdentifier is not specified const { groupPathsByIdentifier, groupIdentifiersByPath,
// at all, or valid, there's nothing to be done connectionIdsByGroupAndName } = treeLookups;
if (!parentIdentifier || identifierSet[parentIdentifier])
return connection;
// If a parent group identifier is present, but not valid const providedIdentifier = connection.parentIdentifier;
if (parentIdentifier)
throw new ParseError({ // The normalized group path for this connection, of the form
message: 'No group with identifier: ' + parentIdentifier, // "ROOT/parent/child"
key: 'IMPORT.ERROR_INVALID_GROUP_IDENTIFIER', let group;
variables: { IDENTIFIER: parentIdentifier }
}); // The identifier for the parent group of this connection
} let parentIdentifier;
// The operator to apply for this connection
let op = DirectoryPatch.Operation.ADD;
// If both are specified, the parent group is ambigious // If both are specified, the parent group is ambigious
if (parentIdentifier) if (providedIdentifier && connection.group)
throw new ParseError({ throw new ParseError({
message: 'Only one of group or parentIdentifier can be set', message: 'Only one of group or parentIdentifier can be set',
key: 'IMPORT.ERROR_AMBIGUOUS_PARENT_GROUP' key: 'IMPORT.ERROR_AMBIGUOUS_PARENT_GROUP'
}); });
// The group path extracted from the user-provided connection, to be // If a parent group identifier is present, but not valid
// translated if needed into an absolute path from the root group else if (providedIdentifier && !groupPathsByIdentifier[providedIdentifier])
let group = connection.group; throw new ParseError({
message: 'No group with identifier: ' + parentIdentifier,
key: 'IMPORT.ERROR_INVALID_GROUP_IDENTIFIER',
variables: { IDENTIFIER: parentIdentifier }
});
// Allow the group to start with a leading slash instead instead of // If the parent identifier is valid, use it to determine the path
// explicitly requiring the root connection group else if (providedIdentifier) {
parentIdentifier = providedIdentifier;
group = groupPathsByIdentifier[providedIdentifier];
}
// If a user-supplied group path is provided, attempt to normalize
// and match it to an existing connection group
else if (connection.group) {
// The group path extracted from the user-provided connection,
// to be translated into an absolute path starting at the root
group = connection.group;
// Allow the group to start with a leading slash instead instead
// of explicitly requiring the root connection group
if (group.startsWith('/')) if (group.startsWith('/'))
group = ROOT_GROUP_IDENTIFIER + group; group = ROOT_GROUP_IDENTIFIER + group;
// Allow groups to begin directly with the path underneath the root // Allow groups to begin directly with the path under the root
else if (!group.startsWith(ROOT_GROUP_IDENTIFIER)) else if (!group.startsWith(ROOT_GROUP_IDENTIFIER))
group = ROOT_GROUP_IDENTIFIER + '/' + group; group = ROOT_GROUP_IDENTIFIER + '/' + group;
@@ -224,30 +285,80 @@ angular.module('import').factory('connectionParseService',
group = group.slice(0, -1); group = group.slice(0, -1);
// Look up the parent identifier for the specified group path // Look up the parent identifier for the specified group path
const identifier = groupLookups[group]; parentIdentifier = groupPathsByIdentifier[group];
// If the group doesn't match anything in the tree // If the group doesn't match anything in the tree
if (!identifier) if (!parentIdentifier)
throw new ParseError({ throw new ParseError({
message: 'No group found named: ' + connection.group, message: 'No group found named: ' + connection.group,
key: 'IMPORT.ERROR_INVALID_GROUP', key: 'IMPORT.ERROR_INVALID_GROUP',
variables: { GROUP: connection.group } variables: { GROUP: connection.group }
}); });
// Set the parent identifier now that it's known }
return {
...connection, // If no group is specified at all, default to the root group
parentIdentifier: identifier else {
}; parentIdentifier = ROOT_GROUP_IDENTIFIER;
group = ROOT_GROUP_IDENTIFIER;
}
// The full path, of the form "ROOT/Child Group/Connection Name"
const path = group + '/' + connection.name;
// Error out if this is a duplicate of a connection already in the
// file
if (!!_.get(connectionsInFile, path))
throw new ParseError({
message: 'Duplicate connection in file: ' + path,
key: 'IMPORT.ERROR_DUPLICATE_CONNECTION_IN_FILE',
variables: { PATH: path }
});
// Mark the current path as already seen in the file
_.setWith(connectionsInFile, path, connection, Object);
// Check if this would be an update to an existing connection
const existingIdentifier = _.get(connectionIdsByGroupAndName,
[parentIdentifier, connection.name]);
let importMode;
let identifier;
// If updates to existing connections are disallowed
if (existingIdentifier && importConfig.replaceConnectionMode ===
ConnectionImportConfig.ReplaceConnectionMode.REJECT)
throw new ParseError({
message: 'Rejecting update to existing connection: ' + path,
key: 'IMPORT.ERROR_REJECT_UPDATE_CONNECTION',
variables: { PATH: path }
});
// If the connection is being replaced, set the existing identifer
else if (existingIdentifier) {
identifier = existingIdentifier;
importMode = ImportConnection.ImportMode.REPLACE;
}
// Otherwise, just create a new connection
else
importMode = ImportConnection.ImportMode.CREATE;
// Set the import mode, normalized path, and validated identifier
return new ImportConnection({ ...connection,
importMode, group, identifier, parentIdentifier });
}); });
} }
/** /**
* Convert a provided ImportConnection array into a ParseResult. Any provided * Convert a provided connection array into a ParseResult. Any provided
* transform functions will be run on each entry in `connectionData` before * transform functions will be run on each entry in `connectionData` before
* any other processing is done. * any other processing is done.
* *
* @param {ConnectionImportConfig} importConfig
* The configuration options selected by the user prior to import.
*
* @param {*[]} connectionData * @param {*[]} connectionData
* An arbitrary array of data. This must evaluate to a ImportConnection * An arbitrary array of data. This must evaluate to a ImportConnection
* object after being run through all functions in `transformFunctions`. * object after being run through all functions in `transformFunctions`.
@@ -256,11 +367,12 @@ angular.module('import').factory('connectionParseService',
* An array of transformation functions to run on each entry in * An array of transformation functions to run on each entry in
* `connection` data. * `connection` data.
* *
* @return {Promise.<Object>} * @return {Promise.<ParseResult>}
* A promise resolving to ParseResult object representing the result of * A promise resolving to ParseResult object representing the result of
* parsing all provided connection data. * parsing all provided connection data.
*/ */
function parseConnectionData(connectionData, transformFunctions) { function parseConnectionData(
importConfig, connectionData, transformFunctions) {
// Check that the provided connection data array is not empty // Check that the provided connection data array is not empty
const checkError = performBasicChecks(connectionData); const checkError = performBasicChecks(connectionData);
@@ -270,11 +382,13 @@ angular.module('import').factory('connectionParseService',
return deferred.promise; return deferred.promise;
} }
// Get the group transformer to apply to each connection let index = 0;
return getGroupTransformer().then(groupTransformer =>
connectionData.reduce((parseResult, data, index) => {
const { patches, users, groups } = parseResult; // Get the group transformer to apply to each connection
return getTreeTransformer(importConfig).then(treeTransformer =>
connectionData.reduce((parseResult, data) => {
const { patches, users, groups, groupPaths } = parseResult;
// Run the array data through each provided transform // Run the array data through each provided transform
let connectionObject = data; let connectionObject = data;
@@ -284,24 +398,77 @@ angular.module('import').factory('connectionParseService',
// All errors found while parsing this connection // All errors found while parsing this connection
const connectionErrors = []; const connectionErrors = [];
parseResult.errors.push(connectionErrors);
// Translate the group on the object to a parentIdentifier // Determine the connection's place in the connection group tree
try { try {
connectionObject = groupTransformer(connectionObject); connectionObject = treeTransformer(connectionObject);
} }
// If there was a problem with the group or parentIdentifier // If there was a problem with the connection group heirarchy
catch (error) { catch (error) {
connectionErrors.push(error); connectionErrors.push(error);
} }
// The users and user groups that should be granted access // If there are any errors for this connection, fail the whole batch
const connectionUsers = connectionObject.users || []; if (connectionErrors.length)
const connectionGroups = connectionObject.groups || []; parseResult.hasErrors = true;
// The value for the patch is a full-fledged Connection
const value = new Connection(connectionObject);
// If a new connection is being created
if (connectionObject.importMode
=== ImportConnection.ImportMode.CREATE)
// Add a patch for creating the connection
patches.push(new DirectoryPatch({
op: DirectoryPatch.Operation.ADD,
path: '/',
value
}));
// The connection is being replaced, and permissions are only being
// added, not replaced
else if (importConfig.existingPermissionMode ===
ConnectionImportConfig.ExistingPermissionMode.ADD)
// Add a patch for replacing the connection
patches.push(new DirectoryPatch({
op: DirectoryPatch.Operation.REPLACE,
path: '/' + connectionObject.identifier,
value
}));
// The connection is being replaced, and permissions are also being
// replaced
else {
// Add a patch for removing the existing connection
patches.push(new DirectoryPatch({
op: DirectoryPatch.Operation.REMOVE,
path: '/' + connectionObject.identifier
}));
// Increment the index for the additional remove patch
index += 1;
// Add a second patch for creating the replacement connection
patches.push(new DirectoryPatch({
op: DirectoryPatch.Operation.ADD,
path: '/',
value
}));
}
// Save the connection group path into the parse result
groupPaths[index] = connectionObject.group;
// Save the errors for this connection into the parse result
parseResult.errors[index] = connectionErrors;
// Add this connection index to the list for each user // Add this connection index to the list for each user
connectionUsers.forEach(identifier => { _.forEach(connectionObject.users, identifier => {
// If there's an existing list, add the index to that // If there's an existing list, add the index to that
if (users[identifier]) if (users[identifier])
@@ -313,7 +480,7 @@ angular.module('import').factory('connectionParseService',
}); });
// Add this connection index to the list for each group // Add this connection index to the list for each group
connectionGroups.forEach(identifier => { _.forEach(connectionObject.groups, identifier => {
// If there's an existing list, add the index to that // If there's an existing list, add the index to that
if (groups[identifier]) if (groups[identifier])
@@ -324,20 +491,10 @@ angular.module('import').factory('connectionParseService',
groups[identifier] = [index]; groups[identifier] = [index];
}); });
// Translate to a full-fledged Connection // Return the existing parse result state and continue on to the
const connection = new Connection(connectionObject); // next connection in the file
index++;
// Finally, add a patch for creating the connection parseResult.connectionCount++;
patches.push(new DirectoryPatch({
op: 'add',
path: '/',
value: connection
}));
// If there are any errors for this connection, fail the whole batch
if (connectionErrors.length)
parseResult.hasErrors = true;
return parseResult; return parseResult;
}, new ParseResult())); }, new ParseResult()));
@@ -349,6 +506,9 @@ angular.module('import').factory('connectionParseService',
* objects containing lists of user and user group identifiers to be granted * objects containing lists of user and user group identifiers to be granted
* to each connection. * to each connection.
* *
* @param {ConnectionImportConfig} importConfig
* The configuration options selected by the user prior to import.
*
* @param {String} csvData * @param {String} csvData
* The CSV-encoded connection list to process. * The CSV-encoded connection list to process.
* *
@@ -356,7 +516,7 @@ angular.module('import').factory('connectionParseService',
* A promise resolving to ParseResult object representing the result of * A promise resolving to ParseResult object representing the result of
* parsing all provided connection data. * parsing all provided connection data.
*/ */
service.parseCSV = function parseCSV(csvData) { service.parseCSV = function parseCSV(importConfig, csvData) {
// Convert to an array of arrays, one per CSV row (including the header) // Convert to an array of arrays, one per CSV row (including the header)
// NOTE: skip_empty_lines is required, or a trailing newline will error // NOTE: skip_empty_lines is required, or a trailing newline will error
@@ -405,7 +565,8 @@ angular.module('import').factory('connectionParseService',
csvTransformer => csvTransformer =>
// Apply the CSV transform to every row // Apply the CSV transform to every row
parseConnectionData(connectionData, [csvTransformer])); parseConnectionData(
importConfig, connectionData, [csvTransformer]));
}; };
@@ -415,6 +576,9 @@ angular.module('import').factory('connectionParseService',
* objects containing lists of user and user group identifiers to be granted * objects containing lists of user and user group identifiers to be granted
* to each connection. * to each connection.
* *
* @param {ConnectionImportConfig} importConfig
* The configuration options selected by the user prior to import.
*
* @param {String} yamlData * @param {String} yamlData
* The YAML-encoded connection list to process. * The YAML-encoded connection list to process.
* *
@@ -422,7 +586,7 @@ angular.module('import').factory('connectionParseService',
* A promise resolving to ParseResult object representing the result of * A promise resolving to ParseResult object representing the result of
* parsing all provided connection data. * parsing all provided connection data.
*/ */
service.parseYAML = function parseYAML(yamlData) { service.parseYAML = function parseYAML(importConfig, yamlData) {
// Parse from YAML into a javascript array // Parse from YAML into a javascript array
let connectionData; let connectionData;
@@ -443,7 +607,7 @@ angular.module('import').factory('connectionParseService',
} }
// Produce a ParseResult // Produce a ParseResult
return parseConnectionData(connectionData); return parseConnectionData(importConfig, connectionData);
}; };
/** /**
@@ -452,6 +616,9 @@ angular.module('import').factory('connectionParseService',
* as a list of objects containing lists of user and user group identifiers * as a list of objects containing lists of user and user group identifiers
* to be granted to each connection. * to be granted to each connection.
* *
* @param {ConnectionImportConfig} importConfig
* The configuration options selected by the user prior to import.
*
* @param {String} jsonData * @param {String} jsonData
* The JSON-encoded connection list to process. * The JSON-encoded connection list to process.
* *
@@ -459,7 +626,7 @@ angular.module('import').factory('connectionParseService',
* A promise resolving to ParseResult object representing the result of * A promise resolving to ParseResult object representing the result of
* parsing all provided connection data. * parsing all provided connection data.
*/ */
service.parseJSON = function parseJSON(jsonData) { service.parseJSON = function parseJSON(importConfig, jsonData) {
// Parse from JSON into a javascript array // Parse from JSON into a javascript array
let connectionData; let connectionData;
@@ -480,7 +647,7 @@ angular.module('import').factory('connectionParseService',
} }
// Produce a ParseResult // Produce a ParseResult
return parseConnectionData(connectionData); return parseConnectionData(importConfig, connectionData);
}; };

View File

@@ -158,3 +158,33 @@
cursor: pointer; cursor: pointer;
} }
.file-upload-container .import-config {
margin-top: 0.5em;
list-style: none;
width: 100%;
padding-left: 0;
}
.file-upload-container .import-config .help {
visibility: hidden;
cursor: pointer;
}
.file-upload-container .import-config .help::after {
content: '';
visibility: visible;
display: inline-block;
background-image: url('images/question.svg');
background-size: contain;
width: 20px;
height: 20px;
position: relative;
top: 4px;
}

View File

@@ -15,6 +15,9 @@
<th guac-sort-order="errorOrder" guac-sort-property="'name'"> <th guac-sort-order="errorOrder" guac-sort-property="'name'">
{{'IMPORT.TABLE_HEADER_NAME' | translate}} {{'IMPORT.TABLE_HEADER_NAME' | translate}}
</th> </th>
<th guac-sort-order="errorOrder" guac-sort-property="'group'">
{{'IMPORT.TABLE_HEADER_GROUP' | translate}}
</th>
<th guac-sort-order="errorOrder" guac-sort-property="'protocol'"> <th guac-sort-order="errorOrder" guac-sort-property="'protocol'">
{{'IMPORT.TABLE_HEADER_PROTOCOL' | translate}} {{'IMPORT.TABLE_HEADER_PROTOCOL' | translate}}
</th> </th>
@@ -27,6 +30,7 @@
<tr ng-repeat="error in errorPage"> <tr ng-repeat="error in errorPage">
<td>{{error.rowNumber}}</td> <td>{{error.rowNumber}}</td>
<td>{{error.name}}</td> <td>{{error.name}}</td>
<td>{{error.group}}</td>
<td>{{error.protocol}}</td> <td>{{error.protocol}}</td>
<td class="error-message" ng-class="{ 'has-errors' : error.errors.getArray().length }"> <td class="error-message" ng-class="{ 'has-errors' : error.errors.getArray().length }">
<ul> <ul>

View File

@@ -38,6 +38,28 @@
</div> </div>
<ul class="import-config">
<li>
<input type="checkbox"
id="replace-connections" ng-model="importConfig.replaceConnectionMode"
ng-true-value="'REPLACE'" ng-false-value="'REJECT'" />
<label for="replace-connections">
{{'IMPORT.FIELD_HEADER_REPLACE_CONNECTIONS' | translate}}
</label>
<span ng-click="showConnectionReplaceHelp()" class="help"></span>
</li>
<li>
<input type="checkbox"
id="replace-permissions" ng-model="importConfig.existingPermissionMode"
ng-disabled="importConfig.replaceConnectionMode === 'REJECT'"
ng-true-value="'REPLACE'" ng-false-value="'ADD'" />
<label for="replace-permissions">
{{'IMPORT.FIELD_HEADER_REPLACE_PERMISSIONS' | translate}}
</label>
<span ng-click="showPermissionReplaceHelp()" class="help"></span>
</li>
</ul>
</div> </div>
<div class="import-buttons"> <div class="import-buttons">

View File

@@ -0,0 +1,103 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Service which defines the ConnectionImportConfig class.
*/
angular.module('import').factory('ConnectionImportConfig', [
function defineConnectionImportConfig() {
/**
* A representation of any user-specified configuration when
* batch-importing connections.
*
* @constructor
* @param {ConnectionImportConfig|Object} [template={}]
* The object whose properties should be copied within the new
* ConnectionImportConfig.
*/
const ConnectionImportConfig = function ConnectionImportConfig(template) {
// Use empty object by default
template = template || {};
/**
* The mode for handling connections that match existing connections.
*
* @type ConnectionImportConfig.ReplaceConnectionMode
*/
this.replaceConnectionMode = template.replaceConnectionMode
|| ConnectionImportConfig.ReplaceConnectionMode.REJECT;
/**
* The mode for handling permissions on existing connections that are
* being updated. Only meaningful if the importer is configured to
* replace existing connections.
*
* @type ConnectionImportConfig.ExistingPermissionMode
*/
this.existingPermissionMode = template.existingPermissionMode
|| ConnectionImportConfig.ExistingPermissionMode.ADD;
};
/**
* Valid modes for the behavior of the importer when attempts are made to
* update existing connections.
*/
ConnectionImportConfig.ReplaceConnectionMode = {
/**
* Any attempt to update existing connections will cause the entire
* import to be rejected with an error.
*/
REJECT : "REJECT",
/**
* Replace/update any existing connections.
*/
REPLACE : "REPLACE"
};
/**
* Valid modes for the behavior of the importer with respect to connection
* permissions when existing connections are being replaced.
*/
ConnectionImportConfig.ExistingPermissionMode = {
/**
* Any new permissions specified in the imported connection will be
* added to the existing connection, without removing any existing
* permissions.
*/
ADD : "ADD",
/**
* Any existing permissions will be removed, ensuring that only the
* users or groups specified in the import file will be granted to the
* replaced connection after import.
*/
REPLACE : "REPLACE"
};
return ConnectionImportConfig;
}]);

View File

@@ -20,8 +20,8 @@
/** /**
* Service which defines the ImportConnection class. * Service which defines the ImportConnection class.
*/ */
angular.module('import').factory('ImportConnection', [ angular.module('import').factory('ImportConnection', ['$injector',
function defineImportConnection() { function defineImportConnection($injector) {
/** /**
* A representation of a connection to be imported, as parsed from an * A representation of a connection to be imported, as parsed from an
@@ -53,6 +53,12 @@ angular.module('import').factory('ImportConnection', [
*/ */
this.group = template.group; this.group = template.group;
/**
* The identifier of the connection being updated. Only meaningful if
* the replace operation is set.
*/
this.identifier = template.identifier;
/** /**
* The human-readable name of this connection, which is not necessarily * The human-readable name of this connection, which is not necessarily
* unique. * unique.
@@ -102,6 +108,31 @@ angular.module('import').factory('ImportConnection', [
*/ */
this.groups = template.groups || []; this.groups = template.groups || [];
/**
* The mode import mode for this connection. If not otherwise specified,
* a brand new connection should be created.
*/
this.importMode = template.importMode || ImportConnection.ImportMode.CREATE;
};
/**
* The possible import modes for a given connection.
*/
ImportConnection.ImportMode = {
/**
* The connection should be created fresh. This mode is valid IFF there
* is no existing connection with the same name and parent group.
*/
CREATE : "CREATE",
/**
* This connection will replace the existing connection with the same
* name and parent group.
*/
REPLACE : "REPLACE"
}; };
return ImportConnection; return ImportConnection;

View File

@@ -56,6 +56,14 @@ angular.module('import').factory('ImportConnectionError', ['$injector',
*/ */
this.name = template.name; this.name = template.name;
/**
* The human-readable connection group path for this connection, of the
* form "ROOT/Parent/Child".
*
* @type String
*/
this.group = template.group;
/** /**
* The name of the protocol associated with this connection, such as * The name of the protocol associated with this connection, such as
* "vnc" or "rdp". * "vnc" or "rdp".

View File

@@ -25,9 +25,9 @@ angular.module('import').factory('ParseResult', [function defineParseResult() {
/** /**
* The result of parsing a connection import file - containing a list of * The result of parsing a connection import file - containing a list of
* API patches ready to be submitted to the PATCH REST API for batch * API patches ready to be submitted to the PATCH REST API for batch
* connection creation, a set of users and user groups to grant access to * connection creation/replacement, a set of users and user groups to grant
* each connection, and any errors that may have occurred while parsing * access to each connection, a group path for every connection, and any
* each connection. * errors that may have occurred while parsing each connection.
* *
* @constructor * @constructor
* @param {ParseResult|Object} [template={}] * @param {ParseResult|Object} [template={}]
@@ -41,7 +41,10 @@ angular.module('import').factory('ParseResult', [function defineParseResult() {
/** /**
* An array of patches, ready to be submitted to the PATCH REST API for * An array of patches, ready to be submitted to the PATCH REST API for
* batch connection creation. * batch connection creation / replacement. Note that this array may
* contain more patches than connections from the original file - in the
* case that connections are being fully replaced, there will be a
* remove and a create patch for each replaced connection.
* *
* @type {DirectoryPatch[]} * @type {DirectoryPatch[]}
*/ */
@@ -50,7 +53,8 @@ angular.module('import').factory('ParseResult', [function defineParseResult() {
/** /**
* An object whose keys are the user identifiers of users specified * An object whose keys are the user identifiers of users specified
* in the batch import, and whose values are an array of indices of * in the batch import, and whose values are an array of indices of
* connections to which those users should be granted access. * connections within the patches array to which those users should be
* granted access.
* *
* @type {Object.<String, Integer[]>} * @type {Object.<String, Integer[]>}
*/ */
@@ -65,10 +69,19 @@ angular.module('import').factory('ParseResult', [function defineParseResult() {
*/ */
this.groups = template.users || {}; this.groups = template.users || {};
/**
* A map of connection index within the patch array, to connection group
* path for that connection, of the form "ROOT/Parent/Child".
*
* @type {Object.<String, String>}
*/
this.groupPaths = template.groupPaths || {};
/** /**
* An array of errors encountered while parsing the corresponding * An array of errors encountered while parsing the corresponding
* connection (at the same array index). Each connection should have a * connection (at the same array index in the patches array). Each
* an array of errors. If empty, no errors occurred for this connection. * connection should have a an array of errors. If empty, no errors
* occurred for this connection.
* *
* @type {ParseError[][]} * @type {ParseError[][]}
*/ */
@@ -79,9 +92,20 @@ angular.module('import').factory('ParseResult', [function defineParseResult() {
* represented by this ParseResult. This should always be true if there * represented by this ParseResult. This should always be true if there
* are a non-zero number of elements in the errors list for any * are a non-zero number of elements in the errors list for any
* connection, or false otherwise. * connection, or false otherwise.
*
* @type {Boolean}
*/ */
this.hasErrors = template.hasErrors || false; this.hasErrors = template.hasErrors || false;
/**
* The integer number of unique connections present in the parse result.
* This may be less than the length of the patches array, if any REMOVE
* patches are present.
*
* @Type {Number}
*/
this.connectionCount = template.connectionCount || 0;
}; };
return ParseResult; return ParseResult;

View File

@@ -110,6 +110,24 @@ angular.module('settings').directive('guacSettingsConnections', [function guacSe
}; };
/**
* Returns whether the current user has the ADMINISTER system
* permission (i.e. they are an administrator).
*
* @return {Boolean}
* true if the current user is an administrator.
*/
$scope.canAdminister = function canAdminister() {
// Abort if permissions have not yet loaded
if (!$scope.permissions)
return false;
// Return whether the current user is an administrator
return PermissionSet.hasSystemPermission(
$scope.permissions, PermissionSet.SystemPermissionType.ADMINISTER);
};
/** /**
* Returns whether the current user can create new connections * Returns whether the current user can create new connections
* within the current data source. * within the current data source.

View File

@@ -10,7 +10,7 @@
<div class="action-buttons"> <div class="action-buttons">
<a class="import-connections button" <a class="import-connections button"
ng-show="canCreateConnections()" ng-show="canAdminister()"
href="#/import/{{dataSource | escape}}/connection/">{{'SETTINGS_CONNECTIONS.ACTION_IMPORT' | translate}}</a> href="#/import/{{dataSource | escape}}/connection/">{{'SETTINGS_CONNECTIONS.ACTION_IMPORT' | translate}}</a>
<a class="add-connection button" <a class="add-connection button"

View File

@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="64"
height="64"
viewBox="0 0 64 64"
version="1.1"
id="svg10"
sodipodi:docname="question.svg"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs14" />
<sodipodi:namedview
id="namedview12"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
showgrid="false"
inkscape:zoom="8"
inkscape:cx="38.5625"
inkscape:cy="31.0625"
inkscape:window-width="2048"
inkscape:window-height="1025"
inkscape:window-x="0"
inkscape:window-y="28"
inkscape:window-maximized="1"
inkscape:current-layer="g8" />
<g
style="stroke-width:1.04766"
id="g8">
<g
style="font-style:normal;font-weight:400;font-size:142.558px;line-height:100%;font-family:Sans;letter-spacing:0;word-spacing:0;fill:#000000;fill-opacity:1;stroke:none;stroke-width:3.20748px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="g6">
<path
d="m 169.301,354.693 c -60.57527,-41.3594 -30.28763,-20.6797 0,0 z m -0.627,90.839 c -60.15727,-101.91873 -30.07863,-50.95937 0,0 z"
style="font-style:normal;font-variant:normal;font-weight:900;font-stretch:normal;font-size:142.558px;line-height:100%;font-family:Roboto;-inkscape-font-specification:'Roboto Heavy';text-align:center;text-anchor:middle;fill:#000000;stroke-width:3.20748px"
transform="matrix(0.3118,0,0,0.31173,-24.457,-91.229)"
aria-label="!"
id="path4"
sodipodi:nodetypes="cccc" />
</g>
<path
id="path2540"
style="fill:#000000"
d="M 32,0.75 A 31.25,31.25 0 0 0 0.75,32 31.25,31.25 0 0 0 32,63.25 31.25,31.25 0 0 0 63.25,32 31.25,31.25 0 0 0 32,0.75 Z m 0,5 A 26.25,26.25 0 0 1 58.25,32 26.25,26.25 0 0 1 32,58.25 26.25,26.25 0 0 1 5.75,32 26.25,26.25 0 0 1 32,5.75 Z" />
<text
xml:space="preserve"
style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;fill:#000000;fill-opacity:1;stroke:none"
x="17.083984"
y="52.78125"
id="text4900"><tspan
id="tspan4898"
x="17.083984"
y="52.78125"
sodipodi:role="line"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:56px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal">?</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -203,35 +203,49 @@
"ERROR_AMBIGUOUS_CSV_HEADER" : "Ambiguous CSV Header \"{HEADER}\" could be either a connection attribute or parameter", "ERROR_AMBIGUOUS_CSV_HEADER" : "Ambiguous CSV Header \"{HEADER}\" could be either a connection attribute or parameter",
"ERROR_AMBIGUOUS_PARENT_GROUP" : "Both group and parentIdentifier may be not specified at the same time", "ERROR_AMBIGUOUS_PARENT_GROUP" : "Both group and parentIdentifier may be not specified at the same time",
"ERROR_ARRAY_REQUIRED" : "The provided file must contain a list of connections", "ERROR_ARRAY_REQUIRED" : "The provided file must contain a list of connections",
"ERROR_DETECTED_INVALID_TYPE" : "Unsupported file type. Please make sure the file is valid CSV, JSON, or YAML.",
"ERROR_DUPLICATE_CONNECTION_IN_FILE" : "Duplicate connection in file at \"{PATH}\"",
"ERROR_DUPLICATE_CSV_HEADER" : "Duplicate CSV Header: {HEADER}", "ERROR_DUPLICATE_CSV_HEADER" : "Duplicate CSV Header: {HEADER}",
"ERROR_EMPTY_FILE" : "The provided file is empty", "ERROR_EMPTY_FILE" : "The provided file is empty",
"ERROR_INVALID_CSV_HEADER" : "Invalid CSV Header \"{HEADER}\" is neither an attribute or parameter", "ERROR_INVALID_CSV_HEADER" : "Invalid CSV Header \"{HEADER}\" is neither an attribute or parameter",
"ERROR_INVALID_MIME_TYPE" : "Unsupported file type: \"{TYPE}\"", "ERROR_INVALID_MIME_TYPE" : "Unsupported file type: \"{TYPE}\"",
"ERROR_DETECTED_INVALID_TYPE" : "Unsupported file type. Please make sure the file is valid CSV, JSON, or YAML.",
"ERROR_INVALID_GROUP" : "No group matching \"{GROUP}\" found", "ERROR_INVALID_GROUP" : "No group matching \"{GROUP}\" found",
"ERROR_INVALID_GROUP_IDENTIFIER" : "No connection group with identifier \"{IDENTIFIER}\" found", "ERROR_INVALID_GROUP_IDENTIFIER" : "No connection group with identifier \"{IDENTIFIER}\" found",
"ERROR_NO_FILE_SUPPLIED" : "Please select a file to import", "ERROR_NO_FILE_SUPPLIED" : "Please select a file to import",
"ERROR_PARSE_FAILURE_CSV" : "Please make sure your file is valid CSV. Parsing failed with error \"{ERROR}\". ", "ERROR_PARSE_FAILURE_CSV" : "Please make sure your file is valid CSV. Parsing failed with error \"{ERROR}\". ",
"ERROR_PARSE_FAILURE_JSON" : "Please make sure your file is valid JSON. Parsing failed with error \"{ERROR}\". ", "ERROR_PARSE_FAILURE_JSON" : "Please make sure your file is valid JSON. Parsing failed with error \"{ERROR}\". ",
"ERROR_PARSE_FAILURE_YAML" : "Please make sure your file is valid YAML. Parsing failed with error \"{ERROR}\". ", "ERROR_PARSE_FAILURE_YAML" : "Please make sure your file is valid YAML. Parsing failed with error \"{ERROR}\". ",
"ERROR_REJECT_UPDATE_CONNECTION" : "Disallowed update to existing connection at \"{PATH}\"",
"ERROR_REQUIRED_NAME" : "No connection name found in the provided file", "ERROR_REQUIRED_NAME" : "No connection name found in the provided file",
"ERROR_REQUIRED_PROTOCOL" : "No connection protocol found in the provided file", "ERROR_REQUIRED_PROTOCOL" : "No connection protocol found in the provided file",
"FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER", "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
"FIELD_HEADER_REPLACE_CONNECTIONS" : "Replace/Update existing connections",
"FIELD_HEADER_REPLACE_PERMISSIONS" : "Reset permissions",
"FIELD_OPTION_DUPLICATE_REPLACE" : "Replace duplicates",
"FIELD_OPTION_DUPLICATE_IGNORE" : "Ignore duplicates",
"FIELD_OPTION_DUPLICATE_ERROR" : "Disallow duplicates",
"HELP_CSV_DESCRIPTION" : "A connection import CSV file has one connection record per row. Each column will specify a connection field. At minimum the connection name and protocol must be specified.", "HELP_CSV_DESCRIPTION" : "A connection import CSV file has one connection record per row. Each column will specify a connection field. At minimum the connection name and protocol must be specified.",
"HELP_CSV_EXAMPLE" : "name,protocol,hostname,group,users,groups,guacd-encryption (attribute)\nconn1,vnc,conn1.web.com,ROOT,guac user 1;guac user 2,Connection 1 Users,none\nconn2,rdp,conn2.web.com,ROOT/Parent Group,guac user 1,,ssl\nconn3,ssh,conn3.web.com,ROOT/Parent Group/Child Group,guac user 2;guac user 3,,\nconn4,kubernetes,,,,,", "HELP_CSV_EXAMPLE" : "name,protocol,hostname,group,users,groups,guacd-encryption (attribute)\nconn1,vnc,conn1.web.com,ROOT,guac user 1;guac user 2,Connection 1 Users,none\nconn2,rdp,conn2.web.com,ROOT/Parent Group,guac user 1,,ssl\nconn3,ssh,conn3.web.com,ROOT/Parent Group/Child Group,guac user 2;guac user 3,,\nconn4,kubernetes,,,,,",
"HELP_CSV_MORE_DETAILS" : "The CSV header for each row specifies the connection field. The connection group ID that the connection should be imported into may be directly specified with \"parentIdentifier\", or the path to the parent group may be specified using \"group\" as shown below. In most cases, there should be no conflict between fields, but if needed, an \" (attribute)\" or \" (parameter)\" suffix may be added to disambiguate. Lists of user or user group identifiers must be semicolon-separated.¹", "HELP_CSV_MORE_DETAILS" : "The CSV header for each row specifies the connection field. The connection group ID that the connection should be imported into may be directly specified with \"parentIdentifier\", or the path to the parent group may be specified using \"group\" as shown below. In most cases, there should be no conflict between fields, but if needed, an \" (attribute)\" or \" (parameter)\" suffix may be added to disambiguate. Lists of user or user group identifiers must be semicolon-separated.¹",
"HELP_FILE_TYPE_DESCRIPTION" : "Three file types are supported for connection import: CSV, JSON, and YAML. The same data may be specified by each file type. This must include the connection name and protocol. Optionally, a connection group location, a list of users and/or user groups to grant access, connection parameters, or connection protocols may also be specified. Any users or user groups that do not exist in the current data source will be automatically created.", "HELP_FILE_TYPE_DESCRIPTION" : "Three file types are supported for connection import: CSV, JSON, and YAML. The same data may be specified by each file type. This must include the connection name and protocol. Optionally, a connection group location, a list of users and/or user groups to grant access, connection parameters, or connection protocols may also be specified. Any users or user groups that do not exist in the current data source will be automatically created. Note that any existing connection permissions will not be removed for updated connections.",
"HELP_FILE_TYPE_HEADER" : "File Types", "HELP_FILE_TYPE_HEADER" : "File Types",
"HELP_JSON_DESCRIPTION" : "A connection import JSON file is a list of connection objects. At minimum the connection name and protocol must be specified in each connection object.", "HELP_JSON_DESCRIPTION" : "A connection import JSON file is a list of connection objects. At minimum the connection name and protocol must be specified in each connection object.",
"HELP_JSON_EXAMPLE" : "[\n \\{\n \"name\": \"conn1\",\n \"protocol\": \"vnc\",\n \"parameters\": \\{ \"hostname\": \"conn1.web.com\" \\},\n \"parentIdentifier\": \"ROOT\",\n \"users\": [ \"guac user 1\", \"guac user 2\" ],\n \"groups\": [ \"Connection 1 Users\" ],\n \"attributes\": \\{ \"guacd-encryption\": \"none\" \\}\n \\},\n \\{\n \"name\": \"conn2\",\n \"protocol\": \"rdp\",\n \"parameters\": \\{ \"hostname\": \"conn2.web.com\" \\},\n \"group\": \"ROOT/Parent Group\",\n \"users\": [ \"guac user 1\" ],\n \"attributes\": \\{ \"guacd-encryption\": \"none\" \\}\n \\},\n \\{\n \"name\": \"conn3\",\n \"protocol\": \"ssh\",\n \"parameters\": \\{ \"hostname\": \"conn3.web.com\" \\},\n \"group\": \"ROOT/Parent Group/Child Group\",\n \"users\": [ \"guac user 2\", \"guac user 3\" ]\n \\},\n \\{\n \"name\": \"conn4\",\n \"protocol\": \"kubernetes\"\n \\}\n]", "HELP_JSON_EXAMPLE" : "[\n \\{\n \"name\": \"conn1\",\n \"protocol\": \"vnc\",\n \"parameters\": \\{ \"hostname\": \"conn1.web.com\" \\},\n \"parentIdentifier\": \"ROOT\",\n \"users\": [ \"guac user 1\", \"guac user 2\" ],\n \"groups\": [ \"Connection 1 Users\" ],\n \"attributes\": \\{ \"guacd-encryption\": \"none\" \\}\n \\},\n \\{\n \"name\": \"conn2\",\n \"protocol\": \"rdp\",\n \"parameters\": \\{ \"hostname\": \"conn2.web.com\" \\},\n \"group\": \"ROOT/Parent Group\",\n \"users\": [ \"guac user 1\" ],\n \"attributes\": \\{ \"guacd-encryption\": \"none\" \\}\n \\},\n \\{\n \"name\": \"conn3\",\n \"protocol\": \"ssh\",\n \"parameters\": \\{ \"hostname\": \"conn3.web.com\" \\},\n \"group\": \"ROOT/Parent Group/Child Group\",\n \"users\": [ \"guac user 2\", \"guac user 3\" ]\n \\},\n \\{\n \"name\": \"conn4\",\n \"protocol\": \"kubernetes\"\n \\}\n]",
"HELP_JSON_MORE_DETAILS" : "The connection group ID that the connection should be imported into may be directly specified with a \"parentIdentifier\" field, or the path to the parent group may be specified using a \"group\" field as shown below. An array of user and user group identifiers to grant access to may be specified per connection.", "HELP_JSON_MORE_DETAILS" : "The connection group ID that the connection should be imported into may be directly specified with a \"parentIdentifier\" field, or the path to the parent group may be specified using a \"group\" field as shown below. An array of user and user group identifiers to grant access to may be specified per connection.",
"HELP_REPLACE_CONNECTION_TITLE" : "Replacing existing connections",
"HELP_REPLACE_CONNECTION_CONTENT" : "Checking this box will allow existing connections to be updated, if an imported connection has the same name and parent group as an existing connection. If unchecked, attempts to update existing connections will be treated as an error.",
"HELP_REPLACE_PERMISSION_TITLE" : "Replacing connection permissions",
"HELP_REPLACE_PERMISSION_CONTENT" : "If replacement of existing connections is enabled, checking this box will allow full replacement of connection permissions. If checked, access permission will only be granted to users and groups specified in the import file for this connection. If unchecked, specified users and groups will be granted access in addition to any existing permissions.",
"HELP_SEMICOLON_FOOTNOTE" : "If present, semicolons can be escaped with a backslash, e.g. \"first\\\\;last\"", "HELP_SEMICOLON_FOOTNOTE" : "If present, semicolons can be escaped with a backslash, e.g. \"first\\\\;last\"",
"HELP_UPLOAD_DROP_TITLE" : "Drop a File Here", "HELP_UPLOAD_DROP_TITLE" : "Drop a File Here",
"HELP_UPLOAD_FILE_TYPES" : "CSV, JSON, or YAML", "HELP_UPLOAD_FILE_TYPES" : "CSV, JSON, or YAML",
"HELP_YAML_DESCRIPTION" : "A connection import YAML file is a list of connection objects with exactly the same structure as the JSON format.", "HELP_YAML_DESCRIPTION" : "A connection import YAML file is a list of connection objects with exactly the same structure as the JSON format.",
"HELP_YAML_EXAMPLE" : "---\n - name: conn1\n protocol: vnc\n parameters:\n hostname: conn1.web.com\n group: ROOT\n users:\n - guac user 1\n - guac user 2\n groups:\n - Connection 1 Users\n attributes:\n guacd-encryption: none\n - name: conn2\n protocol: rdp\n parameters:\n hostname: conn2.web.com\n group: ROOT/Parent Group\n users:\n - guac user 1\n attributes:\n guacd-encryption: none\n - name: conn3\n protocol: ssh\n parameters:\n hostname: conn3.web.com\n group: ROOT/Parent Group/Child Group\n users:\n - guac user 2\n - guac user 3\n - name: conn4\n protocol: kubernetes", "HELP_YAML_EXAMPLE" : "---\n - name: conn1\n protocol: vnc\n parameters:\n hostname: conn1.web.com\n group: ROOT\n users:\n - guac user 1\n - guac user 2\n groups:\n - Connection 1 Users\n attributes:\n guacd-encryption: none\n - name: conn2\n protocol: rdp\n parameters:\n hostname: conn2.web.com\n group: ROOT/Parent Group\n users:\n - guac user 1\n attributes:\n guacd-encryption: none\n - name: conn3\n protocol: ssh\n parameters:\n hostname: conn3.web.com\n group: ROOT/Parent Group/Child Group\n users:\n - guac user 2\n - guac user 3\n - name: conn4\n protocol: kubernetes",
"INFO_CONNECTIONS_IMPORTED_SUCCESS" : "{NUMBER} {NUMBER, plural, one{connection} other{connections}} imported successfully.", "INFO_CONNECTIONS_IMPORTED_SUCCESS" : "{NUMBER} {NUMBER, plural, one{connection} other{connections}} imported successfully.",
"SECTION_HEADER_CONNECTION_IMPORT" : "Connection Import", "SECTION_HEADER_CONNECTION_IMPORT" : "Connection Import",
@@ -241,6 +255,7 @@
"SECTION_HEADER_YAML" : "YAML Format", "SECTION_HEADER_YAML" : "YAML Format",
"TABLE_HEADER_ERRORS" : "Errors", "TABLE_HEADER_ERRORS" : "Errors",
"TABLE_HEADER_GROUP" : "Group",
"TABLE_HEADER_NAME" : "Name", "TABLE_HEADER_NAME" : "Name",
"TABLE_HEADER_PROTOCOL" : "Protocol", "TABLE_HEADER_PROTOCOL" : "Protocol",
"TABLE_HEADER_ROW_NUMBER" : "Row #" "TABLE_HEADER_ROW_NUMBER" : "Row #"