- Added log in functionality
 - Updated gradle wrapper to latest
This commit is contained in:
Josh Stark 2020-01-19 14:16:54 +00:00
parent bd1946f84e
commit 248be84b5d
17 changed files with 428 additions and 110 deletions

View File

@ -1,11 +1,9 @@
plugins {
id 'java'
id 'com.eriwen.gradle.js' version '2.14.1'
id 'com.eriwen.gradle.css' version '2.14.0'
}
sourceCompatibility = 1.8
targetCompatibility = 1.8
sourceCompatibility = 11
targetCompatibility = 11
repositories {
mavenCentral()
@ -70,48 +68,7 @@ jar {
configurations.compile.collect { it.isDirectory() ? it : zipTree(it) }
}
exclude 'META-INF/*.RSA', 'META-INF/*.SF','META-INF/*.DSA'
}
combineJs {
encoding = "UTF-8"
source = [
'src/main/resources/assets/js/jquery-3.3.1.min.js',
'src/main/resources/assets/js/bootstrap.bundle.min.js',
'src/main/resources/assets/js/fontawesome-all.min.js',
'src/main/resources/assets/js/jquery.tablesorter.js',
'src/main/resources/assets/js/fleet.js'
]
dest = file('src/main/resources/assets/js/all.js')
}
minifyJs {
source = combineJs
dest = file('src/main/resources/assets/js/all.min.js')
closure {
warningLevel = 'QUIET'
}
}
combineCss {
source = [
'src/main/resources/assets/css/bootstrap.css',
'src/main/resources/assets/css/fleet.css',
'src/main/resources/assets/css/fontawesome-all.css'
]
dest = file('src/main/resources/assets/css/all.css')
}
minifyCss {
source = combineCss
dest = file('src/main/resources/assets/css/all.min.css')
exclude 'META-INF/INDEX.LIST', 'META-INF/*.RSA', 'META-INF/*.SF','META-INF/*.DSA'
}
task configureLogConfiguration(type: Copy) {

View File

@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-4.9-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-6.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@ -17,12 +17,9 @@
package io.linuxserver.fleet.auth;
import io.linuxserver.fleet.auth.AuthenticationResult;
import io.linuxserver.fleet.auth.security.PasswordEncoder;
public interface AuthenticationDelegate {
AuthenticationResult authenticate(String username, String password);
PasswordEncoder getPasswordEncoder();
String encodePassword(String rawPassword);
}

View File

@ -34,7 +34,7 @@ public class DefaultAuthenticationDelegate implements AuthenticationDelegate {
}
@Override
public PasswordEncoder getPasswordEncoder() {
return authenticator.getPasswordEncoder();
public String encodePassword(final String rawPassword) {
return authenticator.getPasswordEncoder().encode(rawPassword);
}
}

View File

@ -42,7 +42,7 @@ public class DefaultUserAuthenticator implements UserAuthenticator {
final User user = userService.lookUpUser(userCredentials.getUsername());
if (null != user && getPasswordEncoder().matches(userCredentials.getPassword(), user.getPassword())) {
return new AuthenticationResult(true, new AuthenticatedUser(user.getUsername()));
return new AuthenticationResult(true, new AuthenticatedUser(user));
}
return AuthenticationResult.notAuthenticated();

View File

@ -17,11 +17,7 @@
package io.linuxserver.fleet.core;
import io.linuxserver.fleet.auth.AuthenticationDelegate;
import io.linuxserver.fleet.auth.AuthenticationResult;
import io.linuxserver.fleet.auth.DefaultAuthenticationDelegate;
import io.linuxserver.fleet.auth.authenticator.DefaultUserAuthenticator;
import io.linuxserver.fleet.auth.security.PBKDF2PasswordEncoder;
import io.linuxserver.fleet.core.config.WebConfiguration;
import io.linuxserver.fleet.v2.client.docker.DockerApiClient;
import io.linuxserver.fleet.v2.client.docker.dockerhub.DockerHubApiClient;
@ -53,7 +49,6 @@ public class FleetAppController extends AbstractAppController implements Service
private final ScheduleService scheduleService;
private final SynchronisationService syncService;
private final UserService userService;
private final AuthenticationDelegate authenticationDelegate;
private final FileManager fileManager;
public FleetAppController() {
@ -64,9 +59,6 @@ public class FleetAppController extends AbstractAppController implements Service
dockerApiDelegate = new DockerApiDelegate(this);
syncService = new SynchronisationService(this);
userService = new UserService(this, new DefaultUserDAO(getDatabaseProvider()));
authenticationDelegate = new DefaultAuthenticationDelegate(new DefaultUserAuthenticator(userService,
new PBKDF2PasswordEncoder(getAppProperties().getAppSecret())));
}
private static FleetAppController instance;
@ -164,8 +156,8 @@ public class FleetAppController extends AbstractAppController implements Service
return fileManager;
}
public final AuthenticationResult authenticateUser(final String username, final String password) {
return authenticationDelegate.authenticate(username, password);
public final AuthenticationResult authenticateCredentials(final String username, final String password) {
return userService.authenticateCredentials(username, password);
}
public final void trackBranch(final ImageKey imageKey, final String branchName) {

View File

@ -19,21 +19,22 @@ package io.linuxserver.fleet.v2.db;
import io.linuxserver.fleet.core.db.DatabaseProvider;
import io.linuxserver.fleet.db.query.InsertUpdateResult;
import io.linuxserver.fleet.db.query.InsertUpdateStatus;
import io.linuxserver.fleet.v2.key.UserKey;
import io.linuxserver.fleet.v2.types.User;
import io.linuxserver.fleet.v2.types.internal.UserOutlineRequest;
import io.linuxserver.fleet.v2.web.AppRole;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
public class DefaultUserDAO extends AbstractDAO implements UserDAO {
private static final String GetUser = "{CALL User_Get(?)}";
private static final String GetUserByName = "{CALL User_GetByName(?)}";
private static final String GetAllUser = "{CALL User_GetAll()}";
private static final String CreateUser = "{CALL User_Save(?,?,?,?,?)}";
private static final String GetAllUsers = "{CALL User_GetAll()}";
private static final String CreateUser = "{CALL User_CreateOutline(?,?,?,?)}";
public DefaultUserDAO(DatabaseProvider databaseProvider) {
super(databaseProvider);
@ -41,33 +42,121 @@ public class DefaultUserDAO extends AbstractDAO implements UserDAO {
@Override
public User fetchUser(final UserKey userKey) {
return null;
try (final Connection connection = getConnection()) {
try (final CallableStatement call = connection.prepareCall(GetUser)) {
call.setInt(1, userKey.getId());
final ResultSet results = call.executeQuery();
if (results.next()) {
return makeOneUser(results);
}
}
return null;
} catch (SQLException e) {
getLogger().error("fetchAllUsers unable to complete request", e);
throw new RuntimeException("fetchAllUsers", e);
}
}
@Override
public User lookUpUser(final String username) {
return null;
try (final Connection connection = getConnection()) {
try (final CallableStatement call = connection.prepareCall(GetUserByName)) {
call.setString(1, username);
final ResultSet results = call.executeQuery();
if (results.next()) {
return makeOneUser(results);
}
}
return null;
} catch (SQLException e) {
getLogger().error("fetchAllUsers unable to complete request", e);
throw new RuntimeException("fetchAllUsers", e);
}
}
@Override
public InsertUpdateResult<User> createUser(final UserOutlineRequest request) {
return null;
try (final Connection connection = getConnection()) {
try (final CallableStatement call = connection.prepareCall(CreateUser)) {
int i = 1;
call.setString(i++, request.getUsername());
call.setString(i++, request.getPassword());
call.setString(i++, request.getRole().name());
final int statusIndex = i;
call.registerOutParameter(statusIndex, Types.VARCHAR);
final ResultSet results = call.executeQuery();
final DbUpdateStatus status = DbUpdateStatus.valueOf(call.getString(statusIndex));
if (results.next() && status.isInserted()) {
return new InsertUpdateResult<>(makeOneUser(results));
}
if (status.isExists()) {
return new InsertUpdateResult<>(InsertUpdateStatus.FAILED, "User already exists");
}
}
return new InsertUpdateResult<>(InsertUpdateStatus.FAILED, "Unknown error");
} catch (SQLException e) {
getLogger().error("createUser unable to complete request", e);
throw new RuntimeException("createUser", e);
}
}
@Override
public List<User> fetchAllUsers() {
return null;
final List<User> users = new ArrayList<>();
try (final Connection connection = getConnection()) {
try (final CallableStatement call = connection.prepareCall(GetAllUsers)) {
final ResultSet results = call.executeQuery();
while (results.next()) {
users.add(makeOneUser(results));
}
}
} catch (SQLException e) {
getLogger().error("fetchAllUsers unable to complete request", e);
throw new RuntimeException("fetchAllUsers", e);
}
return users;
}
@Override
public InsertUpdateResult<Void> removeUser(User user) {
public InsertUpdateResult<Void> removeUser(final User user) {
return null;
}
private User makeOneUser(final ResultSet results) throws SQLException {
return new User(new UserKey(results.getInt("UserId")),
results.getString("UserName"),
results.getString("Username"),
results.getString("UserPassword"),
results.getTimestamp("ModifiedTime").toLocalDateTime(),
AppRole.valueOf(results.getString("UserRole")));

View File

@ -17,21 +17,75 @@
package io.linuxserver.fleet.v2.service;
import io.linuxserver.fleet.auth.AuthenticationDelegate;
import io.linuxserver.fleet.auth.AuthenticationResult;
import io.linuxserver.fleet.auth.DefaultAuthenticationDelegate;
import io.linuxserver.fleet.auth.authenticator.DefaultUserAuthenticator;
import io.linuxserver.fleet.auth.security.PBKDF2PasswordEncoder;
import io.linuxserver.fleet.core.FleetAppController;
import io.linuxserver.fleet.db.query.InsertUpdateResult;
import io.linuxserver.fleet.v2.db.UserDAO;
import io.linuxserver.fleet.v2.key.UserKey;
import io.linuxserver.fleet.v2.types.User;
import io.linuxserver.fleet.v2.types.internal.UserOutlineRequest;
import java.util.List;
public class UserService extends AbstractAppService {
private final UserDAO userDAO;
private final UserDAO userDAO;
private final AuthenticationDelegate authDelegate;
public UserService(final FleetAppController controller,
final UserDAO userDAO) {
super(controller);
this.userDAO = userDAO;
this.userDAO = userDAO;
this.authDelegate = new DefaultAuthenticationDelegate(new DefaultUserAuthenticator(this,
new PBKDF2PasswordEncoder(getProperties().getAppSecret())));
createInitialAdminUser();
}
public final AuthenticationResult authenticateCredentials(final String username, final String password) {
return authDelegate.authenticate(username, password);
}
public final User lookUpUser(final String username) {
return userDAO.lookUpUser(username);
}
public final User fetchUser(final UserKey userKey) {
return userDAO.fetchUser(userKey);
}
public final List<User> fetchAllUsers() {
return userDAO.fetchAllUsers();
}
public final User createUser(final UserOutlineRequest userOutlineRequest) {
final InsertUpdateResult<User> result = userDAO.createUser(userOutlineRequest);
if (result.isError()) {
getLogger().error("Unable to create new user: {}", result.getStatusMessage());
throw new RuntimeException("Unable to create new user: " + result.getStatusMessage());
}
return result.getResult();
}
private void createInitialAdminUser() {
if (fetchAllUsers().isEmpty()) {
getLogger().info("There are no users! Creating initial user with default credentials");
final UserOutlineRequest encodedUser = UserOutlineRequest.InitialFirstLoadUser.cloneWithPassword(
authDelegate.encodePassword(UserOutlineRequest.InitialFirstLoadUser.getPassword())
);
createUser(encodedUser);
getLogger().warn("!!!!!!!!");
getLogger().warn("DEFAULT USER CREATED. CHANGE THE PASSWORD OR CREATE A NEW USER!");
getLogger().warn("!!!!!!!!");
}
}
}

View File

@ -34,6 +34,10 @@ public class UserOutlineRequest {
this.role = role;
}
public final UserOutlineRequest cloneWithPassword(final String password) {
return new UserOutlineRequest(getUsername(), password, getRole());
}
public final String getUsername() {
return username;
}

View File

@ -66,37 +66,40 @@ public class WebRouteController {
webInstance.routes(() -> {
get(Locations.Login, new LoginController(app), roles(AppRole.Anyone));
final LoginController loginController = new LoginController(app);
get( Locations.Login, loginController, roles(AppRole.Anyone));
post(Locations.Login, loginController, roles(AppRole.Anyone));
get(Locations.Home, new HomeController( app), roles(AppRole.Anyone));
get(Locations.Image, new ImageController(app), roles(AppRole.Anyone));
get(Locations.Admin.Repositories, new AdminRepositoryController(app), roles(AppRole.Anyone));
get(Locations.Admin.Images, new AdminImageController( app), roles(AppRole.Anyone));
get(Locations.Admin.Schedules, new AdminScheduleController( app), roles(AppRole.Anyone));
get(Locations.Admin.Repositories, new AdminRepositoryController(app), roles(AppRole.Admin));
get(Locations.Admin.Images, new AdminImageController( app), roles(AppRole.Admin));
get(Locations.Admin.Schedules, new AdminScheduleController( app), roles(AppRole.Admin));
final AdminImageEditController imageEditController = new AdminImageEditController(app);
get(Locations.Admin.ImageEdit, imageEditController, roles(AppRole.Anyone));
post(Locations.Admin.ImageEdit, imageEditController, roles(AppRole.Anyone));
get( Locations.Admin.ImageEdit, imageEditController, roles(AppRole.Admin));
post(Locations.Admin.ImageEdit, imageEditController, roles(AppRole.Admin));
path(Locations.Internal.Api, () -> {
path(Locations.Internal.Repository, () -> {
put(apiController::updateRepositorySpec, roles(AppRole.Anyone));
post(apiController::addNewRepository, roles(AppRole.Anyone));
delete(apiController::deleteRepository, roles(AppRole.Anyone));
put( apiController::updateRepositorySpec, roles(AppRole.Admin));
post( apiController::addNewRepository, roles(AppRole.Admin));
delete(apiController::deleteRepository, roles(AppRole.Admin));
path(Locations.Internal.Sync, () -> {
put(apiController::syncRepository, roles(AppRole.Anyone));
put(apiController::syncRepository, roles(AppRole.Admin));
});
});
path(Locations.Internal.Image, () -> {
put(apiController::updateImageSpec, roles(AppRole.Anyone));
put(apiController::updateImageSpec, roles(AppRole.Admin));
path(Locations.Internal.Sync, () -> {
put(apiController::syncImage, roles(AppRole.Anyone));
put(apiController::syncImage, roles(AppRole.Admin));
});
path(Locations.Internal.Stats, () -> {
@ -104,12 +107,12 @@ public class WebRouteController {
});
path(Locations.Internal.Track, () -> {
put(apiController::trackNewBranch, roles(AppRole.Anyone));
put(apiController::trackNewBranch, roles(AppRole.Admin));
});
});
path(Locations.Internal.Schedule, () -> {
put(apiController::runSchedule, roles(AppRole.Anyone));
put(apiController::runSchedule, roles(AppRole.Admin));
});
});
});

View File

@ -21,7 +21,7 @@ import io.javalin.core.security.AccessManager;
import io.javalin.core.security.Role;
import io.javalin.http.Context;
import io.javalin.http.Handler;
import io.linuxserver.fleet.v2.types.User;
import io.linuxserver.fleet.auth.AuthenticatedUser;
import io.linuxserver.fleet.v2.web.AppRole;
import io.linuxserver.fleet.v2.web.Locations;
import io.linuxserver.fleet.v2.web.SessionAttributes;
@ -38,12 +38,24 @@ public class DefaultAccessManager implements AccessManager {
handler.handle(ctx);
} else {
final User user = ctx.sessionAttribute(SessionAttributes.AuthenticatedUser);
final AuthenticatedUser user = ctx.sessionAttribute(SessionAttributes.AuthenticatedUser);
if (null == user) {
ctx.redirect(Locations.Login);
} else {
} else if (isUserRoleValid(user, permittedRoles)){
handler.handle(ctx);
} else {
ctx.status(401);
}
}
}
private boolean isUserRoleValid(final AuthenticatedUser user, final Set<Role> permittedRoles) {
for (Role role : permittedRoles) {
if (user.getRoles().contains(role)) {
return true;
}
}
return false;
}
}

View File

@ -45,20 +45,32 @@ public class LoginController extends AbstractPageHandler {
if (null != ctx.formParam("invalidate")) {
doLogOut(ctx);
} else {
doLogIn(ctx);
final boolean loggedIn = doLogIn(ctx);
if (!loggedIn) {
final PageModelSpec pageModelSpec = new PageModelSpec("views/pages/login.ftl");
pageModelSpec.addModelAttribute("loginFailed", "Username or password was incorrect.");
return pageModelSpec;
}
}
return new PageModelSpec("redirect:/");
}
private void doLogIn(final Context ctx) {
private boolean doLogIn(final Context ctx) {
final String username = ctx.formParam("username");
final String password = ctx.formParam("password");
final AuthenticationResult result = getController().authenticateUser(username, password);
final AuthenticationResult result = getController().authenticateCredentials(username, password);
if (result.isAuthenticated()) {
ctx.sessionAttribute(SessionAttributes.AuthenticatedUser, result.getUser());
return true;
} else {
return false;
}
}

View File

@ -0,0 +1,136 @@
DELIMITER //
CREATE OR REPLACE VIEW `User_View` AS (
SELECT
`id` AS `UserId`,
`username` AS `Username`,
`password` AS `UserPassword`,
`modified` AS `ModifiedTime`,
`role` AS `UserRole`
FROM
Users
);
//
CREATE OR REPLACE PROCEDURE `User_CreateOutline`
(
in_username VARCHAR(255),
in_password VARCHAR(255),
in_role ENUM('Admin'),
OUT out_status ENUM('Inserted', 'NoChange', 'Exists')
)
BEGIN
IF NOT EXISTS(SELECT 1 FROM User_View WHERE `Username` = in_username) THEN
INSERT INTO Users
(
`username`,
`password`,
`role`
)
VALUES
(
in_username,
in_password,
in_role
);
SET out_status = 'Inserted';
SELECT * FROM User_View WHERE `UserId` = LAST_INSERT_ID();
ELSE
SET out_status = 'Exists';
END IF;
END;
//
CREATE OR REPLACE PROCEDURE `User_Save`
(
in_id INT,
in_username VARCHAR(255),
in_password VARCHAR(255),
in_role ENUM('Admin'),
OUT out_status ENUM('Updated', 'NoChange')
)
BEGIN
IF EXISTS(SELECT 1 FROM User_View WHERE `Username` = in_username) THEN
UPDATE Users
SET
`password` = in_password,
`role` = in_role
WHERE
`id` = in_id;
SET out_status = 'Updated';
SELECT * FROM User_View WHERE `UserId` = in_id;
ELSE
SET out_status = 'NoChange';
END IF;
END;
//
CREATE OR REPLACE PROCEDURE `User_GetAll` ()
BEGIN
SELECT
*
FROM
User_View;
END;
//
CREATE OR REPLACE PROCEDURE `User_Get`
(
in_id INT
)
BEGIN
SELECT
*
FROM
User_View
WHERE
`UserId` = in_id;
END;
//
CREATE OR REPLACE PROCEDURE `User_GetByName`
(
in_username VARCHAR(255)
)
BEGIN
SELECT
*
FROM
User_View
WHERE
`Username` = in_username;
END;
//
CREATE OR REPLACE PROCEDURE `User_Delete`
(
in_id INT
)
BEGIN
DELETE FROM Users WHERE `id` = in_id;
END;
//
DELIMITER ;

View File

@ -1,5 +1,5 @@
#Sun Jan 19 10:53:51 GMT 2020
app.build.date=2020-01-19T10\:53\:51
#Sun Jan 19 13:34:43 GMT 2020
app.build.date=2020-01-19T13\:34\:43
app.build.os=Linux
app.build.user=josh
app.version=2.0.0

View File

@ -26,7 +26,7 @@
<#import "../ui/elements/table.ftl" as table />
<#import "../ui/elements/button.ftl" as button />
<@base.base title="Images" context="home" showTitle=false>
<@base.base title="Images" context="home" showTitle=false availableRepositories=availableRepositories>
<section class="section is-paddingless-top">
<div class="container">

View File

@ -0,0 +1,60 @@
<#--
Copyright (c) 2019 LinuxServer.io
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<#import "../prebuilt/base.ftl" as base />
<#import "../prebuilt/fleet-title.ftl" as title />
<#import "../ui/elements/box.ftl" as box />
<#import "../ui/form/input.ftl" as input />
<#import "../ui/elements/button.ftl" as button />
<#import "../ui/components/message.ftl" as message />
<#import "../ui/layout/container.ftl" as container />
<@base.base title="Log In" showNav=false context="login" hasHero=false>
<section class="hero is-fullheight">
<div class="hero-body">
<@container.container isFluid=true>
<div class="columns is-centered">
<div class="column is-one-third-desktop is-two-thirds-tablet is-full-mobile">
<form class="needs-validation" novalidate action="/login" method="post">
<@box.box>
<@title.title boldValue="fleet" />
<@input.text id="username" icon="user" placeholder="Username" isRequired=true />
<@input.password id="password" icon="lock" placeholder="Password" isRequired=true />
<@button.submit id="SubmitLogin" extraClasses="is-fullwidth">
Log In
</@button.submit>
<#if loginFailed?has_content>
<@message.message colour="danger" extraClasses="has-margin-top">
${loginFailed?html}
</@message.message>
</#if>
</@box.box>
</form>
</div>
</div>
</@container.container>
</div>
</section>
</@base.base>

View File

@ -21,7 +21,7 @@
<#import "../ui/form/input.ftl" as input />
<#import "../prebuilt/system-alert.ftl" as systemAlert />
<#macro base title context showTitle=true backgroundColour="white" hasHero=false>
<#macro base title context showTitle=true showNav=true backgroundColour="white" hasHero=false availableRepositories=[]>
<!DOCTYPE html>
<html lang="en">
@ -42,6 +42,7 @@
<body class="has-background-${backgroundColour} has-text-grey-dark">
<#if showNav>
<@navbar.navbar id="MainNav" hasShadow=false itemPlacement="end" colour=(hasHero?string('dark', 'normal-colour')) extraClasses=''>
<#if context="home">
@ -58,28 +59,29 @@
</div>
</#if>
<@navbar.dropdown icon="shield-alt" displayText="Admin" isRight=true>
<@navbar.item displayText="Repositories" icon="cubes" link="/admin/repositories" isActive=(context=='admin_repositories') />
<@navbar.item displayText="Schedules" icon="clock" link="/admin/schedules" isActive=(context=='admin_schedules') />
<@navbar.item displayText="Users" icon="users" link="/admin/users" isActive=(context=='admin_users') />
<@navbar.item displayText="System Settings" icon="cog" link="/admin/system" isActive=(context=='admin_system') />
</@navbar.dropdown>
<#if __AuthenticatedUser?has_content>
<@navbar.dropdown displayText=__AuthenticatedUser.name isRight=true>
<@navbar.item displayText="Log Out" icon="sign-out-alt" />
<@navbar.dropdown icon="shield-alt" displayText="Admin" isRight=true>
<@navbar.item displayText="Repositories" icon="cubes" link="/admin/repositories" isActive=(context=='admin_repositories') />
<@navbar.item displayText="Schedules" icon="clock" link="/admin/schedules" isActive=(context=='admin_schedules') />
<#-- <@navbar.item displayText="Users" icon="users" link="/admin/users" isActive=(context=='admin_users') />-->
<#-- <@navbar.item displayText="System Settings" icon="cog" link="/admin/system" isActive=(context=='admin_system') />-->
</@navbar.dropdown>
<@navbar.dropdown displayText=__AuthenticatedUser.name isRight=true icon="user">
<@navbar.item displayText="Log Out" icon="sign-out-alt" icon="sign-out-alt" link="/login?invalidate=true" />
</@navbar.dropdown>
<#else>
<@navbar.buttons size="small">
<@button.link id="LogIn" link="/login" size="small" colour="white">
<@button.link id="LogIn" link="/login" size="small" colour="light">
<i class="fas fa-sign-in-alt"></i> Log In
</@button.link>
</@navbar.buttons>
</#if>
</@navbar.navbar>
</#if>
<div id="NotificationWrapper">
<div id="Notifications"></div>