diff --git a/README.md b/README.md index 7d9597d..c9489af 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # optimaze-viewer-example -This is an example application for the [`optimaze-viewer`](https://github.com/rapal/optimaze-viewer) library. -It is written in TypeScript and uses Webpack for bundling. +This is an example application for the [`optimaze-viewer`](https://github.com/rapal/optimaze-viewer) library. It is written in TypeScript and uses Webpack for bundling. It implements the OAuth 2 authorization code flow for authenticating API requests. Note: The API which this application uses is not yet published but will be published later in 2017. ## Usage 1. Run `yarn` to install dependencies -2. Run `yarn build` +2. Run `yarn start` to run webpack in watch mode + * Or run `yarn build` to create a production build 3. Open `index.html` \ No newline at end of file diff --git a/index.html b/index.html index 2b042e2..4b32201 100644 --- a/index.html +++ b/index.html @@ -3,12 +3,12 @@ optimaze-viewer-example -
+ \ No newline at end of file diff --git a/package.json b/package.json index 788b56e..1a584cd 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,8 @@ "url": "https://github.com/rapal/optimaze-viewer-example" }, "scripts": { - "build": "yarn run lint && rimraf build && webpack", - "start": "webpack-dev-server", + "start": "yarn run lint && rimraf build && webpack -w", + "build": "yarn run lint && rimraf build && webpack -p --env production", "lint": "tslint --config tslint.json --project tsconfig.json" }, "devDependencies": { @@ -25,9 +25,15 @@ "webpack": "^3.6.0" }, "dependencies": { - "@rapal/optimaze-viewer": "^0.2.0", + "@rapal/optimaze-viewer": "^0.3.0", + "@types/date-fns": "^2.6.0", + "@types/es6-promise": "^0.0.33", + "@types/jwt-decode": "^2.2.1", "@types/leaflet": "^1.2.0", "@types/q": "^1.0.5", + "date-fns": "^1.29.0", + "es6-promise": "^4.1.1", + "jwt-decode": "^2.2.0", "leaflet": "^1.2.0", "q": "^1.5.0" } diff --git a/src/app.css b/src/app.css index 0d8803e..61b09d6 100644 --- a/src/app.css +++ b/src/app.css @@ -1,9 +1,34 @@ body { margin: 0; + font-family: sans-serif; } + #viewer { height: 100vh; } + .leaflet-container { background: transparent; } + +.login-button { + position: absolute; + top: 50%; + left: 50%; + font-size: 24px; + width: 100px; + margin-left: -50px; +} + +.user-info { + position: absolute; + top: 10px; + right: 10px; + background: #eee; + border-radius: 3px; + padding: 10px; +} + +.logout-button { + margin-left: 10px; +} \ No newline at end of file diff --git a/src/app.ts b/src/app.ts index b1e7c36..97f6e63 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,145 +1,23 @@ -import * as L from "leaflet"; -import * as Q from "q"; -import { - Viewer, - Space, - Element, - GraphicsLayer, - IDimensions, - IBoundary, - ICoordinate -} from "@rapal/optimaze-viewer"; +import loadViewer from "./viewer"; +import { getAccessToken, showLoginButton, showUserInfo } from "./authentication"; import "leaflet/dist/leaflet.css"; import "./app.css"; -// Specify company id +// Floor plan parameters const companyId = 1361; +const floorId = "m2033670"; -// Get floor id from URL params or use default -const params = new URLSearchParams(document.location.search.substring(1)); -const floorId = params.get("floorId") || "m2033625"; - -// TODO: Replace with production URL once API is published -// TODO: Add support for authentication -const baseUrl = "http://localhost/space/api/public/v1/"; - -const graphicsUrl = `${baseUrl}/${companyId}/floors/${floorId}/graphics`; -const seatsUrl = `${baseUrl}/${companyId}/seats?floorId=${floorId}`; - -function getTileUrlTemplate(layer: GraphicsLayer) { - return `${baseUrl}/${companyId}/floors/${floorId}/tiles?layer=${layer}&x={x}&y={y}&z={z}`; +// Get authorization code from URL +const params = new URLSearchParams(document.location.search); +const authorizationCode = params.get("code"); +if (authorizationCode) { + window.history.replaceState(null, "", window.location.pathname); } -function getJson(url: string) { - return fetch(url, { credentials: "include" }).then(r => r.json()); -} - -Q.all([ - getJson(graphicsUrl), - getJson>(seatsUrl) -]).then(values => { - const floor = values[0]; - const seats = values[1].items; - - const viewer = new Viewer("viewer", floor.dimensions); - - // Add all available tile layers - // floor.graphicsLayers.forEach(l => - // viewer.addTileLayer(getTileUrlTemplate(l)) - // ); - - // Or add specific tile layer - viewer.addTileLayer(getTileUrlTemplate(GraphicsLayer.Architect)); - - // Creating custom panes is not neccessary, but makes sure - // that elements of the same type are shown at the same z-index - viewer.createPane("spaces").style.zIndex = "405"; - viewer.createPane("seats").style.zIndex = "410"; - - // Create selectable space layers - const spaceLayers = floor.spaceGraphics.map(s => { - return new Space( - s.id, - s.boundaries, - { pane: "spaces" }, - { selectable: true } - ); - }); - - spaceLayers.forEach(space => { - // Add to map - viewer.addLayer(space); - - // Deselect other spaces and log space id when selected - space.on("select", e => { - const id = e.target.id; - spaceLayers.filter(s => s.id !== id).forEach(s => (s.selected = false)); - console.log("select space " + id); - }); - - // Log space id when deselected - space.on("deselect", e => { - const id = e.target.id; - console.log("deselect space " + id); - }); - }); - - // Create selectable seat layer - // Seats are shown as circles with 500mm radius - // Seat styles are specified using the style function - const seatLayers = seats.map(s => { - const circle = L.circle(L.latLng(s.y, s.x), { radius: 500, pane: "seats" }); - return new Element(s.id.toString(), [circle], { - selectable: true, - styleFunction: e => ({ - color: e.selected ? "#f00" : "#666", - weight: e.selected ? 2 : 1, - opacity: 1, - fillColor: e.selected ? "#faa" : "#ccc", - fillOpacity: 1, - pane: "seats" - }) - }); - }); - - seatLayers.forEach(seat => { - // Add to map - viewer.addLayer(seat); - - // Deselect other seats and log seat id when selected - seat.on("select", e => { - const id = e.target.id; - seatLayers.filter(s => s.id !== id).forEach(s => (s.selected = false)); - console.log("select seat " + id); - }); - - // Log seat id when deselected - seat.on("deselect", e => { - const id = e.target.id; - console.log("deselect seat " + id); - }); - }); -}); - -interface IFloorGraphics { - dimensions: IDimensions; - graphicsLayers: GraphicsLayer[]; - spaceGraphics: ISpaceGraphics[]; - scale: number; -} - -interface ISpaceGraphics { - id: string; - boundaries: IBoundary[]; -} - -interface IList { - items: TItem[]; -} - -interface ISeat { - id: number; - x: number; - y: number; -} +getAccessToken(authorizationCode) + .then(accessToken => { + loadViewer(companyId, floorId, accessToken); + showUserInfo(accessToken); + }) + .catch(() => showLoginButton()); diff --git a/src/authentication.ts b/src/authentication.ts new file mode 100644 index 0000000..c6d6ed9 --- /dev/null +++ b/src/authentication.ts @@ -0,0 +1,189 @@ +import { isFuture, addSeconds } from "date-fns"; +import * as jwtDecode from "jwt-decode"; +import { oauthUrl, clientId, clientSecret, scope } from "./config"; + +/** + * Gets access token from local storage or by requesting new token. + * Throws error if no valid token can be returned. + */ +export async function getAccessToken( + authorizationCode: string | null +): Promise { + const redirectUrl = document.location.href.split("?")[0]; + const accessToken = window.localStorage.getItem("access_token"); + const accessTokenExpires = window.localStorage.getItem( + "access_token_expires" + ); + const refreshToken = window.localStorage.getItem("refresh_token"); + + // 1. Access token is available and not expired + if (accessToken && accessTokenExpires && isFuture(accessTokenExpires)) { + return accessToken; + } + + // 2. Refresh token is available, get new access token + if (refreshToken) { + return await refreshAccessToken(refreshToken, redirectUrl); + } + + // 3. Authorization code is available, get refresh and access tokens + if (authorizationCode) { + return await getRefreshAndAccessTokens(authorizationCode, redirectUrl); + } + + throw new Error( + "Cannot get access code. Make sure 'authorization_code' is saved to session storage." + ); +} + +/** + * Gets a refresh token and access token using the authorization code. + * Stores the refresh token, access token and it's update time in session storage. + * Returns a promise that resolves with the access token. + */ +async function getRefreshAndAccessTokens( + authorizationCode: string, + redirectUrl: string +) { + const tokenUrl = oauthUrl + "/token"; + const payload = new URLSearchParams(); + payload.set("grant_type", "authorization_code"); + payload.set("code", authorizationCode); + payload.set("redirect_uri", redirectUrl); + payload.set("client_id", clientId); + payload.set("client_secret", clientSecret); + + const json = await fetchJson(tokenUrl, { + method: "POST", + body: payload + }); + + window.localStorage.setItem("refresh_token", json.refresh_token); + window.localStorage.setItem("access_token", json.access_token); + window.localStorage.setItem( + "access_token_expires", + addSeconds(Date.now(), json.expires_in).toString() + ); + + return json.access_token; +} + +/** + * Gets a new access token using the refresh token. + * Stores the refresh token, access token and it's update time in session storage. + * Returns a promise that resolves with the access token. + */ +async function refreshAccessToken(refreshToken: string, redirectUrl: string) { + const tokenUrl = oauthUrl + "/token"; + const payload = new URLSearchParams(); + payload.set("grant_type", "refresh_token"); + payload.set("refresh_token", refreshToken); + payload.set("redirect_uri", redirectUrl); + payload.set("client_id", clientId); + payload.set("client_secret", clientSecret); + + const json = await fetchJson(tokenUrl, { + method: "POST", + body: payload + }); + + window.localStorage.setItem("access_token", json.access_token); + window.localStorage.setItem( + "access_token_expires", + addSeconds(Date.now(), json.expires_in).toString() + ); + + return json.access_token; +} + +/** + * Fetches and returns parsed json. + * Throws error if response is not ok. + */ +async function fetchJson( + input: RequestInfo, + init?: RequestInit | undefined +) { + const response = await fetch(input, init); + if (response.ok) { + const json: TData = await response.json(); + return json; + } else { + throw new Error(response.statusText); + } +} + +interface AuthorizationCodeResponse { + access_token: string; + expires_in: number; + refresh_token: string; + token_type: string; +} + +interface RefreshTokenResponse { + access_token: string; + expires_in: number; + token_type: string; +} + +/** + * Redirects the user to the Portal authorize page, + * or to the login page if the user is not logged in. + */ +function login() { + const redirectUrl = document.location.href.split("?")[0]; + + const params = new URLSearchParams(); + params.set("response_type", "code"); + params.set("client_id", clientId); + params.set("redirect_uri", redirectUrl); + params.set("scope", scope); + params.set("client_secret", clientSecret); + + document.location.href = `${oauthUrl}/authorize?${params}`; +} + +/** + * Logs out the user by clearing all tokens from local storage and reloading the page. + */ +function logout() { + window.localStorage.removeItem("refresh_token"); + window.localStorage.removeItem("access_token"); + window.localStorage.removeItem("access_token_expires"); + window.location.reload(); +} + +/** + * Shows login button which takes the user to the authorize page. + */ +export function showLoginButton() { + const loginButton = document.createElement("button"); + loginButton.innerText = "Log in"; + loginButton.setAttribute("class", "login-button"); + loginButton.onclick = ev => login(); + + document.body.appendChild(loginButton); +} + +/** + * Adds an element to the body that shows the currently logged in user and a logout link. + */ +export function showUserInfo(accessToken: string) { + const jwt: User = jwtDecode(accessToken); + + const userInfo = document.createElement("div"); + userInfo.innerText = jwt.unique_name; + userInfo.setAttribute("class", "user-info"); + + const logoutButton = document.createElement("button"); + logoutButton.innerText = "Log out"; + logoutButton.setAttribute("class", "logout-button"); + logoutButton.onclick = () => logout(); + userInfo.appendChild(logoutButton); + + document.body.appendChild(userInfo); +} + +interface User { + unique_name: string; +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..113acd6 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,6 @@ +// TODO: Replace with production URL once API is published +export const apiUrl = "https://space.spartan.rapalnet.fi/api/public/v1"; +export const oauthUrl = "https://portal.spartan.rapalnet.fi/oauth"; +export const clientId = "optimaze-viewer-example"; +export const scope = "space.read"; +export const clientSecret = "vc2Eml0oxTc9rS6wcJbl"; diff --git a/src/viewer.ts b/src/viewer.ts new file mode 100644 index 0000000..0403e80 --- /dev/null +++ b/src/viewer.ts @@ -0,0 +1,170 @@ +import * as L from "leaflet"; +import * as Q from "q"; +import { + Viewer, + Space, + Element, + GraphicsLayer, + Dimensions, + Boundary, + TileCoordinates, + FunctionalTileLayer +} from "@rapal/optimaze-viewer"; +import { apiUrl } from "./config"; + +export default function loadViewer( + companyId: number, + floorId: string, + accessToken: string +) { + // Authenticated JSON request + function getJson(url: string) { + return fetch(url, { + headers: { + authorization: "Bearer " + accessToken + } + }).then(r => r.json()); + } + + function getFloorGraphics() { + const url = `${apiUrl}/${companyId}/floors/${floorId}/graphics`; + return getJson(url); + } + + function getSeats() { + const url = `${apiUrl}/${companyId}/seats?floorId=${floorId}`; + return getJson>(url); + } + + const tileCache: { [url: string]: string } = {}; + + function getTile( + layer: GraphicsLayer, + coordinates: TileCoordinates + ): Promise { + const url = + `${apiUrl}/${companyId}/floors/${floorId}/tiles?` + + `layer=${layer}&x=${coordinates.x}&y=${coordinates.y}&z=${coordinates.z}`; + + return getJson(url); + } + + Q.all([getFloorGraphics(), getSeats()]).then(values => { + const floor = values[0]; + const seats = values[1].items; + + const viewer = new Viewer("viewer", floor.dimensions); + + // Add architect layer if available + if (floor.graphicsLayers.filter(l => l === GraphicsLayer.Architect)) { + const architectLayer = new FunctionalTileLayer( + coordinates => getTile(GraphicsLayer.Architect, coordinates), + viewer.dimensions + ); + viewer.addLayer(architectLayer); + } + + // Add furniture layer if available + if (floor.graphicsLayers.filter(l => l === GraphicsLayer.Furniture)) { + const furnitureLayer = new FunctionalTileLayer( + coordinates => getTile(GraphicsLayer.Furniture, coordinates), + viewer.dimensions + ); + viewer.addLayer(furnitureLayer); + } + + // Creating custom panes is not neccessary, but makes sure + // that elements of the same type are shown at the same z-index + viewer.createPane("spaces").style.zIndex = "405"; + viewer.createPane("seats").style.zIndex = "410"; + + // Create selectable space layers + const spaceLayers = floor.spaceGraphics.map(s => { + return new Space( + s.id, + s.boundaries, + { pane: "spaces" }, + { selectable: true } + ); + }); + + spaceLayers.forEach(space => { + // Add to map + viewer.addLayer(space); + + // Deselect other spaces and log space id when selected + space.on("select", e => { + const id = e.target.id; + spaceLayers.filter(s => s.id !== id).forEach(s => (s.selected = false)); + console.log("select space " + id); + }); + + // Log space id when deselected + space.on("deselect", e => { + const id = e.target.id; + console.log("deselect space " + id); + }); + }); + + // Create selectable seat layer + // Seats are shown as circles with 500mm radius + // Seat styles are specified using the style function + const seatLayers = seats.map(s => { + const circle = L.circle(L.latLng(s.y, s.x), { + radius: 500, + pane: "seats" + }); + return new Element(s.id.toString(), [circle], { + selectable: true, + styleFunction: e => ({ + color: e.selected ? "#f00" : "#666", + weight: e.selected ? 2 : 1, + opacity: 1, + fillColor: e.selected ? "#faa" : "#ccc", + fillOpacity: 1, + pane: "seats" + }) + }); + }); + + seatLayers.forEach(seat => { + // Add to map + viewer.addLayer(seat); + + // Deselect other seats and log seat id when selected + seat.on("select", e => { + const id = e.target.id; + seatLayers.filter(s => s.id !== id).forEach(s => (s.selected = false)); + console.log("select seat " + id); + }); + + // Log seat id when deselected + seat.on("deselect", e => { + const id = e.target.id; + console.log("deselect seat " + id); + }); + }); + }); + + interface FloorGraphics { + dimensions: Dimensions; + graphicsLayers: GraphicsLayer[]; + spaceGraphics: SpaceGraphics[]; + scale: number; + } + + interface SpaceGraphics { + id: string; + boundaries: Boundary[]; + } + + interface List { + items: TItem[]; + } + + interface Seat { + id: number; + x: number; + y: number; + } +} diff --git a/tslint.json b/tslint.json index 48ff833..61acee7 100644 --- a/tslint.json +++ b/tslint.json @@ -8,7 +8,8 @@ "trailing-comma": false, "member-ordering": false, "ordered-imports": false, - "no-console": false + "no-console": false, + "interface-name": [true, "never-prefix"] }, "rulesDirectory": [] } diff --git a/webpack.config.js b/webpack.config.js index 13de84f..a364d8e 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,39 +1,47 @@ var path = require("path"); var ExtractTextPlugin = require("extract-text-webpack-plugin"); -module.exports = { - devtool: "inline-source-map", - entry: "./src/app.ts", - output: { - path: path.resolve("build"), - filename: "bundle.js" - }, - plugins: [ - new ExtractTextPlugin({ - filename: "bundle.css" - }) - ], - resolve: { - modules: [path.resolve("node_modules")], - extensions: [".ts", ".js"] - }, - module: { - loaders: [ - { - test: /\.ts$/, - include: path.resolve("src"), - loader: "ts-loader" - }, - { - test: /\.css$/, - loader: ExtractTextPlugin.extract({ - use: "css-loader" - }) - }, - { - test: /\.png$/, - loader: "file-loader" - } - ] - } +module.exports = function(env) { + var production = env === "production"; + + return { + entry: "./src/app.ts", + output: { + path: path.resolve("build"), + filename: "bundle.js" + }, + devtool: production ? "source-map" : "inline-source-map", + stats: { + chunks: false, + children: false + }, + plugins: [ + new ExtractTextPlugin({ + filename: "bundle.css" + }) + ], + resolve: { + modules: [path.resolve("node_modules")], + extensions: [".ts", ".js"] + }, + module: { + loaders: [ + { + test: /\.ts$/, + include: path.resolve("src"), + loader: "ts-loader" + }, + { + test: /\.css$/, + loader: ExtractTextPlugin.extract({ + use: "css-loader" + }) + }, + { + test: /\.png$/, + loader: "file-loader" + } + ] + } + }; }; diff --git a/yarn.lock b/yarn.lock index e742f27..35d39cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,17 +2,31 @@ # yarn lockfile v1 -"@rapal/optimaze-viewer@^0.2.0": - version "0.2.0" - resolved "https://registry.yarnpkg.com/@rapal/optimaze-viewer/-/optimaze-viewer-0.2.0.tgz#e3de4d7f86b46fe04d75a49b63d109e11d3de86b" +"@rapal/optimaze-viewer@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@rapal/optimaze-viewer/-/optimaze-viewer-0.3.0.tgz#a6eef1eb54551ffd654060fc98f0843601e83bbd" dependencies: "@types/leaflet" "^1.2.0" leaflet "^1.2.0" +"@types/date-fns@^2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@types/date-fns/-/date-fns-2.6.0.tgz#b062ca46562002909be0c63a6467ed173136acc1" + dependencies: + date-fns "*" + +"@types/es6-promise@^0.0.33": + version "0.0.33" + resolved "https://registry.yarnpkg.com/@types/es6-promise/-/es6-promise-0.0.33.tgz#280a707e62b1b6bef1a86cc0861ec63cd06c7ff3" + "@types/geojson@*": version "1.0.3" resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-1.0.3.tgz#fbcf7fa5eb6dd108d51385cc6987ec1f24214523" +"@types/jwt-decode@^2.2.1": + version "2.2.1" + resolved "https://registry.yarnpkg.com/@types/jwt-decode/-/jwt-decode-2.2.1.tgz#afdf5c527fcfccbd4009b5fd02d1e18241f2d2f2" + "@types/leaflet@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@types/leaflet/-/leaflet-1.2.0.tgz#5f15f5012b24d30507f3c4dc3271c88acbe4be49" @@ -658,6 +672,10 @@ dashdash@^1.12.0: dependencies: assert-plus "^1.0.0" +date-fns@*, date-fns@^1.29.0: + version "1.29.0" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.29.0.tgz#12e609cdcb935127311d04d33334e2960a2a54e6" + date-now@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b" @@ -813,6 +831,10 @@ es6-map@^0.1.3: es6-symbol "~3.1.1" event-emitter "~0.3.5" +es6-promise@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.1.1.tgz#8811e90915d9a0dba36274f0b242dbda78f9c92a" + es6-set@~0.1.5: version "0.1.5" resolved "https://registry.yarnpkg.com/es6-set/-/es6-set-0.1.5.tgz#d2b3ec5d4d800ced818db538d28974db0a73ccb1" @@ -1436,6 +1458,10 @@ jsprim@^1.2.2: json-schema "0.2.3" verror "1.10.0" +jwt-decode@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-2.2.0.tgz#7d86bd56679f58ce6a84704a657dd392bba81a79" + kind-of@^3.0.2: version "3.2.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"