diff --git a/adapter/i18n/en.pot b/adapter/i18n/en.pot index d5654aeda..e01ff78a3 100644 --- a/adapter/i18n/en.pot +++ b/adapter/i18n/en.pot @@ -5,8 +5,14 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2022-09-22T19:11:17.660Z\n" -"PO-Revision-Date: 2022-09-22T19:11:17.660Z\n" +"POT-Creation-Date: 2022-10-18T07:20:55.997Z\n" +"PO-Revision-Date: 2022-10-18T07:20:55.997Z\n" + +msgid "You don't have access to the {{appName}} app" +msgstr "You don't have access to the {{appName}} app" + +msgid "Contact your system administrator for assistance with app access." +msgstr "Contact your system administrator for assistance with app access." msgid "Save your data" msgstr "Save your data" diff --git a/adapter/src/components/AppWrapper.js b/adapter/src/components/AppWrapper.js index bbc9c799a..5edba40ea 100644 --- a/adapter/src/components/AppWrapper.js +++ b/adapter/src/components/AppWrapper.js @@ -3,6 +3,7 @@ import React from 'react' import { useCurrentUserLocale } from '../utils/useLocale.js' import { useVerifyLatestUser } from '../utils/useVerifyLatestUser.js' import { Alerts } from './Alerts.js' +import { AuthBoundary } from './AuthBoundary.js' import { ConnectedHeaderBar } from './ConnectedHeaderBar.js' import { ErrorBoundary } from './ErrorBoundary.js' import { LoadingMask } from './LoadingMask.js' @@ -10,7 +11,7 @@ import { styles } from './styles/AppWrapper.style.js' export const AppWrapper = ({ children, plugin }) => { const { loading: localeLoading } = useCurrentUserLocale() - const { loading: latestUserLoading } = useVerifyLatestUser() + const { loading: latestUserLoading, user } = useVerifyLatestUser() if (localeLoading || latestUserLoading) { return @@ -22,7 +23,7 @@ export const AppWrapper = ({ children, plugin }) => { {!plugin && }
window.location.reload()}> - {children} + {children}
diff --git a/adapter/src/components/AuthBoundary.js b/adapter/src/components/AuthBoundary.js new file mode 100644 index 000000000..22b357c78 --- /dev/null +++ b/adapter/src/components/AuthBoundary.js @@ -0,0 +1,57 @@ +import { useConfig } from '@dhis2/app-runtime' +import { CenteredContent, NoticeBox } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React from 'react' +import i18n from '../locales' + +const IS_PRODUCTION_ENV = process.env.NODE_ENV === 'production' +const APP_MANAGER_AUTHORITY = 'M_dhis-web-maintenance-appmanager' +const APP_AUTH_NAME = process.env.REACT_APP_DHIS2_APP_AUTH_NAME +const LEGACY_APP_AUTH_NAME = process.env.REACT_APP_DHIS2_APP_LEGACY_AUTH_NAME + +const isAppAvailable = ({ userAuthorities, apiVersion }) => { + // Skip check on dev + if (!IS_PRODUCTION_ENV) { + return true + } + + // On server versions < 35, auth name uses config.title instead of .name + const requiredAppAuthority = + apiVersion >= 35 ? APP_AUTH_NAME : LEGACY_APP_AUTH_NAME + + // Check for three possible authorities + return userAuthorities.some((authority) => + ['ALL', APP_MANAGER_AUTHORITY, requiredAppAuthority].includes(authority) + ) +} + +/** + * Block the app if the user doesn't have the correct permissions to view this + * app. + */ +export function AuthBoundary({ user, children }) { + const { appName, apiVersion } = useConfig() + + return isAppAvailable({ userAuthorities: user.authorities, apiVersion }) ? ( + children + ) : ( + + + {i18n.t( + 'Contact your system administrator for assistance with app access.' + )} + + + ) +} +AuthBoundary.propTypes = { + children: PropTypes.node, + user: PropTypes.shape({ + authorities: PropTypes.arrayOf(PropTypes.string), + }), +} diff --git a/adapter/src/utils/useVerifyLatestUser.js b/adapter/src/utils/useVerifyLatestUser.js index 4207247aa..85ed0c052 100644 --- a/adapter/src/utils/useVerifyLatestUser.js +++ b/adapter/src/utils/useVerifyLatestUser.js @@ -8,7 +8,7 @@ import { useState } from 'react' const USER_QUERY = { user: { resource: 'me', - params: { fields: ['id'] }, + params: { fields: ['id', 'username', 'authorities'] }, }, } @@ -22,7 +22,7 @@ const LATEST_USER_KEY = 'dhis2.latestUser' export function useVerifyLatestUser() { const { pwaEnabled } = useConfig() const [finished, setFinished] = useState(false) - const { loading, error } = useDataQuery(USER_QUERY, { + const { loading, error, data } = useDataQuery(USER_QUERY, { onComplete: async (data) => { const latestUserId = localStorage.getItem(LATEST_USER_KEY) const currentUserId = data.user.id @@ -38,10 +38,9 @@ export function useVerifyLatestUser() { setFinished(true) }, }) - if (error) { throw new Error('Failed to fetch user ID: ' + error) } - return { loading: loading || !finished } + return { loading: loading || !finished, user: data?.user } } diff --git a/cli/src/lib/constructAppUrl.js b/cli/src/lib/constructAppUrl.js index 17cf1641d..605a3a46f 100644 --- a/cli/src/lib/constructAppUrl.js +++ b/cli/src/lib/constructAppUrl.js @@ -1,12 +1,16 @@ -module.exports.constructAppUrl = (baseUrl, config, serverVersion) => { +const formatUrlSafeAppSlug = (appName) => { + return appName.replace(/[^A-Za-z0-9\s-]/g, '').replace(/\s+/g, '-') +} + +const constructAppUrl = (baseUrl, config, serverVersion) => { let appUrl = baseUrl const isModernServer = serverVersion.major >= 2 && serverVersion.minor >= 35 // From core version 2.35, short_name is used instead of the human-readable title to generate the url slug - const urlSafeAppSlug = (isModernServer ? config.name : config.title) - .replace(/[^A-Za-z0-9\s-]/g, '') - .replace(/\s+/g, '-') + const urlSafeAppSlug = formatUrlSafeAppSlug( + isModernServer ? config.name : config.title + ) // From core version 2.35, core apps are hosted at the server root under the /dhis-web-* namespace if (config.coreApp && isModernServer) { @@ -25,3 +29,8 @@ module.exports.constructAppUrl = (baseUrl, config, serverVersion) => { appUrl = scheme + appUrl.substr(scheme.length).replace(/\/+/g, '/') return appUrl } + +module.exports = { + constructAppUrl, + formatUrlSafeAppSlug, +} diff --git a/cli/src/lib/formatAppAuthName.js b/cli/src/lib/formatAppAuthName.js new file mode 100644 index 000000000..c90fabffc --- /dev/null +++ b/cli/src/lib/formatAppAuthName.js @@ -0,0 +1,35 @@ +const { formatUrlSafeAppSlug } = require('./constructAppUrl') + +const APP_AUTH_PREFIX = 'M_' +const DHIS_WEB = 'dhis-web-' + +/** + * Returns the string that identifies the 'App view permission' + * required to view the app + * + * Ex: coreApp && name = 'data-visualizer': authName = 'M_dhis-web-data-visualizer' + * Ex: name = 'pwa-example': authName = 'M_pwaexample' + * Ex: name = 'BNA Action Tracker': authName = 'M_BNA_Action_Tracker' + * + * The 'legacy' parameter specifies server version < 2.35 which uses + * config.title instead of config.name + */ +const formatAppAuthName = ({ config, legacy }) => { + const appName = legacy ? config.title : config.name + + if (config.coreApp) { + return APP_AUTH_PREFIX + DHIS_WEB + formatUrlSafeAppSlug(appName) + } + + // This formatting is drawn from https://github.com/dhis2/dhis2-core/blob/master/dhis-2/dhis-api/src/main/java/org/hisp/dhis/appmanager/App.java#L494-L499 + // (replaceAll is only introduced in Node 15) + return ( + APP_AUTH_PREFIX + + appName + .trim() + .replace(/[^a-zA-Z0-9\s]/g, '') + .replace(/\s/g, '_') + ) +} + +module.exports = formatAppAuthName diff --git a/cli/src/lib/formatAppAuthName.test.js b/cli/src/lib/formatAppAuthName.test.js new file mode 100644 index 000000000..aa044c33e --- /dev/null +++ b/cli/src/lib/formatAppAuthName.test.js @@ -0,0 +1,39 @@ +const formatAppAuthName = require('./formatAppAuthName.js') + +describe('core app handling', () => { + it('should handle core apps', () => { + const config = { coreApp: true, name: 'data-visualizer' } + const formattedAuthName = formatAppAuthName({ config }) + + expect(formattedAuthName).toBe('M_dhis-web-data-visualizer') + }) +}) + +describe('non-core app handling', () => { + it('should handle app names with hyphens', () => { + const config = { name: 'hyphenated-string-example' } + const formattedAuthName = formatAppAuthName({ config }) + + expect(formattedAuthName).toBe('M_hyphenatedstringexample') + }) + + it('should handle app names with capitals and spaces', () => { + const config = { name: 'Multi Word App Name' } + const formattedAuthName = formatAppAuthName({ config }) + + expect(formattedAuthName).toBe('M_Multi_Word_App_Name') + }) +}) + +describe('legacy app handling', () => { + it('should use title instead of name if the legacy flag is added', () => { + const config = { + name: 'Multi Word App Name', + title: 'Title of the App', + } + const formattedAuthName = formatAppAuthName({ config, legacy: true }) + + expect(formattedAuthName).not.toBe('M_Multi_Word_App_Name') + expect(formattedAuthName).toBe('M_Title_of_the_App') + }) +}) diff --git a/cli/src/lib/shell/index.js b/cli/src/lib/shell/index.js index e2c737427..595913cdd 100644 --- a/cli/src/lib/shell/index.js +++ b/cli/src/lib/shell/index.js @@ -1,4 +1,5 @@ const { exec } = require('@dhis2/cli-helpers-engine') +const formatAppAuthName = require('../formatAppAuthName') const { getPWAEnvVars } = require('../pwa') const bootstrap = require('./bootstrap') const getEnv = require('./env') @@ -7,6 +8,8 @@ module.exports = ({ config, paths }) => { const baseEnvVars = { name: config.title, version: config.version, + auth_name: formatAppAuthName({ config }), + legacy_auth_name: formatAppAuthName({ config, legacy: true }), } return { diff --git a/examples/pwa-app/d2.config.js b/examples/pwa-app/d2.config.js index a088cc478..b49ee508f 100644 --- a/examples/pwa-app/d2.config.js +++ b/examples/pwa-app/d2.config.js @@ -1,5 +1,7 @@ const config = { type: 'app', + name: 'pwa-example', + title: 'PWA Example', pwa: { enabled: true,