summaryrefslogtreecommitdiffstats
path: root/src/main
diff options
context:
space:
mode:
authorUdo Walter2020-05-07 19:30:48 +0200
committerUdo Walter2020-05-07 19:30:48 +0200
commit90f06cbe0b2ff3b1a9d85d2f73fe5864e692341b (patch)
tree17139070c9ac144f9cd214a01e4af5e86a895dbe /src/main
parentPrevent user from sticking to connection if it was reset. (diff)
downloadbwlp-guacamole-ext-90f06cbe0b2ff3b1a9d85d2f73fe5864e692341b.tar.gz
bwlp-guacamole-ext-90f06cbe0b2ff3b1a9d85d2f73fe5864e692341b.tar.xz
bwlp-guacamole-ext-90f06cbe0b2ff3b1a9d85d2f73fe5864e692341b.zip
Add location selection
Diffstat (limited to 'src/main')
-rw-r--r--src/main/java/de/bwlehrpool/bwlp_guac/AvailableClient.java14
-rw-r--r--src/main/java/de/bwlehrpool/bwlp_guac/BwlpAuthenticationProvider.java92
-rw-r--r--src/main/java/de/bwlehrpool/bwlp_guac/BwlpUserContext.java11
-rw-r--r--src/main/java/de/bwlehrpool/bwlp_guac/ConnectionManager.java116
-rw-r--r--src/main/java/de/bwlehrpool/bwlp_guac/JsonLocation.java34
-rw-r--r--src/main/java/de/bwlehrpool/bwlp_guac/LocationField.java26
-rw-r--r--src/main/resources/bwlpModule.js5
-rw-r--r--src/main/resources/config/locationConfig.js11
-rw-r--r--src/main/resources/controllers/locationFieldController.js23
-rw-r--r--src/main/resources/guac-manifest.json14
-rw-r--r--src/main/resources/styles/locationField.css47
-rw-r--r--src/main/resources/templates/locationField.html18
-rw-r--r--src/main/resources/translations/en.json5
13 files changed, 384 insertions, 32 deletions
diff --git a/src/main/java/de/bwlehrpool/bwlp_guac/AvailableClient.java b/src/main/java/de/bwlehrpool/bwlp_guac/AvailableClient.java
index c886397..e9144f8 100644
--- a/src/main/java/de/bwlehrpool/bwlp_guac/AvailableClient.java
+++ b/src/main/java/de/bwlehrpool/bwlp_guac/AvailableClient.java
@@ -1,6 +1,7 @@
package de.bwlehrpool.bwlp_guac;
import java.io.IOException;
+import java.util.ArrayList;
import java.util.concurrent.atomic.AtomicLong;
import org.apache.guacamole.protocol.GuacamoleConfiguration;
@@ -15,10 +16,14 @@ public class AvailableClient implements Cloneable {
private static final AtomicLong CON_ID = new AtomicLong();
+ public ArrayList<JsonLocation> locationList = new ArrayList<JsonLocation>();
+
private final String clientip;
private String password;
+ private int locationid;
+
private State state;
private String inUseBy;
@@ -35,6 +40,7 @@ public class AvailableClient implements Cloneable {
public AvailableClient(JsonClient source) {
this.clientip = source.clientip;
+ this.locationid = source.locationid;
update(source);
}
@@ -110,6 +116,14 @@ public class AvailableClient implements Cloneable {
return clientip + "/" + state + "/" + inUseBy;
}
+ public State getState() {
+ return state;
+ }
+
+ public int getLocationid() {
+ return locationid;
+ }
+
public GuacamoleConfiguration toGuacConfig() {
GuacamoleConfiguration cfg = new GuacamoleConfiguration();
cfg.setProtocol("vnc");
diff --git a/src/main/java/de/bwlehrpool/bwlp_guac/BwlpAuthenticationProvider.java b/src/main/java/de/bwlehrpool/bwlp_guac/BwlpAuthenticationProvider.java
index 4ec4f48..b902621 100644
--- a/src/main/java/de/bwlehrpool/bwlp_guac/BwlpAuthenticationProvider.java
+++ b/src/main/java/de/bwlehrpool/bwlp_guac/BwlpAuthenticationProvider.java
@@ -1,16 +1,25 @@
package de.bwlehrpool.bwlp_guac;
-import java.util.Collections;
-import java.util.Map;
-import java.util.WeakHashMap;
+import java.io.IOException;
+import java.util.*;
import org.apache.guacamole.GuacamoleException;
-import org.apache.guacamole.net.auth.AuthenticatedUser;
-import org.apache.guacamole.net.auth.AuthenticationProvider;
-import org.apache.guacamole.net.auth.Credentials;
-import org.apache.guacamole.net.auth.UserContext;
+import org.apache.guacamole.form.TextField;
+import org.apache.guacamole.net.auth.*;
+import org.apache.guacamole.net.auth.credentials.GuacamoleCredentialsException;
+import org.apache.guacamole.net.auth.credentials.GuacamoleInvalidCredentialsException;
+import org.codehaus.jackson.JsonGenerationException;
+import org.codehaus.jackson.JsonNode;
+import org.codehaus.jackson.map.JsonMappingException;
+import org.codehaus.jackson.map.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import javax.servlet.http.HttpServletRequest;
+import javax.xml.soap.Text;
+
+import org.apache.guacamole.form.Field;
+import org.apache.guacamole.net.auth.credentials.GuacamoleInsufficientCredentialsException;
+import org.apache.guacamole.net.auth.credentials.CredentialsInfo;
public class BwlpAuthenticationProvider implements AuthenticationProvider {
@@ -44,7 +53,7 @@ public class BwlpAuthenticationProvider implements AuthenticationProvider {
}
public UserContext updateUserContext(UserContext context, AuthenticatedUser authenticatedUser,
- Credentials credentials) throws GuacamoleException {
+ Credentials credentials) throws GuacamoleException {
LOGGER.warn("Ignoring updateUserContext called with " + context.toString());
return null;
}
@@ -52,13 +61,19 @@ public class BwlpAuthenticationProvider implements AuthenticationProvider {
public UserContext decorate(UserContext context, AuthenticatedUser authenticatedUser, Credentials credentials)
throws GuacamoleException {
String username = authenticatedUser.getCredentials().getUsername();
+
LOGGER.warn("decorate called for " + username);
BwlpUserContext user = oldMappings.get(username);
+
if (user != null)
return user;
+
+ int locationid = requestLocation(credentials);
+
LOGGER.warn("Doing the decoration");
- user = new BwlpUserContext(authenticatedUser, context);
+ user = new BwlpUserContext(authenticatedUser, context, locationid);
oldMappings.put(username, user);
+
return user;
}
@@ -67,17 +82,74 @@ public class BwlpAuthenticationProvider implements AuthenticationProvider {
public UserContext redecorate(UserContext decorated, UserContext context, AuthenticatedUser authenticatedUser,
Credentials credentials) throws GuacamoleException {
+
String username = authenticatedUser.getCredentials().getUsername();
+
LOGGER.warn("REdecorate called for " + username);
BwlpUserContext user = oldMappings.get(username);
+
if (user != null && user.hasValidConnection())
return user;
+
+ int locationid = requestLocation(credentials);
+
LOGGER.warn("Doing the REdecoration");
- user = new BwlpUserContext(authenticatedUser, context);
+ user = new BwlpUserContext(authenticatedUser, context, locationid);
oldMappings.put(username, user);
+
return user;
}
+
+ private int requestLocation(Credentials credentials) throws GuacamoleException {
+ // Request the user to select a location
+ ConnectionManager.updateList();
+ HttpServletRequest request = credentials.getRequest();
+ String locationJson = request.getParameter("location");
+
+ if (locationJson == null) {
+ throw new GuacamoleInsufficientCredentialsException(
+ "Select Location", new CredentialsInfo(
+ Collections.<Field>singletonList(new LocationField())
+ ));
+ }
+
+ ObjectMapper mapper = new ObjectMapper();
+
+ String message = "Select a Location";
+
+ int selectedId = 0;
+ boolean tryAgain = false;
+ String password = "";
+ String correctPassword = null;
+ try {
+ JsonNode location = mapper.readTree(locationJson);
+ selectedId = Integer.parseInt(location.get("id").asText());
+ if (selectedId != 0) {
+ password = location.get("password").asText();
+ correctPassword = ConnectionManager.getLocationPool().get(selectedId).password;
+ }
+ } catch (Exception e) {
+ LOGGER.info("Error reading location");
+ LOGGER.info(e.toString());
+ tryAgain = true;
+ }
+
+ if (selectedId != 0 && correctPassword != null && !password.equals(correctPassword)) {
+ tryAgain = true;
+ message = "Wrong password!";
+ }
+
+ if (tryAgain) {
+ throw new GuacamoleCredentialsException(
+ message, new CredentialsInfo(
+ Collections.<Field>singletonList(new LocationField())
+ ));
+ }
+
+ return selectedId;
+ }
+
public void shutdown() {
}
diff --git a/src/main/java/de/bwlehrpool/bwlp_guac/BwlpUserContext.java b/src/main/java/de/bwlehrpool/bwlp_guac/BwlpUserContext.java
index 9a6e5d7..05fa78f 100644
--- a/src/main/java/de/bwlehrpool/bwlp_guac/BwlpUserContext.java
+++ b/src/main/java/de/bwlehrpool/bwlp_guac/BwlpUserContext.java
@@ -8,6 +8,8 @@ import org.apache.guacamole.net.auth.Connection;
import org.apache.guacamole.net.auth.Directory;
import org.apache.guacamole.net.auth.User;
import org.apache.guacamole.net.auth.UserContext;
+import org.apache.guacamole.net.auth.credentials.GuacamoleCredentialsException;
+import org.apache.guacamole.net.auth.credentials.GuacamoleInsufficientCredentialsException;
import org.apache.guacamole.net.auth.permission.ObjectPermissionSet;
import org.apache.guacamole.net.auth.simple.SimpleConnection;
import org.apache.guacamole.net.auth.simple.SimpleDirectory;
@@ -29,6 +31,7 @@ public class BwlpUserContext extends AbstractUserContext {
private final AuthenticatedUser authUser;
private final UserContext originalContext;
+ private final Integer locationid;
/**
* The Directory with access to all connections within the root group associated
@@ -36,15 +39,17 @@ public class BwlpUserContext extends AbstractUserContext {
*/
private Directory<Connection> connectionDirectory;
- public BwlpUserContext(AuthenticatedUser authenticatedUser, UserContext context) {
+ public BwlpUserContext(AuthenticatedUser authenticatedUser, UserContext context, int locationid)
+ throws GuacamoleCredentialsException {
authUser = authenticatedUser;
originalContext = context;
+ this.locationid = locationid;
// OK
addConn();
}
- private void addConn() {
- WrappedConnection connection = ConnectionManager.getForUser(authUser.getCredentials().getUsername());
+ private void addConn() throws GuacamoleCredentialsException {
+ WrappedConnection connection = ConnectionManager.getForUser(authUser.getCredentials().getUsername(), locationid);
if (connection != null) {
connectionDirectory = new SimpleDirectory<Connection>(connection);
} else {
diff --git a/src/main/java/de/bwlehrpool/bwlp_guac/ConnectionManager.java b/src/main/java/de/bwlehrpool/bwlp_guac/ConnectionManager.java
index f1ad057..eacb347 100644
--- a/src/main/java/de/bwlehrpool/bwlp_guac/ConnectionManager.java
+++ b/src/main/java/de/bwlehrpool/bwlp_guac/ConnectionManager.java
@@ -10,8 +10,7 @@ import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
-import java.util.Iterator;
-import java.util.LinkedHashMap;
+import java.util.*;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.HttpsURLConnection;
@@ -21,6 +20,10 @@ import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
+import org.apache.guacamole.form.Field;
+import org.apache.guacamole.net.auth.credentials.CredentialsInfo;
+import org.apache.guacamole.net.auth.credentials.GuacamoleCredentialsException;
+import org.apache.guacamole.net.auth.credentials.GuacamoleInsufficientCredentialsException;
import org.codehaus.jackson.map.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -31,6 +34,7 @@ import org.slf4j.LoggerFactory;
*/
public class ConnectionManager {
+
private static final Logger LOGGER = LoggerFactory.getLogger(ConnectionManager.class);
// TODO: Config
@@ -38,11 +42,16 @@ public class ConnectionManager {
private static final LinkedHashMap<String, AvailableClient> clientPool = new LinkedHashMap<String, AvailableClient>();
+ private static final LinkedHashMap<Integer, JsonLocation> locationPool = new LinkedHashMap<Integer, JsonLocation>();
+
+ public static LinkedHashMap<Integer, JsonLocation> getLocationPool() { return locationPool; }
+
/**
* Pass plain user name, get existing connection (if any), or a fresh one
* @param user (LDAP) user name
*/
- public static WrappedConnection getForUser(String user) {
+ public static WrappedConnection getForUser(String user, int locationid)
+ throws GuacamoleCredentialsException {
if (SOURCE_URL == null) {
LOGGER.warn("Don't have a source URL for client machines!");
return null;
@@ -51,17 +60,35 @@ public class ConnectionManager {
updateList();
// Find existing/free client
AvailableClient freeClient;
- for (;;) {
+ for (; ; ) {
freeClient = null;
synchronized (clientPool) {
- for (AvailableClient ac : clientPool.values()) {
+ Collection<AvailableClient> clients;
+ if (locationid == 0) {
+ clients = new HashSet<AvailableClient>();
+ for (JsonLocation loc : locationPool.values()) {
+ if (!loc.hasPassword()) clients.addAll(loc.clientList);
+ }
+ } else {
+ JsonLocation location = locationPool.get(locationid);
+ if (location == null) {
+ // Request the user to select a location
+ throw new GuacamoleInsufficientCredentialsException(
+ "Select Location", new CredentialsInfo(
+ Collections.<Field>singletonList(new LocationField())
+ ));
+ }
+ clients = location.clientList;
+ }
+
+ for (AvailableClient ac : clients) {
if (ac.isInUseBy(user)) {
freeClient = ac;
break;
}
}
if (freeClient == null) {
- for (AvailableClient ac : clientPool.values()) {
+ for (AvailableClient ac : clients) {
if (ac.claim(user)) {
freeClient = ac;
break;
@@ -69,8 +96,13 @@ public class ConnectionManager {
}
}
}
- if (freeClient == null)
- return null; // TODO: No more clients available -- how to handle?
+ if (freeClient == null) {
+ // Request the user to select another location
+ throw new GuacamoleCredentialsException(
+ "No free client. Select another location.", new CredentialsInfo(
+ Collections.<Field>singletonList(new LocationField())
+ ));
+ }
// Found free or existing client, check if connection is (still) possible
if (freeClient.checkConnection(0)) {
LOGGER.info("Establishing mapping for user " + user + " to " + freeClient);
@@ -79,6 +111,8 @@ public class ConnectionManager {
// Connection check failed - release and loop again
freeClient.releaseConnection(user);
}
+ } catch (GuacamoleCredentialsException e) {
+ throw e;
} catch (Exception e) {
LOGGER.warn("KACKE AM DAMPFEN", e);
return null;
@@ -91,7 +125,7 @@ public class ConnectionManager {
* Fetch fresh client list from satellite server.
* Cache for 15 seconds.
*/
- private static synchronized void updateList() {
+ public static synchronized void updateList() {
long now = System.currentTimeMillis();
if (now < lastUpdate) {
lastUpdate = now;
@@ -139,23 +173,68 @@ public class ConnectionManager {
LOGGER.warn("Not updating local list");
return;
}
- list = root.clients;
- if (list == null) {
- LOGGER.info("Client list null");
+
+
+ if (root.locations == null) {
+ LOGGER.info("Location list null");
}
- if (root.locations != null) {
- for (JsonLocation l : root.locations) {
- LOGGER.info("Location " + l.name + " with pw " + l.password);
+ synchronized (locationPool) {
+ for (JsonLocation lnew : root.locations) {
+ JsonLocation existing = locationPool.get(lnew.id);
+ boolean redoClientMapping = false;
+ if (existing == null) {
+ locationPool.put(lnew.id, lnew);
+ existing = lnew;
+ redoClientMapping = true;
+ } else {
+ if (existing.locationids != lnew.locationids) redoClientMapping = true;
+ existing.locationids = lnew.locationids;
+ existing.name = lnew.name;
+ existing.password = lnew.password;
+ }
+ if (redoClientMapping) {
+ existing.clientList = new ArrayList<AvailableClient>();
+ for (AvailableClient client : clientPool.values()) {
+ int locationid = client.getLocationid();
+ for (int id : existing.locationids) {
+ if (id == locationid) {
+ existing.clientList.add(client);
+ break;
+ }
+ }
+ }
+ }
+ existing.checked = true;
+ LOGGER.info("Location " + lnew.name + " with pw " + lnew.password);
}
+ for (JsonLocation loc : locationPool.values()) {
+ if (loc.checked) loc.checked = false;
+ else locationPool.remove(loc.id);
+ }
+ }
+
+ if (root.clients == null) {
+ LOGGER.info("Client list null");
}
synchronized (clientPool) {
- for (JsonClient cnew : list) {
+ for (JsonClient cnew : root.clients) {
if (cnew.password == null || cnew.clientip == null)
continue; // Invalid
AvailableClient existing = clientPool.get(cnew.clientip);
if (existing == null) {
// New client
- clientPool.put(cnew.clientip, new AvailableClient(cnew));
+ AvailableClient newClient = new AvailableClient(cnew);
+ clientPool.put(cnew.clientip, newClient);
+
+ for (JsonLocation loc : locationPool.values()) {
+ for (int id : loc.locationids) {
+ if (id == cnew.locationid) {
+ loc.clientList.add(newClient);
+ newClient.locationList.add(loc);
+ break;
+ }
+ }
+ }
LOGGER.info("New client " + cnew.clientip);
} else {
existing.update(cnew);
@@ -166,6 +245,9 @@ public class ConnectionManager {
AvailableClient c = it.next();
if (c.isTimeout(NOW)) {
LOGGER.info("Removing client " + c + " from list");
+ for (JsonLocation loc : c.locationList) {
+ loc.clientList.remove(c);
+ }
it.remove();
}
diff --git a/src/main/java/de/bwlehrpool/bwlp_guac/JsonLocation.java b/src/main/java/de/bwlehrpool/bwlp_guac/JsonLocation.java
index 6bf9d1c..e6fc1b6 100644
--- a/src/main/java/de/bwlehrpool/bwlp_guac/JsonLocation.java
+++ b/src/main/java/de/bwlehrpool/bwlp_guac/JsonLocation.java
@@ -1,5 +1,10 @@
package de.bwlehrpool.bwlp_guac;
+import org.codehaus.jackson.annotate.JsonIgnore;
+import org.codehaus.jackson.annotate.JsonProperty;
+
+import java.util.ArrayList;
+
public class JsonLocation {
public int id;
@@ -7,7 +12,34 @@ public class JsonLocation {
public int[] locationids;
public String name;
-
+
public String password;
+
+ @JsonProperty("password")
+ public Boolean hasPassword() { return password != null; }
+
+ @JsonProperty("freeCount")
+ public int getFreeCount() {
+ int count = 0;
+ for (AvailableClient client : clientList) {
+ if (client.getState() == JsonClient.State.IDLE) count++;
+ }
+ return count;
+ }
+
+ @JsonProperty("offlineCount")
+ public int getOfflineCount() {
+ int count = 0;
+ for (AvailableClient client : clientList) {
+ if (client.getState() == JsonClient.State.OFFLINE || client.getState() == JsonClient.State.STANDBY) count++;
+ }
+ return count;
+ }
+
+ @JsonIgnore
+ public ArrayList<AvailableClient> clientList = new ArrayList<AvailableClient>();
+
+ @JsonIgnore
+ public boolean checked = false;
}
diff --git a/src/main/java/de/bwlehrpool/bwlp_guac/LocationField.java b/src/main/java/de/bwlehrpool/bwlp_guac/LocationField.java
new file mode 100644
index 0000000..5e0266d
--- /dev/null
+++ b/src/main/java/de/bwlehrpool/bwlp_guac/LocationField.java
@@ -0,0 +1,26 @@
+package de.bwlehrpool.bwlp_guac;
+
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.form.Field;
+import org.codehaus.jackson.annotate.JsonProperty;
+import org.codehaus.jackson.map.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.Collection;
+
+/**
+ * Field which lets the user select a location.
+ */
+public class LocationField extends Field {
+
+ public LocationField() {
+ super("location", "LOCATION");
+ }
+
+ @JsonProperty("locations")
+ public Collection<JsonLocation> getLocations() throws GuacamoleException {
+ return ConnectionManager.getLocationPool().values();
+ }
+} \ No newline at end of file
diff --git a/src/main/resources/bwlpModule.js b/src/main/resources/bwlpModule.js
new file mode 100644
index 0000000..c74580a
--- /dev/null
+++ b/src/main/resources/bwlpModule.js
@@ -0,0 +1,5 @@
+angular.module('location', [
+ 'form'
+]);
+
+angular.module('index').requires.push('location'); \ No newline at end of file
diff --git a/src/main/resources/config/locationConfig.js b/src/main/resources/config/locationConfig.js
new file mode 100644
index 0000000..6074752
--- /dev/null
+++ b/src/main/resources/config/locationConfig.js
@@ -0,0 +1,11 @@
+angular.module('location').config(['formServiceProvider',
+ function locationConfig(formServiceProvider) {
+
+ // Define field for the TOTP code provided by the user
+ formServiceProvider.registerFieldType('LOCATION', {
+ module : 'location',
+ controller : 'locationFieldController',
+ templateUrl : 'app/ext/bwlp/templates/locationField.html'
+ });
+
+ }]); \ No newline at end of file
diff --git a/src/main/resources/controllers/locationFieldController.js b/src/main/resources/controllers/locationFieldController.js
new file mode 100644
index 0000000..8117ed7
--- /dev/null
+++ b/src/main/resources/controllers/locationFieldController.js
@@ -0,0 +1,23 @@
+angular.module('location').controller('locationFieldController', ['$scope', '$window',
+ function locationFieldController($scope, $window) {
+
+ $scope.data = { id: 0, password: '' }
+
+ $scope.$watch('data', function(newValue) {
+ $scope.model = JSON.stringify(newValue);
+ }, true);
+
+ $scope.selectLocation = function selectLocation($event, id) {
+ if (angular.element($event.target).hasClass('bwlp-password-input')) return;
+ $scope.data.password = '';
+ if ($scope.data.id === id) {
+ $scope.data.id = 0;
+ angular.element('.selected-location').removeClass('selected-location');
+ return;
+ }
+ $scope.data.id = id;
+ angular.element('.selected-location').removeClass('selected-location');
+ angular.element($event.currentTarget).addClass('selected-location');
+ };
+
+}]); \ No newline at end of file
diff --git a/src/main/resources/guac-manifest.json b/src/main/resources/guac-manifest.json
index ef3c431..e507183 100644
--- a/src/main/resources/guac-manifest.json
+++ b/src/main/resources/guac-manifest.json
@@ -3,5 +3,17 @@
"name" : "bwLehrpool virtual pool",
"namespace" : "de.bwlehrpool",
"authProviders": ["de.bwlehrpool.bwlp_guac.BwlpAuthenticationProvider"],
- "html" : [ "disclaimer.html" ]
+ "html" : [ "disclaimer.html" ],
+ "translations" : [
+ "translations/en.json"
+ ],
+ "js" : [
+ "bwlp.min.js"
+ ],
+ "css" : [
+ "bwlp.min.css"
+ ],
+ "resources" : {
+ "templates/locationField.html" : "text/html"
+ }
}
diff --git a/src/main/resources/styles/locationField.css b/src/main/resources/styles/locationField.css
new file mode 100644
index 0000000..cec1466
--- /dev/null
+++ b/src/main/resources/styles/locationField.css
@@ -0,0 +1,47 @@
+.bwlp-location-container {
+ border: 2px solid #3d3d3d;
+ width: 100%;
+ border-spacing: 0;
+}
+
+.bwlp-location {
+ background-color: white;
+ cursor: pointer;
+}
+
+.bwlp-location > td {
+ padding: 14px;
+}
+
+.bwlp-location .bwlp-password {
+ padding: 4px 8px !important;
+ margin: -10px 0 !important;
+}
+
+.bwlp-location:hover {
+ filter: brightness(0.9);
+}
+
+.bwlp-location.selected-location {
+ background-color: #c8ffc8;
+}
+
+.bwlp-location-status {
+ font-size: 16px;
+ text-align: center;
+}
+
+.bwlp-password {
+ text-align: right;
+}
+
+.bwlp-password > span {
+ font-size: 16px;
+ opacity: 0.5;
+}
+
+.bwlp-password-input {
+ margin: 0 !important;
+ padding: 5px 8px !important;
+ background-color: white !important;
+} \ No newline at end of file
diff --git a/src/main/resources/templates/locationField.html b/src/main/resources/templates/locationField.html
new file mode 100644
index 0000000..f76259e
--- /dev/null
+++ b/src/main/resources/templates/locationField.html
@@ -0,0 +1,18 @@
+<table class="bwlp-location-container">
+ <tr ng-repeat="location in field.locations" class="bwlp-location"
+ ng-click="selectLocation($event, location.id)">
+ <td>
+ {{ location.name }}
+ </td>
+ <td class="bwlp-location-status">
+ {{ location.freeCount }} available ({{ location.offlineCount }} offline)
+ </td>
+ <td style="width: 200px">
+ <div ng-if="location.password" class="bwlp-password">
+ <span ng-if="data.id !== location.id">Password protected</span>
+ <input ng-if="data.id === location.id" ng-model="data.password"
+ type="password" placeholder="Password" class="bwlp-password-input" required>
+ </div>
+ </td>
+ </tr>
+</table>
diff --git a/src/main/resources/translations/en.json b/src/main/resources/translations/en.json
new file mode 100644
index 0000000..9ad430b
--- /dev/null
+++ b/src/main/resources/translations/en.json
@@ -0,0 +1,5 @@
+{
+ "LOGIN" : {
+ "FIELD_HEADER_LOCATION" : ""
+ }
+} \ No newline at end of file