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,