diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..3edecc4 --- /dev/null +++ b/.env.sample @@ -0,0 +1,3 @@ +QUAD_WORD= +API_KEY= +FIREBASE_CONFIG= diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 3f89839..9fbcaa3 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -47,5 +47,11 @@ jobs: service-account-email: ${{ secrets.SERVICE_ACCOUNT_EMAIL }} project-id: ${{ secrets.PROJECT_ID }} preview: yes + prebuild-command: | + cd functions + echo "ENVIRONMENT=stage" > .env + echo "AGS_HOST=https://wrimaps.at.utah.gov" >> .env build-command: npm run build:stage repo-token: ${{ secrets.GITHUB_TOKEN }} + env: + FIREBASE_CONFIG: ${{ secrets.FIREBASE_CONFIG }} diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 3f66208..7c5beb6 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -52,9 +52,15 @@ jobs: identity-provider: ${{ secrets.IDENTITY_PROVIDER }} service-account-email: ${{ secrets.SERVICE_ACCOUNT_EMAIL }} project-id: ${{ secrets.PROJECT_ID }} - prebuild-command: npx grunt bump --setversion=${{ needs.release.outputs.released_version }} + prebuild-command: | + npx grunt bump --setversion=${{ needs.release.outputs.released_version }} + cd functions + echo "ENVIRONMENT=stage" > .env + echo "AGS_HOST=https://wrimaps.at.utah.gov" >> .env build-command: npm run build:stage repo-token: ${{ secrets.GITHUB_TOKEN }} + env: + FIREBASE_CONFIG: ${{ secrets.FIREBASE_CONFIG }} deploy-prod: name: Deploy to production @@ -71,7 +77,11 @@ jobs: with: identity-provider: ${{ secrets.IDENTITY_PROVIDER }} service-account-email: ${{ secrets.SERVICE_ACCOUNT_EMAIL }} - prebuild-command: npx grunt bump --setversion=${{ needs.release.outputs.released_version }} + prebuild-command: | + npx grunt bump --setversion=${{ needs.release.outputs.released_version }} + cd functions + echo "ENVIRONMENT=prod" > .env + echo "AGS_HOST=https://wrimaps.utah.gov" >> .env project-id: ${{ secrets.PROJECT_ID }} build-command: npm run build:prod service-now-instance: ${{ secrets.SN_INSTANCE }} @@ -80,3 +90,5 @@ jobs: service-now-username: ${{ secrets.SN_USERNAME }} service-now-password: ${{ secrets.SN_PASSWORD }} repo-token: ${{ secrets.GITHUB_TOKEN }} + env: + FIREBASE_CONFIG: ${{ secrets.FIREBASE_CONFIG }} diff --git a/.gitignore b/.gitignore index 48958fb..dba3dc3 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,11 @@ scripts/DATABASE.sde # pro project maps/maps.gdb maps/scratch -maps/Index \ No newline at end of file +maps/Index + +.env +functions/.secret.local + +# firebase +*-debug.log +emulator_data/ diff --git a/.vscode/settings.json b/.vscode/settings.json index b763fb3..b0ca58b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,16 +3,21 @@ "cSpell.words": [ "ABES", "autoplay", + "firestore", "fishsample", "fullscreen", "hostingchannels", + "mapservice", "nonwritable", "nosniff", + "oidc", + "outfile", "prebuild", "SAMEORIGIN", "setversion", "UDWR", "udwrgis", + "utahid", "WILDADMIN", "wrimaps" ] diff --git a/Gruntfile.js b/Gruntfile.js index 5ce1e66..f09fd09 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -22,15 +22,26 @@ module.exports = function (grunt) { options: { sourceMap: true, presets: ['latest'], - plugins: ['transform-remove-strict-mode'] + plugins: [ + 'transform-remove-strict-mode', + ['transform-inline-environment-variables', { + include: [ + 'API_KEY', + 'FIREBASE_CONFIG', + 'QUAD_WORD' + ] + }] + ] }, src: { - files: [{ - expand: true, - cwd: '_src/app/', - src: ['**/*.js'], - dest: 'src/app/' - }] + files: [ + { + expand: true, + cwd: '_src/app/', + src: ['**/*.js'], + dest: 'src/app/' + } + ] } }, bump: { @@ -49,12 +60,14 @@ module.exports = function (grunt) { }, copy: { dist: { - files: [{ - expand: true, - cwd: 'src/', - src: ['*.html'], - dest: 'dist/' - }] + files: [ + { + expand: true, + cwd: 'src/', + src: ['*.html'], + dest: 'dist/' + } + ] }, src: { expand: true, @@ -66,12 +79,18 @@ module.exports = function (grunt) { dojo: { prod: { options: { - profiles: ['profiles/prod.build.profile.js', 'profiles/build.profile.js'] + profiles: [ + 'profiles/prod.build.profile.js', + 'profiles/build.profile.js' + ] } }, stage: { options: { - profiles: ['profiles/stage.build.profile.js', 'profiles/build.profile.js'] + profiles: [ + 'profiles/stage.build.profile.js', + 'profiles/build.profile.js' + ] } }, options: { @@ -92,13 +111,15 @@ module.exports = function (grunt) { options: { optimizationLevel: 3 }, - files: [{ - expand: true, - cwd: 'src/', - // exclude tests because some images in dojox throw errors - src: ['**/*.{png,jpg,gif}', '!**/tests/**/*.*'], - dest: 'src/' - }] + files: [ + { + expand: true, + cwd: 'src/', + // exclude tests because some images in dojox throw errors + src: ['**/*.{png,jpg,gif}', '!**/tests/**/*.*'], + dest: 'src/' + } + ] } }, jasmine: { @@ -132,13 +153,15 @@ module.exports = function (grunt) { compress: false, 'resolve url': true }, - files: [{ - expand: true, - cwd: '_src/', - src: ['app/resources/App.styl'], - dest: 'src/', - ext: '.css' - }] + files: [ + { + expand: true, + cwd: '_src/', + src: ['app/resources/App.styl'], + dest: 'src/', + ext: '.css' + } + ] } }, uglify: { @@ -161,12 +184,14 @@ module.exports = function (grunt) { dest: 'dist/dojo/dojo.js' }, prod: { - files: [{ - expand: true, - cwd: 'dist', - src: ['**/*.js', '!proj4/**/*.js'], - dest: 'dist' - }] + files: [ + { + expand: true, + cwd: 'dist', + src: ['**/*.js', '!proj4/**/*.js'], + dest: 'dist' + } + ] } }, watch: { diff --git a/_SpecRunner.html b/_SpecRunner.html index e2fd3a0..bb57f83 100644 --- a/_SpecRunner.html +++ b/_SpecRunner.html @@ -18,8 +18,6 @@ - - diff --git a/_src/ServiceWorker.js b/_src/ServiceWorker.js new file mode 100644 index 0000000..75e60a8 --- /dev/null +++ b/_src/ServiceWorker.js @@ -0,0 +1,131 @@ +/* eslint-disable no-restricted-globals */ + +// this is copied from https://github.com/agrc/electrofishing + +// inspired by: https://firebase.google.com/docs/auth/web/service-worker-sessions +import { initializeApp } from 'firebase/app'; +import { getAuth, getIdToken, onAuthStateChanged } from 'firebase/auth'; + +initializeApp(process.env.FIREBASE_CONFIG); + +const auth = getAuth(); + +const onError = () => { + self.clients.matchAll().then((matchedClients) => { + matchedClients.forEach((client) => { + client.postMessage({ type: 'idTokenError' }); + }); + }); +}; + +const getIdTokenPromise = () => { + if (!auth.currentUser) { + return new Promise((resolve) => { + const unsubscribe = onAuthStateChanged(auth, (user) => { + if (user) { + unsubscribe(); + getIdToken(user).then( + (idToken) => { + resolve(idToken); + }, + (error) => { + console.error( + 'Error getting ID token after auth state change', + error + ); + onError(); + resolve(null); + } + ); + } else { + console.error('No user is signed in.'); + } + }); + }); + } + + return getIdToken(auth.currentUser).catch((error) => { + console.log('error getting initial id token', error); + + onError(error); + }); +}; + +self.addEventListener('install', (event) => { + console.log(`service worker install at: ${new Date().toLocaleTimeString()}`); + + event.waitUntil(self.skipWaiting()); // don't wait for any previous workers to finish +}); + +self.addEventListener('activate', (event) => { + console.log(`service worker activate at: ${new Date().toLocaleTimeString()}`); + // eslint-disable-next-line no-undef + event.waitUntil(self.clients.claim()); // immediately begin to catch fetch events without a page reload +}); + +// Get underlying body if available. Works for text and json bodies. +const getBodyContent = (req) => { + return Promise.resolve() + .then(() => { + if (req.method !== 'GET') { + if (req.headers.get('Content-Type').indexOf('json') !== -1) { + return req.json().then((json) => { + return JSON.stringify(json); + }); + } + + return req.text(); + } + }) + .catch(() => { + // Ignore error. + }); +}; + +self.addEventListener('fetch', (event) => { + /** @type {FetchEvent} */ + const evt = event; + + const requestProcessor = (idToken) => { + let req = evt.request; + let processRequestPromise = Promise.resolve(); + // Clone headers as request headers are immutable. + const headers = new Headers(); + req.headers.forEach((val, key) => { + headers.append(key, val); + }); + // Add ID token to header. + headers.append('Authorization', 'Bearer ' + idToken); + processRequestPromise = getBodyContent(req).then((body) => { + try { + req = new Request(req.url, { + method: req.method, + headers: headers, + cache: req.cache, + redirect: req.redirect, + referrer: req.referrer, + body + }); + } catch (e) { + // This will fail for CORS requests. We just continue with the + // fetch caching logic below and do not pass the ID token. + } + }); + + return processRequestPromise.then(() => { + return fetch(req); + }); + }; + + if (event.request.url.includes('/maps/')) { + return evt.respondWith( + getIdTokenPromise().then(requestProcessor, requestProcessor) + ); + } + + return event.respondWith(fetch(event.request)); +}); + +console.log( + `service worker initialized at: ${new Date().toLocaleTimeString()}` +); diff --git a/_src/app/App.js b/_src/app/App.js index 3636fe4..3ed9d29 100644 --- a/_src/app/App.js +++ b/_src/app/App.js @@ -9,9 +9,12 @@ define([ 'dijit/_WidgetsInTemplateMixin', 'dojo/dom-construct', + 'dojo/has', 'dojo/text!app/templates/App.html', 'dojo/_base/declare', + 'esri/request', + 'sherlock/Sherlock', 'sherlock/providers/MapService', @@ -29,9 +32,12 @@ define([ _WidgetsInTemplateMixin, domConstruct, + has, template, declare, + esriRequest, + Sherlock, MapService, @@ -54,13 +60,89 @@ define([ }, postCreate: function () { this.dataEntryAppLink.href = config.dataEntryApp; + + this.auth = window.firebase.getAuth(); + + if (!has('agrc-build')) { + // comment out this and the auth config in firebase.json to hit utahid directly + window.firebase.connectAuthEmulator(this.auth, 'http://127.0.0.1:9099'); + } + + this.auth.onAuthStateChanged((user) => { + if (user) { + this.initializeUser(user); + } + }); + + this.initServiceWorker(); }, - startup: function () { + initServiceWorker: function () { + if ('serviceWorker' in navigator === false) { + // eslint-disable-next-line no-alert + window.alert('This browser does not support service workers. Please use a more modern browser.'); + + return; + } + + try { + navigator.serviceWorker.register('ServiceWorker.js'); + } catch (error) { + console.error('Service worker registration failed', error); + } + }, + login: function () { + const provider = new window.firebase.OAuthProvider('oidc.utahid'); + provider.addScope('app:DWRElectroFishing'); + + window.firebase.signInWithPopup(this.auth, provider); + }, + initializeUser: function (user) { + console.log('initializeUser', user); + user.getIdTokenResult().then((response) => { + if (this.checkClaims(response.claims)) { + this.user = user; + this.loginButton.style.display = 'none'; + this.logoutButton.style.display = 'block'; + this.userSpan.innerHTML = user.email; + + this.initApp(); + } else { + // eslint-disable-next-line no-alert + window.alert(`${user.email} is not authorized to use this app.`); + } + }); + }, + checkClaims: function (claims) { + if (!has('agrc-build')) { + return true; + } + + const utahIDEnvironments = { + prod: 'Prod', + stage: 'AT', + development: 'Dev', + test: 'Test' + }; + + return ( + claims.firebase?.sign_in_attributes?.['DWRElectroFishing:AccessGranted'] && + claims.firebase.sign_in_attributes['DWRElectroFishing:AccessGranted'].includes( + utahIDEnvironments[has('agrc-build') || 'development'] + ) + ); + }, + logout: function () { + window.firebase.signOut(this.auth); + this.loginButton.style.display = 'block'; + this.logoutButton.style.display = 'none'; + this.userSpan.innerHTML = ''; + + window.location.reload(); + }, + initApp: function () { // summary: // Fires when - console.log('app.App::startup', arguments); - - this.inherited(arguments); + console.log('app.App::initApp', arguments); mapController.initMap(this.mapDiv); diff --git a/_src/app/config.js b/_src/app/config.js index 52c5652..827137d 100644 --- a/_src/app/config.js +++ b/_src/app/config.js @@ -1,7 +1,6 @@ /* eslint-disable max-len, no-magic-numbers, camelcase */ define([ 'dojo/has', - 'dojo/request/xhr', 'esri/Color', 'esri/config', @@ -11,7 +10,6 @@ define([ 'dojo/domReady!' ], function ( has, - xhr, Color, esriConfig, @@ -22,39 +20,35 @@ define([ // electrofishing-query.*.utah.gov let apiKey = 'AGRC-E029C84C956966'; let quadWord; - let servicesFolder = 'Electrofishing'; let databaseName = 'Electrofishing'; let dataEntryApp = 'https://electrofishing.ugrc.utah.gov'; + let baseUrl; + const projectId = JSON.parse(process.env.FIREBASE_CONFIG).projectId; if (has('agrc-build') === 'prod') { // *.ugrc.utah.gov quadWord = 'dinner-oregano-india-bahama'; agsDomain = 'wrimaps.utah.gov'; + baseUrl = `https://us-central1-${projectId}.cloudfunctions.net/maps`; } else if (has('agrc-build') === 'stage') { // *.dev.utah.gov quadWord = 'wedding-tactic-enrico-yes'; agsDomain = 'wrimaps.at.utah.gov'; dataEntryApp = 'https://electrofishing.dev.utah.gov'; + baseUrl = `https://us-central1-${projectId}.cloudfunctions.net/maps`; } else { // localhost // agsDomain = window.location.host; - agsDomain = 'wrimaps.at.utah.gov'; + agsDomain = 'localhost:5001'; + baseUrl = `http://${agsDomain}/${projectId}/us-central1/maps`; - xhr(require.baseUrl + 'secrets.json', { - handleAs: 'json', - sync: true - }).then(function (secrets) { - quadWord = secrets.quadWord; - apiKey = secrets.apiKey; - }, function () { - throw 'Error getting secrets!'; - }); + quadWord = process.env.QUAD_WORD; + apiKey = process.env.API_KEY; } // force api to use CORS on udwrgis thus removing the test request on app load // e.g. http://wrimaps.utah.gov/ArcGIS/rest/info?f=json esriConfig.defaults.io.corsEnabledServers.push(agsDomain); - const baseUrl = `https://${agsDomain}/arcgis/rest/services/${servicesFolder}`; const drawingColor = [51, 160, 44]; const STATION_ID = 'STATION_ID'; window.AGRC = { @@ -93,9 +87,9 @@ define([ urls: { baseUrl, - mapService: baseUrl + '/MapService/MapServer', - referenceService: baseUrl + '/Reference/MapServer', - download: baseUrl + '/Download/GPServer/Download', + mapService: baseUrl + '/mapservice', + referenceService: baseUrl + '/reference', + download: baseUrl + '/toolbox/Download', esriStreets: '//server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer' }, diff --git a/_src/app/resources/App.styl b/_src/app/resources/App.styl index 855d22d..dbf634e 100644 --- a/_src/app/resources/App.styl +++ b/_src/app/resources/App.styl @@ -48,15 +48,25 @@ html, text-decoration none .version:hover text-decoration underline !important - .logoutDiv a - margin 0 + + /* AUTH PANE */ + .auth-pane + top: 50px + background-color: $background-grey + min-height: 34px; + border-radius: 0; + border-bottom: 1px solid $border-color; + .navbar-text, .navbar-btn + margin-top: 0; + margin-bottom: 0; + padding: 6px 0; /* CENTER (MAP) */ .center padding 0 .map-div width 100% - top 50px + top 100px bottom 0 position absolute .esriSimpleSliderTL diff --git a/_src/app/run.js b/_src/app/run.js index a45c71e..7b68474 100644 --- a/_src/app/run.js +++ b/_src/app/run.js @@ -1,5 +1,5 @@ (function () { - // the baseUrl is relavant in source version and while running unit tests. + // the baseUrl is relevant in source version and while running unit tests. // the`typeof` is for when this file is passed as a require argument to the build system // since it runs on node, it doesn't have a window object. The basePath for the build system // is defined in build.profile.js @@ -14,6 +14,10 @@ has.add('web-workers', function () { return window.Worker; }); + + window.firebase.initializeApp(JSON.parse(process.env.FIREBASE_CONFIG)); + console.log('firebase app initialized'); + parser.parse(); }); }()); diff --git a/_src/app/templates/App.html b/_src/app/templates/App.html index 229cef8..795d72e 100644 --- a/_src/app/templates/App.html +++ b/_src/app/templates/App.html @@ -20,6 +20,33 @@ +