diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml new file mode 100644 index 000000000..dfd3bab25 --- /dev/null +++ b/docker/docker-compose.prod.yml @@ -0,0 +1,46 @@ +version: "3" +services: + db: + image: postgres:13.9 + container_name: phpreport-db + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + environment: + POSTGRES_PASSWORD: phpreport + POSTGRES_USER: phpreport + POSTGRES_DB: phpreport + phpreport-app: + build: + context: ../ + dockerfile: docker/dev.app.Dockerfile + container_name: phpreport-app + ports: + - "8000:8000" + depends_on: + - db + api: + build: + context: ../ + dockerfile: docker/prod.api.Dockerfile + container_name: phpreport-api + env_file: + - ../.env + ports: + - "8555:8555" + depends_on: + - db + frontend: + build: + context: ../ + dockerfile: docker/prod.frontend.Dockerfile + container_name: phpreport-frontend + env_file: + - ../frontend/.env.local + ports: + - "5173:3000" + depends_on: + - api +volumes: + pgdata: diff --git a/docker/prod.frontend.Dockerfile b/docker/prod.frontend.Dockerfile new file mode 100644 index 000000000..b0ea6a5de --- /dev/null +++ b/docker/prod.frontend.Dockerfile @@ -0,0 +1,46 @@ +FROM node:lts-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk add --no-cache libc6-compat + +WORKDIR /frontend +COPY frontend/package*.json /frontend + +RUN npm ci + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /frontend + +COPY --from=deps /frontend/node_modules ./node_modules +COPY frontend /frontend + +ENV NEXT_TELEMETRY_DISABLED 1 + +RUN npm run build + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /frontend + +ENV NODE_ENV production +ENV NEXT_TELEMETRY_DISABLED 1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# Set the correct permission for prerender cache +RUN mkdir .next +RUN chown nextjs:nodejs .next + +COPY --from=builder --chown=nextjs:nodejs /frontend/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /frontend/.next/static ./.next/static + +USER nextjs + +EXPOSE 5173 + +CMD ["node", "server.js"] + diff --git a/frontend/next.config.js b/frontend/next.config.js index 1333f0fd2..f6e9133f1 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -1,5 +1,6 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + output: 'standalone', async redirects() { return [ { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cce1b8a7d..84754abb0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,9 +14,8 @@ "@mui/joy": "^5.0.0-beta.20", "@tanstack/react-query": "^4.35.3", "@testing-library/user-event": "^14.5.1", - "@types/node": "20.6.0", - "@types/react": "18.2.21", - "@types/react-dom": "18.2.7", + "@types/react": "18.2.23", + "@types/react-dom": "18.2.8", "date-fns": "^2.30.0", "eslint": "8.49.0", "eslint-config-next": "^14.0.4", @@ -2267,7 +2266,8 @@ "node_modules/@types/node": { "version": "20.6.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.6.0.tgz", - "integrity": "sha512-najjVq5KN2vsH2U/xyh2opaSEz6cZMR2SetLIlxlj08nOcmPOemJmUK2o4kUzfLqfrWE0PIrNeE16XhYDd3nqg==" + "integrity": "sha512-najjVq5KN2vsH2U/xyh2opaSEz6cZMR2SetLIlxlj08nOcmPOemJmUK2o4kUzfLqfrWE0PIrNeE16XhYDd3nqg==", + "dev": true }, "node_modules/@types/parse-json": { "version": "4.0.0", @@ -2280,9 +2280,9 @@ "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" }, "node_modules/@types/react": { - "version": "18.2.21", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.21.tgz", - "integrity": "sha512-neFKG/sBAwGxHgXiIxnbm3/AAVQ/cMRS93hvBpg8xYRbeQSPVABp9U2bRnPf0iI4+Ucdv3plSxKK+3CW2ENJxA==", + "version": "18.2.23", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.23.tgz", + "integrity": "sha512-qHLW6n1q2+7KyBEYnrZpcsAmU/iiCh9WGCKgXvMxx89+TYdJWRjZohVIo9XTcoLhfX3+/hP0Pbulu3bCZQ9PSA==", "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -2290,9 +2290,9 @@ } }, "node_modules/@types/react-dom": { - "version": "18.2.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.7.tgz", - "integrity": "sha512-GRaAEriuT4zp9N4p1i8BDBYmEyfo+xQ3yHjJU4eiK5NDa1RmUZG+unZABUTK4/Ox/M+GaHwb6Ow8rUITrtjszA==", + "version": "18.2.8", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.8.tgz", + "integrity": "sha512-bAIvO5lN/U8sPGvs1Xm61rlRHHaq5rp5N3kp9C+NJ/Q41P8iqjkXSu0+/qu8POsjH9pNWb0OYabFez7taP7omw==", "dependencies": { "@types/react": "*" } @@ -10078,7 +10078,8 @@ "@types/node": { "version": "20.6.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.6.0.tgz", - "integrity": "sha512-najjVq5KN2vsH2U/xyh2opaSEz6cZMR2SetLIlxlj08nOcmPOemJmUK2o4kUzfLqfrWE0PIrNeE16XhYDd3nqg==" + "integrity": "sha512-najjVq5KN2vsH2U/xyh2opaSEz6cZMR2SetLIlxlj08nOcmPOemJmUK2o4kUzfLqfrWE0PIrNeE16XhYDd3nqg==", + "dev": true }, "@types/parse-json": { "version": "4.0.0", @@ -10091,9 +10092,9 @@ "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" }, "@types/react": { - "version": "18.2.21", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.21.tgz", - "integrity": "sha512-neFKG/sBAwGxHgXiIxnbm3/AAVQ/cMRS93hvBpg8xYRbeQSPVABp9U2bRnPf0iI4+Ucdv3plSxKK+3CW2ENJxA==", + "version": "18.2.23", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.23.tgz", + "integrity": "sha512-qHLW6n1q2+7KyBEYnrZpcsAmU/iiCh9WGCKgXvMxx89+TYdJWRjZohVIo9XTcoLhfX3+/hP0Pbulu3bCZQ9PSA==", "requires": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -10101,9 +10102,9 @@ } }, "@types/react-dom": { - "version": "18.2.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.7.tgz", - "integrity": "sha512-GRaAEriuT4zp9N4p1i8BDBYmEyfo+xQ3yHjJU4eiK5NDa1RmUZG+unZABUTK4/Ox/M+GaHwb6Ow8rUITrtjszA==", + "version": "18.2.8", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.8.tgz", + "integrity": "sha512-bAIvO5lN/U8sPGvs1Xm61rlRHHaq5rp5N3kp9C+NJ/Q41P8iqjkXSu0+/qu8POsjH9pNWb0OYabFez7taP7omw==", "requires": { "@types/react": "*" } diff --git a/frontend/package.json b/frontend/package.json index f84f5bc38..665e1450a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,9 +17,8 @@ "@mui/joy": "^5.0.0-beta.20", "@tanstack/react-query": "^4.35.3", "@testing-library/user-event": "^14.5.1", - "@types/node": "20.6.0", - "@types/react": "18.2.21", - "@types/react-dom": "18.2.7", + "@types/react": "18.2.23", + "@types/react-dom": "18.2.8", "date-fns": "^2.30.0", "eslint": "8.49.0", "eslint-config-next": "^14.0.4", diff --git a/frontend/src/app/api/auth/[...nextauth]/authOptions.ts b/frontend/src/app/api/auth/[...nextauth]/authOptions.ts new file mode 100644 index 000000000..4c2370883 --- /dev/null +++ b/frontend/src/app/api/auth/[...nextauth]/authOptions.ts @@ -0,0 +1,101 @@ +import { NextAuthOptions } from 'next-auth' +import KeycloakProvider from 'next-auth/providers/keycloak' +import { fetchFactory } from '@/infra/lib/apiClient' +import { makeGetCurrentUser } from '@/infra/user/getCurrentUser' +import { JWT } from 'next-auth/jwt' + +/** + * Takes a token, and returns a new token with updated + * `accessToken` and `accessTokenExpires`. If an error occurs, + * returns the old token and an error property + */ +async function refreshAccessToken(token: JWT) { + try { + const url = `${process.env.OIDC_TOKEN_ENDPOINT}` + + const params = { + grant_type: 'refresh_token', + client_id: process.env.OIDC_CLIENT_ID!, + client_secret: process.env.OIDC_CLIENT_SECRET!, + refresh_token: token.refreshToken! + } + + const response = await fetch(url, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams(params), + method: 'POST' + }) + + const refreshedTokens = await response.json() + + if (!response.ok) { + throw refreshedTokens + } + + return { + ...token, + accessToken: refreshedTokens.access_token, + accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000, + refreshToken: refreshedTokens.refresh_token ?? token.refreshToken // Fall back to old refresh token + } + } catch (error) { + return { + ...token, + error: 'RefreshAccessTokenError' + } + } +} + +export const authOptions: NextAuthOptions = { + providers: [ + KeycloakProvider({ + clientId: process.env.OIDC_CLIENT_ID!, + clientSecret: process.env.OIDC_CLIENT_SECRET!, + issuer: process.env.OIDC_AUTHORITY + }) + ], + pages: { + error: '/auth/error' + }, + callbacks: { + async redirect({ baseUrl }) { + return baseUrl + }, + async session({ session, token }) { + session.accessToken = token.accessToken + session.user = { ...session.user, ...token.user } + session.accessTokenExpires = token.accessTokenExpires + session.refreshToken = token.refreshToken + + return session + }, + async jwt({ token, account, profile, trigger }) { + if (trigger === 'update' && Date.now() > token.accessTokenExpires!) { + const newToken = await refreshAccessToken(token) + + return newToken + } + + if (account && profile) { + token.accessToken = account.access_token + token.accessTokenExpires = account.expires_at * 1000 + token.refreshToken = account.refresh_token + token.id = profile.id + + const apiClient = fetchFactory({ baseURL: process.env.API_BASE!, token: token.accessToken }) + const getCurrentUser = makeGetCurrentUser(apiClient) + + + const user = await getCurrentUser() + + token.user = user + } + + return token + } + } + } + + diff --git a/frontend/src/app/api/auth/[...nextauth]/route.ts b/frontend/src/app/api/auth/[...nextauth]/route.ts index 553434800..7ba56c26a 100644 --- a/frontend/src/app/api/auth/[...nextauth]/route.ts +++ b/frontend/src/app/api/auth/[...nextauth]/route.ts @@ -1,101 +1,5 @@ -import NextAuth, { NextAuthOptions } from 'next-auth' -import KeycloakProvider from 'next-auth/providers/keycloak' -import { fetchFactory } from '@/infra/lib/apiClient' -import { makeGetCurrentUser } from '@/infra/user/getCurrentUser' -import { JWT } from 'next-auth/jwt' - -/** - * Takes a token, and returns a new token with updated - * `accessToken` and `accessTokenExpires`. If an error occurs, - * returns the old token and an error property - */ -async function refreshAccessToken(token: JWT) { - try { - const url = `${process.env.OIDC_TOKEN_ENDPOINT}` - - const params = { - grant_type: 'refresh_token', - client_id: process.env.OIDC_CLIENT_ID!, - client_secret: process.env.OIDC_CLIENT_SECRET!, - refresh_token: token.refreshToken! - } - - const response = await fetch(url, { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: new URLSearchParams(params), - method: 'POST' - }) - - const refreshedTokens = await response.json() - - if (!response.ok) { - throw refreshedTokens - } - - return { - ...token, - accessToken: refreshedTokens.access_token, - accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000, - refreshToken: refreshedTokens.refresh_token ?? token.refreshToken // Fall back to old refresh token - } - } catch (error) { - return { - ...token, - error: 'RefreshAccessTokenError' - } - } -} - -export const authOptions: NextAuthOptions = { - providers: [ - KeycloakProvider({ - clientId: process.env.OIDC_CLIENT_ID!, - clientSecret: process.env.OIDC_CLIENT_SECRET!, - issuer: process.env.OIDC_AUTHORITY - }) - ], - pages: { - error: '/auth/error' - }, - callbacks: { - async redirect({ baseUrl }) { - return baseUrl - }, - async session({ session, token }) { - session.accessToken = token.accessToken - session.user = { ...session.user, ...token.user } - session.accessTokenExpires = token.accessTokenExpires - session.refreshToken = token.refreshToken - - return session - }, - async jwt({ token, account, profile, trigger }) { - if (trigger === 'update' && Date.now() > token.accessTokenExpires!) { - const newToken = await refreshAccessToken(token) - - return newToken - } - - if (account && profile) { - token.accessToken = account.access_token - token.accessTokenExpires = account.expires_at * 1000 - token.refreshToken = account.refresh_token - token.id = profile.id - - const apiClient = fetchFactory({ baseURL: process.env.API_BASE!, token: token.accessToken }) - - const getCurrentUser = makeGetCurrentUser(apiClient) - const user = await getCurrentUser() - - token.user = user - } - - return token - } - } -} +import NextAuth from 'next-auth' +import { authOptions } from './authOptions' const handler = NextAuth(authOptions) diff --git a/frontend/src/app/auth/error/page.tsx b/frontend/src/app/auth/error/page.tsx index a2dad028e..b81478be1 100644 --- a/frontend/src/app/auth/error/page.tsx +++ b/frontend/src/app/auth/error/page.tsx @@ -1,14 +1,28 @@ 'use client' +import { useSearchParams } from 'next/navigation' -type ErrorProps = { - error: Error; -} -export default function Error({error}: ErrorProps) { +export default function Error(){ + const searchParams = useSearchParams() + const errorType = searchParams.get('error') ?? "1"; + let message = ""; + + switch(errorType){ + case "Configuration": + message = "There is a problem with the server configuration" + break; + case "AccessDenied": + message = "Access denied. You do not have permission to use the application." + break; + default: + message = "That was unexpected. Please contact an admin for assistance." + } return ( <>

