diff --git a/.circleci/config.yml b/.circleci/config.yml index 09269cd..895f7aa 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -97,20 +97,21 @@ jobs: - run: name: Install dependencies and build command: | - export ENV NODE_OPTIONS=--openssl-legacy-provider + export NODE_OPTIONS=--openssl-legacy-provider cd vue yarn + # Node unit suites, no browser (35 WR-07). LC_ALL=C pins + # toLocaleString, which log-paging-store.cjs compares against. + LC_ALL=C yarn test:unit + LC_ALL=C node ../src/test/deploykey.cjs + LC_ALL=C node ../src/test/legacy-close-out.cjs yarn build:perf - yarn test + yarn test:ci - store_test_results: name: Collect test results - path: ./vue/coverage - - # The `path` key is an absolute or relative path to your working_directory containing - # subdirectories of JUnit XML or Cucumber JSON test metadata files, - # or the path of a single file containing all test results. - # Make sure that your path value is not a hidden folder (example: .my_hidden_directory would be an invalid format). + # The Cypress JUnit files written by cy:test:ci (one per spec file). + path: ./vue/cypress/results - store_artifacts: name: Collect screenshots @@ -179,9 +180,11 @@ workflows: # only: # - thinx-staging + # No context (48-REVIEW WR-03): this job runs yarn with no lockfile, so unpinned + # dependencies' lifecycle scripts run here, and no step needs a credential. The + # Cypress suites stub the API; the parent repo's test-vue-console runs them the same way. - test_vue: name: Test Vue console - context: sonarcloud # - ggshield/scan: # name: Scan using Gitguardian shield @@ -212,7 +215,6 @@ workflows: context: - dockerhub - thinx-docker-repo - - sonarcloud - console requires: - Test Vue console diff --git a/.gitignore b/.gitignore index 19c01aa..44f3f50 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ html src/.nyc_output src/cypress/screenshots src/cypress/videos +src/cypress/.env.test yarn.lock .dccache settings.json diff --git a/src/app/js/controllers/ChannelController.js b/src/app/js/controllers/ChannelController.js index df281e3..17036bf 100644 --- a/src/app/js/controllers/ChannelController.js +++ b/src/app/js/controllers/ChannelController.js @@ -13,7 +13,6 @@ angular.module( "RTM" ).controller( "ChannelController", [ "$rootScope", "$scope Thinx.channelList() .done( function( data ) { - console.log( "+++ updateChannels " ); $scope.$emit( "updateChannels", data ); } ) .fail( error => $scope.$emit( "xhrFailed", error ) ); @@ -33,7 +32,6 @@ angular.module( "RTM" ).controller( "ChannelController", [ "$rootScope", "$scope console.log( response ); Thinx.channelList() .done( function( data ) { - console.log( "+++ updateChannels " ); $scope.$emit( "updateChannels", data ); } ) .fail( error => $scope.$emit( "xhrFailed", error ) ); @@ -69,7 +67,6 @@ angular.module( "RTM" ).controller( "ChannelController", [ "$rootScope", "$scope Thinx.channelList() .done( function( data ) { - console.log( "+++ updateChannels " ); $scope.$emit( "updateChannels", data ); } ) .fail( error => $scope.$emit( "xhrFailed", error ) ); diff --git a/src/app/js/controllers/DeploykeyController.js b/src/app/js/controllers/DeploykeyController.js index d3bcfe3..ecd1e3b 100644 --- a/src/app/js/controllers/DeploykeyController.js +++ b/src/app/js/controllers/DeploykeyController.js @@ -13,7 +13,6 @@ angular.module( "RTM" ).controller( "DeploykeyController", [ "$rootScope", "$sco Thinx.deploykeyList() .done( function( data ) { - console.log( "+++ updateDeploykeys " ); $scope.$emit( "updateDeploykeys", data ); } ) .fail( error => $scope.$emit( "xhrFailed", error ) ); @@ -24,45 +23,118 @@ angular.module( "RTM" ).controller( "DeploykeyController", [ "$rootScope", "$sco $scope.searchText = ""; - $scope.createDeploykey = function() { - - console.log( "--creating deploy key--" ); - - Thinx.createDeploykey() - .done( function( response ) { + // Deploy key names (v1.18 Phase 48, D-09, UI-C18, 35 IN-03/IN-04). The sentences mirror + // vue/src/utils/keyNames.js word for word (src/test/deploykey.cjs D10 compares them); the + // server (RSAKey.validateKeyName) stays the authority, this check only saves a round trip. + var KEY_NAME_MAX = 120; + var KEY_NAME_MESSAGES = { + empty: "Enter a name for the key.", + too_long: "The name is longer than 120 characters.", + control_chars: "The name contains a control character, such as a tab or a line break.", + line_separator: "The name contains a hidden line or paragraph separator.", + bidi: "The name contains a hidden direction-override character.", + invisible: "The name contains an invisible character, such as a zero-width space.", + reserved: "This name is reserved for the key's default label. Choose a different name." + }; + var KEY_NAME_FALLBACK = "This name is not allowed. Choose a different name."; + var CREATE_FAILED = "Could not generate the deploy key. Try again."; + // Phase 40 D-03: OpenBao unavailable or key generation switched off (503). The fixed + // sentence only; the code and any OpenBao detail are never shown. + var KEY_SERVICE_UNAVAILABLE = "Deploy keys can't be created right now, try again later."; + + // The reason the trimmed name is refused before sending, or null. + // Code units are compared as numbers on purpose: a regular expression with a + // control-character class trips Codacy no-control-regex (quick 261012-r9z). + function keyNameProblem( name ) { + for ( var i = 0; i < name.length; i++ ) { + var c = name.charCodeAt( i ); + if ( c < 0x20 || c === 0x7F ) { + return "control_chars"; + } + } + if ( name.length > KEY_NAME_MAX ) { + return "too_long"; + } + return null; + } - response = JSON.parse( response ); + // The fixed sentence for a refusal reason; never the reason code itself. + function keyNameSentence( reason ) { + if ( typeof reason === "string" && Object.prototype.hasOwnProperty.call( KEY_NAME_MESSAGES, reason ) ) { + return KEY_NAME_MESSAGES[ reason ]; + } + return KEY_NAME_FALLBACK; + } - if ( typeof( response ) !== "undefined" ) { - if ( response.success ) { - console.log( response ); - toastr.success( "Key created.", "", { timeOut: 5000 } ); + // One fixed sentence, written as text (toastr renders HTML, so no reply field goes in). + function showCreateError( sentence ) { + $( ".msg-warning" ).text( sentence ); + $( ".msg-warning" ).show(); + toastr.error( sentence, "", { timeOut: 5000 } ); + } - $scope.deploykeyCreated = response.status.name; - $scope.deploykeyValue = response.status.pubkey; + $scope.createDeploykey = function() { - Thinx.deploykeyList() - .done( function( data ) { - console.log( "+++ updateDeploykeys " ); - $scope.$emit( "updateDeploykeys", data ); - } ) - .fail( error => $scope.$emit( "xhrFailed", error ) ); + console.log( "--creating deploy key--" ); + if ($scope.creatingDeploykey || !$scope.deploykeyName || !$scope.deploykeyName.trim()) return; + var name = $scope.deploykeyName.trim(); + var problem = keyNameProblem( name ); + if ( problem !== null ) { + showCreateError( keyNameSentence( problem ) ); + return; + } + $scope.creatingDeploykey = true; + Thinx.createDeploykey( name ) + .done( function( response ) { + // jQuery does not wrap .done callbacks: a throw here would skip .always, so the + // reset lives in finally as well (35 IN-03). + try { + if ( response && response.success ) { + var key = response.response || response.status; + if ( !key || typeof key !== "object" || !key.pubkey ) { + showCreateError( CREATE_FAILED ); + } else { + toastr.success( "Key created.", "", { timeOut: 5000 } ); + + $scope.deploykeyCreated = key.date || key.name; + $scope.deploykeyValue = key.pubkey; + + Thinx.deploykeyList() + .done( function( data ) { + $scope.$emit( "updateDeploykeys", data ); + } ) + .fail( error => $scope.$emit( "xhrFailed", error ) ); + } + } else if ( response && response.response === "invalid_key_name" ) { + showCreateError( keyNameSentence( response.reason ) ); + } else if ( response && response.response === "key_service_unavailable" ) { + showCreateError( KEY_SERVICE_UNAVAILABLE ); } else { - console.log( response.status ); - toastr.error( "Error.", "", { timeOut: 5000 } ); + showCreateError( CREATE_FAILED ); } + } catch ( e ) { + showCreateError( CREATE_FAILED ); + } finally { + $scope.creatingDeploykey = false; + $scope.$applyAsync(); + } + } ) + .fail( function( jqXHR ) { + var body = jqXHR && jqXHR.responseJSON; + if ( body && body.response === "invalid_key_name" ) { + showCreateError( keyNameSentence( body.reason ) ); + } else if ( body && body.response === "key_service_unavailable" ) { + showCreateError( KEY_SERVICE_UNAVAILABLE ); } else { - console.log( "error" ); - console.log( response ); + showCreateError( CREATE_FAILED ); } + $scope.$emit( "xhrFailed", jqXHR ); } ) - .fail( function( error ) { - $( ".msg-warning" ).text( error ); - $( ".msg-warning" ).show(); - $scope.$emit( "xhrFailed", error ); - toastr.error( "Error.", "", { timeOut: 5000 } ); - } ); + .always(function() { + $scope.creatingDeploykey = false; + $scope.$applyAsync(); + }); }; @@ -80,7 +152,6 @@ angular.module( "RTM" ).controller( "DeploykeyController", [ "$rootScope", "$sco Thinx.deploykeyList() .done( function( data ) { - console.log( "+++ updateDeploykeys " ); $scope.$emit( "updateDeploykeys", data ); } ) .fail( error => $scope.$emit( "xhrFailed", error ) ); @@ -116,6 +187,8 @@ angular.module( "RTM" ).controller( "DeploykeyController", [ "$rootScope", "$sco }; $scope.resetModal = function() { + $scope.deploykeyName = ""; + $scope.creatingDeploykey = false; $scope.deploykeyCreated = null; $scope.deploykeyValue = null; $scope.selectedItems = []; diff --git a/src/app/js/controllers/DeviceController.js b/src/app/js/controllers/DeviceController.js index 5affa68..319ef10 100644 --- a/src/app/js/controllers/DeviceController.js +++ b/src/app/js/controllers/DeviceController.js @@ -12,21 +12,18 @@ angular.module( "RTM" ).controller( "DeviceController", [ "$rootScope", "$scope" Thinx.sourceList() .done( function( data ) { - console.log( "+++ updateSources " ); $scope.$emit( "updateSources", data ); } ) .fail( error => $scope.$emit( "xhrFailed", error ) ); Thinx.apikeyList() .done( function( data ) { - console.log( "+++ updateApikeys " ); $scope.$emit( "updateApikeys", data ); } ) .fail( error => $scope.$emit( "xhrFailed", error ) ); Thinx.channelList() .done( function( data ) { - console.log( "+++ updateChannels " ); $scope.$emit( "updateChannels", data ); } ) .fail( error => $scope.$emit( "xhrFailed", error ) ); @@ -722,7 +719,6 @@ angular.module( "RTM" ).controller( "DeviceController", [ "$rootScope", "$scope" // refresh latest firmware envelope Thinx.getLatestFirmwareEnvelope( $scope.deviceForm.udid ) .done( function( data ) { - console.log( "+++ updateLatestFirmwareEnvelope " ); $scope.$emit( "updateLatestFirmwareEnvelope", data ); } ) .fail( error => $scope.$emit( "xhrFailed", error ) ); diff --git a/src/app/js/controllers/DevicesController.js b/src/app/js/controllers/DevicesController.js index e3a77f8..5c0f50d 100644 --- a/src/app/js/controllers/DevicesController.js +++ b/src/app/js/controllers/DevicesController.js @@ -5,14 +5,12 @@ angular.module( "RTM" ).controller( "DevicesController", [ "$rootScope", "$scope Thinx.sourceList() .done( function( data ) { - console.log( "+++ updateSources " ); $scope.$emit( "updateSources", data ); } ) .fail( error => $scope.$emit( "xhrFailed", error ) ); Thinx.apikeyList() .done( function( data ) { - console.log( "+++ updateApikeys " ); $scope.$emit( "updateApikeys", data ); } ) .fail( error => $scope.$emit( "xhrFailed", error ) ); @@ -114,8 +112,10 @@ angular.module( "RTM" ).controller( "DevicesController", [ "$rootScope", "$scope } } + // The build reply carries no status field (Phase 43 carry, D-09, UI-C19): fixed copy only, + // no reply value reaches toastr (it renders HTML). toastr.info( - response.status + "

