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; +};