Generated project
This commit is contained in:
74
frontend/auth.ts
Normal file
74
frontend/auth.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { login as loginImpl, LoginResult, logout as logoutImpl } from '@vaadin/flow-frontend';
|
||||
import { appStore } from './stores/app-store';
|
||||
|
||||
interface Authentication {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
let authentication: Authentication | undefined = undefined;
|
||||
|
||||
const AUTHENTICATION_KEY = 'authentication';
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// Get authentication from local storage
|
||||
const storedAuthenticationJson = localStorage.getItem(AUTHENTICATION_KEY);
|
||||
if (storedAuthenticationJson !== null) {
|
||||
const storedAuthentication = JSON.parse(storedAuthenticationJson) as Authentication;
|
||||
// Check that the stored timestamp is not older than 30 days
|
||||
const hasRecentAuthenticationTimestamp = new Date().getTime() - storedAuthentication.timestamp < THIRTY_DAYS_MS;
|
||||
if (hasRecentAuthenticationTimestamp) {
|
||||
// Use loaded authentication
|
||||
authentication = storedAuthentication;
|
||||
} else {
|
||||
// Delete expired stored authentication
|
||||
setSessionExpired();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces the session to expire and removes user information stored in
|
||||
* `localStorage`.
|
||||
*/
|
||||
export function setSessionExpired() {
|
||||
authentication = undefined;
|
||||
|
||||
// Delete the authentication from the local storage
|
||||
localStorage.removeItem(AUTHENTICATION_KEY);
|
||||
}
|
||||
|
||||
export function isAuthenticated() {
|
||||
return !!authentication;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login wrapper method that retrieves user information.
|
||||
*
|
||||
* Uses `localStorage` for offline support.
|
||||
*/
|
||||
export async function login(username: string, password: string): Promise<LoginResult> {
|
||||
const result = await loginImpl(username, password);
|
||||
if (!result.error) {
|
||||
// Get user info from endpoint
|
||||
await appStore.fetchUserInfo();
|
||||
authentication = {
|
||||
timestamp: new Date().getTime(),
|
||||
};
|
||||
|
||||
// Save the authentication to local storage
|
||||
localStorage.setItem(AUTHENTICATION_KEY, JSON.stringify(authentication));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login wrapper method that retrieves user information.
|
||||
*
|
||||
* Uses `localStorage` for offline support.
|
||||
*/
|
||||
export async function logout() {
|
||||
setSessionExpired();
|
||||
await logoutImpl();
|
||||
appStore.clearUserInfo();
|
||||
location.href = '';
|
||||
}
|
||||
24
frontend/index.html
Normal file
24
frontend/index.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Fusion Management</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
#outlet {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
<!-- index.ts is included here automatically (either by the dev server or during the build) -->
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="outlet"></div>
|
||||
</body>
|
||||
</html>
|
||||
20
frontend/index.ts
Normal file
20
frontend/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Router } from '@vaadin/router';
|
||||
import { routes } from './routes';
|
||||
import { appStore } from './stores/app-store';
|
||||
|
||||
export const router = new Router(document.querySelector('#outlet'));
|
||||
|
||||
appStore.fetchUserInfo().finally(() => {
|
||||
// Ensure router access checks are not done before we know if we are logged in
|
||||
router.setRoutes(routes);
|
||||
});
|
||||
|
||||
window.addEventListener('vaadin-router-location-changed', (e) => {
|
||||
appStore.setLocation((e as CustomEvent).detail.location);
|
||||
const title = appStore.currentViewTitle;
|
||||
if (title) {
|
||||
document.title = title + ' | ' + appStore.applicationName;
|
||||
} else {
|
||||
document.title = appStore.applicationName;
|
||||
}
|
||||
});
|
||||
80
frontend/routes.ts
Normal file
80
frontend/routes.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Route } from '@vaadin/router';
|
||||
import Role from './generated/com/example/application/data/Role';
|
||||
import { appStore } from './stores/app-store';
|
||||
import './views/home/home-view';
|
||||
import './views/main-layout';
|
||||
|
||||
export type ViewRoute = Route & {
|
||||
title?: string;
|
||||
icon?: string;
|
||||
requiresLogin?: boolean;
|
||||
rolesAllowed?: Role[];
|
||||
children?: ViewRoute[];
|
||||
};
|
||||
|
||||
export const hasAccess = (route: Route) => {
|
||||
const viewRoute = route as ViewRoute;
|
||||
if (viewRoute.requiresLogin && !appStore.loggedIn) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (viewRoute.rolesAllowed) {
|
||||
return viewRoute.rolesAllowed.some((role) => appStore.isUserInRole(role));
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const views: ViewRoute[] = [
|
||||
// place routes below (more info https://vaadin.com/docs/latest/fusion/routing/overview)
|
||||
{
|
||||
path: '',
|
||||
component: 'home-view',
|
||||
icon: 'la la-home',
|
||||
title: 'Home',
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
component: 'profile-view',
|
||||
requiresLogin: true,
|
||||
icon: 'la la-user',
|
||||
title: 'Profile',
|
||||
action: async (_context, _command) => {
|
||||
if (!hasAccess(_context.route)) {
|
||||
return _command.redirect('login');
|
||||
}
|
||||
await import('./views/profile/profile-view');
|
||||
return;
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'admin',
|
||||
component: 'admin-view',
|
||||
rolesAllowed: [Role.ADMIN],
|
||||
icon: 'la la-user-lock',
|
||||
title: 'Admin',
|
||||
action: async (_context, _command) => {
|
||||
if (!hasAccess(_context.route)) {
|
||||
return _command.redirect('login');
|
||||
}
|
||||
await import('./views/admin/admin-view');
|
||||
return;
|
||||
},
|
||||
},
|
||||
];
|
||||
export const routes: ViewRoute[] = [
|
||||
{
|
||||
path: '',
|
||||
component: 'main-layout',
|
||||
children: [...views],
|
||||
},
|
||||
{
|
||||
path: 'login',
|
||||
component: 'login-view',
|
||||
icon: '',
|
||||
title: 'Login',
|
||||
action: async (_context, _command) => {
|
||||
await import('./views/login/login-view');
|
||||
return;
|
||||
},
|
||||
},
|
||||
];
|
||||
48
frontend/stores/app-store.ts
Normal file
48
frontend/stores/app-store.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { RouterLocation } from '@vaadin/router';
|
||||
import User from 'Frontend/generated/com/cubetiqs/fusion/data/entity/User';
|
||||
import Role from 'Frontend/generated/com/cubetiqs/fusion/data/Role';
|
||||
import { UserEndpoint } from 'Frontend/generated/UserEndpoint';
|
||||
import { makeAutoObservable } from 'mobx';
|
||||
|
||||
export class AppStore {
|
||||
applicationName = 'Fusion Management';
|
||||
|
||||
// The location, relative to the base path, e.g. "hello" when viewing "/hello"
|
||||
location = '';
|
||||
|
||||
currentViewTitle = '';
|
||||
|
||||
user: User | undefined = undefined;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
setLocation(location: RouterLocation) {
|
||||
if (location.route) {
|
||||
this.location = location.route.path;
|
||||
} else if (location.pathname.startsWith(location.baseUrl)) {
|
||||
this.location = location.pathname.substr(location.baseUrl.length);
|
||||
} else {
|
||||
this.location = location.pathname;
|
||||
}
|
||||
this.currentViewTitle = (location?.route as any)?.title || '';
|
||||
}
|
||||
|
||||
async fetchUserInfo() {
|
||||
this.user = await UserEndpoint.getAuthenticatedUser();
|
||||
}
|
||||
|
||||
clearUserInfo() {
|
||||
this.user = undefined;
|
||||
}
|
||||
|
||||
get loggedIn() {
|
||||
return !!this.user;
|
||||
}
|
||||
|
||||
isUserInRole(role: Role) {
|
||||
return this.user?.roles?.includes(role);
|
||||
}
|
||||
}
|
||||
export const appStore = new AppStore();
|
||||
50
frontend/themes/fusionmanagement/main-layout.css
Normal file
50
frontend/themes/fusionmanagement/main-layout.css
Normal file
@@ -0,0 +1,50 @@
|
||||
.sidemenu-header {
|
||||
align-items: center;
|
||||
box-shadow: var(--lumo-box-shadow-s);
|
||||
display: flex;
|
||||
height: var(--lumo-size-xl);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidemenu-header h1 {
|
||||
font-size: var(--lumo-font-size-l);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidemenu-menu #logo {
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
padding: var(--lumo-space-s) var(--lumo-space-m);
|
||||
}
|
||||
|
||||
.sidemenu-menu #logo img {
|
||||
height: calc(var(--lumo-size-l) * 1.5);
|
||||
}
|
||||
|
||||
.sidemenu-menu #logo span {
|
||||
font-size: var(--lumo-font-size-xl);
|
||||
font-weight: 600;
|
||||
margin: 0 var(--lumo-space-s);
|
||||
}
|
||||
|
||||
.sidemenu-menu vaadin-tab {
|
||||
font-size: var(--lumo-font-size-s);
|
||||
height: var(--lumo-size-l);
|
||||
font-weight: 600;
|
||||
color: var(--lumo-body-text-color);
|
||||
}
|
||||
|
||||
.sidemenu-menu vaadin-tab:hover {
|
||||
background-color: var(--lumo-contrast-5pct);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sidemenu-menu vaadin-tab[selected] {
|
||||
background-color: var(--lumo-primary-color-10pct);
|
||||
color: var(--lumo-primary-text-color);
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 0;
|
||||
}
|
||||
9
frontend/themes/fusionmanagement/styles.css
Normal file
9
frontend/themes/fusionmanagement/styles.css
Normal file
@@ -0,0 +1,9 @@
|
||||
@import url('./main-layout.css');
|
||||
@import url('./views/home-view.css');
|
||||
@import url('./views/profile-view.css');
|
||||
@import url('./views/admin-view.css');
|
||||
@import url('line-awesome/dist/line-awesome/css/line-awesome.min.css');
|
||||
@import url('@fontsource/roboto'); html {
|
||||
--lumo-font-family: Roboto;
|
||||
|
||||
}
|
||||
1
frontend/themes/fusionmanagement/theme.json
Normal file
1
frontend/themes/fusionmanagement/theme.json
Normal file
@@ -0,0 +1 @@
|
||||
{"lumoImports":["typography","color","spacing","badge","utility"]}
|
||||
3
frontend/themes/fusionmanagement/views/admin-view.css
Normal file
3
frontend/themes/fusionmanagement/views/admin-view.css
Normal file
@@ -0,0 +1,3 @@
|
||||
admin-view {
|
||||
display: block;
|
||||
}
|
||||
3
frontend/themes/fusionmanagement/views/home-view.css
Normal file
3
frontend/themes/fusionmanagement/views/home-view.css
Normal file
@@ -0,0 +1,3 @@
|
||||
home-view {
|
||||
display: block;
|
||||
}
|
||||
3
frontend/themes/fusionmanagement/views/profile-view.css
Normal file
3
frontend/themes/fusionmanagement/views/profile-view.css
Normal file
@@ -0,0 +1,3 @@
|
||||
profile-view {
|
||||
display: block;
|
||||
}
|
||||
10
frontend/views/admin/admin-view.ts
Normal file
10
frontend/views/admin/admin-view.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { html } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { View } from '../../views/view';
|
||||
|
||||
@customElement('admin-view')
|
||||
export class AdminView extends View {
|
||||
render() {
|
||||
return html`<div>Content placeholder</div>`;
|
||||
}
|
||||
}
|
||||
10
frontend/views/home/home-view.ts
Normal file
10
frontend/views/home/home-view.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { html } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { View } from '../../views/view';
|
||||
|
||||
@customElement('home-view')
|
||||
export class HomeView extends View {
|
||||
render() {
|
||||
return html`<div>Content placeholder</div>`;
|
||||
}
|
||||
}
|
||||
40
frontend/views/login/login-view.ts
Normal file
40
frontend/views/login/login-view.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import '@vaadin/vaadin-login';
|
||||
import { LoginI18n } from '@vaadin/vaadin-login';
|
||||
import { html } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators';
|
||||
import { View } from '../../views/view';
|
||||
|
||||
const loginI18nDefault: LoginI18n = {
|
||||
form: {
|
||||
title: 'Log in',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
submit: 'Log in',
|
||||
forgotPassword: 'Forgot password',
|
||||
},
|
||||
errorMessage: {
|
||||
title: 'Incorrect username or password',
|
||||
message: 'Check that you have entered the correct username and password and try again.',
|
||||
},
|
||||
};
|
||||
@customElement('login-view')
|
||||
export class LoginView extends View {
|
||||
@state()
|
||||
private error = false;
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<vaadin-login-overlay
|
||||
opened
|
||||
.error=${this.error}
|
||||
action="login"
|
||||
no-forgot-password
|
||||
.i18n=${Object.assign(
|
||||
{ header: { title: 'Fusion Management', description: 'Login using user/user or admin/admin' } },
|
||||
loginI18nDefault
|
||||
)}
|
||||
>
|
||||
</vaadin-login-overlay>
|
||||
`;
|
||||
}
|
||||
}
|
||||
83
frontend/views/main-layout.ts
Normal file
83
frontend/views/main-layout.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import '@vaadin/vaadin-app-layout';
|
||||
import { AppLayoutElement } from '@vaadin/vaadin-app-layout';
|
||||
import '@vaadin/vaadin-app-layout/vaadin-drawer-toggle';
|
||||
import '@vaadin/vaadin-avatar/vaadin-avatar';
|
||||
import '@vaadin/vaadin-context-menu';
|
||||
import '@vaadin/vaadin-tabs';
|
||||
import '@vaadin/vaadin-tabs/vaadin-tab';
|
||||
import '@vaadin/vaadin-template-renderer';
|
||||
import { html, render } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { logout } from '../auth';
|
||||
import { router } from '../index';
|
||||
import { hasAccess, views } from '../routes';
|
||||
import { appStore } from '../stores/app-store';
|
||||
import { Layout } from './view';
|
||||
|
||||
interface RouteInfo {
|
||||
path: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
@customElement('main-layout')
|
||||
export class MainLayout extends Layout {
|
||||
render() {
|
||||
return html`
|
||||
<vaadin-app-layout primary-section="drawer">
|
||||
<header slot="navbar" theme="dark" class="sidemenu-header">
|
||||
<vaadin-drawer-toggle></vaadin-drawer-toggle>
|
||||
<h1>${appStore.currentViewTitle}</h1>
|
||||
${appStore.user
|
||||
? html` <vaadin-context-menu class="ms-auto me-m" open-on="click" .renderer="${this.renderLogoutOptions}">
|
||||
<vaadin-avatar img="${appStore.user.profilePictureUrl}" name="${appStore.user.name}"></vaadin-avatar
|
||||
></vaadin-context-menu>`
|
||||
: html`<a class="ms-auto me-m" router-ignore href="login">Sign in</a>`}
|
||||
</header>
|
||||
|
||||
<div slot="drawer" class="sidemenu-menu">
|
||||
<div id="logo">
|
||||
<img src="images/logo.png" alt="${appStore.applicationName} logo" />
|
||||
<span>${appStore.applicationName}</span>
|
||||
</div>
|
||||
<hr />
|
||||
<vaadin-tabs orientation="vertical" theme="minimal" .selected=${this.getSelectedViewRoute()}>
|
||||
${this.getMenuRoutes().map(
|
||||
(viewRoute) => html`
|
||||
<vaadin-tab>
|
||||
<span class="${viewRoute.icon} pr-s"></span>
|
||||
<a href=${router.urlForPath(viewRoute.path)} tabindex="-1">${viewRoute.title}</a>
|
||||
</vaadin-tab>
|
||||
`
|
||||
)}
|
||||
</vaadin-tabs>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</vaadin-app-layout>
|
||||
`;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.classList.add('block', 'h-full');
|
||||
this.reaction(
|
||||
() => appStore.location,
|
||||
() => {
|
||||
AppLayoutElement.dispatchCloseOverlayDrawerEvent();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private renderLogoutOptions(root: HTMLElement) {
|
||||
render(html`<vaadin-list-box><vaadin-item @click=${() => logout()}>Logout</vaadin-item></vaadin-list-box>`, root);
|
||||
}
|
||||
|
||||
private getMenuRoutes(): RouteInfo[] {
|
||||
return views.filter((route) => route.title).filter((route) => hasAccess(route)) as RouteInfo[];
|
||||
}
|
||||
|
||||
private getSelectedViewRoute(): number {
|
||||
const path = appStore.location;
|
||||
return this.getMenuRoutes().findIndex((viewRoute) => viewRoute.path == path);
|
||||
}
|
||||
}
|
||||
10
frontend/views/profile/profile-view.ts
Normal file
10
frontend/views/profile/profile-view.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { html } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { View } from '../../views/view';
|
||||
|
||||
@customElement('profile-view')
|
||||
export class ProfileView extends View {
|
||||
render() {
|
||||
return html`<div>Content placeholder</div>`;
|
||||
}
|
||||
}
|
||||
51
frontend/views/view.ts
Normal file
51
frontend/views/view.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { MobxLitElement } from '@adobe/lit-mobx';
|
||||
import { applyTheme } from 'Frontend/generated/theme';
|
||||
import { autorun, IAutorunOptions, IReactionDisposer, IReactionOptions, IReactionPublic, reaction } from 'mobx';
|
||||
|
||||
export class MobxElement extends MobxLitElement {
|
||||
private disposers: IReactionDisposer[] = [];
|
||||
|
||||
/**
|
||||
* Creates a MobX reaction using the given parameters and disposes it when this element is detached.
|
||||
*
|
||||
* This should be called from `connectedCallback` to ensure that the reaction is active also if the element is attached again later.
|
||||
*/
|
||||
protected reaction<T>(
|
||||
expression: (r: IReactionPublic) => T,
|
||||
effect: (arg: T, prev: T, r: IReactionPublic) => void,
|
||||
opts?: IReactionOptions
|
||||
): void {
|
||||
this.disposers.push(reaction(expression, effect, opts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a MobX autorun using the given parameters and disposes it when this element is detached.
|
||||
*
|
||||
* This should be called from `connectedCallback` to ensure that the reaction is active also if the element is attached again later.
|
||||
*/
|
||||
protected autorun(view: (r: IReactionPublic) => any, opts?: IAutorunOptions): void {
|
||||
this.disposers.push(autorun(view, opts));
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.disposers.forEach((disposer) => {
|
||||
disposer();
|
||||
});
|
||||
this.disposers = [];
|
||||
}
|
||||
}
|
||||
|
||||
export class View extends MobxElement {
|
||||
createRenderRoot() {
|
||||
// Do not use a shadow root
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export class Layout extends MobxElement {
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
applyTheme(this.shadowRoot!);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user