Click to show build log...", + ( response.response === "queued" ? "Build queued." : "Build started." ) + "

Click to show build log...", "THiNX Builder", { timeOut: 3000, @@ -132,7 +132,9 @@ angular.module( "RTM" ).controller( "DevicesController", [ "$rootScope", "$scope $scope.$apply(); } else { console.log( response ); - toastr.error( response.status, "", { timeOut: 5000 } ); + // A refused build replies { success: false, response: "" } with no status + // field: one fixed sentence, no reply value reaches toastr (48-REVIEW WR-04). + toastr.error( "Build failed.", "", { timeOut: 5000 } ); } } else { console.log( "error" ); diff --git a/src/app/js/controllers/LogviewController.js b/src/app/js/controllers/LogviewController.js index db9a7a2..502084e 100644 --- a/src/app/js/controllers/LogviewController.js +++ b/src/app/js/controllers/LogviewController.js @@ -1,3 +1,36 @@ +/* Notification frames come off the websocket and are device-supplied: a + * device's current owner, or a transferred device's previous one, writes + * body, title and nid. toastr renders its message and title as HTML, so + * every frame field is escaped (or validated, for nid) before it is used in + * markup, ids or selectors, and toastr is only called through its four + * display methods. The frame itself stays unchanged. */ +var THINX_HTML_ENTITIES = { "&": "&", "<": "<", ">": ">", "\"": """, "'": "'", "`": "`" }; +var THINX_TOAST_METHODS = [ "info", "success", "warning", "error" ]; +var THINX_NID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; + +function thinxEscapeHtml( value ) { + if ( value === null || typeof( value ) === "undefined" ) { + return ""; + } + return String( value ).replace( /[&<>"'`]/g, function( c ) { + return THINX_HTML_ENTITIES[ c ]; + } ); +} + +// toastr method for a frame type, or null; never indexes toastr with frame data +function thinxToastMethod( type ) { + return THINX_TOAST_METHODS.indexOf( type ) !== -1 ? type : null; +} + +// nid safe for ids, names and jQuery selectors, or null +function thinxSafeNid( nid ) { + if ( typeof( nid ) !== "string" && typeof( nid ) !== "number" ) { + return null; + } + nid = String( nid ); + return THINX_NID_PATTERN.test( nid ) ? nid : null; +} + /* Setup blank page controller */ angular.module( "RTM" ).controller( "LogviewController", [ "$rootScope", "$scope", "settings", function( $rootScope, $scope, settings ) { $scope.$on( "$viewContentLoaded", function() { @@ -24,11 +57,12 @@ angular.module( "RTM" ).controller( "LogviewController", [ "$rootScope", "$scope // open websocket $rootScope.wss = new WebSocket( "/" + $rootScope.profile.owner ); + // Always subscribe first, so a tab whose first socket opens from the log overlay still + // receives Build Status and device frames (D-09, 43-REVIEW IN-04); then tail the selected build. $rootScope.wss.onopen = function() { + $rootScope.wssinit(); if ( typeof( $rootScope.modalBuildId ) !== "undefined" ) { $rootScope.wsstailLog( $rootScope.modalBuildId ); - } else { - $rootScope.wssinit(); } }; $rootScope.wss.onmessage = function( message ) { @@ -105,7 +139,10 @@ angular.module( "RTM" ).controller( "LogviewController", [ "$rootScope", "$scope // $rootScope.logdata.buffer[$rootScope.modalBuildId] = ""; $rootScope.logdata[ build_id ] = ""; $rootScope.modalBuildId = build_id; - $rootScope.wss.send( JSON.stringify( message ) ); + // A connecting socket would throw InvalidStateError; it sends the tail from its own onopen. + if ( $rootScope.wss && $rootScope.wss.readyState === 1 ) { + $rootScope.wss.send( JSON.stringify( message ) ); + } }; $rootScope.wssinit = function() { @@ -190,7 +227,14 @@ angular.module( "RTM" ).controller( "LogviewController", [ "$rootScope", "$scope timestamp: nowTime }; - // prepare user metadata for particular device + // prepare user metadata for particular device; a device with no build history has + // no array yet, and a throw here would lose the frame's toast (43-UI-REVIEW fix 3, D-09) + if ( typeof( $rootScope.meta.deviceBuilds ) !== "object" || $rootScope.meta.deviceBuilds === null ) { + $rootScope.meta.deviceBuilds = {}; + } + if ( !Array.isArray( $rootScope.meta.deviceBuilds[ msg.udid ] ) ) { + $rootScope.meta.deviceBuilds[ msg.udid ] = []; + } $rootScope.meta.deviceBuilds[ msg.udid ].push( buildRecord ); $scope.$apply(); @@ -213,16 +257,24 @@ angular.module( "RTM" ).controller( "LogviewController", [ "$rootScope", "$scope // show toast with action dialog if ( msg.type == "actionable" ) { + // nid ends up in ids, names and selectors; anything else is not ours + var nid = thinxSafeNid( msg.nid ); + if ( nid === null ) { + return; + } + var actionBody = thinxEscapeHtml( msg.body ); + var actionTitle = thinxEscapeHtml( msg.title ); + // YES/NO if ( msg.response_type == "bool" ) { - toastr[ "info" ]( - msg.body + "

" + - msg.nid + "

" + - "
" + - "
", - msg.title, + actionTitle, { timeOut: 0, extendedTimeOut: 0, @@ -234,12 +286,12 @@ angular.module( "RTM" ).controller( "LogviewController", [ "$rootScope", "$scope } ); - $( "#okBtn-" + msg.nid ).on( "click", function( e ) { + $( "#okBtn-" + nid ).on( "click", function( e ) { $( this ).parent().slideToggle( 500 ); $scope.$emit( "submitNotificationResponse", true ); } ); - $( "#cancelBtn-" + msg.nid ).on( "click", function( e ) { + $( "#cancelBtn-" + nid ).on( "click", function( e ) { $( this ).parent().slideToggle( 500 ); $scope.$emit( "submitNotificationResponse", false ); } ); @@ -247,13 +299,13 @@ angular.module( "RTM" ).controller( "LogviewController", [ "$rootScope", "$scope // INPUT string if ( msg.response_type == "string" ) { - toastr[ "warning" ]( - msg.body + "

" + - msg.nid + "

" + - "

" + - "
", - msg.title, + actionTitle, { timeOut: 0, extendedTimeOut: 0, @@ -265,21 +317,32 @@ angular.module( "RTM" ).controller( "LogviewController", [ "$rootScope", "$scope } ); - $( "#sendBtn-" + msg.nid ).on( "click", function( e ) { + $( "#sendBtn-" + nid ).on( "click", function( e ) { $( this ).parent().slideToggle( 500 ); - $scope.$emit( "submitNotificationResponse", $( "input[name=reply-" + msg.nid + "]" ).val() ); + $scope.$emit( "submitNotificationResponse", $( "input[name=reply-" + nid + "]" ).val() ); } ); } + return; + } + + // Supported msg.types by Toastr: "error", "info", "success", "warning" + var toastMethod = thinxToastMethod( msg.type ); + if ( toastMethod === null ) { + return; + } + + // JSON.stringify does not escape markup + var toastBody = thinxEscapeHtml( JSON.stringify( msg.body ) ); // non-actionable status notification - } else if ( typeof( msg.body.status ) !== "undefined" ) { + if ( msg.body !== null && typeof( msg.body ) !== "undefined" && typeof( msg.body.status ) !== "undefined" ) { var msgTitle = "Device Status Update"; // process status message - toastr[ msg.type ]( - JSON.stringify( msg.body ), + toastr[ toastMethod ]( + toastBody, msgTitle, { timeOut: 8000, @@ -305,15 +368,9 @@ angular.module( "RTM" ).controller( "LogviewController", [ "$rootScope", "$scope // non-actionable notification without status } else { - // Supported msg.types by Toastr - // "error" - // "info" - // "success" - // "warning" - - toastr[ msg.type ]( - JSON.stringify( msg.body ), - msg.title, + toastr[ toastMethod ]( + toastBody, + thinxEscapeHtml( msg.title ), { timeOut: 20000, extendedTimeOut: 0, diff --git a/src/app/js/controllers/SourceController.js b/src/app/js/controllers/SourceController.js index 0066569..0684f15 100644 --- a/src/app/js/controllers/SourceController.js +++ b/src/app/js/controllers/SourceController.js @@ -13,7 +13,6 @@ angular.module( "RTM" ).controller( "SourceController", [ "$rootScope", "$scope" Thinx.sourceList() .done( function( data ) { - console.log( "+++ updateSources " ); $scope.$emit( "updateSources", data ); Thinx.deviceList() diff --git a/src/app/js/thinx-api.js b/src/app/js/thinx-api.js index add8746..98f6aee 100644 --- a/src/app/js/thinx-api.js +++ b/src/app/js/thinx-api.js @@ -14,6 +14,20 @@ if ( urlBase.indexOf( "localhost" ) !== -1 ) { } ); } +// Phase 25 D-18 seam: same wire contract as assets/thinx/csrf.js (XSRF-TOKEN cookie -> X-XSRF-TOKEN header), +// read at send time, API-bound calls only (urlBase or same-origin relative), never to a foreign origin. +$.ajaxSetup( { + beforeSend: function( xhr, settings ) { + var url = ( settings && typeof( settings.url ) === "string" ) ? settings.url : ""; + var apiBound = url === urlBase || url.indexOf( urlBase + "/" ) === 0 || + ( url.charAt( 0 ) === "/" && url.charAt( 1 ) !== "/" && url.charAt( 1 ) !== "\\" ); + var match = document.cookie.match( /(?:^|; )XSRF-TOKEN=([^;]*)/ ); + if ( apiBound && match && match[ 1 ] ) { + xhr.setRequestHeader( "X-XSRF-TOKEN", decodeURIComponent( match[ 1 ] ) ); + } + } +} ); + // eslint-disable-next-line no-redeclare var Thinx = { // RSA @@ -38,8 +52,8 @@ var Thinx = { deploykeyList: function() { return deploykeyList(); }, - createDeploykey: function() { - return createDeploykey(); + createDeploykey: function(name) { + return createDeploykey(name); }, revokeDeploykeys: function( filenames ) { return revokeDeploykeys( filenames ); @@ -338,11 +352,14 @@ function init( $rootScope, $scope ) { } function updateDeploykeys( data ) { - $rootScope.deploykeys = data.response; + if (typeof data === "string") data = JSON.parse(data); + if (!data || !data.success) return; + $rootScope.deploykeys = Array.isArray(data.response) ? data.response : []; safeApply( $scope ); - // save user-spcific goal achievement - if ( $rootScope.profile.info.goals.length > 0 ) { + // save user-spcific goal achievement; same rule as the sibling handlers (goals.length > 0), + // kept null-safe (35 IN-05) + if ( $rootScope.profile && $rootScope.profile.info && Array.isArray( $rootScope.profile.info.goals ) && $rootScope.profile.info.goals.length > 0 ) { if ( !$rootScope.profile.info.goals.includes( "deploykey" ) && $rootScope.deploykeys.length > 0 ) { $rootScope.profile.info.goals.push( "deploykey" ); $scope.$emit( "saveProfileChanges", [ "goals" ] ); @@ -1027,10 +1044,12 @@ function deploykeyList() { } ); } -function createDeploykey() { +function createDeploykey(name) { return $.ajax( { url: urlBase + "/user/rsakey/create", - type: "GET" + type: "GET", + data: { name: name }, + dataType: "json" } ); } @@ -1192,6 +1211,10 @@ function getProfile() { } ); } +// users/edit merges `info` per key. transformers is deliberately absent: this +// sends a session-old $rootScope snapshot (also on every info-note dismissal), +// which would revert transformers written by the Vue console since login. +// Transformer edits go through submitProfileChanges( [ "transformers" ] ) (35 WR-04). function submitProfile( profile ) { var info = { first_name: profile.info.first_name, @@ -1204,7 +1227,6 @@ function submitProfile( profile ) { username: profile.info.username, owner: profile.info.owner, tags: profile.info.tags, - transformers: profile.info.transformers, timezone_abbr: profile.timezone_abbr, timezone_offset: profile.timezone_offset }; diff --git a/src/app/tpl/footer.html b/src/app/tpl/footer.html index 535235d..f856706 100644 --- a/src/app/tpl/footer.html +++ b/src/app/tpl/footer.html @@ -1,4 +1,4 @@ - +
diff --git a/src/app/views/deploykey.html b/src/app/views/deploykey.html index 01a9ddb..b5b28c8 100644 --- a/src/app/views/deploykey.html +++ b/src/app/views/deploykey.html @@ -86,9 +86,9 @@

Deploy Keys
- + @@ -107,10 +107,16 @@

Deploy Keys

diff --git a/src/auth.html b/src/auth.html index 76e9023..7d7bfdb 100644 --- a/src/auth.html +++ b/src/auth.html @@ -149,7 +149,7 @@

Contacting us

- + - + diff --git a/src/package.json b/src/package.json index 23ec1e5..e489709 100644 --- a/src/package.json +++ b/src/package.json @@ -18,11 +18,14 @@ "cy:open": "./node_modules/cypress/bin/cypress open", "test:csp": "node test/csp/source-check.cjs && node test/csp/public-scripts.cjs", "test:digest": "node test/digest-guard.cjs", + "test:xsrf": "node test/xsrf-seam.cjs", "test:env": "node test/env-json.cjs", "test:ng-scope": "node test/ng-scope.cjs", + "test:toast": "node test/toast-escape.cjs", "test:csp:built": "node test/csp/source-check.cjs html", "test:csp:browser": "node test/csp/browser.cjs && node test/csp/app-browser.cjs && node test/csp/auth-browser.cjs && node test/csp/vendor-browser.cjs", - "test:csp:external": "node test/csp/external-browser.cjs" + "test:csp:external": "node test/csp/external-browser.cjs", + "test:deploykeys": "node test/deploykey.cjs" }, "author": "Mikulas Sychra @ THiNX Cloud", "repository": { diff --git a/src/password.html b/src/password.html index 91b2b6d..a7663ab 100644 --- a/src/password.html +++ b/src/password.html @@ -75,7 +75,7 @@ - + +
+

No log output is available for this build yet.

+ + + + diff --git a/vue/src/components/Header/Header.scss b/vue/src/components/Header/Header.scss index ba212b4..36ad37e 100644 --- a/vue/src/components/Header/Header.scss +++ b/vue/src/components/Header/Header.scss @@ -40,6 +40,12 @@ } } + // Account menu glyph (D-16, UI-C07): 8px either side replaces the 7px px-2 utility; size and + // colours come from the navbar link rules. + .account-menu-icon { + padding: 0 8px; + } + .search-group { .input-group-text { padding-left: .85rem; diff --git a/vue/src/components/Header/Header.vue b/vue/src/components/Header/Header.vue index 8a62243..627b46c 100644 --- a/vue/src/components/Header/Header.vue +++ b/vue/src/components/Header/Header.vue @@ -1,16 +1,6 @@ @@ -20,6 +18,7 @@ import { mapState, mapActions } from 'vuex'; import Sidebar from '@/components/Sidebar/Sidebar'; import Header from '@/components/Header/Header'; import ImpersonationBanner from '@/components/ImpersonationBanner/ImpersonationBanner'; +import AppFooter from '@/components/AppFooter/AppFooter'; // Removed: Helper (Flatlogic template "Configuration" panel — Purchase / Go FULL / Documentation / social-sharing). // Not part of THiNX product surface; was leftover from the light-blue-vue template. @@ -27,7 +26,7 @@ import './Layout.scss'; export default { name: 'Layout', - components: { Sidebar, Header, ImpersonationBanner }, + components: { Sidebar, Header, ImpersonationBanner, AppFooter }, methods: { ...mapActions( 'layout', ['switchSidebar', 'changeSidebarActive'], diff --git a/vue/src/components/List/List.vue b/vue/src/components/List/List.vue index b679a6d..5652fc1 100644 --- a/vue/src/components/List/List.vue +++ b/vue/src/components/List/List.vue @@ -33,7 +33,7 @@ diff --git a/vue/src/components/Notifications/Notifications.scss b/vue/src/components/Notifications/Notifications.scss index a1660f7..9df46cc 100644 --- a/vue/src/components/Notifications/Notifications.scss +++ b/vue/src/components/Notifications/Notifications.scss @@ -39,3 +39,29 @@ top: 2px; } } + +// Navbar Recent Builds rows (UI-B12, 35 IN-06): spans instead of a heading and a paragraph, +// which are not valid inside the row's button. +.notification-title { + display: block; + margin-bottom: 4px; + font-size: 1rem; + font-weight: 300; +} + +.notification-meta { + display: block; + font-size: .875rem; + font-weight: 400; +} + +// A row without a build id is static text (D-14). The compound selector is needed because +// NotificationsDemo/ListGroup.scss loads after this file with an equally specific +// .listGroupItem:hover background. +.listGroupItem.listGroupItem--static { + cursor: default; +} + +.listGroupItem.listGroupItem--static:hover { + background-color: transparent; +} diff --git a/vue/src/components/Notifications/Notifications.vue b/vue/src/components/Notifications/Notifications.vue index 0085541..09b144d 100644 --- a/vue/src/components/Notifications/Notifications.vue +++ b/vue/src/components/Notifications/Notifications.vue @@ -8,10 +8,14 @@

No recent builds.

+ @@ -19,13 +23,14 @@ -
{{ build.name || shortUdid(build.udid) }}
-

- {{ build.status || 'UNKNOWN' }} + {{ build.name || shortUdid(build.udid) }} + + {{ build.status || '—' }} · {{ shortUdid(build.udid) }} -

+
+
See all builds diff --git a/vue/src/components/Sidebar/Sidebar.vue b/vue/src/components/Sidebar/Sidebar.vue index e34690d..0ddc93f 100644 --- a/vue/src/components/Sidebar/Sidebar.vue +++ b/vue/src/components/Sidebar/Sidebar.vue @@ -50,7 +50,7 @@ iconName="flaticon-network-1" index="settings" :childrenLinks="[ - { header: 'RSA Keys', link: '/app/rsakeys' }, + { header: 'Deploy Keys', link: '/app/rsakeys' }, { header: 'Transformers', link: '/app/transformers' }, { header: 'Environment Globals', link: '/app/enviros' }, { header: 'Mesh Channels', link: '/app/channels' }, diff --git a/vue/src/core/api.js b/vue/src/core/api.js index 288829d..f691b0c 100644 --- a/vue/src/core/api.js +++ b/vue/src/core/api.js @@ -69,11 +69,19 @@ export default class Api { parseResult(result) { if (result && typeof result.success !== 'undefined' && result.success) { - let keys = Object.keys(result).filter( key => key !== 'success' ); - return { + let keys = Object.keys(result).filter( key => key !== 'success' && key !== 'paging' ); + // 35 IN-01: no data key gives an undefined response (not {}), so empty states hold. + const out = { 'success': result.success, - 'response': result[keys[0]] + 'response': Object.prototype.hasOwnProperty.call(result, 'response') + ? result.response + : (keys.length === 0 ? undefined : keys.length === 1 ? result[keys[0]] : keys.reduce((doc, key) => { doc[key] = result[key]; return doc; }, {})) }; + // Paged log endpoints (phase 26, D-19) send `paging` next to `response`. + if (result.paging && typeof result.paging === 'object') { + out.paging = result.paging; + } + return out; } return result || { success: false }; } @@ -90,10 +98,29 @@ export default class Api { // SEC-CSRF-01 (21-REVIEW CR-01): mutating calls await the shared XSRF prime // and retry once on a csrf_token_invalid rejection. GETs are never checked. const options = this.composeOptions(method, body); - const response = (method === 'GET') - ? await fetch(this.composePath(path), options) - : await fetchWithCsrf(this.csrfBase(), this.composePath(path), options); - const text = await response.text(); + let response; + let text; + try { + response = (method === 'GET') + ? await fetch(this.composePath(path), options) + : await fetchWithCsrf(this.csrfBase(), this.composePath(path), options); + // The body is read inside the same guard: a stream that fails after the headers + // arrived (connection reset, page unload, an aborted intercept) fails the same + // way as a request that never got a response (48-REVIEW WR-01). + text = await response.text(); + } catch (_networkError) { + // A request that never got a response (offline, aborted by a page unload) + // resolves instead of rejecting, so a caller without a catch never leaves an + // unhandled rejection (48-08, CI-07). networkError tells it apart from an empty + // or refused reply, so a page can still show its load or network error + // (48-REVIEW CR-01). + return { + success: false, + status: response ? response.status : 0, + response: null, + networkError: true, + }; + } if (!text) { return { @@ -134,6 +161,11 @@ export default class Api { return this.request('PUT', path, body); } + // Mutating like PUT and DELETE, so request() sends it through fetchWithCsrf. + async $patch(path, body) { + return this.request('PATCH', path, body); + } + async $delete(path, body) { return this.request('DELETE', path, body); } diff --git a/vue/src/fonts/icomoon/_icomoon.scss b/vue/src/fonts/icomoon/_icomoon.scss new file mode 100644 index 0000000..47ca1a2 --- /dev/null +++ b/vue/src/fonts/icomoon/_icomoon.scss @@ -0,0 +1,26 @@ +// icomoon icon font, ported from the Legacy console (services/console/src/assets/ +// thinx/css/custom.css, lines 1-30) for the one hammer glyph of the Build action +// (phase 44, D-05). Only the woff and ttf files are copied (no IE, no SVG fonts). +// URLs are relative to src/styles/theme.scss, which imports this partial, the same +// way as src/fonts/flaticon/_flaticon.scss. + +@font-face { + font-family: 'icomoon'; + src: + url('../fonts/icomoon/icomoon.woff') format('woff'), + url('../fonts/icomoon/icomoon.ttf') format('truetype'); + font-weight: normal; + font-style: normal; +} + +.icomoon-hammer_icon { + font-family: 'icomoon' !important; + speak: none; + font-style: normal; + font-weight: normal; + line-height: 1; +} + +.icomoon-hammer_icon:before { + content: "\e900"; +} diff --git a/vue/src/fonts/icomoon/icomoon.ttf b/vue/src/fonts/icomoon/icomoon.ttf new file mode 100644 index 0000000..acba01a Binary files /dev/null and b/vue/src/fonts/icomoon/icomoon.ttf differ diff --git a/vue/src/fonts/icomoon/icomoon.woff b/vue/src/fonts/icomoon/icomoon.woff new file mode 100644 index 0000000..540d996 Binary files /dev/null and b/vue/src/fonts/icomoon/icomoon.woff differ diff --git a/vue/src/main.js b/vue/src/main.js index 52c2a7d..7da63da 100644 --- a/vue/src/main.js +++ b/vue/src/main.js @@ -2,7 +2,6 @@ // (runtime-only or standalone) has been set in webpack.base.conf with an alias. import Vue from 'vue'; import BootstrapVue from 'bootstrap-vue'; -import * as VueGoogleMaps from 'vue2-google-maps'; import Toasted from 'vue-toasted'; import VCalendar from 'v-calendar'; import VueApexCharts from 'vue-apexcharts'; @@ -80,12 +79,6 @@ Vue.use(BootstrapVue); Vue.use(VCalendar, { firstDayOfWeek: 2 }); -Vue.use(VueGoogleMaps, { - load: { - key: process.env.VUE_APP_GOOGLE_MAPS_APIKEY, - }, -}); - Vue.component('apexchart', VueApexCharts); Vue.mixin(layoutMixin); Vue.use(Toasted, {duration: 10000}); diff --git a/vue/src/pages/AdminUsers/AdminUsers.vue b/vue/src/pages/AdminUsers/AdminUsers.vue index 5c980a0..ec50a62 100644 --- a/vue/src/pages/AdminUsers/AdminUsers.vue +++ b/vue/src/pages/AdminUsers/AdminUsers.vue @@ -87,6 +87,7 @@ export default { revokeSession: 'admin/revokeSession', impersonate: 'admin/impersonate', persistSession: 'auth/persistSession', + fetchProfile: 'profile/fetchProfile', }), formatDate(ts) { if (!ts) return '—'; @@ -126,6 +127,17 @@ export default { return; } await this.persistSession({ accessToken: access_token, refreshToken: null }); + // The store must not keep the admin's profile for the impersonated session: the + // Routes.js admin guard and the Sidebar decide from a non-empty store without a request, + // and nothing under /app remounts the Header to refetch (48.1-REVIEW WR-01). Reset, then + // read the impersonated user's profile with the new token; a failed read leaves {} and + // the guard/Sidebar fail closed until the Header retries. + this.$store.commit('profile/saveProfile', {}); + try { + await this.fetchProfile(); + } catch (_e) { + // Fails closed; see above. + } this.$router.push('/app/dashboard'); }, }, diff --git a/vue/src/pages/Apikeys/Apikeys.vue b/vue/src/pages/Apikeys/Apikeys.vue index dcff8c9..8c6e18e 100644 --- a/vue/src/pages/Apikeys/Apikeys.vue +++ b/vue/src/pages/Apikeys/Apikeys.vue @@ -18,8 +18,10 @@

{{ error }} + Could not load API keys. Refresh to retry. item.hash); if (!hashes.length) return; const result = await this.deleteItems(hashes); - if (!result.success) this.error = result.message || 'Failed to delete API keys.'; + if (!result.success) { + this.error = result.message || 'Failed to delete API keys.'; + } else if (result.refreshed === false) { + // The delete was stored but the follow-up read failed: reload, so the page shows + // the load error instead of the deleted rows (audit W2). + await this.loadData(); + } else { + // The store re-read the list after the delete; show it (PD-09). + this.items = this.getItems(); + } this.selectedIds = []; this.isSelected = false; this.selectedCount = 0; @@ -99,13 +111,26 @@ export default { this.selectedCount = value.count; this.selectedIds = value.items || []; }, - loadData() { + // A network failure throws out of apikeys/fetchItems (audit W2): show one load error and + // no stale rows or selection, so nothing stale can be deleted. A refused reply resolves + // with the current list, as before. + async loadData() { this.loading = true; - this.fetchItems().then(() => { + try { + await this.fetchItems(); this.items = this.getItems(); this.headers = this.getHeaders(); + this.loadFailed = false; + } catch (e) { + this.items = []; + this.selectedIds = []; + this.isSelected = false; + this.selectedCount = 0; + this.loadFailed = true; + this.error = null; + } finally { this.loading = false; - }); + } }, copyToClipboard(value) { navigator.clipboard.writeText(value).catch(() => { diff --git a/vue/src/pages/Channels/Channels.vue b/vue/src/pages/Channels/Channels.vue index 73c8833..8d7adfd 100644 --- a/vue/src/pages/Channels/Channels.vue +++ b/vue/src/pages/Channels/Channels.vue @@ -18,10 +18,12 @@

{{ error }} + Could not load mesh channels. Refresh to retry.

Mesh channels allow groups of devices to communicate with each other.

item.mesh_id); if (!mesh_ids.length) return; const result = await this.deleteItems(mesh_ids); - if (!result.success) this.error = result.message || 'Failed to delete mesh channels.'; + if (!result.success) { + this.error = result.message || 'Failed to delete mesh channels.'; + } else if (result.refreshed === false) { + // The delete was stored but the follow-up read failed: reload, so the page shows + // the load error instead of the deleted rows (audit W2). + await this.loadData(); + } else { + // The store re-read the list after the delete; show it (PD-09). + this.items = this.getItems(); + } this.selectedIds = []; this.isSelected = false; this.selectedCount = 0; @@ -98,13 +110,26 @@ export default { this.selectedCount = value.count; this.selectedIds = value.items || []; }, - loadData() { + // A network failure throws out of channels/fetchItems (audit W2): show one load error and + // no stale rows or selection, so nothing stale can be deleted. A refused reply resolves + // with the current list, as before. + async loadData() { this.loading = true; - this.fetchItems().then(() => { + try { + await this.fetchItems(); this.items = this.getItems(); this.headers = this.getHeaders(); + this.loadFailed = false; + } catch (e) { + this.items = []; + this.selectedIds = []; + this.isSelected = false; + this.selectedCount = 0; + this.loadFailed = true; + this.error = null; + } finally { this.loading = false; - }); + } }, }, }; diff --git a/vue/src/pages/Devices/DeviceDetail.vue b/vue/src/pages/Devices/DeviceDetail.vue index 10d7b68..fd07572 100644 --- a/vue/src/pages/Devices/DeviceDetail.vue +++ b/vue/src/pages/Devices/DeviceDetail.vue @@ -8,142 +8,184 @@ Device - {{ device.alias }} - {{ error }} - {{ message }} + +
+ {{ error }} + {{ message }} - - - - -
-
Created on {{deploykey.name}}
+
{{deploykey.name}} — Created on {{deploykey.date}}
{{deploykey.pubkey}}
- {{ item[header.prop] }} + {{ item[header.prop] }}
- - - - - - - - -
UDID{{ device.udid }}
MAC{{ device.mac }}
Platform{{ device.platform }}
Firmware{{ device.firmware }}
Version{{ device.version }}
Status{{ device.status }}
Last seen{{ device.lastupdate | fromNow }}
Commit{{ device.commit }}
- + + + + + Build Firmware + + {{ buildPending ? 'Requesting build…' : '' }} +