diff --git a/docker-compose.development.yml b/docker-compose.development.yml
index ac492a69e..8f2162085 100644
--- a/docker-compose.development.yml
+++ b/docker-compose.development.yml
@@ -50,12 +50,6 @@ services:
links:
- api
- watchtower:
- image: containrrr/watchtower
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock
- command: --interval 1800
-
volumes:
mongodb_data:
caddy_data:
diff --git a/docker-compose.production.yml b/docker-compose.production.yml
index 0bd71b721..d5307502e 100644
--- a/docker-compose.production.yml
+++ b/docker-compose.production.yml
@@ -50,12 +50,6 @@ services:
links:
- api
- watchtower:
- image: containrrr/watchtower
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock
- command: --interval 43200
-
volumes:
mongodb_data:
caddy_data:
diff --git a/packages/frontend/.env.template b/packages/frontend/.env.template
index b53f984bf..acfec9f84 100644
--- a/packages/frontend/.env.template
+++ b/packages/frontend/.env.template
@@ -10,3 +10,10 @@ REACT_APP_SERVER_URL=https://localhost
# Port number used when running frontend for development, outside of Docker
PORT=3000
+
+# App version from package.json
+REACT_APP_VERSION=$npm_package_version
+
+# Github repo info
+REACT_APP_GITHUB_REPO_OWNER=commons-stack
+REACT_APP_GITHUB_REPO_NAME=praise
\ No newline at end of file
diff --git a/packages/frontend/src/layouts/AuthenticatedLayout.tsx b/packages/frontend/src/layouts/AuthenticatedLayout.tsx
index 3ff448795..b738b2eb1 100644
--- a/packages/frontend/src/layouts/AuthenticatedLayout.tsx
+++ b/packages/frontend/src/layouts/AuthenticatedLayout.tsx
@@ -7,14 +7,18 @@ import { useRecoilValue } from 'recoil';
import { SingleSetting, useAllSettingsQuery } from '@/model/settings';
import { useAllPeriodsQuery } from '@/model/periods';
import { useAllUsersQuery } from '@/model/users';
-import { ActiveUserRoles } from '@/model/auth';
+import { ActiveUserRoles, HasRole, ROLE_ADMIN } from '@/model/auth';
import { Nav } from '@/navigation/Nav';
import { AuthenticatedRoutes } from '@/navigation/AuthenticatedRoutes';
+import { usePraiseAppVersion } from '@/model/app';
export const AuthenticatedLayout = (): JSX.Element | null => {
const [sidebarOpen, setSidebarOpen] = useState(false);
const siteNameSetting = useRecoilValue(SingleSetting('NAME'));
const activeUserRoles = useRecoilValue(ActiveUserRoles);
+ const isAdmin = useRecoilValue(HasRole(ROLE_ADMIN));
+ const appVersion = usePraiseAppVersion();
+
useAllPeriodsQuery();
useAllSettingsQuery();
useAllUsersQuery();
@@ -83,6 +87,27 @@ export const AuthenticatedLayout = (): JSX.Element | null => {
+ {isAdmin && appVersion.newVersionAvailable && (
+
+
+ 🎉 There is a new version of the Praise out! You are running{' '}
+ {appVersion.current}, latest version is {appVersion.latest}.{' '}
+
+ Release notes
+
+
+
+ )}
+
+
+ {appVersion.current}
+
+
{/* Static sidebar for desktop */}
{/* Sidebar component, swap this element with another sidebar if you like */}
diff --git a/packages/frontend/src/model/api.ts b/packages/frontend/src/model/api.ts
index 7acc0bab8..4b5012a05 100644
--- a/packages/frontend/src/model/api.ts
+++ b/packages/frontend/src/model/api.ts
@@ -8,7 +8,7 @@ import {
} from 'recoil';
import { makeApiAuthClient } from '../utils/api';
-type RequestParams = {
+export type RequestParams = {
[key: string]: SerializableParam;
url: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
diff --git a/packages/frontend/src/model/app.ts b/packages/frontend/src/model/app.ts
new file mode 100644
index 000000000..8e2a578f9
--- /dev/null
+++ b/packages/frontend/src/model/app.ts
@@ -0,0 +1,44 @@
+import { AxiosResponse } from 'axios';
+import { selector, useRecoilValue } from 'recoil';
+import { isResponseOk } from './api';
+import { ExternalGet } from './axios';
+
+export interface GithubResponse {
+ name: string;
+}
+
+export const GithubVersionQuery = selector({
+ key: 'GithubVersionQuery',
+ get: ({ get }): AxiosResponse
=> {
+ const repoOwner = process.env.REACT_APP_GITHUB_REPO_OWNER;
+ const repoName = process.env.REACT_APP_GITHUB_REPO_NAME;
+
+ return get(
+ ExternalGet({
+ url: `https://api.github.com/repos/${repoOwner}/${repoName}/releases/latest`,
+ })
+ ) as AxiosResponse;
+ },
+});
+
+interface PraiseAppVersion {
+ current: string | undefined;
+ latest: string | undefined;
+ newVersionAvailable: boolean;
+}
+
+export const usePraiseAppVersion = (): PraiseAppVersion => {
+ const appVersion: PraiseAppVersion = {
+ current: process.env.REACT_APP_VERSION,
+ latest: undefined,
+ newVersionAvailable: false,
+ };
+
+ const response = useRecoilValue(GithubVersionQuery);
+ if (isResponseOk(response)) {
+ appVersion.latest = (response.data as GithubResponse).name.substring(1);
+ appVersion.newVersionAvailable = appVersion.latest !== appVersion.current;
+ }
+
+ return appVersion;
+};
diff --git a/packages/frontend/src/model/axios.ts b/packages/frontend/src/model/axios.ts
new file mode 100644
index 000000000..e73113ae1
--- /dev/null
+++ b/packages/frontend/src/model/axios.ts
@@ -0,0 +1,22 @@
+import { AxiosResponse } from 'axios';
+import { selectorFamily } from 'recoil';
+import { makeClient } from '@/utils/axios';
+import { RequestParams } from './api';
+
+/**
+ * External GET request
+ */
+export const ExternalGet = selectorFamily<
+ AxiosResponse,
+ RequestParams
+>({
+ key: 'ExternalGet',
+ get: (params: RequestParams) => async (): Promise> => {
+ const { config, url } = params;
+
+ const client = makeClient();
+ const response = await client.get(url, config);
+
+ return response;
+ },
+});
diff --git a/packages/frontend/src/styles/globals.css b/packages/frontend/src/styles/globals.css
index 54ecc1025..fdafd6fb8 100644
--- a/packages/frontend/src/styles/globals.css
+++ b/packages/frontend/src/styles/globals.css
@@ -11,7 +11,7 @@
@apply dark:border-slate-700;
}
#root {
- @apply min-h-screen text-sm text-warm-gray-800 dark:bg-slate-800 dark:text-white bg-warm-gray-300;
+ @apply min-h-screen text-sm text-warm-gray-800 dark:bg-slate-800 dark:text-white bg-warm-gray-200;
}
h2 {
diff --git a/packages/frontend/src/utils/api.ts b/packages/frontend/src/utils/api.ts
index 644cccb90..95269207a 100644
--- a/packages/frontend/src/utils/api.ts
+++ b/packages/frontend/src/utils/api.ts
@@ -1,9 +1,9 @@
import axios, { AxiosError, AxiosInstance } from 'axios';
import createAuthRefreshInterceptor from 'axios-auth-refresh';
-import { toast } from 'react-hot-toast';
import { getRecoil } from 'recoil-nexus';
import { ActiveTokenSet } from '@/model/auth';
import { requestApiAuthRefresh } from './auth';
+import { handleErrors } from './axios';
/**
* Attempt to refresh auth token and retry request
@@ -19,32 +19,6 @@ const refreshAuthTokenSet = async (err: AxiosError): Promise => {
] = `Bearer ${tokenSet.accessToken}`;
};
-/**
- * Handle error responses (excluding initial 401 response)
- *
- * @param err
- */
-const handleErrors = (err: AxiosError): void => {
- // Any HTTP Code which is not 2xx will be considered as error
- const statusCode = err?.response?.status;
-
- if (err?.request && !err?.response) {
- toast.error('Server did not respond');
- } else if (statusCode === 404) {
- window.location.href = '/404';
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- //@ts-ignore
- } else if ([403, 400].includes(statusCode) && err?.response?.data?.message) {
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- //@ts-ignore
- toast.error(err.response.data.message);
- } else if (statusCode === 401) {
- window.location.href = '/';
- } else {
- toast.error('Unknown Error');
- }
-};
-
/**
* We assume the API to be running on the same domain in production currently.
* Why? The frontend is built as a static website and cannot easily accept
diff --git a/packages/frontend/src/utils/axios.ts b/packages/frontend/src/utils/axios.ts
new file mode 100644
index 000000000..f6b859297
--- /dev/null
+++ b/packages/frontend/src/utils/axios.ts
@@ -0,0 +1,44 @@
+import axios, { AxiosError, AxiosInstance } from 'axios';
+import { toast } from 'react-hot-toast';
+
+/**
+ * Handle error responses (excluding initial 401 response)
+ *
+ * @param err
+ */
+export const handleErrors = (err: AxiosError): void => {
+ // Any HTTP Code which is not 2xx will be considered as error
+ const statusCode = err?.response?.status;
+
+ if (err?.request && !err?.response) {
+ toast.error('Server did not respond');
+ } else if (statusCode === 404) {
+ window.location.href = '/404';
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ //@ts-ignore
+ } else if ([403, 400].includes(statusCode) && err?.response?.data?.message) {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ //@ts-ignore
+ toast.error(err.response.data.message);
+ } else if (statusCode === 401) {
+ window.location.href = '/';
+ } else {
+ toast.error('Unknown Error');
+ }
+};
+
+/**
+ * Client for external requests.
+ * @returns
+ */
+export const makeClient = (): AxiosInstance => {
+ const client = axios.create();
+
+ client.interceptors.response.use(
+ (res) => res,
+ (err) => {
+ return handleErrors(err);
+ }
+ );
+ return client;
+};