fixed client config export to configure a client with password

added possibility to export client config on exam side with including
examId in the config
This commit is contained in:
anhefti 2020-10-27 13:13:43 +01:00
parent 14334f0d7e
commit 6c8aa7b12c
No known key found for this signature in database
GPG key ID: E9AD9471B6BC114D
12 changed files with 334 additions and 35 deletions

View file

@ -0,0 +1,176 @@
/*
* Copyright (c) 2020 ETH Zürich, Educational Development and Technology (LET)
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package ch.ethz.seb.sebserver.gui.content;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import org.eclipse.rap.rwt.RWT;
import org.eclipse.rap.rwt.client.service.UrlLauncher;
import org.eclipse.swt.widgets.Composite;
import org.eclipse.swt.widgets.Label;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import ch.ethz.seb.sebserver.gbl.Constants;
import ch.ethz.seb.sebserver.gbl.model.Domain;
import ch.ethz.seb.sebserver.gbl.model.EntityKey;
import ch.ethz.seb.sebserver.gbl.model.sebconfig.ConfigCreationInfo;
import ch.ethz.seb.sebserver.gbl.model.sebconfig.SEBClientConfig;
import ch.ethz.seb.sebserver.gbl.model.sebconfig.SEBClientConfig.ConfigPurpose;
import ch.ethz.seb.sebserver.gbl.profile.GuiProfile;
import ch.ethz.seb.sebserver.gbl.util.Tuple;
import ch.ethz.seb.sebserver.gbl.util.Utils;
import ch.ethz.seb.sebserver.gui.form.FormBuilder;
import ch.ethz.seb.sebserver.gui.form.FormHandle;
import ch.ethz.seb.sebserver.gui.service.i18n.LocTextKey;
import ch.ethz.seb.sebserver.gui.service.page.ModalInputDialogComposer;
import ch.ethz.seb.sebserver.gui.service.page.PageContext;
import ch.ethz.seb.sebserver.gui.service.page.PageService;
import ch.ethz.seb.sebserver.gui.service.page.impl.ModalInputDialog;
import ch.ethz.seb.sebserver.gui.service.page.impl.PageAction;
import ch.ethz.seb.sebserver.gui.service.remote.download.DownloadService;
import ch.ethz.seb.sebserver.gui.service.remote.download.SEBClientConfigDownload;
import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.seb.clientconfig.GetClientConfigs;
@Lazy
@Component
@GuiProfile
public class ExamCreateClientConfigPopup {
private static final LocTextKey TITLE_KEY = new LocTextKey("sebserver.exam.form.export.config.popup.title");
private static final LocTextKey CONFIG_NAME_KEY = new LocTextKey("sebserver.exam.form.export.config.name");
private static final LocTextKey CONFIG_TEXT_KEY = new LocTextKey("sebserver.exam.form.export.config.popup.text");
private static final LocTextKey NO_CONFIG_TEXT_KEY =
new LocTextKey("sebserver.exam.form.export.config.popup.noconfig");
private final PageService pageService;
private final DownloadService downloadService;
private final String downloadFileName;
public ExamCreateClientConfigPopup(
final PageService pageService,
final DownloadService downloadService,
@Value("${sebserver.gui.seb.exam.config.download.filename}") final String downloadFileName) {
this.pageService = pageService;
this.downloadService = downloadService;
this.downloadFileName = downloadFileName;
}
public Function<PageAction, PageAction> exportFunction() {
return action -> {
final ModalInputDialog<FormHandle<?>> dialog =
new ModalInputDialog<FormHandle<?>>(
action.pageContext().getParent().getShell(),
this.pageService.getWidgetFactory())
.setLargeDialogWidth();
final CreationFormContext creationFormContext = new CreationFormContext(
this.pageService,
action.pageContext());
final Predicate<FormHandle<?>> doCreate = formHandle -> doCreate(
this.pageService,
action.pageContext(),
action.getEntityKey(),
formHandle);
dialog.open(
TITLE_KEY,
doCreate,
Utils.EMPTY_EXECUTION,
creationFormContext);
return action;
};
}
private boolean doCreate(
final PageService pageService,
final PageContext pageContext,
final EntityKey examKey,
final FormHandle<?> formHandle) {
if (formHandle == null) {
return true;
}
final UrlLauncher urlLauncher = RWT.getClient().getService(UrlLauncher.class);
final String modelId = formHandle.getForm().getFieldValue(Domain.SEB_CLIENT_CONFIGURATION.ATTR_ID);
final String downloadURL = this.downloadService.createDownloadURL(
modelId,
examKey.modelId,
SEBClientConfigDownload.class,
this.downloadFileName);
urlLauncher.openURL(downloadURL);
return true;
}
private final class CreationFormContext implements ModalInputDialogComposer<FormHandle<?>> {
private final PageService pageService;
private final PageContext pageContext;
protected CreationFormContext(
final PageService pageService,
final PageContext pageContext) {
this.pageService = pageService;
this.pageContext = pageContext;
}
@Override
public Supplier<FormHandle<?>> compose(final Composite parent) {
final List<Tuple<String>> configs = this.pageService.getRestService().getBuilder(GetClientConfigs.class)
.withQueryParam(SEBClientConfig.FILTER_ATTR_ACTIVE, Constants.TRUE_STRING)
.call()
.getOrThrow()
.stream()
.filter(config -> config.configPurpose == ConfigPurpose.START_EXAM)
.map(config -> new Tuple<>(config.getModelId(), config.name))
.collect(Collectors.toList());
if (configs.isEmpty()) {
final Label text = this.pageService
.getWidgetFactory()
.labelLocalized(parent, NO_CONFIG_TEXT_KEY);
text.setData(RWT.MARKUP_ENABLED, true);
return null;
} else {
final Label text = this.pageService
.getWidgetFactory()
.labelLocalized(parent, CONFIG_TEXT_KEY);
text.setData(RWT.MARKUP_ENABLED, true);
final FormHandle<ConfigCreationInfo> formHandle = this.pageService.formBuilder(
this.pageContext.copyOf(parent))
.readonly(false)
.addField(FormBuilder.singleSelection(
Domain.SEB_CLIENT_CONFIGURATION.ATTR_ID,
CONFIG_NAME_KEY,
configs.get(0)._1,
() -> configs))
.build();
return () -> formHandle;
}
}
}
}

View file

@ -166,11 +166,13 @@ public class ExamForm implements TemplateComposer {
private final String downloadFileName;
private final WidgetFactory widgetFactory;
private final RestService restService;
private final ExamCreateClientConfigPopup examCreateClientConfigPopup;
protected ExamForm(
final PageService pageService,
final ResourceService resourceService,
final DownloadService downloadService,
final ExamCreateClientConfigPopup examCreateClientConfigPopup,
@Value("${sebserver.gui.seb.exam.config.download.filename}") final String downloadFileName) {
this.pageService = pageService;
@ -179,6 +181,7 @@ public class ExamForm implements TemplateComposer {
this.downloadFileName = downloadFileName;
this.widgetFactory = pageService.getWidgetFactory();
this.restService = this.resourceService.getRestService();
this.examCreateClientConfigPopup = examCreateClientConfigPopup;
this.consistencyMessageMapping = new HashMap<>();
this.consistencyMessageMapping.put(
@ -245,6 +248,7 @@ public class ExamForm implements TemplateComposer {
final boolean modifyGrant = userGrantCheck.m();
final ExamStatus examStatus = exam.getStatus();
final boolean isExamRunning = examStatus == ExamStatus.RUNNING;
final boolean writeGrant = userGrantCheck.w();
final boolean editable = examStatus == ExamStatus.UP_COMING
|| examStatus == ExamStatus.RUNNING
&& currentUser.get().hasRole(UserRole.EXAM_ADMIN);
@ -391,6 +395,11 @@ public class ExamForm implements TemplateComposer {
.withExec(this.cancelModifyFunction())
.publishIf(() -> !readonly)
.newAction(ActionDefinition.EXAM_SEB_CLIENT_CONFIG_EXPORT)
.withEntityKey(entityKey)
.withExec(this.examCreateClientConfigPopup.exportFunction())
.publishIf(() -> writeGrant && readonly)
.newAction(ActionDefinition.EXAM_MODIFY_SEB_RESTRICTION_DETAILS)
.withEntityKey(entityKey)
.withExec(ExamSEBRestrictionSettings.settingsFunction(this.pageService))

View file

@ -306,6 +306,7 @@ public class SEBClientConfigForm implements TemplateComposer {
FALLBACK_ATTRIBUTES::contains,
ffa -> ffa.setVisible(BooleanUtils.isTrue(clientConfig.fallback)));
if (!isReadonly) {
formHandle.getForm().getFieldInput(SEBClientConfig.ATTR_FALLBACK)
.addListener(SWT.Selection, event -> formHandle.process(
FALLBACK_ATTRIBUTES::contains,
@ -317,6 +318,7 @@ public class SEBClientConfigForm implements TemplateComposer {
ffa.setStringValue(StringUtils.EMPTY);
}
}));
}
final UrlLauncher urlLauncher = RWT.getClient().getService(UrlLauncher.class);
this.pageService.pageActionBuilder(formContext.clearEntityKeys())

View file

@ -353,6 +353,11 @@ public enum ActionDefinition {
ImageIcon.CANCEL,
PageStateDefinitionImpl.EXAM_VIEW,
ActionCategory.FORM),
EXAM_SEB_CLIENT_CONFIG_EXPORT(
new LocTextKey("sebserver.exam.action.createClientToStartExam"),
ImageIcon.EXPORT,
PageStateDefinitionImpl.EXAM_VIEW,
ActionCategory.FORM),
SEB_CLIENT_CONFIG_LIST(
new LocTextKey("sebserver.clientconfig.list.title"),

View file

@ -13,6 +13,7 @@ import java.io.InputStream;
import java.io.OutputStream;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
@ -20,7 +21,9 @@ import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import ch.ethz.seb.sebserver.gbl.api.API;
import ch.ethz.seb.sebserver.gbl.model.Domain.EXAM;
import ch.ethz.seb.sebserver.gbl.profile.GuiProfile;
import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.RestCall;
import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.RestService;
import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.seb.clientconfig.ExportClientConfig;
@ -45,8 +48,15 @@ public class SEBClientConfigDownload extends AbstractDownloadServiceHandler {
@Override
protected void webserviceCall(final String modelId, final String parentModelId, final OutputStream downloadOut) {
final InputStream input = this.restService.getBuilder(ExportClientConfig.class)
.withURIVariable(API.PARAM_MODEL_ID, modelId)
final RestCall<InputStream>.RestCallBuilder restCallBuilder = this.restService
.getBuilder(ExportClientConfig.class)
.withURIVariable(API.PARAM_MODEL_ID, modelId);
if (StringUtils.isNotBlank(parentModelId)) {
restCallBuilder.withQueryParam(EXAM.ATTR_ID, parentModelId);
}
final InputStream input = restCallBuilder
.call()
.getOrThrow();

View file

@ -0,0 +1,38 @@
/*
* Copyright (c) 2020 ETH Zürich, Educational Development and Technology (LET)
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package ch.ethz.seb.sebserver.gui.service.remote.webservice.api.seb.clientconfig;
import java.util.List;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.core.type.TypeReference;
import ch.ethz.seb.sebserver.gbl.api.API;
import ch.ethz.seb.sebserver.gbl.api.EntityType;
import ch.ethz.seb.sebserver.gbl.model.sebconfig.SEBClientConfig;
import ch.ethz.seb.sebserver.gbl.profile.GuiProfile;
import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.PageToListCallAdapter;
@Lazy
@Component
@GuiProfile
public class GetClientConfigs extends PageToListCallAdapter<SEBClientConfig> {
public GetClientConfigs() {
super(
GetClientConfigPage.class,
EntityType.SEB_CLIENT_CONFIGURATION,
new TypeReference<List<SEBClientConfig>>() {
},
API.SEB_CLIENT_CONFIG_ENDPOINT);
}
}

View file

@ -31,9 +31,9 @@ import org.springframework.transaction.annotation.Transactional;
import ch.ethz.seb.sebserver.gbl.api.APIMessage;
import ch.ethz.seb.sebserver.gbl.api.APIMessage.APIMessageException;
import ch.ethz.seb.sebserver.gbl.api.APIMessage.ErrorMessage;
import ch.ethz.seb.sebserver.gbl.api.EntityType;
import ch.ethz.seb.sebserver.gbl.client.ClientCredentialService;
import ch.ethz.seb.sebserver.gbl.client.ClientCredentials;
import ch.ethz.seb.sebserver.gbl.api.EntityType;
import ch.ethz.seb.sebserver.gbl.model.Domain;
import ch.ethz.seb.sebserver.gbl.model.EntityKey;
import ch.ethz.seb.sebserver.gbl.model.sebconfig.SEBClientConfig;
@ -245,18 +245,20 @@ public class SEBClientConfigDAOImpl implements SEBClientConfigDAO {
checkUniqueName(sebClientConfig);
final SebClientConfigRecord record =
this.sebClientConfigRecordMapper.selectByPrimaryKey(sebClientConfig.id);
final SebClientConfigRecord newRecord = new SebClientConfigRecord(
sebClientConfig.id,
null,
record.getInstitutionId(),
sebClientConfig.name,
null,
null,
null,
record.getDate(),
record.getClientName(),
record.getClientSecret(),
getEncryptionPassword(sebClientConfig),
null);
record.getActive());
this.sebClientConfigRecordMapper
.updateByPrimaryKeySelective(newRecord);
this.sebClientConfigRecordMapper.updateByPrimaryKey(newRecord);
saveAdditionalAttributes(sebClientConfig, newRecord.getId());

View file

@ -45,10 +45,12 @@ public interface ClientConfigService {
* as described here: https://www.safeexambrowser.org/developer/seb-file-format.html
*
* @param out OutputStream to write the export to
* @param modelId the model identifier of the SEBClientConfiguration to export */
* @param modelId the model identifier of the SEBClientConfiguration to export
* @param examId The exam identifier. May be null, if not the exported client config will contain the exam information*/
void exportSEBClientConfiguration(
OutputStream out,
final String modelId);
final String modelId,
final Long examId);
/** Get the ClientDetails for given client name that identifies a SEBClientConfiguration entry.
*

View file

@ -13,6 +13,8 @@ import java.io.OutputStream;
import java.io.PipedInputStream;
import java.io.PipedOutputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.Collection;
import java.util.Collections;
@ -21,6 +23,7 @@ import java.util.UUID;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils;
import org.bouncycastle.util.encoders.Hex;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
@ -46,6 +49,7 @@ import ch.ethz.seb.sebserver.gbl.client.ClientCredentialService;
import ch.ethz.seb.sebserver.gbl.client.ClientCredentials;
import ch.ethz.seb.sebserver.gbl.model.institution.Institution;
import ch.ethz.seb.sebserver.gbl.model.sebconfig.SEBClientConfig;
import ch.ethz.seb.sebserver.gbl.model.sebconfig.SEBClientConfig.ConfigPurpose;
import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile;
import ch.ethz.seb.sebserver.gbl.util.Result;
import ch.ethz.seb.sebserver.gbl.util.Utils;
@ -66,6 +70,7 @@ public class ClientConfigServiceImpl implements ClientConfigService {
private static final Logger log = LoggerFactory.getLogger(ClientConfigServiceImpl.class);
private static final String SEB_CLIENT_CONFIG_EXAM_PROP_NAME = "exam";
private static final String SEB_CLIENT_CONFIG_TEMPLATE_XML =
" <dict>%n" +
" <key>sebMode</key>%n" +
@ -80,7 +85,7 @@ public class ClientConfigServiceImpl implements ClientConfigService {
" <key>sebServerConfiguration</key>%n" +
" <dict>%n" +
" <key>institution</key>%n" +
" <string>%s</string>%n" +
" <string>%s</string>%n%s" +
" <key>clientName</key>%n" +
" <string>%s</string>%n" +
" <key>clientSecret</key>%n" +
@ -186,16 +191,17 @@ public class ClientConfigServiceImpl implements ClientConfigService {
@Override
public void exportSEBClientConfiguration(
final OutputStream output,
final String modelId) {
final String modelId,
final Long examId) {
final SEBClientConfig config = this.sebClientConfigDAO
.byModelId(modelId).getOrThrow();
final CharSequence encryptionPassword = this.sebClientConfigDAO
.getConfigPasswordCipher(config.getModelId())
.getOr(StringUtils.EMPTY);
.getOr((config.getConfigPurpose() == ConfigPurpose.START_EXAM) ? null : StringUtils.EMPTY);
final String plainTextXMLContent = extractXMLContent(config);
final String plainTextXMLContent = extractXMLContent(config, examId);
PipedOutputStream pOut = null;
PipedInputStream pIn = null;
@ -223,7 +229,7 @@ public class ClientConfigServiceImpl implements ClientConfigService {
if (encryptionPassword != null) {
// encrypt zipped plain text and add header
passwordEncryption(zipOut, encryptionPassword, pIn);
passwordEncryption(zipOut, encryptionPassword, config.getConfigPurpose(), pIn);
} else {
// just add plain text header
this.sebConfigEncryptionService.streamEncrypted(
@ -248,7 +254,7 @@ public class ClientConfigServiceImpl implements ClientConfigService {
}
}
private String extractXMLContent(final SEBClientConfig config) {
private String extractXMLContent(final SEBClientConfig config, final Long examId) {
String fallbackAddition = "";
if (BooleanUtils.isTrue(config.fallback)) {
@ -289,6 +295,14 @@ public class ClientConfigServiceImpl implements ClientConfigService {
}
}
String examIdAddition = "";
if (examId != null) {
examIdAddition = String.format(
SEB_CLIENT_CONFIG_STRING_TEMPLATE,
SEB_CLIENT_CONFIG_EXAM_PROP_NAME,
examId);
}
final ClientCredentials sebClientCredentials = this.sebClientConfigDAO
.getSEBClientCredentials(config.getModelId())
.getOrThrow();
@ -305,6 +319,7 @@ public class ClientConfigServiceImpl implements ClientConfigService {
fallbackAddition,
this.webserviceInfo.getExternalServerURL(),
config.institutionId,
examIdAddition,
plainClientId,
plainClientSecret,
this.webserviceInfo.getDiscoveryEndpoint());
@ -375,22 +390,50 @@ public class ClientConfigServiceImpl implements ClientConfigService {
private void passwordEncryption(
final OutputStream output,
final CharSequence encryptionPassword,
final ConfigPurpose configPurpose,
final InputStream input) {
if (log.isDebugEnabled()) {
log.debug("*** SEB client configuration with password based encryption");
}
final CharSequence encryptionPasswordPlaintext = (encryptionPassword == StringUtils.EMPTY)
? StringUtils.EMPTY
: this.clientCredentialService.decrypt(encryptionPassword);
final CharSequence plainTextPassword = getPlainTextPassword(
encryptionPassword,
configPurpose);
this.sebConfigEncryptionService.streamEncrypted(
output,
input,
EncryptionContext.contextOf(
(encryptionPassword == StringUtils.EMPTY) ? Strategy.PASSWORD_PWCC : Strategy.PASSWORD_PSWD,
encryptionPasswordPlaintext));
(configPurpose == ConfigPurpose.CONFIGURE_CLIENT)
? Strategy.PASSWORD_PWCC
: Strategy.PASSWORD_PSWD,
plainTextPassword));
}
private CharSequence getPlainTextPassword(
final CharSequence encryptionPassword,
final ConfigPurpose configPurpose) {
CharSequence plainTextPassword = (encryptionPassword == StringUtils.EMPTY)
? StringUtils.EMPTY
: this.clientCredentialService.decrypt(encryptionPassword);
if (configPurpose == ConfigPurpose.CONFIGURE_CLIENT && plainTextPassword != StringUtils.EMPTY) {
MessageDigest digest;
try {
digest = MessageDigest.getInstance("SHA-256");
final byte[] hash = digest.digest(
plainTextPassword.toString().getBytes(StandardCharsets.UTF_8));
final byte[] encode = Hex.encode(hash);
plainTextPassword = new String(encode, StandardCharsets.UTF_8);
} catch (final NoSuchAlgorithmException e) {
log.error("Failed to generate password hash for config encryption.", e);
plainTextPassword = StringUtils.EMPTY;
}
}
return plainTextPassword;
}
/** Get a encoded clientSecret for the SEBClientConfiguration with specified clientId/clientName.

View file

@ -69,6 +69,7 @@ public class PasswordEncryptor implements SEBConfigCryptor {
try {
final CharSequence password = context.getPassword();
if (password.length() == 0) {
encryptOutput = new AES256JNCryptorOutputStreamEmptyPwdSupport(
output,

View file

@ -30,6 +30,7 @@ import org.springframework.validation.FieldError;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import ch.ethz.seb.sebserver.gbl.Constants;
@ -37,6 +38,7 @@ import ch.ethz.seb.sebserver.gbl.api.API;
import ch.ethz.seb.sebserver.gbl.api.APIMessage;
import ch.ethz.seb.sebserver.gbl.api.POSTMapper;
import ch.ethz.seb.sebserver.gbl.model.Domain;
import ch.ethz.seb.sebserver.gbl.model.Domain.EXAM;
import ch.ethz.seb.sebserver.gbl.model.sebconfig.SEBClientConfig;
import ch.ethz.seb.sebserver.gbl.model.user.PasswordChange;
import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile;
@ -83,6 +85,7 @@ public class SEBClientConfigController extends ActivatableEntityController<SEBCl
produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public void downloadSEBConfig(
@PathVariable final String modelId,
@RequestParam(name = EXAM.ATTR_ID, required = false) final Long examId,
final HttpServletResponse response) throws IOException {
this.entityDAO.byModelId(modelId)
@ -98,7 +101,8 @@ public class SEBClientConfigController extends ActivatableEntityController<SEBCl
this.sebClientConfigService.exportSEBClientConfiguration(
pout,
modelId);
modelId,
examId);
IOUtils.copyLarge(pin, outputStream);

View file

@ -423,6 +423,7 @@ sebserver.exam.action.deactivate=Deactivate Exam
sebserver.exam.action.sebrestriction.enable=Apply SEB Lock
sebserver.exam.action.sebrestriction.disable=Release SEB Lock
sebserver.exam.action.sebrestriction.details=SEB Restriction Details
sebserver.exam.action.createClientToStartExam=Export Start Exam Config
sebserver.exam.info.pleaseSelect=At first please select an Exam from the list
@ -450,6 +451,12 @@ sebserver.exam.form.type.tooltip=The type of the exam.<br/><br/>This has only de
sebserver.exam.form.supporter=Exam Supporter
sebserver.exam.form.supporter.tooltip=A list of users that are allowed to support this exam<br/><br/>To add a user in edit mode click into the field on the right-hand side and start typing the first letters of the username.<br/>A filtered choice will drop down. Select a specific username in the dropdown list to add the user to the list.<br/>To remove a user from the list, just double-click the username on the list.
sebserver.exam.form.export.config.popup.title=Export SEB Client Configuration for Starting the Exam
sebserver.exam.form.export.config.name=Name
sebserver.exam.form.export.config.name.tooltip=The name of the SEB Client Configuration
sebserver.exam.form.export.config.popup.text=Please select the SEB Client Configuration you want to use for starting this exam<br/>and click OK to start the download.
sebserver.exam.form.export.config.popup.noconfig=There is currently no active SEB Client Configuration for the purpose of "Starting an Exam".<br/><br/>Please go the SEB Client Configuration section and create a new one."
sebserver.exam.form.sebrestriction.title=SEB Restriction Details
sebserver.exam.form.sebrestriction.title.subtitle=
sebserver.exam.form.sebrestriction.info=Info