Source

client/core/modules/session.js

/**
 * 
 * ## Session manager
 * 
 * **This module is 100% specific and only works in combination with KissJS server.**
 * **KissJS server is not open source yet, but you can contact us for a licence.**
 * 
 * Dependencies:
 * - kiss.ajax, to send credentials to the server
 * - kiss.views, to popup the login window
 * - kiss.router, to route to the right application view if session is valid
 * - kiss.websocket, to init the connection, to check that it's alive and reconnect if not
 * 
 * @namespace
 */
kiss.session = {

	// Observe img tags to detect failed load due to outdated token to try to refresh them
	// and reload the said resource
	resourcesObserver: null,

	/**
	 * Max idle time, in minutes (4 hours by default).
	 * After that delay, the user is logged out and its tokens are deleted from localStorage
	 */
	maxIdleTime: 4 * 60,

	/**
	 * The user id.
	 * By default, before authenticating, the user is "anonymous".
	 * Once logged in, the user id is the email used to authenticate.
	 * Always "anonymous" in offline and memory mode.
	 */
	userId: "anonymous",

	/**
	 * Flag (true/false) to track if the active user is the account owner.
	 */
	isOwner: false,

	// Track current invitations and collaborations
	invitedBy: [],
	isCollaboratorOf: [],

	// Defaults views
	defaultViews: {
		login: "authentication-login",
		home: "home-start",
		application: "application-start"
	},

	/**
	 * Possible login methods are currently:
	 * - internal (email/password)
	 * - google
	 * - microsoftAD
	 * - microsoft365
	 * - linkedin
	 * - facebook
	 * 
	 * Default login methods are internal, google and microsoftAD
	 */
	loginMethods: ["internal", "google", "microsoftAD"],

	/**
	 * Current host for session requests.
	 */
	host: "",

	/**
	 * Http port for session requests.
	 */
	httpPort: 80,

	/**
	 * Https port for session requests.
	 */
	httpsPort: 443,

	/**
	 * Websocket host for session requests.
	 */
	wsHost: "",

	/**
	 * Websocket port for session requests.
	 */
	wsPort: 80,

	/**
	 * Secure websocket port for session requests.
	 */
	wssPort: 443,

	/**
	 * Set the hosts and ports for session requests.
	 * 
	 * @param {object} config
	 * @param {string} [config.host]
	 * @param {number} [config.httpPort]
	 * @param {number} [config.httpsPort]
	 * @param {number} [config.wsPort]
	 * @param {number} [config.wssPort]
	 * 
	 * @example
	 * kiss.session.setHost({
	 *  host: "your-host.com",
	 *  httpPort: 3000,
	 *  httpsPort: 4000,
	 *  wsHost: "ws.your-host.com",
	 *  wsPort: 3000,
	 *  wssPort: 4000
	 * })
	 */
	setHost(config) {
		config.host = config.host || ""
		config.httpPort = config.httpPort || 80
		config.httpsPort = config.httpsPort || 443
		config.wsHost = config.wsHost || ""
		config.wsPort = config.wsPort || 80
		config.wssPort = config.wssPort || 443
		Object.assign(kiss.session, config)
	},

	/**
	 * Flag (true/false) to check the protocol security for session requests (both HTTP and Websocket).
	 */
	secure: true,

	/**
	 * Set the protocol security for session requests.
	 * If true (default):
	 * - will use "https" for HTTP
	 * - will use "wss" for Websocket
	 * 
	 * @param {string} host
	 * 
	 * @param secure
	 * @example
	 * kiss.session.setSecure(true)
	 */
	setSecure(secure = true) {
		kiss.session.secure = secure
	},

	/**
	 * Get the Http host with protocol and port
	 * 
	 * @returns {string} The host with protocol and port
	 * 
	 * @example
	 * kiss.session.getHttpHost() // "https://your-host.com:443"
	 */
	getHttpHost() {
		const host = (!this.host) ? window.location.host : this.host
		const url = (this.secure) ? "https://" + host : "http://" + host
		return (this.secure) ? url + ":" + this.httpsPort : url + ":" + this.httpPort
	},

	/**
	 * Define the default views:
	 * - login: view to login
	 * - home: view to display after login
	 * 
	 * It allows KissJS to display the right login view and the right home view after login.
	 * Defaults are:
	 * - login: "authentication-login"
	 * - home: "home-start"
	 * 
	 * @param {object} config
	 * @param {string} config.login - Default = "authentication-login"
	 * @param {string} config.home - Default = "home-start"
	 * 
	 * @param views
	 * @example
	 * kiss.session.setDefaultViews({
	 *  login: "your-login-view",
	 *  home: "your-home-view"
	 * })
	 */
	setDefaultViews(views) {
		Object.assign(this.defaultViews, views)
	},

	/**
	 * Define the default websocket view
	 * (view used to display websocket messages)
	 * 
	 * @ignore
	 * @param {string} viewId
	 */
	setWebSocketMessageView(viewId) {
		this.webSocketMessageView = viewId
	},

	/**
	 * Define all login methods
	 * 
	 * @ignore
	 * @returns {object[]} Array of login methods and their properties (text, icon...)
	 */
	getLoginMethodTypes: () => [{
		type: "internal",
		alias: "i"
	},
	{
		type: "google",
		alias: "g",
		text: "Google",
		icon: "fab fa-google",
		callback: "/auth/google"
	},
	{
		type: "microsoftAD",
		alias: "a",
		text: "Microsoft",
		icon: "fab fa-microsoft",
		callback: "/auth/azureAd"
	},
	{
		type: "microsoft365",
		alias: "m",
		text: "Microsoft 365",
		icon: "fab fa-microsoft",
		callback: "/auth/microsoft"
	},
	{
		type: "linkedin",
		alias: "l",
		text: "LinkedIn",
		icon: "fab fa-linkedin",
		callback: "/auth/linkedin"
	},
	{
		type: "facebook",
		alias: "f",
		text: "Facebook",
		icon: "fab fa-facebook",
		callback: "/auth/facebook"
	},
	{
		//TODO
		type: "instagram",
		alias: "s",
		text: "Instagram",
		icon: "fab fa-instragram",
		callback: "/auth/instagram"
	},
	{
		//TODO
		type: "twitter",
		alias: "t",
		text: " Twitter",
		icon: "fab fa-twitter",
		callback: "/auth/twitter"
	}
	],

	/**
	 * Set the possible login methods.
	 * 
	 * Possible login methods are currently:
	 * - internal
	 * - google
	 * - microsoftAD
	 * - microsoft365
	 * - linkedin
	 * - facebook
	 * 
	 * Default login methods are internal, google and microsoftAD
	 * 
	 * @param {string[]} methods
	 * 
	 * @example
	 * kiss.session.setLoginMethods(["internal", "google"])
	 */
	setLoginMethods(methods) {
		kiss.session.loginMethods = methods
	},

	/**
	 * Encode the active login methods into a short string.
	 * Used internally to adapt the login prompt depending on the lm (login method) parameter
	 * 
	 * @ignore
	 * @returns {string} For example "igf" means internal + google + facebook
	 */
	getLoginMethods() {
		if (!kiss.session.loginMethods) {
			return kiss.session.getLoginMethodTypes().map(method => method.alias).join("")
		} else {
			return kiss.session.loginMethods.map(loginMethodType => kiss.session.getLoginMethodTypes().find(loginMethod => loginMethod.type == loginMethodType))
				.filter(loginMethod => loginMethod !== undefined)
				.map(loginMethod => loginMethod.alias)
				.join("")
		}
	},

	/**
	 * Check if the environment is offline
	 * 
	 * @returns {boolean} true if the environment is offline
	 */
	isOffline: () => ["memory", "offline"].includes(kiss.db.mode),

	/**
	 * Check if the environment is online
	 * 
	 * @returns {boolean} true if the environment is online
	 */
	isOnline: () => !kiss.session.isOffline(),

	/**
	 * Set the maximum idle time before automatically logging out the user
	 * 
	 * @param {number} newIdleTime - Max idle time in minutes
	 * 
	 * @example
	 * kiss.session.setMaxIdleTime(60) // 1 hour
	 */
	setMaxIdleTime(newIdleTime) {
		this.maxIdleTime = newIdleTime
		localStorage.setItem("session-max-idle-time", this.maxIdleTime)
	},

	/**
	 * Get the maximum idle time before automatically logging out the user
	 * 
	 * @returns {number} The maximum idle time in minutes
	 */
	getMaxIdleTime() {
		return localStorage.getItem("session-max-idle-time") || kiss.session.maxIdleTime
	},

	/**
	 * Display a window to set the maximum idle time before automatically logging out the user
	 */
	selectMaxIdleTime() {
		const currentMaxIdleTime = kiss.session.getMaxIdleTime() / 60

		createPanel({
			id: "idle-time",
			title: txtTitleCase("#auto logout"),
			icon: "fas fa-clock",
			modal: true,
			backdropFilter: true,
			draggable: true,
			closable: true,
			align: "center",
			verticalAlign: "center",
			padding: 50,
			items: [
				{
					id: "maxIdleTime",
					type: "slider",
					value: currentMaxIdleTime,
					label: txtTitleCase("#auto logout help"),
					labelPosition: "top",
					min: 0.5,
					max: 8,
					step: 0.5,
					width: 500,
					events: {
						change: function () {
							const newMaxIdleTime = this.getValue() * 60
							kiss.session.setMaxIdleTime(newMaxIdleTime)
							createNotification(txtTitleCase("#update done"))
						}
					}
				}
			]
		}).render()
	},

	/**
	 * Get the application's server runtinme environment
	 * 
	 * @ignore
	 * @async
	 * @returns {string} "dev" | "production" | ... | "unknown"
	 */
	getServerEnvironment: async () => {
		const response = await kiss.ajax.request({
			url: "/getEnvironment"
		})
		return response.environment || "unknown"
	},

	/**
	 * Get access token
	 * 
	 * @ignore
	 */
	getToken: () => localStorage.getItem("session-token"),

	/**
	 * Get refresh token
	 * 
	 * @ignore
	 */
	getRefreshToken: () => localStorage.getItem("session-refresh-token"),

	/**
	 * Get token's expiration
	 * 
	 * @ignore
	 */
	getExpiration: () => localStorage.getItem("session-expiration"),

	/**
	 * Get client protocol mode (secure/insecure/both)
	 * 
	 * @ignore
	 */
	getWebsocketMode: () => localStorage.getItem("session-ws.mode"),

	/**
	 * Get websocket host
	 * 
	 * @ignore
	 */
	getWebsocketHost: () => localStorage.getItem("session-ws.host"),

	/**
	 * Get websocket port
	 *
	 * @ignore
	 */
	getWebsocketPort: () => localStorage.getItem("session-ws.port"),

	/**
	 * Get the date/time of the last user activity which was tracked
	 * 
	 * @returns {date} The date/time of the last user activity
	 */
	getLastActivity: () => {
		const lastActivity = localStorage.getItem("session-lastActivity")
		if (lastActivity) return new Date(lastActivity)
		else return new Date()
	},

	/**
	 * Get authenticated user's id.
	 * 
	 * Returns "anonymous" in offline and memory mode.
	 * 
	 * @returns {string} The user id
	 */
	getUserId: () => (kiss.session.isOffline()) ? "anonymous" : localStorage.getItem("session-userId") || "anonymous",

	/**
	 * Check if the user is authenticated
	 * 
	 * @returns {boolean} true if the user is authenticated
	 */
	isAuthenticated: () => (kiss.session.isOffline()) ? true : kiss.session.getUserId() != "anonymous",

	/**
	 * Get authenticated user's first name.
	 * 
	 * Returns "anonymous" in offline and memory mode.
	 * 
	 * @returns {string} The user's first name
	 */
	getFirstName: () => (kiss.session.isOffline()) ? "anonymous" : localStorage.getItem("session-firstName"),

	/**
	 * Get authenticated user's last name.
	 * 
	 * Returns "anonymous" in offline and memory mode.
	 * 
	 * @returns {string} The user's last name
	 */
	getLastName: () => (kiss.session.isOffline()) ? "anonymous" : localStorage.getItem("session-lastName"),

	/**
	 * Get authenticated user's full name.
	 * 
	 * Returns "anonymous" in offline and memory mode.
	 * 
	 * @returns {string} The user's full name
	 */
	getUserName: () => (kiss.session.isOffline()) ? "anonymous" : kiss.session.getFirstName() + " " + kiss.session.getLastName(),

	/**
	 * Get authenticated user's account id.
	 * 
	 * Returns "anonymous" in offline and memory mode.
	 * 
	 * @returns {string} The account id
	 */
	getAccountId: () => (kiss.session.isOffline()) ? "anonymous" : localStorage.getItem("session-accountId"),

	/**
	 * Get authenticated user's current account id.
	 * KissJS allows a mechanism to switch from one account to another.
	 * The current account id is the one the user is currently working on.
	 * 
	 * Returns "anonymous" in offline and memory mode.
	 * 
	 * @returns {string} The current account id
	 */
	getCurrentAccountId: () => (kiss.session.isOffline()) ? "anonymous" : localStorage.getItem("session-currentAccountId"),

	/**
	 * Get all current user's accounts he collaborates with.
	 * 
	 * Returns an empty array in offline and memory mode.
	 * 
	 * @returns {string[]} Array of account ids
	 */
	getCollaborators: () => {
		if (!kiss.session.isOffline()) {
			try {
				return JSON.parse(localStorage.getItem("session-isCollaboratorOf"))
			} catch (err) {}
		}
		return []
	},

	/**
	 * Get all the user pending invitations to collaborate with.
	 * 
	 * Returns an empty array in offline and memory mode.
	 * 
	 * @returns {string[]} Array of account ids
	 */
	getInvitations: () => {
		if (!kiss.session.isOffline()) {
			try {
				return JSON.parse(localStorage.getItem("session-invitedBy"))
			} catch (err) {}
		}
		return []
	},

	/**
	 * Tell if the authenticated user is the owner of the account
	 * 
	 * @returns {boolean} true if the user is the account owner
	 */
	isAccountOwner: () => {
		if (kiss.session.isOffline()) return true
		return (localStorage.getItem("session-accountId") == localStorage.getItem("session-currentAccountId"))
	},

	/**
	 * Tell if the authenticated user is one of the account managers.
	 * An account manager is a user who has been promoted to manage the account like the owner.
	 * 
	 * @returns {boolean} true if the user is an account manager
	 */
	isAccountManager() {
		if (kiss.session.isOffline()) return true
		if (!kiss.session.account) return false
		return (kiss.session.account.managers || []).includes(this.getUserId())
	},

	/**
	 * Init the session account by retrieving the record which holds the account data.
	 * When offline, generates a fake offline account.
	 * 
	 * @ignore
	 * @async
	 */
	async initAccount() {
		if (kiss.session.isOnline()) {
			kiss.session.account = await kiss.app.collections.account.findOne(kiss.session.getCurrentAccountId())

			// Observe account updates
			kiss.pubsub.subscribe("EVT_DB_UPDATE:ACCOUNT", async () => kiss.session.account = await kiss.app.collections.account.findOne(kiss.session.getCurrentAccountId()))

		} else {
			kiss.session.account = kiss.app.models.account.create({
				accountId: "anonymous",
				status: "active"
			})
		}

		// Init the account owner & managers
		this.initAccountOwner()
		this.initAccountManagers()

		// Enable local cache if the user has it enabled in their account settings
		if (kiss.session.account.localCache !== false && !kiss.session.isCacheEnabled) {
			kiss.session.isCacheEnabled = true
			kiss.app.enableLocalCache()
		}
	},

	/**
	 * Initialize the account owner
	 * Note: a user is always the account owner for in-memory and offline mode
	 * 
	 * @ignore
	 */
	initAccountOwner() {
		if (kiss.db.mode == "memory" || kiss.db.mode == "offline") {
			kiss.session.isOwner = true
		}
		else {
			kiss.session.isOwner = this.isAccountOwner()
		}
	},

	/**
	 * Initialize the account managers
	 * Note: a user is always an account manager for in-memory and offline mode
	 * 
	 * @ignore
	 */
	initAccountManagers() {
		if (kiss.db.mode == "memory" || kiss.db.mode == "offline") {
			kiss.session.isManager = true
		}
		else {
			kiss.session.isManager = this.isAccountOwner() || this.isAccountManager()
		}
	},

	/**
	 * Hooks
	 * 
	 * @ignore
	 */
	hooks: {
		beforeInit: [],
		beforeRestore: [],
		afterInit: [
			async () => await kiss.session.initAccount()
		],
		afterRestore: [
			async () => await kiss.session.initAccount()            
		]
	},

	/**
	 * Add a hook to perform an action before or after the session initialization
	 * 
	 * @param {string} event - "beforeInit" | "afterInit" | "beforeRestore" | "afterRestore"
	 * @param {function} callback - Function to execute. It receives the following parameters: *beforeInit(sessionData), *afterInit(sessionData), *beforeRestore(), *afterRestore()
	 * @returns this
	 * 
	 * @example
	 * kiss.session.addHook("afterInit", function(sessionData) {
	 *  console.log("The session data is...", sessionData)
	 * })
	 */
	addHook(event, callback) {
		if (["beforeInit", "afterInit", "beforeRestore", "afterRestore"].includes(event)) this.hooks[event].push(callback)
		return this
	},

	/**
	 * Process hook
	 * 
	 * @private
	 * @ignore
	 * @param {string} event - "beforeInit" | "afterInit" | "beforeRestore" | "afterRestore"
	 * @param {*} sessionData
	 */
	_processHook(event, sessionData) {
		if (this.hooks[event].length != 0) {
			this.hooks[event].forEach(hook => {
				hook(sessionData)
			})
		}
	},

	/**
	 * Switch the user from one account to another.
	 * 
	 * @async
	 * @param accountId
	 * @returns {object} The /switchAccount response
	 */
	async switchAccount(accountId) {
		// Prevent from switching from an application
		if (kiss.context.ui == this.defaultViews.application) {
			kiss.router.navigateTo({
				ui: this.defaultViews.home
			})
		}

		const data = await kiss.ajax.request({
			url: "/switchAccount",
			method: "post",
			showLoading: true,
			body: JSON.stringify({
				accountId
			})
		})

		if (!data) return
		if (data.error) return data

		if (typeof data === "object") {
			this._updateCurrentAccount(Object.assign(data, {
				accountId
			}))
		}

		// We want to reload for the user to get his entire UI setup for the account he switched to
		window.location.reload()
	},

	/**
	 * Accepts an invitation from another account to collaborate
	 * 
	 * @ignore
	 * @async
	 * @param {string} accountId
	 * @returns {object} The /acceptInvitation response
	 */
	async acceptInvitationOf(accountId) {
		const response = await kiss.ajax.request({
			url: "/acceptInvitationOf",
			method: "post",
			showLoading: true,
			body: JSON.stringify({
				accountId
			})
		})

		if (response.error) return response

		kiss.session.invitedBy.splice(kiss.session.invitedBy.indexOf(accountId), 1)
		kiss.session.isCollaboratorOf.push(accountId)

		localStorage.setItem("session-invitedBy", JSON.stringify(kiss.session.invitedBy))
		localStorage.setItem("session-isCollaboratorOf", JSON.stringify(kiss.session.isCollaboratorOf))

		createNotification({
			message: txtTitleCase("invitation accepted")
		})

		return response
	},

	/**
	 * Rejects an invitation from another account to collaborate
	 * 
	 * @ignore
	 * @async
	 * @param {string} accountId
	 * @returns {object} The /rejectInvitation response
	 */
	async rejectInvitationOf(accountId) {
		const response = await kiss.ajax.request({
			url: "/rejectInvitationOf",
			method: "post",
			showLoading: true,
			body: JSON.stringify({
				accountId
			})
		})

		if (response && response.error) return response

		kiss.session.invitedBy.splice(kiss.session.invitedBy.indexOf(kiss.session.accountId), 1)
		localStorage.setItem("session-invitedBy", JSON.stringify(kiss.session.invitedBy))

		return response
	},

	/**
	 * Allow the current user to end a collaboration
	 * 
	 * @ignore
	 * @async
	 * @param accountId
	 * @returns {object} The /quiAccount response
	 */
	async quitAccount(accountId) {
		const response = await kiss.ajax.request({
			url: "/quitAccount",
			method: "post",
			showLoading: true,
			body: JSON.stringify({
				accountId
			})
		})

		if (response.error) return response

		const {
			currentAccountChanged,
			currentAccountId,
			token,
			refreshToken,
			expiresIn
		} = response

		if (!currentAccountChanged) return response

		this.isCollaboratorOf.splice(kiss.session.isCollaboratorOf.indexOf(accountId), 1)
		localStorage.setItem("session.isCollaboratorOf", JSON.stringify(this.isCollaboratorOf))

		this._updateCurrentAccount({
			accountId: currentAccountId,
			refreshToken,
			token,
			expiresIn
		})

		kiss.router.navigateTo({
			ui: this.defaultViews.home
		})
	},

	/**
	 * Update current account after a switch
	 * 
	 * @private
	 * @ignore
	 * @param {string} accountId
	 * @param {string} refreshToken
	 * @param {string} token
	 * @param {int} expiresIn
	 */
	_updateCurrentAccount({
		accountId,
		refreshToken,
		token,
		expiresIn
	}) {
		// Since token needed to be re-generated, we must update them into the session
		const expirationDate = new Date()
		expirationDate.setSeconds(expirationDate.getSeconds() + expiresIn)
		localStorage.setItem("session-refresh-token", refreshToken)
		localStorage.setItem("session-token", token)
		localStorage.setItem("session-expiration", expirationDate)
		localStorage.setItem("session-currentAccountId", accountId)
	},    

	/**
	 * Attach an event to each provided download link to handle a session expiry.
	 * Excludes public files from the process.
	 * 
	 * @ignore
	 * @param {...HTMLLinkElement} links
	 */
	setupDownloadLink(...links) {
		links.filter(link => !!link).forEach(link => {
            
			// Excludes public links from the process
			if (link.getAttribute("public")) return

			link.addEventListener("click", async e => {
				// According to the spec, e.currentTarget is null outside the context of the event
				// handler. Since the event handler logic don't await async handlers, thus after
				// the first await, e.currentTarget can't be accessed anymore.
				const currentTarget = e.currentTarget

				e.stopImmediatePropagation()

				if (e.isTrusted) {
					e.preventDefault()
					const response = await fetch(currentTarget.href, {
						method: "head"
					})

					if (response.status === 498) {
						if (!await kiss.session.getNewToken()) {
							log("kiss.session - setupDownloadLink - Unable to get a new token", 1)
							return
						}
					} else if (response.status === 401) {
						log("kiss.session - setupDownloadLink - Unauthorized", 1)
						return
					}
					// else if (response.status == 204){
	                //     createNotification({
		            //         message: txtTitleCase("Unable to download this file.")
	                //     })
					// 	return
					// }

					link.dispatchEvent(new MouseEvent("click"))
				}
			})
		})
	},

	/**
	 * Attach an event to each provided image to handle a session expiry.
	 * Excludes public files from the process.
	 * 
	 * @ignore
	 * @param {...HTMLImageElement} imgs
	 */
	setupImg(...imgs) {
		imgs.filter(img => !!img).forEach(img => {

			// Exclude public images from the process
			if (img.getAttribute("public")) return

			img.addEventListener("error", async e => {
				// According to the spec, e.currentTarget is null outside the context of the event
				// handler. Since the event handler logic don't await async handlers, thus after
				// the first await, e.currentTarget can't be accessed anymore.
				const currentTarget = e.currentTarget

				let src = currentTarget.src
				const response = await fetch(src, {
					method: "head"
				})

				// Refresh token expired
				if (response.status === 401) {
					kiss.session.showLogin()
					return
				}

				if (response.status !== 498) return

				if (await kiss.session.checkTokenValidity(true)) {
					currentTarget.src = ""
					currentTarget.src = src
				} else {
					kiss.session.showLogin()
				}
			})
		})
	},

	/**
	 * Set the session params:
	 * - token
	 * - expiration date
	 * - accountId
	 * - user's id
	 * - user's first name
	 * - user's last name
	 * - user's account ownership
	 * 
	 * @ignore
	 * @async
	 * @param {object} sessionData
	 */
	async init(sessionData) {
		this.observeResources()

		// Abort if there is no token
		if (!sessionData.token) return

		// Hook before the session is initialized
		await this._processHook("beforeInit", sessionData)

		sessionData.expirationDate = new Date()
		sessionData.expirationDate.setSeconds(sessionData.expirationDate.getSeconds() + sessionData.expiresIn)
		Object.assign(this, sessionData)

		// Store session params locally
		localStorage.setItem("session-token", sessionData.token)
		localStorage.setItem("session-refresh-token", sessionData.refreshToken)
		localStorage.setItem("session-expiration", sessionData.expirationDate)
		localStorage.setItem("session-userId", sessionData.userId)
		localStorage.setItem("session-firstName", sessionData.firstName)
		localStorage.setItem("session-lastName", sessionData.lastName)
		localStorage.setItem("session-accountId", sessionData.accountId)
		localStorage.setItem("session-currentAccountId", sessionData.currentAccountId)
		localStorage.setItem("session-isCollaboratorOf", JSON.stringify(sessionData.isCollaboratorOf))
		localStorage.setItem("session-invitedBy", JSON.stringify(sessionData.invitedBy))
		localStorage.setItem("session-isOwner", this.isAccountOwner())
		localStorage.setItem("session-ws.mode", sessionData.ws.clientMode)
		localStorage.setItem("session-ws.host", sessionData.ws.host)
		localStorage.setItem("session-ws.port", sessionData.ws.port)

		// Init or re-init websocket
		await kiss.websocket.init({
			clientMode: sessionData.ws.clientMode,
			socketHost: sessionData.ws.host,
			port: sessionData.ws.port,
		})
			.then(() => {
				log("kiss.session - restore - Websocket connected")
			})
			.catch(err => {
				log("kiss.session - restore - Websocket error: ", 4, err)
			})

		// Observe websocket errors
		this.observeWebsocket()

		// Observe user collaborations
		this.observeCollaborations()

		// Init activity tracker
		this.initIdleTracker()

		// Hook after the session is initialized
		await this._processHook("afterInit", sessionData)
	},

	/**
	 * Restore session variables (typically after a browser refresh).
	 * 
	 * @async
	 */
	async restore() {
		// Offline sessions don't manage any user info
		if (kiss.session.isOffline()) {
			await this._processHook("afterRestore")
			return true
		}

		// Abort if there is no token to restore
		this.token = this.getToken()
		if (!this.token) return

		// Hook before the session is restored
		await this._processHook("beforeRestore")

		// Restore session infos
		this.refreshToken = this.getRefreshToken()
		this.expirationDate = this.getExpiration()
		this.userId = this.getUserId()
		this.firstName = this.getFirstName()
		this.lastName = this.getLastName()
		this.accountId = this.getAccountId()
		this.currentAccountId = this.getCurrentAccountId()
		this.isCollaboratorOf = this.getCollaborators()
		this.invitedBy = this.getInvitations()
		this.isOwner = this.isAccountOwner()
		this.ws = {
			clientMode: this.getWebsocketMode(),
			host: this.getWebsocketHost(),
			port: this.getWebsocketPort(),
		}

		// Restore websocket connection
		await kiss.websocket.init({
			clientMode: this.ws.clientMode,
			socketHost: kiss.app.socketHost,
			port: this.ws.port,
		})
			.then(() => {
				log("kiss.session - restore - Websocket connected")
			})
			.catch(err => {
				log("kiss.session - restore - Websocket error:", 4, err)
			})

		// Restore activity tracker
		this.lastActivity = this.getLastActivity()
		kiss.session.initIdleTracker()

		// Hook after the session is restored
		await this._processHook("afterRestore")
	},

	/**
	 * Reset all kiss.session variables
	 */
	reset() {
		const propertiesToReset = ["token", "refreshToken", "accountId", "currentAccountId", "userId", "isOwner", "firstName", "lastName", "lastActivity", "expirationDate"]
		propertiesToReset.forEach(prop => delete this[prop])

		localStorage.removeItem("session-token")
		localStorage.removeItem("session-refresh-token")
		localStorage.removeItem("session-expiration")
		localStorage.removeItem("session-userId")
		localStorage.removeItem("session-firstName")
		localStorage.removeItem("session-lastName")
		localStorage.removeItem("session-lastActivity")
		localStorage.removeItem("session-accountId")
		localStorage.removeItem("session-currentAccountId")
		localStorage.removeItem("session-isCollaboratorOf")
		localStorage.removeItem("session-invitedBy")
		localStorage.removeItem("session-isOwner")
		localStorage.removeItem("session-ws.mode")
		localStorage.removeItem("session-ws.host")

		// Close the websocket connection
		if (kiss.websocket.connection.readyState !== WebSocket.CLOSED) {
			kiss.websocket.close()
		}
	},

	/**
	 * Initialize user idleness tracker.
	 * By default, the user is considered idle if his mouse doesn't move for too long (maxIdleTime).
	 * After that delay, the system automatically logout and clear sensitive tokens.
	 * 
	 * @ignore
	 */
	initIdleTracker() {
		// Initialize the max idle time
		this.setMaxIdleTime(this.getMaxIdleTime())

		const reportActivity = () => {
			this.lastActivity = new Date()
			localStorage.setItem("session-lastActivity", new Date())
		}

		// Track mouse moves
		if (!this.idleObserver) {
			this.idleObserver = document.body.addEventListener("mousemove", kiss.tools.throttle(10 * 1000, reportActivity))
		}

		// Logout if user is idle
		setInterval(() => {
			if (kiss.session.isIddle()) {
				log("kiss.session - activity tracker - You were logged out because considered iddled", 1)
				kiss.session.logout()
			}
		}, 1000 * 60)
	},

	/**
	 * Check if the user is idle (= no activity for n minutes).
	 * 
	 * Set the idle threshold with setMaxIdleTime().
	 */
	isIddle() {
		if ((new Date() - this.getLastActivity()) > this.maxIdleTime * 1000 * 60) return true
		return false
	},

	/**
	 * Get the user's ACL.
	 * 
	 * @returns {string[]} Array containing all the user names and groups (32 hex id) by which the user is recognized to access the data.
	 * 
	 * @example:
	 * ["*", "bob.wilson@gmail.com", "ED7E7E4CA6F9B6D544257F54003B8F80", "3E4971CB41048BD844257FF70074D40F"]
	 */
	getACL() {
		return kiss.directory.getUserACL(this.userId)
	},

	/**
	 * Show the login prompt
	 * 
	 * @param {object} [redirecto] - Route to execute after login, following kiss.router convention. Route to the home page by default.
	 * @param redirectTo
	 * @example
	 * kiss.session.showLogin({
	 *  ui: "form-view",
	 *  modelId: "0183b2a8-cfb4-70ec-9c14-75d215c5e635",
	 *  recordId: "0183b2a8-d08a-7067-b400-c110194da391"
	 * })
	 */
	showLogin(redirectTo) {
		if (redirectTo) {
			kiss.context.redirectTo = redirectTo
		} else {
			kiss.context.redirectTo = {
				ui: this.defaultViews.home
			}
		}

		kiss.router.navigateTo({
			ui: this.defaultViews.login
		})
	},

	/**
	 * Login the user
	 * 
	 * The method takes either a username/password OR a token from 3rd party services
	 * 
	 * @ignore
	 * @async
	 * @param {object} login - login informations: username/password, or token
	 * @param {string} login.username
	 * @param {string} login.password
	 * @param {string} login.token
	 * @returns {boolean} false if the login failed
	 */
	async login(login) {
		let data

		if (!login.token) {
			// Authentication with username / password
			data = await kiss.ajax.request({
				url: "/login",
				method: "post",
				showLoading: true,
				body: JSON.stringify({
					username: login.username,
					password: login.password
				})
			})
		} else {
			// Authentication with 3rd party token
			data = await kiss.ajax.request({
				url: "/verifyToken",
				method: "post",
				body: JSON.stringify({
					token: login.token
				})
			})
		}

		// Wrong username / password
		if (!data || !data.token) return false

		// Reset the session locally with the new token issued by the server
		await kiss.session.init(data)

		// If the login was prompted because of:
		// - a session timeout (498)
		// - a forbidden route (401)
		// ... we have to resume where we aimed to go, otherwise, return true
		const currentRoute = kiss.router.getRoute()

		// If acceptInvitationOf is defined, the user clicked on a mail to accept an invitation from an account.
		if (kiss.context.acceptInvitationOf) {
			await kiss.session.acceptInvitationOf(kiss.context.acceptInvitationOf)
		}

		if (currentRoute && currentRoute.ui != this.defaultViews.login) {
			location.reload()
		} else {
			return true
		}
	},

	/**
	 * Renew the current access token if needed. If token is not valid and can"t be renewed, return false
	 * 
	 * @ignore
	 * @async
	 * @param {boolean} [autoRenew=true] If true, will try to renew the token if invalid token code (498) is received.
	 * @returns {Promise<boolean>}
	 */
	async checkTokenValidity(autoRenew = true) {
		try {
			const resp = await fetch(kiss.session.host + "/checkTokenValidity", {
				headers: {
					authorization: "Bearer " + this.getToken()
				}
			})
    
			if (autoRenew && resp.status === 498) return await this.getNewToken()
    
			return resp.status === 200
		}
		catch (err) {
			log(err)
			return false
		}
	},

	/**
	 * Logout the user and redirect to the login page
	 */
	async logout() {
		// Reset the tokens on the server
		await kiss.ajax.request({
			url: kiss.session.host + "/logout",
			method: "get"
		})

		// Close the websocket connection
		if (kiss.websocket.connection.readyState !== WebSocket.CLOSED) {
			kiss.websocket.close()
		}

		// Reset the tokens locally
		kiss.session.reset()
		document.location.reload()
	},

	/**
	 * Gets a new token from the Refresh Token
	 * 
	 * @ignore
	 * @async
	 * @returns The token, or false if it failed
	 */
	async getNewToken() {
		const newToken = await kiss.ajax.request({
			url: "/refreshToken",
			method: "post",
			body: JSON.stringify({
				refreshToken: kiss.session.getRefreshToken()
			})
		})

		if (newToken) {
			await kiss.session.init(newToken)
			return newToken
		} else {
			// If the refresh token is not valid anymore, we don't want to maintain the socket connection.
			kiss.websocket.close()

			// Close all active windows except login window
			kiss.tools.closeAllWindows(["login"])
			return false
		}
	},

	/**
	 * Init resource observer
	 * 
	 * @ignore
	 */
	observeResources() {
		if (this.resourcesObserver) return

		this.resourcesObserver = new MutationObserver(mutations => {
			for (let mutation of mutations) {
				for (let addedNode of mutation.addedNodes) {
					if (addedNode.tagName === "IMG") {
						kiss.session.setupImg(addedNode)
					} else if (addedNode.tagName === "A" && addedNode.hasAttribute("download")) {
						kiss.session.setupDownloadLink(addedNode)
					} else if (addedNode.querySelectorAll) {
						kiss.session.setupImg(...addedNode.querySelectorAll("img"))
						kiss.session.setupDownloadLink(...addedNode.querySelectorAll("a[download]"))
					}
				}
			}
		})

		this.resourcesObserver.observe(document.body, {
			childList: true,
			subtree: true
		})
	},

	/**
	 * Init websocket observer
	 * 
	 * @ignore
	 */
	observeWebsocket() {
		if (this.websocketObserver) return

		// Disconnection
		kiss.pubsub.subscribe("EVT_DISCONNECTED", () => this.showWebsocketMessage("#websocket disconnected"))

		// Reconnection
		kiss.pubsub.subscribe("EVT_RECONNECTED", () => {
			log("kiss.session - observeWebsocket - Socket reconnected")
			this.hideWebsocketMessage()
		})

		// Connection lost
		kiss.pubsub.subscribe("EVT_CONNECTION_LOST", () => this.showWebsocketMessage("#websocket connection lost"))

		// Unusable token
		kiss.pubsub.subscribe("EVT_UNUSABLE_TOKEN", () => {
			kiss.session.reset()
			window.location.reload()
		})

		this.websocketObserver = true
	},

	/**
	 * Observe collaborations
	 * 
	 * @ignore
	 */
	observeCollaborations() {
		if (this.collaborationObserver) return

		// New collaboration
		kiss.pubsub.subscribe("EVT_COLLABORATION:RECEIVED", data => {
			this.invitedBy.push(data.accountId)
			window.localStorage.setItem("session-invitedBy", JSON.stringify(this.invitedBy))
		})

		// Collaboration deleted
		kiss.pubsub.subscribe("EVT_COLLABORATION:DELETED", (data) => {
			this._updateCurrentAccount({
				accountId: this.getAccountId(),
				token: data.token,
				refreshToken: data.refreshToken,
				expiresIn: data.expiresIn
			})

			kiss.router.navigateTo({
				ui: this.defaultViews.home
			})
		})

		this.collaborationObserver = true
	},

	/**
	 * Show the websocket message
	 * 
	 * @ignore
	 * @param {string} message
	 */
	showWebsocketMessage(message) {
		if ($("websocket-message")) $("websocket-message").remove()

		createBlock({
			id: "websocket-message",
			fullscreen: true,
			background: "transparent",
			items: [{
				type: "panel",
				maxWidth: () => Math.min(kiss.screen.current.width / 2, 1000),
				header: false,
				layout: "vertical",
				align: "center",
				verticalAlign: "center",
				alignItems: "center",
				justifyContent: "center",
				items: [{
					type: "html",
					padding: "3.2rem",
					html: `<div style="font-size: 1.8rem; text-align: center;">${txtTitleCase(message)}</div>`
				}]
			}]
		}).render()
	},    

	/**
	 * Hide the websocket message
	 * 
	 * @ignore
	 */
	hideWebsocketMessage() {
		if ($("websocket-message")) $("websocket-message").remove()
	},

	/**
	 * Retrieve the branding informations for the specified account, if any.
	 * This can be used to brand the login screen for example
	 * 
	 * @param {string} accountId 
	 * @returns {object} The branding object
	 * 
	 * @example
	 * {
	 *   "logo": "https://mydomain.com/mylogo.png",
	 *   "backgroundColor1": "#4A90E2",
	 *   "backgroundColor2": "#9013FE",
	 *   "gradientDirection": "to right"
	 * }
	 */
	async getBranding(accountId) {
		kiss.session.branding = await kiss.ajax.request({
			url: `/branding/get/${accountId}`,
			method: "get"
		})

		return kiss.session.branding
	}
}