Something went wrong

-

{error?.message || ""}

+

+ {message} +

) } diff --git a/frontend/src/app/planner/[slug]/page.tsx b/frontend/src/app/planner/[slug]/page.tsx index abb37a42e..621a95d9a 100644 --- a/frontend/src/app/planner/[slug]/page.tsx +++ b/frontend/src/app/planner/[slug]/page.tsx @@ -1,4 +1,4 @@ -import { Breadcrumbs, Chip, Link, Typography } from '@mui/joy' +import { Breadcrumbs, Link, Typography } from '@mui/joy' import { makeGetProject } from '@/infra/project/getProject' import { serverFetch } from '@/infra/lib/serverFetch' diff --git a/frontend/src/app/tasks/DayView.tsx b/frontend/src/app/tasks/DayView.tsx index 7ca57ca21..579e77dea 100644 --- a/frontend/src/app/tasks/DayView.tsx +++ b/frontend/src/app/tasks/DayView.tsx @@ -33,7 +33,9 @@ export const DayView = ({ projects, taskTypes, templates }: DayViewProps) => { }, columnGap: '30px', rowGap: '16px', - margin: '0 auto' + margin: '0 auto', + maxWidth: '1146px', + width: '100%' }} > diff --git a/frontend/src/app/tasks/actions/getTasksGroupedByDate.ts b/frontend/src/app/tasks/actions/getTasksGroupedByDate.ts index f3f5ae2df..6da554d22 100644 --- a/frontend/src/app/tasks/actions/getTasksGroupedByDate.ts +++ b/frontend/src/app/tasks/actions/getTasksGroupedByDate.ts @@ -1,6 +1,6 @@ import { makeGetTasks } from '@/infra/task/getTasks' import { serverFetch } from '@/infra/lib/serverFetch' -import { authOptions } from '@/app/api/auth/[...nextauth]/route' +import { authOptions } from '@/app/api/auth/[...nextauth]/authOptions' import { getServerSession } from 'next-auth' import { Task } from '@/domain/Task' diff --git a/frontend/src/app/tasks/components/SaveTemplateModal.tsx b/frontend/src/app/tasks/components/SaveTemplateModal.tsx index a41de004b..26100705f 100644 --- a/frontend/src/app/tasks/components/SaveTemplateModal.tsx +++ b/frontend/src/app/tasks/components/SaveTemplateModal.tsx @@ -34,15 +34,6 @@ export const SaveTemplateModal = ({ task }: SaveAsTemplateProps) => { { - const template = await createTemplate(task, formData) - if (template.error) { - showError(template.error) - } else { - showSuccess('Template created') - } - closeModal() - }} sx={{ minWidth: 300, minHeight: 200, @@ -54,6 +45,15 @@ export const SaveTemplateModal = ({ task }: SaveAsTemplateProps) => { gap: '8px' }} > +
{ + const template = await createTemplate(task, formData) + if (template.error) { + showError(template.error) + } else { + showSuccess('Template created') + } + closeModal() + }}> { Save template +
diff --git a/frontend/src/app/tasks/components/WorkSummaryPanel.tsx b/frontend/src/app/tasks/components/WorkSummaryPanel.tsx index faa0b1021..2f2e6edfe 100644 --- a/frontend/src/app/tasks/components/WorkSummaryPanel.tsx +++ b/frontend/src/app/tasks/components/WorkSummaryPanel.tsx @@ -1,6 +1,5 @@ 'use client' import { useState } from 'react'; -import Box from '@mui/joy/Box'; import Stack from '@mui/joy/Stack'; import AccordionGroup from '@mui/joy/AccordionGroup'; import Accordion from '@mui/joy/Accordion' @@ -80,7 +79,7 @@ export const WorkSummaryPanel = ({contentSidebarExpanded} : WorkSummaryProps) => Hours by project - +
diff --git a/frontend/src/app/tasks/hooks/useWorkSummary.ts b/frontend/src/app/tasks/hooks/useWorkSummary.ts index 11ff835fe..49d752847 100644 --- a/frontend/src/app/tasks/hooks/useWorkSummary.ts +++ b/frontend/src/app/tasks/hooks/useWorkSummary.ts @@ -10,6 +10,7 @@ export const useGetWorkSummary = () => { const getWorkSummary = makeGetWorkSummary(apiClient) const { data } = useQuery({ + // @ts-expect-error: Not sure why this fails queryKey: ['workSummary', userId], queryFn: () => getWorkSummary(), initialData: [] diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 4c11f651a..0aa24bfa0 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -3,7 +3,7 @@ import { makeGetProjects } from '@/infra/project/getProjects' import { makeGetTaskTypes } from '@/infra/taskType/getTaskTypes' import { makeGetTemplates } from '@/infra/template/getTemplates' import { serverFetch } from '@/infra/lib/serverFetch' -import { authOptions } from '@/app/api/auth/[...nextauth]/route' +import { authOptions } from '@/app/api/auth/[...nextauth]/authOptions' import { getServerSession } from 'next-auth' const getPageData = async () => { diff --git a/frontend/src/infra/lib/serverFetch.ts b/frontend/src/infra/lib/serverFetch.ts index 01343b0c9..310ed73d9 100644 --- a/frontend/src/infra/lib/serverFetch.ts +++ b/frontend/src/infra/lib/serverFetch.ts @@ -1,4 +1,4 @@ -import { authOptions } from '@/app/api/auth/[...nextauth]/route' +import { authOptions } from '@/app/api/auth/[...nextauth]/authOptions' import { getServerSession } from 'next-auth' import { fetchFactory } from './apiClient' diff --git a/frontend/src/ui/Input/Input.tsx b/frontend/src/ui/Input/Input.tsx index 8fc1b6b13..7720423aa 100644 --- a/frontend/src/ui/Input/Input.tsx +++ b/frontend/src/ui/Input/Input.tsx @@ -1,7 +1,6 @@ import * as React from 'react' import { StyledLabel, StyledInput } from './styles' import JoyInput from '@mui/joy/Input' -import { Box } from '@mui/joy' import { SxProps } from '@mui/joy/styles/types' type InputProps = React.InputHTMLAttributes & { diff --git a/frontend/src/ui/SubmitButton/SubmitButton.tsx b/frontend/src/ui/SubmitButton/SubmitButton.tsx index 337d3d39a..7a3f60418 100644 --- a/frontend/src/ui/SubmitButton/SubmitButton.tsx +++ b/frontend/src/ui/SubmitButton/SubmitButton.tsx @@ -1,4 +1,4 @@ -import { useFormStatus } from 'react-dom' +import { experimental_useFormStatus as useFormStatus } from 'react-dom' import { Button } from '@mui/joy' import { PropsWithChildren } from 'react' import { SxProps } from '@mui/joy/styles/types'
Project