├── public ├── favicon.ico └── release.png ├── .husky └── pre-commit ├── .dockerignore ├── postcss.config.js ├── types ├── RuntimeAppSettings.ts ├── Page.ts └── ProblemDetails.ts ├── functions ├── BackendApiUrl.ts ├── DefaultApiRequestHeader.ts ├── AppSettings.ts ├── useSwrFetcherWithAccessToken.ts ├── AuthorizationContext.ts ├── tryFetchJson.ts └── useFetchWithAccessToken.ts ├── next-env.d.ts ├── .vscode ├── settings.json └── launch.json ├── appsettings.js ├── styles └── globals.css ├── .env.development ├── components ├── Title.tsx ├── SessionErrorHandler.tsx ├── Authorize.tsx ├── Buttons.tsx └── DefautLayout.tsx ├── pages ├── index.tsx ├── api │ ├── end-session.ts │ ├── be │ │ └── [...apiGateway].ts │ └── auth │ │ └── [...nextauth].ts ├── _app.tsx └── dashboard │ └── index.tsx ├── tailwind.config.js ├── .eslintrc.json ├── .github ├── dependabot.yml └── workflows │ └── docker-image.yml ├── LICENSE ├── Dockerfile ├── package.json ├── next.config.js ├── .gitignore ├── tsconfig.json └── README.md /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/accelist/nextjs-starter/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/release.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/accelist/nextjs-starter/HEAD/public/release.png -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run pre-commit-check 5 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .github 3 | .husky 4 | .next 5 | .vscode 6 | node_modules 7 | 8 | .env.* 9 | .env 10 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /types/RuntimeAppSettings.ts: -------------------------------------------------------------------------------- 1 | import type ServerRuntimeConfig from '../appsettings'; 2 | 3 | export type RuntimeAppSettings = typeof ServerRuntimeConfig; 4 | -------------------------------------------------------------------------------- /functions/BackendApiUrl.ts: -------------------------------------------------------------------------------- 1 | // Request will be proxied via /api/be/[...apiGateway].ts 2 | const baseUrl = '/api/be'; 3 | 4 | export const BackendApiUrl = { 5 | test: baseUrl + '/api/test' 6 | } 7 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "*.css": "tailwindcss" 4 | }, 5 | "editor.quickSuggestions": { 6 | "strings": "on" 7 | }, 8 | "typescript.tsdk": "./node_modules/typescript/lib" 9 | } -------------------------------------------------------------------------------- /appsettings.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | backendApiHost: process.env['BACKEND_API_HOST'] ?? '', 3 | oidcIssuer: process.env['OIDC_ISSUER'] ?? '', 4 | oidcClientId: process.env['OIDC_CLIENT_ID'] ?? '', 5 | oidcScope: process.env['OIDC_SCOPE'] ?? '', 6 | }; 7 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @import 'nprogress/nprogress.css'; 6 | @import '@fortawesome/fontawesome-svg-core/styles.css'; 7 | 8 | #nprogress .bar { 9 | background: limegreen !important; 10 | } 11 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | NEXTAUTH_URL=http://localhost:3000 2 | NEXTAUTH_SECRET=e01b7895a403fa7364061b2f01a650fc 3 | BACKEND_API_HOST=https://demo.duendesoftware.com 4 | OIDC_ISSUER=https://demo.duendesoftware.com 5 | OIDC_CLIENT_ID=interactive.public.short 6 | OIDC_SCOPE=openid profile email api offline_access 7 | -------------------------------------------------------------------------------- /functions/DefaultApiRequestHeader.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Gets the HTTP header for disabling request caching to Web API. 3 | */ 4 | export const DefaultApiRequestHeader = { 5 | 'Content-Type': 'application/json', 6 | 'Cache-Control': 'no-cache', 7 | 'Pragma': 'no-cache', 8 | 'Expires': '0', 9 | }; 10 | -------------------------------------------------------------------------------- /components/Title.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Head from 'next/head'; 3 | 4 | export const Title: React.FC<{ 5 | children: string | number 6 | }> = ({ children }) => { 7 | return ( 8 | 9 | {children.toString() + '- Accelist Next.js Starter'} 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /types/Page.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /** 4 | * Alias of `React.FunctionComponent` but allows attaching render function in the `layout` field. 5 | * https://nextjs.org/docs/basic-features/layouts#with-typescript 6 | */ 7 | export interface Page

> extends React.FunctionComponent

{ 8 | layout?: (page: React.ReactElement) => React.ReactNode 9 | } 10 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { WithDefaultLayout } from '../components/DefautLayout'; 2 | import { Title } from '../components/Title'; 3 | import { Page } from '../types/Page'; 4 | 5 | const IndexPage: Page = () => { 6 | return ( 7 |

8 | Home 9 | Hello World! 10 |
11 | ); 12 | } 13 | 14 | IndexPage.layout = WithDefaultLayout; 15 | export default IndexPage; 16 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./pages/**/*.{js,ts,jsx,tsx}", 5 | "./components/**/*.{js,ts,jsx,tsx}", 6 | "./app/**/*.{js,ts,jsx,tsx}", 7 | ], 8 | theme: { 9 | extend: { 10 | colors: { 11 | topbar: '#001529', 12 | }, 13 | }, 14 | }, 15 | plugins: [], 16 | } 17 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "extends": [ 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/eslint-recommended", 7 | "plugin:@typescript-eslint/recommended", 8 | "next/core-web-vitals" 9 | ], 10 | "rules": { 11 | "react/prop-types": 0, 12 | "@next/next/no-img-element": 0 13 | }, 14 | "ignorePatterns": [ 15 | ".next", 16 | "next.config.js", 17 | "public", 18 | "postcss.config.js", 19 | "tailwind.config.js" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Ryan Elian 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /pages/api/end-session.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next' 2 | import { Issuer } from 'openid-client'; 3 | import { AppSettings } from '../../functions/AppSettings'; 4 | 5 | export default async function endSession(_req: NextApiRequest, res: NextApiResponse) { 6 | // https://openid.net/specs/openid-connect-session-1_0-17.html#RPLogout 7 | // if redirection to Next.js is required, provide id_token_hint and post_logout_redirect_uri 8 | const discovery = await Issuer.discover(AppSettings.current.oidcIssuer); 9 | res.redirect(302, discovery.metadata.end_session_endpoint ?? '/'); 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "name": "Next.js: Debug Client-Side", 5 | "type": "msedge", 6 | "request": "launch", 7 | "url": "http://localhost:3000" 8 | }, 9 | { 10 | "name": "Next.js: Debug Full-Stack", 11 | "type": "node-terminal", 12 | "request": "launch", 13 | "command": "npm run debug", 14 | "serverReadyAction": { 15 | "pattern": "started server on .+, url: (https?://.+)", 16 | "uriFormat": "%s", 17 | "action": "debugWithEdge" 18 | } 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /functions/AppSettings.ts: -------------------------------------------------------------------------------- 1 | import getConfig from 'next/config'; 2 | import type { RuntimeAppSettings } from '../types/RuntimeAppSettings'; 3 | 4 | /** 5 | * Returns runtime application Environment Variables readable only from server-side code. 6 | * Environment variables read from the machine should be set in `appsettings.js` 7 | */ 8 | export const AppSettings = { 9 | get current(): RuntimeAppSettings { 10 | const config = getConfig(); 11 | return { ...config.serverRuntimeConfig }; 12 | } 13 | } 14 | 15 | // Configure environment variables read in the `appsettings.js` file 16 | // During development, use `.env.development` or `.env.local` to add environment variables 17 | // During production (running in a container), use ONLY machine environment variables (docker -e) 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Base Node.js LTS image with dependencies pre-installed 2 | FROM node:lts-slim AS base 3 | # Update npm to latest to avoid ERR_SOCKET_TIMEOUT errors! 4 | RUN npm install -g npm 5 | COPY ./package.json /app/package.json 6 | COPY ./package-lock.json /app/package-lock.json 7 | WORKDIR /app 8 | RUN npm ci 9 | RUN npx next telemetry disable 10 | 11 | # Build image 12 | FROM base AS build 13 | COPY . /app 14 | RUN npm run build 15 | 16 | # Production image 17 | FROM base AS app 18 | COPY --from=build /app/next.config.js /app/next.config.js 19 | COPY --from=build /app/appsettings.js /app/appsettings.js 20 | COPY --from=build /app/public /app/public 21 | COPY --from=build /app/.next /app/.next 22 | ENV NODE_ENV production 23 | RUN npm prune --production 24 | EXPOSE 80 25 | CMD ["npm", "run", "start"] 26 | -------------------------------------------------------------------------------- /components/SessionErrorHandler.tsx: -------------------------------------------------------------------------------- 1 | import { notification } from "antd"; 2 | import { signIn, useSession } from "next-auth/react"; 3 | import nProgress from "nprogress"; 4 | import React, { useEffect } from "react"; 5 | 6 | export const SessionErrorHandler: React.FC<{ children: React.ReactNode }> = ({ children }) => { 7 | const { data: session } = useSession(); 8 | 9 | useEffect(() => { 10 | // this error bubbles up from [...nextauth].ts, refreshAccessToken() 11 | if (session?.['error'] === "RefreshAccessTokenError") { 12 | notification['warning']({ 13 | message: 'Login Required', 14 | description: 'Your session has ended. Redirecting to login page...' 15 | }); 16 | nProgress.start(); 17 | signIn('oidc'); // Force sign in to hopefully resolve error 18 | } 19 | }, [session]); 20 | 21 | return ( 22 | <> 23 | {children} 24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /functions/useSwrFetcherWithAccessToken.ts: -------------------------------------------------------------------------------- 1 | import { useFetchWithAccessToken } from "./useFetchWithAccessToken"; 2 | 3 | /** 4 | * This hook can be used inside `` component to add Access Token to request headers. 5 | * Caching is disabled via `DefaultApiRequestHeader` object. 6 | * @returns SWR Fetcher 7 | */ 8 | export function useSwrFetcherWithAccessToken() { 9 | const { fetchGET } = useFetchWithAccessToken(); 10 | 11 | return async (url: string) => { 12 | // reasoning: SWR fetcher requires `any` data type promise result 13 | // https://swr.vercel.app/docs/data-fetching#fetch 14 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 15 | const { data, error, problem } = await fetchGET(url); 16 | 17 | if (error) { 18 | throw error; 19 | } 20 | 21 | // throw when status code is not 2XX 22 | // https://swr.vercel.app/docs/error-handling#status-code-and-error-object 23 | if (problem) { 24 | throw problem; 25 | } 26 | return data; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | push: 5 | branches: [ master, main ] 6 | tags: 7 | - 'v*' 8 | pull_request: 9 | branches: [ master, main ] 10 | 11 | env: 12 | REGISTRY: ghcr.io 13 | IMAGE_NAME: ${{ github.repository }} 14 | 15 | jobs: 16 | 17 | build: 18 | 19 | runs-on: ubuntu-latest 20 | 21 | permissions: 22 | contents: read 23 | packages: write 24 | 25 | steps: 26 | - uses: actions/checkout@v2 27 | 28 | - name: Log in to the Container registry 29 | if: github.event_name != 'pull_request' 30 | uses: docker/login-action@v1 31 | with: 32 | registry: ${{ env.REGISTRY }} 33 | username: ${{ github.actor }} 34 | password: ${{ secrets.GITHUB_TOKEN }} 35 | 36 | - name: Extract metadata (tags, labels) for Docker 37 | id: meta 38 | uses: docker/metadata-action@v3 39 | with: 40 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 41 | tags: | 42 | type=ref,event=branch 43 | type=ref,event=pr 44 | type=semver,pattern={{version}} 45 | type=semver,pattern={{major}}.{{minor}} 46 | 47 | - name: Build and push Docker image 48 | uses: docker/build-push-action@v2 49 | with: 50 | context: . 51 | push: ${{ github.event_name != 'pull_request' }} 52 | tags: ${{ steps.meta.outputs.tags }} 53 | labels: ${{ steps.meta.outputs.labels }} 54 | -------------------------------------------------------------------------------- /functions/AuthorizationContext.ts: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | 3 | export interface UserInfoAddress { 4 | // formatted: string; 5 | street_address: string; 6 | locality: string; 7 | region: string; 8 | postal_code: string; 9 | country: string; 10 | } 11 | 12 | export interface UserInfo { 13 | id: string; 14 | name: string; 15 | // given_name: string; 16 | // family_name: string; 17 | // middle_name: string; 18 | // nickname: string; 19 | // preferred_username: string; 20 | // profile: string; 21 | // picture: string; 22 | // website: string; 23 | email: string; 24 | // email_verified: boolean; 25 | // gender: string; 26 | // birthdate: string; 27 | // zoneinfo: string; 28 | // locale: string; 29 | // phone_number: string; 30 | // phone_number_verified: boolean; 31 | // address: UserInfoAddress; 32 | // updated_at: number; 33 | role: string[] 34 | } 35 | 36 | export interface AuthorizationContextData { 37 | accessToken: string; 38 | user: UserInfo; 39 | isAuthenticated: boolean; 40 | } 41 | 42 | export const AuthorizationContext = React.createContext({ 43 | accessToken: '', 44 | user: { 45 | id: '', 46 | name: '', 47 | email: '', 48 | role: [] 49 | }, 50 | isAuthenticated: false 51 | }); 52 | 53 | export function useAuthorizationContext() { 54 | return useContext(AuthorizationContext); 55 | } 56 | -------------------------------------------------------------------------------- /components/Authorize.tsx: -------------------------------------------------------------------------------- 1 | import { Spin } from "antd"; 2 | import { useSession, signIn } from "next-auth/react"; 3 | import nProgress from "nprogress"; 4 | import React from "react"; 5 | import { AuthorizationContext, AuthorizationContextData, UserInfo } from "../functions/AuthorizationContext"; 6 | 7 | export const Authorize: React.FC<{ 8 | children: React.ReactNode; 9 | }> = ({ children }) => { 10 | const { data: session, status } = useSession({ 11 | required: true, 12 | onUnauthenticated() { 13 | nProgress.start(); 14 | signIn('oidc'); 15 | }, 16 | }); 17 | 18 | function getAccessToken(): string { 19 | const accessToken = session?.['accessToken']; 20 | if (typeof accessToken === 'string') { 21 | if (accessToken) { 22 | return accessToken; 23 | } 24 | } 25 | 26 | console.warn('Authorize: Access Token is empty!'); 27 | return ''; 28 | } 29 | 30 | if (status !== 'authenticated') { 31 | return ( 32 |
33 | 34 |
35 | ) 36 | } 37 | 38 | const ctx: AuthorizationContextData = { 39 | accessToken: getAccessToken(), 40 | user: session.user as UserInfo, 41 | isAuthenticated: true, 42 | }; 43 | 44 | return ( 45 | 46 | {children} 47 | 48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /functions/tryFetchJson.ts: -------------------------------------------------------------------------------- 1 | import { ProblemDetails } from "../types/ProblemDetails"; 2 | 3 | /** 4 | * Simplified wrapper of the Fetch API response object. 5 | */ 6 | export interface ResponseDetails { 7 | /** 8 | * JSON parsed object of the response body if `response.ok` 9 | */ 10 | data?: T; 11 | 12 | /** 13 | * RFC 7807 Problem Details JSON response if not `response.ok` or a `string` if the response is not JSON. 14 | */ 15 | problem?: ProblemDetails | string; 16 | 17 | /** 18 | * Exception thrown when performing the HTTP request. 19 | */ 20 | error?: unknown; 21 | } 22 | 23 | /** 24 | * Wraps the Fetch API inside a try-catch block and expects JSON response when ok 25 | * and RFC 7807 Problem Details JSON response when not ok. 26 | * @param url 27 | * @param init 28 | * @returns `data` when `response.ok`, `problem` when not `response.ok`, and `error` when exception 29 | */ 30 | export async function tryFetchJson(url: RequestInfo | URL, init: RequestInit): Promise> { 31 | try { 32 | const response = await fetch(url, init); 33 | if (response.ok) { 34 | const data: T = await response.json(); 35 | return { 36 | data: data 37 | }; 38 | } 39 | 40 | try { 41 | const problem: ProblemDetails = await response.json(); 42 | return { 43 | problem: problem 44 | }; 45 | } catch (problemNotJson) { 46 | const responseBody = await response.text(); 47 | return { 48 | problem: responseBody 49 | }; 50 | } 51 | } catch (err) { 52 | return { 53 | error: err 54 | }; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /components/Buttons.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /** 4 | * AButton component's props definitions. 5 | */ 6 | interface ButtonProps extends React.ButtonHTMLAttributes { 7 | text: string 8 | // Add your custom properties here. 9 | } 10 | 11 | /** 12 | * Default style for buttons using Tailwind CSS. 13 | */ 14 | const buttonStyle = "text-white font-bold py-2 px-4 rounded"; 15 | 16 | const defaultColor = "bg-gray-500 hover:bg-gray-700 "; 17 | const primaryColor = "bg-blue-500 hover:bg-blue-700 "; 18 | const cancelColor = "bg-red-500 hover:bg-red-700 "; 19 | 20 | const disabledButton = " opacity-50 cursor-not-allowed"; 21 | 22 | /** 23 | * Function for conditionally assigning the button's style. 24 | * @param buttonType Type for the button (reset | submit | cancel) 25 | * @param disabled The disabled flag for marking the button disabled state. (true | false). 26 | * @returns Named class name for the button component. 27 | */ 28 | function getButtonClassNames(buttonType?: string, disabled?: boolean): string { 29 | let className: string; 30 | if (buttonType === "reset") { 31 | className = cancelColor + buttonStyle + (disabled ? disabledButton : "") 32 | } 33 | else if (buttonType == "submit") { 34 | className = primaryColor + buttonStyle + (disabled ? disabledButton : "") 35 | } 36 | else { 37 | className = defaultColor + buttonStyle + (disabled ? disabledButton : "") 38 | } 39 | return className; 40 | } 41 | 42 | /** 43 | * AButton component definitions. 44 | */ 45 | export const AButton: React.FC = ( 46 | props 47 | ) => { 48 | return ( 49 | 52 | ) 53 | } -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { NextPage } from 'next'; 3 | import type { AppProps } from 'next/app'; 4 | import type { Session } from 'next-auth'; 5 | import Router from 'next/router'; 6 | import NProgress from 'nprogress'; 7 | import { SessionProvider } from 'next-auth/react'; 8 | import { SessionErrorHandler } from '../components/SessionErrorHandler'; 9 | 10 | // https://fontawesome.com/v5/docs/web/use-with/react#next-js 11 | import { config } from '@fortawesome/fontawesome-svg-core'; 12 | import '../styles/globals.css'; 13 | config.autoAddCss = false; 14 | 15 | type NextPageWithLayout = NextPage & { 16 | layout?: (page: React.ReactElement) => React.ReactNode; 17 | } 18 | 19 | type AppPropsWithLayout = AppProps<{ 20 | session?: Session; 21 | }> & { 22 | Component: NextPageWithLayout; 23 | } 24 | 25 | function CustomApp({ 26 | Component, 27 | pageProps: { session, ...pageProps } 28 | }: AppPropsWithLayout): JSX.Element { 29 | // https://nextjs.org/docs/basic-features/layouts#per-page-layouts 30 | const withLayout = Component.layout ?? (page => page); 31 | return ( 32 | // https://next-auth.js.org/getting-started/client#sessionprovider 33 | 35 | 36 | {withLayout()} 37 | 38 | 39 | ); 40 | } 41 | 42 | NProgress.configure({ 43 | showSpinner: false 44 | }); 45 | 46 | Router.events.on('routeChangeStart', NProgress.start); 47 | Router.events.on('routeChangeComplete', NProgress.done); 48 | Router.events.on('routeChangeError', NProgress.done); 49 | 50 | export default CustomApp; 51 | -------------------------------------------------------------------------------- /pages/api/be/[...apiGateway].ts: -------------------------------------------------------------------------------- 1 | import Proxy from 'http-proxy'; 2 | import type { NextApiRequest, NextApiResponse } from 'next'; 3 | import { AppSettings } from '../../../functions/AppSettings'; 4 | 5 | // Great way to avoid using CORS and making API calls from HTTPS pages to back-end HTTP servers 6 | // Recommendation for projects in Kubernetes cluster: set target to Service DNS name instead of public DNS name 7 | const server = Proxy.createProxyServer({ 8 | target: AppSettings.current.backendApiHost, 9 | // changeOrigin to support name-based virtual hosting 10 | changeOrigin: true, 11 | xfwd: true, 12 | // https://github.com/http-party/node-http-proxy#proxying-websockets 13 | ws: false, 14 | }); 15 | 16 | server.on('proxyReq', (proxyReq, req) => { 17 | // Proxy requests from /api/be/... to http://my-web-api.com/... 18 | const urlRewrite = req.url?.replace(new RegExp('^/api/be'), ''); 19 | if (urlRewrite) { 20 | proxyReq.path = urlRewrite; 21 | } else { 22 | proxyReq.path = '/'; 23 | } 24 | proxyReq.removeHeader('cookie'); 25 | // console.log(JSON.stringify(proxyReq.getHeaders(), null, 4)); 26 | console.log('API Proxy:', req.url, '-->', AppSettings.current.backendApiHost + urlRewrite); 27 | }); 28 | 29 | const apiGateway = async (req: NextApiRequest, res: NextApiResponse) => { 30 | const startTime = new Date().getTime(); 31 | 32 | server.web(req, res, {}, (err) => { 33 | if (err instanceof Error) { 34 | throw err; 35 | } 36 | 37 | throw new Error(`Failed to proxy request: '${req.url}'`); 38 | }); 39 | 40 | res.on('finish', () => { 41 | const endTime = new Date().getTime(); 42 | console.log(`API Proxy: Finished ${res.req.url} in ${endTime - startTime}ms `); 43 | }); 44 | } 45 | 46 | export default apiGateway; 47 | 48 | export const config = { 49 | api: { 50 | externalResolver: true, 51 | bodyParser: false 52 | }, 53 | } 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@accelist/nextjs-starter", 3 | "version": "0.1.0", 4 | "description": "Next.js project starter template for PT. Accelist Lentera Indonesia", 5 | "author": "Ryan Elian", 6 | "homepage": "https://github.com/accelist/nextjs-starter#readme", 7 | "bugs": "https://github.com/accelist/nextjs-starter/issues", 8 | "license": "Apache-2.0", 9 | "main": "index.js", 10 | "private": true, 11 | "scripts": { 12 | "test": "echo \"Error: no test specified\" && exit 1", 13 | "dev:nextjs": "next dev", 14 | "typescript": "tsc -w --pretty --preserveWatchOutput", 15 | "eslint": "esw -w --ext .js,.jsx,.ts,.tsx", 16 | "dev": "concurrently \"npm:prepare\" \"npm:typescript\" \"npm:eslint\" \"npm:dev:nextjs\"", 17 | "build": "next build", 18 | "start": "next start -p 80", 19 | "debug": "cross-env NODE_OPTIONS='--inspect' next dev", 20 | "prepare": "husky install", 21 | "pre-commit-check": "tsc && next lint" 22 | }, 23 | "devDependencies": { 24 | "@types/http-proxy": "^1.17.10", 25 | "@types/node": "^18.15.11", 26 | "@types/nprogress": "^0.2.0", 27 | "@types/react": "^18.0.33", 28 | "@typescript-eslint/eslint-plugin": "5.57.1", 29 | "@typescript-eslint/parser": "5.57.1", 30 | "autoprefixer": "10.4.14", 31 | "concurrently": "8.0.1", 32 | "cross-env": "7.0.3", 33 | "eslint": "8.38.0", 34 | "eslint-config-next": "13.3.0", 35 | "eslint-watch": "8.0.0", 36 | "husky": "8.0.3", 37 | "postcss": "8.4.21", 38 | "tailwindcss": "3.3.1", 39 | "typescript": "5.0.4" 40 | }, 41 | "dependencies": { 42 | "@fortawesome/fontawesome-svg-core": "6.4.0", 43 | "@fortawesome/free-solid-svg-icons": "6.4.0", 44 | "@fortawesome/react-fontawesome": "0.2.0", 45 | "@hookform/error-message": "2.0.1", 46 | "@hookform/resolvers": "3.0.1", 47 | "antd": "5.4.0", 48 | "dayjs": "1.11.7", 49 | "http-proxy": "1.18.1", 50 | "jotai": "2.0.3", 51 | "next": "13.3.0", 52 | "next-auth": "4.22.0", 53 | "nprogress": "0.2.0", 54 | "openid-client": "5.4.0", 55 | "react": "18.2.0", 56 | "react-dom": "18.2.0", 57 | "react-hook-form": "7.43.9", 58 | "swr": "2.1.2", 59 | "zod": "3.21.4" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | // https://securityheaders.com/ 2 | function getSecurityHeaders(isProd) { 3 | const headers = [ 4 | { 5 | key: 'X-Frame-Options', 6 | value: 'SAMEORIGIN' 7 | }, 8 | { 9 | key: 'X-Content-Type-Options', 10 | value: 'nosniff' 11 | }, 12 | { 13 | key: 'Referrer-Policy', 14 | value: 'origin-when-cross-origin' 15 | }, 16 | { 17 | // https://www.permissionspolicy.com/ 18 | key: 'Permissions-Policy', 19 | value: 'accelerometer=(), autoplay=(), camera=(), cross-origin-isolated=(), display-capture=(), document-domain=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=()' 20 | }, 21 | { 22 | key: 'X-XSS-Protection', 23 | value: '1; mode=block' 24 | } 25 | ]; 26 | 27 | if (isProd) { 28 | headers.push( 29 | { 30 | key: 'Strict-Transport-Security', 31 | value: 'max-age=31536000' 32 | }, 33 | { 34 | // https://report-uri.com/home/generate 35 | // https://csp-evaluator.withgoogle.com/ 36 | key: 'Content-Security-Policy', 37 | value: `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; font-src 'self'; object-src 'none'` 38 | } 39 | ); 40 | } 41 | 42 | return headers; 43 | } 44 | 45 | const isProd = process.env['NODE_ENV'] === 'production'; 46 | 47 | /** @type { import('next').NextConfig } */ 48 | const nextConfig = { 49 | // add environment variables accessible via AppSettings here: 50 | // visible only by server-side Next.js (secrets) 51 | // if accessing variables required in browser-side code, use getServerSideProps 52 | // https://nextjs.org/docs/basic-features/data-fetching/get-server-side-props 53 | serverRuntimeConfig: require('./appsettings'), 54 | productionBrowserSourceMaps: true, 55 | reactStrictMode: true, 56 | async headers() { 57 | return [ 58 | { 59 | // Apply these headers to all routes in your application. 60 | source: '/:path*', 61 | headers: getSecurityHeaders(isProd), 62 | }, 63 | ] 64 | }, 65 | }; 66 | 67 | module.exports = nextConfig; 68 | -------------------------------------------------------------------------------- /pages/dashboard/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import useSWR from 'swr'; 3 | import { WithDefaultLayout } from '../../components/DefautLayout'; 4 | import { Title } from '../../components/Title'; 5 | import { Page } from '../../types/Page'; 6 | import { Table } from 'antd'; 7 | import { BackendApiUrl } from '../../functions/BackendApiUrl'; 8 | import { Authorize } from '../../components/Authorize'; 9 | import { useSwrFetcherWithAccessToken } from '../../functions/useSwrFetcherWithAccessToken'; 10 | import type { ColumnsType } from 'antd/es/table'; 11 | 12 | interface DataItem { 13 | type: string; 14 | value: number; 15 | } 16 | 17 | interface DataRow extends DataItem { 18 | rowNumber: number; 19 | key: React.Key; 20 | } 21 | 22 | const columns: ColumnsType = [ 23 | { 24 | title: 'No.', 25 | dataIndex: 'rowNumber' 26 | }, 27 | { 28 | title: 'Type', 29 | dataIndex: 'type', 30 | }, 31 | { 32 | title: 'Value', 33 | dataIndex: 'value', 34 | }, 35 | ]; 36 | 37 | const Dashboard: React.FC = () => { 38 | 39 | const [selectedRowKeys, setSelectedRowKeys] = useState([]); 40 | 41 | // Because is inside we can use the access token 42 | // to create an SWR Fetcher with Authorization Bearer header 43 | const swrFetcher = useSwrFetcherWithAccessToken(); 44 | 45 | const { data, error, isValidating } = useSWR(BackendApiUrl.test, swrFetcher); 46 | 47 | function dataSource(): DataRow[] { 48 | if (!data) { 49 | return []; 50 | } 51 | 52 | return data.map((item, index) => { 53 | const row: DataRow = { 54 | key: index, 55 | rowNumber: index + 1, 56 | type: item.type, 57 | value: item.value, 58 | }; 59 | return row; 60 | }) 61 | } 62 | 63 | return ( 64 |
65 | setSelectedRowKeys(e) 71 | }} 72 | /> 73 |
{JSON.stringify(selectedRowKeys)}
74 |

75 | {error?.toString()} 76 |

77 | 78 | ); 79 | }; 80 | 81 | const DashboardPage: Page = () => { 82 | return ( 83 | 84 | Dashboard 85 | 86 | 87 | ); 88 | } 89 | 90 | DashboardPage.layout = WithDefaultLayout; 91 | export default DashboardPage; 92 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | # Next.js inlines environment files into the final build, which is not ideal for production. 73 | # Container-based deployment like Kubernetes requires environment variables read from the machine. 74 | # Thus we definitely do not want people submitting .env and .env.production file into the CI, 75 | # causing the final build image to have inlined environment variable from the file! 76 | .env 77 | .env.production 78 | .env.local 79 | .env.test 80 | 81 | # parcel-bundler cache (https://parceljs.org/) 82 | .cache 83 | .parcel-cache 84 | 85 | # Next.js build output 86 | .next 87 | out 88 | 89 | # Nuxt.js build / generate output 90 | .nuxt 91 | dist 92 | 93 | # Gatsby files 94 | .cache/ 95 | # Comment in the public line in if your project uses Gatsby and not Next.js 96 | # https://nextjs.org/blog/next-9-1#public-directory-support 97 | # public 98 | 99 | # vuepress build output 100 | .vuepress/dist 101 | 102 | # Serverless directories 103 | .serverless/ 104 | 105 | # FuseBox cache 106 | .fusebox/ 107 | 108 | # DynamoDB Local files 109 | .dynamodb/ 110 | 111 | # TernJS port file 112 | .tern-port 113 | 114 | # Stores VSCode versions used for testing VSCode extensions 115 | .vscode-test 116 | 117 | # yarn v2 118 | .yarn/cache 119 | .yarn/unplugged 120 | .yarn/build-state.yml 121 | .yarn/install-state.gz 122 | .pnp.* 123 | -------------------------------------------------------------------------------- /types/ProblemDetails.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A machine-readable format for specifying errors in HTTP API responses based on https://tools.ietf.org/html/rfc7807. 3 | */ 4 | export interface ProblemDetails { 5 | 6 | /** 7 | * A URI reference [RFC3986] that identifies the problem type. 8 | * This specification encourages that, when dereferenced, it provide human-readable documentation for the problem type 9 | * (e.g., using HTML [W3C.REC-html5-20141028]). 10 | * When this member is not present, its value is assumed to be "about:blank". 11 | */ 12 | type?: string; 13 | 14 | /** 15 | * A short, human-readable summary of the problem type. 16 | * It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization 17 | * (e.g., using proactive content negotiation; see [RFC7231], Section 3.4). 18 | */ 19 | title?: string; 20 | 21 | /** 22 | * A human-readable explanation specific to this occurrence of the problem. 23 | */ 24 | detail?: string; 25 | 26 | /** 27 | * A URI reference that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced. 28 | */ 29 | instance?: string; 30 | 31 | /** 32 | * The HTTP status code ([RFC7231], Section 6) generated by the origin server for this occurrence of the problem. 33 | */ 34 | status?: number; 35 | 36 | /** 37 | * A unique identifier to represent a request in ASP.NET Core trace logs. 38 | */ 39 | traceId?: string; 40 | 41 | /** 42 | * Gets the validation errors associated with this instance of ProblemDetails. 43 | */ 44 | errors?: Record; 45 | 46 | /** 47 | * 48 | */ 49 | [key: string]: unknown; 50 | } 51 | 52 | /* 53 | // https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.problemdetails?view=aspnetcore-6.0 54 | { 55 | "type": "https://tools.ietf.org/html/rfc7231#section-6.6.1", 56 | "title": "An error occurred while processing your request.", 57 | "status": 500, 58 | "traceId": "00-330d73096d49e6a561d1422e355e9720-95889ed30f285704-00" 59 | } 60 | 61 | // https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.validationproblemdetails?view=aspnetcore-6.0 62 | { 63 | "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1", 64 | "title": "One or more validation errors occurred.", 65 | "status": 400, 66 | "traceId": "00-7ce36b7e0f395a48fb132b194ead79eb-b1b296f394a57968-00", 67 | "errors": { 68 | "Email": [ 69 | "'Email' must not be empty.", 70 | "'Email' is not a valid email address." 71 | ], 72 | "Password": [ 73 | "'Password' must not be empty.", 74 | "Password is not strong enough" 75 | ], 76 | "GivenName": [ 77 | "'Given Name' must not be empty." 78 | ], 79 | "FamilyName": [ 80 | "'Family Name' must not be empty." 81 | ] 82 | } 83 | } 84 | */ 85 | -------------------------------------------------------------------------------- /functions/useFetchWithAccessToken.ts: -------------------------------------------------------------------------------- 1 | import { DefaultApiRequestHeader } from "./DefaultApiRequestHeader"; 2 | import { useAuthorizationContext } from "./AuthorizationContext"; 3 | import { tryFetchJson } from "./tryFetchJson"; 4 | 5 | /** 6 | * This hook can be used inside `` component to add Access Token to fetch requests headers. 7 | * Caching is disabled via `DefaultApiRequestHeader` object. 8 | * @returns Fetch wrapper methods 9 | */ 10 | export function useFetchWithAccessToken() { 11 | const { accessToken, isAuthenticated } = useAuthorizationContext(); 12 | 13 | const headers: Record = { 14 | ...DefaultApiRequestHeader 15 | }; 16 | if (isAuthenticated && accessToken) { 17 | headers['Authorization'] = `Bearer ${accessToken}`; 18 | } 19 | 20 | return { 21 | /** 22 | * Fetch a URL with GET method with JSON response. 23 | * If method is called inside `` component context, will append Access Token to request header 24 | * @param url 25 | * @returns `data` when `response.ok`, `problem` when not `response.ok`, and `error` when exception 26 | */ 27 | fetchGET: function (url: RequestInfo | URL) { 28 | return tryFetchJson(url, { 29 | method: 'GET', 30 | headers: headers, 31 | }); 32 | }, 33 | 34 | /** 35 | * Fetch a URL with POST method with JSON serialized request body and JSON response. 36 | * If method is called inside `` component context, will append Access Token to request header 37 | * @param url 38 | * @param body 39 | * @returns `data` when `response.ok`, `problem` when not `response.ok`, and `error` when exception 40 | */ 41 | fetchPOST: function (url: RequestInfo | URL, body: unknown = undefined) { 42 | return tryFetchJson(url, { 43 | method: 'POST', 44 | headers: headers, 45 | body: JSON.stringify(body), 46 | }); 47 | }, 48 | 49 | /** 50 | * Fetch a URL with PUT method with JSON serialized request body and JSON response. 51 | * If method is called inside `` component context, will append Access Token to request header 52 | * @param url 53 | * @param body 54 | * @returns `data` when `response.ok`, `problem` when not `response.ok`, and `error` when exception 55 | */ 56 | fetchPUT: function (url: RequestInfo | URL, body: unknown = undefined) { 57 | return tryFetchJson(url, { 58 | method: 'PUT', 59 | headers: headers, 60 | body: JSON.stringify(body), 61 | }); 62 | }, 63 | 64 | /** 65 | * Fetch a URL with PATCH method with JSON serialized request body and JSON response. 66 | * If method is called inside `` component context, will append Access Token to request header 67 | * @param url 68 | * @param body 69 | * @returns `data` when `response.ok`, `problem` when not `response.ok`, and `error` when exception 70 | */ 71 | fetchPATCH: function (url: RequestInfo | URL, body: unknown = undefined) { 72 | return tryFetchJson(url, { 73 | method: 'PATCH', 74 | headers: headers, 75 | body: JSON.stringify(body), 76 | }); 77 | }, 78 | 79 | /** 80 | * Fetch a URL with DELETE method with JSON response. 81 | * If method is called inside `` component context, will append Access Token to request header 82 | * @param url 83 | * @returns `data` when `response.ok`, `problem` when not `response.ok`, and `error` when exception 84 | */ 85 | fetchDELETE: function (url: RequestInfo | URL) { 86 | return tryFetchJson(url, { 87 | method: 'DELETE', 88 | headers: headers, 89 | }); 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "noEmit": true, 11 | "incremental": true, 12 | "module": "ESNext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "jsx": "preserve", 16 | 17 | /* Interop Constraints */ 18 | "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 19 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 20 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */ 21 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 22 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 23 | 24 | /* Type Checking */ 25 | "strict": true, /* Enable all strict type-checking options. */ 26 | "noImplicitAny": false, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 27 | "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 28 | "strictFunctionTypes": false, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 29 | "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 30 | "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 31 | "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 32 | "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 33 | "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 34 | "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 35 | "noUnusedParameters": false, /* Raise an error when a function parameter isn't read */ 36 | "exactOptionalPropertyTypes": false, /* Interpret optional property types as written, rather than adding 'undefined'. */ 37 | "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 38 | "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 39 | "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 40 | "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 41 | "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 42 | "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 43 | "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 44 | 45 | /* Completeness */ 46 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 47 | "skipLibCheck": true, /* Skip type checking all .d.ts files. */ 48 | "paths": { 49 | "@/*": ["./*"] 50 | } 51 | }, 52 | "include": [ 53 | "next-env.d.ts", 54 | "**/*.ts", 55 | "**/*.tsx" 56 | ], 57 | "exclude": [ 58 | "node_modules", 59 | ".next", 60 | "public" 61 | ] 62 | } 63 | -------------------------------------------------------------------------------- /pages/api/auth/[...nextauth].ts: -------------------------------------------------------------------------------- 1 | import NextAuth, { NextAuthOptions } from "next-auth" 2 | import type { JWT } from "next-auth/jwt"; 3 | import { Issuer } from 'openid-client'; 4 | import { custom } from 'openid-client'; 5 | import { AppSettings } from "../../../functions/AppSettings" 6 | import { UserInfo } from "../../../functions/AuthorizationContext"; 7 | 8 | /** 9 | * Takes a token, and returns a new token with updated 10 | * `accessToken` and `accessTokenExpires`. If an error occurs, 11 | * returns the old token and an error property 12 | */ 13 | async function refreshAccessToken(token: JWT & { refreshToken?: string }) { 14 | try { 15 | if (!token.refreshToken) { 16 | throw new Error('Refresh token is empty!'); 17 | } 18 | 19 | const discovery = await Issuer.discover(AppSettings.current.oidcIssuer); 20 | 21 | const client = new discovery.Client({ 22 | client_id: AppSettings.current.oidcClientId, 23 | token_endpoint_auth_method: 'none', 24 | }); 25 | 26 | client[custom.clock_tolerance] = 10; // to allow a 10 second skew 27 | 28 | // console.log('NextAuth refreshing token: ', token.refreshToken); 29 | const update = await client.refresh(token.refreshToken); 30 | 31 | return { 32 | ...token, 33 | accessToken: update.access_token, 34 | accessTokenExpires: calculateExpireAtMilliseconds(update.expires_at), 35 | refreshToken: update.refresh_token ?? token.refreshToken, // Fall back to old refresh token 36 | } 37 | } catch (err) { 38 | console.log('NextAuth error when refreshing token: ', err); 39 | 40 | return { 41 | ...token, 42 | error: "RefreshAccessTokenError", 43 | } 44 | } 45 | } 46 | 47 | function calculateExpireAtMilliseconds(expireAtSeconds: number | undefined) { 48 | // we didn't get expireAt value, just assume it will expire in 15 minutes 49 | if (!expireAtSeconds) { 50 | return Date.now() + 15 * 60 * 1000; 51 | } 52 | 53 | return expireAtSeconds * 1000; 54 | } 55 | 56 | function hasNotExpired(expireAtSeconds: unknown): boolean { 57 | if (typeof expireAtSeconds !== 'number') { 58 | return false; 59 | } 60 | 61 | if (!expireAtSeconds) { 62 | return false; 63 | } 64 | 65 | return (Date.now() < expireAtSeconds); 66 | } 67 | 68 | export const authOptions: NextAuthOptions = { 69 | providers: [ 70 | { 71 | id: "oidc", 72 | name: "OpenID Connect", 73 | type: "oauth", 74 | wellKnown: AppSettings.current.oidcIssuer + '/.well-known/openid-configuration', 75 | client: { 76 | token_endpoint_auth_method: 'none' 77 | }, 78 | clientId: AppSettings.current.oidcClientId, 79 | authorization: { 80 | params: { 81 | scope: AppSettings.current.oidcScope, 82 | } 83 | }, 84 | checks: ["pkce", "state"], 85 | idToken: true, 86 | userinfo: { 87 | async request(context) { 88 | // idToken: true makes next-auth parse user info from id_token 89 | // this code below makes next-auth query the user info endpoint instead 90 | if (context.tokens.access_token) { 91 | return await context.client.userinfo(context.tokens.access_token) 92 | } 93 | return {}; 94 | } 95 | }, 96 | async profile(profile) { 97 | // add claims obtained from user info endpoint to the session.user data 98 | // reference: https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims 99 | // console.log(profile); 100 | return { 101 | id: profile.sub, 102 | name: profile.name, 103 | // given_name: profile.given_name, 104 | // family_name: profile.family_name, 105 | // middle_name: profile.middle_name, 106 | // nickname: profile.nickname, 107 | // preferred_username: profile.preferred_username, 108 | // profile: profile.profile, 109 | // picture: profile.picture, 110 | // website: profile.website, 111 | email: profile.email, 112 | // email_verified: profile.email_verified, 113 | // gender: profile.gender, 114 | // birthdate: profile.birthdate, 115 | // zoneinfo: profile.zoneinfo, 116 | // locale: profile.locale, 117 | // phone_number: profile.phone_number, 118 | // phone_number_verified: profile.phone_number_verified, 119 | // address: profile.address, 120 | // updated_at: profile.updated_at 121 | role: profile.role 122 | } 123 | }, 124 | } 125 | ], 126 | callbacks: { 127 | async jwt({ token, account, user }) { 128 | // Initial sign in 129 | if (account && user) { 130 | // console.log(JSON.stringify(account, null, 4)); 131 | return { 132 | accessToken: account.access_token, 133 | accessTokenExpires: calculateExpireAtMilliseconds(account.expires_at), 134 | refreshToken: account.refresh_token, 135 | user, 136 | } 137 | } 138 | 139 | // Return previous token if the access token has not expired yet 140 | // console.log(Date.now(), accessTokenExpires); 141 | if (hasNotExpired(token['accessTokenExpires'])) { 142 | // console.log('Token not expired yet'); 143 | return token; 144 | } 145 | 146 | // console.log('Token has expired'); 147 | // Access token has expired, try to update it 148 | return refreshAccessToken(token) 149 | }, 150 | async session({ session, token }) { 151 | // Send properties to the client, like an access_token from a provider. 152 | session.user = token['user'] as UserInfo; 153 | session['accessToken'] = token['accessToken']; 154 | session['error'] = token['error']; 155 | return session 156 | } 157 | }, 158 | } 159 | 160 | export default NextAuth(authOptions) 161 | 162 | // generate new NEXTAUTH_SECRET for production 163 | // https://generate-secret.vercel.app/32 164 | -------------------------------------------------------------------------------- /components/DefautLayout.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import Head from 'next/head'; 3 | import { Avatar, Button, ConfigProvider, Drawer, Layout, Menu, MenuProps } from "antd"; 4 | import { faBars, faSignOut, faSignIn, faHome, faCubes, faUser, faUsers, faFlaskVial } from '@fortawesome/free-solid-svg-icons' 5 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 6 | import { useRouter } from "next/router"; 7 | import { useSession, signIn, signOut } from "next-auth/react"; 8 | import nProgress from "nprogress"; 9 | 10 | const { Content, Sider } = Layout; 11 | 12 | const sidebarBackgroundColor = '#001529'; 13 | const sidebarMenuSelectedItemBackgroundColor = '#1677ff'; 14 | 15 | const DefaultLayout: React.FC<{ 16 | children: React.ReactNode 17 | }> = ({ children }) => { 18 | 19 | const [drawerOpen, setDrawerOpen] = useState(false); 20 | const router = useRouter(); 21 | const { data: session, status } = useSession(); 22 | 23 | // menu.key must match the router.pathname, see example below: "/dashboard" 24 | const [selected, setSelected] = useState([router.pathname]); 25 | 26 | // key must also be unique, for obvious reason 27 | function getMenu(): MenuProps['items'] { 28 | const menu: MenuProps['items'] = []; 29 | 30 | menu.push({ 31 | key: '/', 32 | label: 'Home', 33 | icon: , 34 | onClick: () => router.push('/') 35 | }); 36 | 37 | menu.push( 38 | { 39 | key: '#menu-1', 40 | label: 'Menu 1', 41 | icon: , 42 | children: [ 43 | { 44 | key: '/dashboard', 45 | label: 'Dashboard', 46 | onClick: () => router.push('/dashboard') 47 | }, 48 | { 49 | key: '/sub-menu-b', 50 | label: 'Sub Menu B', 51 | onClick: () => router.push('/') 52 | }, 53 | { 54 | key: '/sub-menu-c', 55 | label: 'Sub Menu C', 56 | onClick: () => router.push('/') 57 | } 58 | ] 59 | }, 60 | { 61 | key: '#menu-2', 62 | label: 'Menu 2', 63 | icon: , 64 | children: [ 65 | { 66 | key: '/sub-menu-d', 67 | label: 'Sub Menu D', 68 | onClick: () => router.push('/') 69 | }, 70 | { 71 | key: '/sub-menu-e', 72 | label: 'Sub Menu E', 73 | onClick: () => router.push('/') 74 | }, 75 | { 76 | key: '/sub-menu-f', 77 | label: 'Sub Menu F', 78 | onClick: () => router.push('/') 79 | } 80 | ] 81 | }, 82 | { 83 | key: '#menu-3', 84 | label: 'Menu 3', 85 | icon: , 86 | children: [ 87 | { 88 | key: '/sub-menu-g', 89 | label: 'Sub Menu G', 90 | onClick: () => router.push('/') 91 | }, 92 | { 93 | key: '/sub-menu-h', 94 | label: 'Sub Menu H', 95 | onClick: () => router.push('/') 96 | }, 97 | { 98 | key: '/sub-menu-i', 99 | label: 'Sub Menu I', 100 | onClick: () => router.push('/') 101 | } 102 | ] 103 | } 104 | ); 105 | 106 | if (status === 'authenticated') { 107 | menu.push({ 108 | key: '/sign-out', 109 | label: 'Sign out', 110 | icon: , 111 | onClick: () => { 112 | nProgress.start(); 113 | signOut(); 114 | // HINT: use this method call if need to end SSO server authentication session: 115 | // signOut({ 116 | // callbackUrl: '/api/end-session' 117 | // }); 118 | } 119 | }); 120 | } else { 121 | menu.push({ 122 | key: '/sign-in', 123 | label: 'Sign in', 124 | icon: , 125 | onClick: () => { 126 | nProgress.start(); 127 | signIn('oidc'); 128 | } 129 | }); 130 | } 131 | 132 | return menu; 133 | } 134 | 135 | const displayUserName = session?.user?.name; 136 | 137 | function renderAvatar() { 138 | if (status === 'authenticated') { 139 | return ( 140 |
141 |
142 | } /> 143 |
144 |
145 | Hello, {displayUserName} 146 |
147 |
148 | ); 149 | } 150 | 151 | return null; 152 | } 153 | 154 | return ( 155 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 |
Logo
173 | {renderAvatar()} 174 | 184 | setSelected(e.selectedKeys)} /> 186 | 187 | 188 | setDrawerOpen(false)}> 189 | 197 | setSelected(e.selectedKeys)} /> 199 | 200 | 201 | 202 |
203 |
204 | 207 |
208 |
209 | Logo 210 |
211 |
212 |
213 | 214 | {children} 215 | 216 |
217 | 218 | 219 | ); 220 | } 221 | 222 | export const WithDefaultLayout = (page: React.ReactElement) => {page}; 223 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Accelist Next.js Starter 2 | 3 | > Next.js project starter template for PT. Accelist Lentera Indonesia 4 | 5 | ## Features 6 | 7 | - Pure Next.js: Zero framework customization 8 | 9 | - TypeScript + ESLint configured: type-check and lint as you type! 10 | 11 | - Visual Studio Code breakpoint and debugging configured 12 | 13 | - Responsive dashboard with sidebar template 14 | 15 | - `Page` Component Type: Supports variable layout 16 | 17 | - The Twelve-Factor App principled: Multi-Stage Docker build 18 | 19 | - `AppSettings` API: Supports Runtime Environment Variables for Kubernetes deployment 20 | 21 | - Plug-and-play OpenID Connect integrations to standard providers (Such as Keycloak, IdentityServer, OpenIddict, FusionAuth, etc.) 22 | 23 | - API Gateway for proxying HTTP requests to back-end web API bypassing CORS 24 | 25 | - Automatic progress bar during page navigation 26 | 27 | - Convenient Fetch API wrapper and SWR Fetcher implementation 28 | 29 | - Enabled container builds on GitHub Action 30 | 31 | - Batteries included: 32 | 33 | - Enterprise-level React components by [Ant Design](https://ant.design/components/overview/) 34 | 35 | - Thousands of [utility classes](https://tailwind.build/classes) powered by Tailwind CSS with `className` IntelliSense in React components 36 | 37 | - Simple atomic React state management using [Jotai](https://jotai.org/) 38 | 39 | - Thousands of icons by [FontAwesome 6](https://fontawesome.com/search?o=r&m=free) 40 | 41 | - TypeScript object schema validation with [Zod](https://zod.dev/) 42 | 43 | - Simple form validation with [React Hook Form](https://react-hook-form.com/get-started), designed to be [integrated with Ant Design](https://react-hook-form.com/get-started#IntegratingControlledInputs) and [Zod](https://react-hook-form.com/get-started#SchemaValidation) 44 | 45 | - Provide sane defaults for the most common security headers 46 | 47 | ## Getting Started 48 | 49 | [Download The Template as Zip File](https://github.com/accelist/nextjs-starter/archive/refs/heads/master.zip) 50 | 51 | Unzip and rename the folder to your actual project name. 52 | 53 | Run `npm ci` in the project root folder, then `npm run dev` 54 | 55 | The web app should be accessible at http://localhost:3000 56 | 57 | To display ESLint errors in Visual Studio Code, install [the official ESLint extension by Microsoft](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint). 58 | 59 | To display Tailwind CSS IntelliSense in Visual Studio Code, install [the official Tailwind CSS IntelliSense extension](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss). 60 | 61 | ## Project Structure 62 | 63 | ### `components` Folder 64 | 65 | Place reusable React components in this folder. 66 | 67 | It is recommended to develop using [function components](https://reactjs.org/docs/components-and-props.html) with [hooks](https://reactjs.org/docs/hooks-intro.html) instead of class components. 68 | 69 | ### Styling a Component 70 | 71 | Components should be styled with one of these techniques, sorted from the most recommended to the least recommended: 72 | 73 | - [Tailwind CSS](https://flowbite.com/tools/tailwind-cheat-sheet/) utility classes in `className` prop for best website performance. 74 | 75 | ```tsx 76 | // These websites provide Tailwind CSS components: 77 | // https://tailwindui.com/all-access 78 | // https://tailwind-elements.com 79 | // https://flowbite.com 80 | 81 | 82 | ``` 83 | 84 | > :bulb: Tailwind CSS should be used to make reusable components. Projects should always strive to have many reusable React components, each using many Tailwind CSS base classes (easier to maintain), rather than having many global CSS classes which are used everywhere (harder to maintain). This concept is called Utility-First: https://tailwindcss.com/docs/utility-first 85 | 86 | - [Local CSS Modules](https://nextjs.org/docs/basic-features/built-in-css-support#adding-component-level-css) specific to certain components or pages should be placed next to the corresponding `.tsx` files instead (e.g. `components/Button.module.css` next to `components/Button.tsx`). Tailwind CSS features such as [`theme()`](https://tailwindcss.com/docs/functions-and-directives#theme), [`screen()`](https://tailwindcss.com/docs/functions-and-directives#screen), and [`@apply`](https://tailwindcss.com/docs/functions-and-directives#apply) can be used here. 87 | 88 | > CSS Modules should only be used to develop very small, reusable components ONLY when Tailwind CSS base classes cannot do the job. **Avoid using CSS Modules to style most of the application components!!** https://tailwindcss.com/docs/reusing-styles#avoiding-premature-abstraction 89 | 90 | - Global Stylesheets: place plain `.css` files in `styles` folder and import them from `globals.css` to apply them to all pages and components. 91 | 92 | > :warning: Due to the global nature of stylesheets, and to avoid conflicts, they may not be imported from pages / components. 93 | 94 | ### `functions` Folder 95 | 96 | Place reusable plain JS functions in this folder. 97 | 98 | ### `pages` Folder 99 | 100 | In Next.js, a page is a default-exported React Component from a `.js`, `.jsx`, `.ts`, or `.tsx` file in the `pages` directory. Each page is associated with a route based on its file name. 101 | 102 | > **Example:** If `pages/about.tsx` is created, it will be accessible at `/about`. 103 | 104 | Next.js supports pages with dynamic routes. For example, if a file is called `pages/posts/[id].tsx`, then it will be accessible at `posts/1`, `posts/2`, etc. 105 | 106 | > Read more about pages: https://nextjs.org/docs/basic-features/pages 107 | 108 | > Read more about dynamic routes: https://nextjs.org/docs/routing/dynamic-routes 109 | 110 | ### `pages/_app.tsx` File 111 | 112 | Next.js uses the `App` component to initialize pages which can be overridden to allow: 113 | 114 | - Persisting layout between page changes 115 | 116 | - Keeping state when navigating pages 117 | 118 | - [Custom error handling using `componentDidCatch`](https://reactjs.org/docs/error-boundaries.html) 119 | 120 | - Inject additional data into pages 121 | 122 | - [Add global CSS](https://nextjs.org/docs/basic-features/built-in-css-support#adding-a-global-stylesheet) 123 | 124 | This template ships with `_app.tsx` file which implements some of the above-mentioned behaviors, including additional features: 125 | 126 | - Progress bar on navigation 127 | 128 | - OpenID Connect provider configuration 129 | 130 | > Read more about custom `App`: https://nextjs.org/docs/advanced-features/custom-app 131 | 132 | ### `public` Folder 133 | 134 | Next.js can serve static files, like images, under a folder called `public` in the root directory. Files inside `public` can then be referenced by your code starting from the base URL (`/`). 135 | 136 | > Read more about static files: https://nextjs.org/docs/basic-features/static-file-serving 137 | 138 | ### `types` Folder 139 | 140 | Place type declarations in this folder. For example: `interface` or `type` or [`.d.ts`](https://www.typescriptlang.org/docs/handbook/declaration-files/by-example.html) files. 141 | 142 | ### `.eslintrc.json` File 143 | 144 | ESLint configuration file for TypeScript and Next.js (`next/core-web-vitals` including `react` and `react-hooks` ESLint plugins). 145 | 146 | > Read more about ESLint configuration: https://eslint.org/docs/user-guide/configuring/ 147 | 148 | | Rules | Documentation | 149 | | ------------- | ----------------------------------------------------------------- | 150 | | TypeScript | https://www.npmjs.com/package/@typescript-eslint/eslint-plugin | 151 | | React | https://www.npmjs.com/package/eslint-plugin-react | 152 | | React Hooks | https://www.npmjs.com/package/eslint-plugin-react-hooks | 153 | | Next.js | https://nextjs.org/docs/basic-features/eslint#eslint-plugin | 154 | 155 | ### `package.json` & `package.lock.json` Files 156 | 157 | The `package.json` file is a manifest for the project. It is where `npm` store the names and versions for all the installed packages. The `package.json` shipped with the template describes the following (but not limited to) metadata: 158 | 159 | - `private` if set to `true` prevents the app to be accidentally published on `npm` 160 | 161 | - `scripts` defines a set of scripts runnable via [`npm run`](https://docs.npmjs.com/cli/v8/commands/npm-run-script) 162 | 163 | - `dependencies` sets a list of npm packages installed as runtime dependencies 164 | 165 | - `devDependencies` sets a list of npm packages installed as development dependencies, which are not installed in Production environments. 166 | 167 | > Read more about `package.json`: https://docs.npmjs.com/cli/v8/configuring-npm/package-json https://nodejs.dev/learn/the-package-json-guide 168 | 169 | `package-lock.json` is automatically generated for any operations where npm modifies either the `node_modules` tree, or `package.json`. It describes the exact tree that was generated, such that subsequent installs can generate identical trees, regardless of intermediate dependency updates. This file is intended to be committed into source repositories. 170 | 171 | > Read more about `package.lock.json`: https://docs.npmjs.com/cli/v8/configuring-npm/package-lock-json https://nodejs.dev/learn/the-package-lock-json-file 172 | 173 | **Restoring packages should be done using `npm ci` NOT `npm install` command to prevent accidentally modifying the `package.json` and `package.lock.json`** 174 | 175 | ### `tsconfig.json` File 176 | 177 | The presence of a `tsconfig.json` file in a directory indicates that the directory is the root of a TypeScript project. The `tsconfig.json` file specifies the root files and the compiler options required to compile the project. 178 | 179 | The `tsconfig.json` shipped with the template has been fine-tuned for strict Next.js project type-checking. 180 | 181 | > List of all supported TypeScript compiler options: https://www.typescriptlang.org/tsconfig https://www.typescriptlang.org/docs/handbook/compiler-options.html 182 | 183 | ### `next.config.js` File 184 | 185 | For custom advanced configuration of Next.js (such as webpack), `next.config.js` in the root of the project directory (next to package.json) can be modified. 186 | 187 | `next.config.js` is a regular Node.js module and gets used by the Next.js server and build phases. It is not included in the browser build. 188 | 189 | > Read more: https://nextjs.org/docs/api-reference/next.config.js/introduction 190 | 191 | > Read more about custom webpack configuration: https://nextjs.org/docs/api-reference/next.config.js/custom-webpack-config 192 | 193 | ## Building and Running Production Build 194 | 195 | ```sh 196 | npm run build 197 | ``` 198 | 199 | ```sh 200 | npx cross-env \ 201 | NODE_ENV='production' \ 202 | NEXTAUTH_URL='https://www.my-website.com' \ 203 | NEXTAUTH_SECRET='e01b7895a403fa7364061b2f01a650fc' \ 204 | BACKEND_API_HOST='https://demo.duendesoftware.com' \ 205 | OIDC_ISSUER='https://demo.duendesoftware.com' \ 206 | OIDC_CLIENT_ID='interactive.public.short' \ 207 | OIDC_SCOPE='openid profile email api offline_access' \ 208 | npm run start 209 | ``` 210 | 211 | > **DO NOT FORGET** to randomize `NEXTAUTH_SECRET` value for Production Environment with https://generate-secret.vercel.app/32 or `openssl rand -base64 32` 212 | 213 | To use SSL Certificates, simply use reverse proxy such as [NGINX](https://www.nginx.com/resources/wiki/start/topics/tutorials/install/) or [Traefik](https://doc.traefik.io/traefik/getting-started/install-traefik/). 214 | 215 | ## Building and Running as Container 216 | 217 | This template ships with `Dockerfile` and `.dockerignore` for building the app as a standard container image. To proceed, please [install Docker](https://docs.docker.com/get-docker/) or any OCI container CLI such as [`podman`](https://podman.io/) in your machine. (The examples given will use Docker) 218 | 219 | To build the container image, use this command: 220 | 221 | ```sh 222 | docker build -t my-app . 223 | ``` 224 | 225 | > Run this command on the same directory level as `Dockerfile` file. 226 | 227 | > Note that all `.env` and `.env.*` files are listed as ignored files in `.dockerignore` to prevent unwanted Environment Variables leaking to Production environment. 228 | 229 | When running container locally, it is recommended to create a dedicated network for containers inside to connect to each other: 230 | 231 | ```sh 232 | docker network create my-network 233 | ``` 234 | 235 | ```sh 236 | docker run \ 237 | -e NEXTAUTH_URL="https://www.my-website.com" \ 238 | -e NEXTAUTH_SECRET="e01b7895a403fa7364061b2f01a650fc" \ 239 | -e BACKEND_API_HOST="https://demo.duendesoftware.com" \ 240 | -e OIDC_ISSUER="https://demo.duendesoftware.com" \ 241 | -e OIDC_CLIENT_ID="interactive.public.short" \ 242 | -e OIDC_SCOPE="openid profile email api offline_access" \ 243 | -p 80:80 \ 244 | --network my-network \ 245 | --restart always \ 246 | --name my-container \ 247 | -d my-app 248 | ``` 249 | 250 | > **DO NOT FORGET** to randomize `NEXTAUTH_SECRET` value for Production Environment with https://generate-secret.vercel.app/32 or `openssl rand -base64 32` 251 | 252 | ## `AppSettings` API 253 | 254 | [Next.js allows using `process.env` to read Environment Variables](https://nextjs.org/docs/basic-features/environment-variables), but it is not suitable for container-based deployment because the Environment Variables are burned during build-time (non-changeable). 255 | 256 | This technique does not adhere to [The Twelve-Factor App](https://12factor.net/build-release-run) methodology: a release is defined as a combination of a build (i.e. Container) + a config (i.e. Environment Variables). 257 | 258 | ![Build, Release, Run](https://raw.githubusercontent.com/accelist/nextjs-starter/master/public/release.png) 259 | 260 | For this reason, [Runtime Configuration](https://nextjs.org/docs/api-reference/next.config.js/runtime-configuration) is recommended to be used instead. 261 | 262 | This project template ships [`AppSettings`](https://github.com/accelist/nextjs-starter/blob/master/functions/AppSettings.ts) API as a high-level abstraction of the runtime Environment Variables: 263 | 264 | ``` 265 | Environment Variables --> appsettings.js --> next.config.js --> AppSettings 266 | ``` 267 | 268 | ### Environment Variables 269 | 270 | The values of Environment Variables are sourced differently depending on how the app is being run: 271 | 272 | * Development environment using `npm run dev`: values will be obtained from `.env` files such as `.env.development` or `.env.local` 273 | 274 | > Read more about Environment Variables Load Order: https://nextjs.org/docs/basic-features/environment-variables#environment-variable-load-order 275 | 276 | * Production environment using container (build with `Dockerfile` and `.dockerignore` in this template): values will be obtained from Machine Environment Variables supplied via `-e` or `--env` flag. 277 | 278 | > Read more about Environment Variables in Docker: https://docs.docker.com/engine/reference/commandline/run/#set-environment-variables--e---env---env-file 279 | 280 | ### Add Environment Variables to `appsettings.js` 281 | 282 | ```js 283 | module.exports = { 284 | backendApiHost: process.env['BACKEND_API_HOST'] ?? '', 285 | oidcIssuer: process.env['OIDC_ISSUER'] ?? '', 286 | oidcClientId: process.env['OIDC_CLIENT_ID'] ?? '', 287 | oidcScope: process.env['OIDC_SCOPE'] ?? '', 288 | }; 289 | ``` 290 | 291 | The Environment Variables added in `appsettings.js` will be added to the `serverRuntimeConfig` field in `next.config.js` file and are only available on the server-side code. (in `getServerSideProps` or in API routes) 292 | 293 | > Read more for explanation about this behavior: https://www.saltycrane.com/blog/2021/04/buildtime-vs-runtime-environment-variables-nextjs-docker/ 294 | 295 | ### Using `AppSettings` 296 | 297 | Import the `AppSettings` object from `getServerSideProps` to read registered Environment Variables and pass it down to the page as props. For example: 298 | 299 | ```tsx 300 | import { AppSettings } from '../functions/AppSettings'; 301 | 302 | const MyPage: Page<{ 303 | myEnv: string 304 | }> = ({ myEnv }) => { 305 | return ( 306 |
307 |

308 | {myEnv} 309 |

310 |
311 | ); 312 | } 313 | 314 | export default MyPage; 315 | 316 | export async function getServerSideProps() { 317 | return { 318 | props: { 319 | myEnv: AppSettings.current.myEnv 320 | }, 321 | } 322 | } 323 | 324 | ``` 325 | 326 | > :warning: Doing this will expose the environment variable to the browser / end-user. Exercise caution. 327 | 328 | > :bulb: Sensitive environment variables should only be used as part of a Web API, either in the back-end project (e.g. ASP.NET Core) or in the Next.js API Routes. 329 | 330 | ## `Page` & Layout 331 | 332 | The `Page` interface shipped with this project template extends the standard `React.FunctionComponent` interface, with an additional static property named `layout`. The `layout` property allows attaching a render function which returns the layout for a specific page. 333 | 334 | The below example illustrates how to develop a layout function and attach it to a page: 335 | 336 | ```tsx 337 | // components/MyLayout.tsx 338 | 339 | import React from "react"; 340 | 341 | const MyLayout: React.FC = ({ children }) => { 342 | return ( 343 | 344 |
345 | {children} 346 |
347 |
348 | ); 349 | } 350 | 351 | // This layout pattern enables state persistence because the React component tree is maintained between page transitions. 352 | // With the component tree, React can understand which elements have changed to preserve state. 353 | export const WithMyLayout = (page: React.ReactElement) => {page}; 354 | ``` 355 | 356 | ```tsx 357 | // pages/MyPage.tsx 358 | 359 | import { Page } from '../types/Page'; 360 | import { WithMyLayout } from '../components/MyLayout'; 361 | 362 | const MyPage: Page = () => { 363 | return ( 364 |
365 |

Hello World!

366 |
367 | ); 368 | } 369 | 370 | MyPage.layout = WithMyLayout; 371 | export default MyPage; 372 | ``` 373 | 374 | > Read more about Per-Page Layouts: https://nextjs.org/docs/basic-features/layouts#per-page-layouts 375 | 376 | ## Fetch API Wrapper 377 | 378 | This template ships with a lightweight, sane-but-opinionated wrapper around [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) which integrates with [RFC 7807 Problem Details](https://www.rfc-editor.org/rfc/rfc7807). 379 | 380 | ```ts 381 | const { 382 | fetchGET, 383 | fetchPOST, 384 | fetchPUT, 385 | fetchPATCH, 386 | fetchDELETE 387 | } = useFetchWithAccessToken(); 388 | 389 | const { data, error, problem } = await fetchGET('http://my-app.test/api/v1/products'); 390 | 391 | const { data, error, problem } = await fetchPOST('http://my-app.test/api/v1/products', { 392 | name: 'Software X' 393 | }); 394 | 395 | // tryFetchJson is a lower-level fetch wrapper used by above functions 396 | const { data, error, problem } = await tryFetchJson('http://my-app.test/api/v1/cities', { 397 | method: 'GET', 398 | headers: { 399 | ...DefaultApiRequestHeader, 400 | }, 401 | }); 402 | ``` 403 | 404 | > :warning: `useFetchWithAccessToken` is a hook and it can ONLY be called from the top-level code block of React function components. https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level 405 | 406 | The wrapper serializes HTTP request body (second parameter of POST / PUT / PATCH methods) as JSON and expects strictly JSON response from the Web API. 407 | 408 | When `response.ok` (status in the range 200–299), `data` will have the data type passed to the generic of the Fetch API. 409 | 410 | When not `response.ok`, 411 | 412 | - `problem` may contain an object describing a RFC 7807 Problem Details based on [ASP.NET Core `ValidationProblemDetails` class](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.validationproblemdetails?view=aspnetcore-6.0). 413 | 414 | - When that is not the case, `problem` can be a generic JSON object (values accessible via index syntax: `problem['someData']`) or simply a `string` if the response body is not JSON (use `if (typeof problem === 'object')` to check). 415 | 416 | Unlike Fetch API, these wrappers will not throw. If an unhandled exception has occurred when performing the HTTP request, `error` will contain the caught exception. 417 | 418 | The functions returned from `useFetchWithAccessToken` use these default HTTP request headers: 419 | 420 | ```ts 421 | { 422 | 'Content-Type': 'application/json', 423 | 'Cache-Control': 'no-cache', 424 | 'Pragma': 'no-cache', 425 | 'Expires': '0', 426 | } 427 | ``` 428 | 429 | When the function is called inside the `` component context, it will automatically append `Authorization: Bearer ACCESS_TOKEN` header into the HTTP request. 430 | 431 | > :bulb: Contrary to the function name, **it is safe to use `useFetchWithAccessToken` outside `` component context.** 432 | 433 | ## Sending Files and Form Data 434 | 435 | If advanced solution is required, such as sending non-JSON or [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) request bodies [or accepting non-JSON responses](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Using_readable_streams#consuming_a_fetch_as_a_stream), the above Fetch API wrappers cannot be used. (Use [the base Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/fetch) or [`XMLHttpRequest`](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) instead) 436 | 437 | ```ts 438 | // Example: PUT File to AWS S3 presigned URL 439 | var xhr = new XMLHttpRequest(); 440 | xhr.open('PUT', presignedUrl, true); 441 | xhr.setRequestHeader('Content-Type', file.type); 442 | xhr.onload = () => { 443 | if (xhr.status === 200) { 444 | // success 445 | } else { 446 | // problem 447 | } 448 | }; 449 | xhr.onerror = () => { 450 | // error 451 | }; 452 | xhr.upload.onprogress = (e) => { 453 | if (e.lengthComputable) { 454 | var percent = Math.round((e.loaded / e.total) * 100) 455 | // Update UI progress bar here 456 | // Use lodash.throttle to control state change frequency 457 | // https://lodash.com/docs/4.17.15#throttle 458 | // For example: const updateProgressBar = useCallback(throttle(setProgressBar, 300), []); 459 | } 460 | }; 461 | // `file` is a File object 462 | // https://developer.mozilla.org/en-US/docs/Web/API/File 463 | xhr.send(file); 464 | ``` 465 | 466 | ## Default SWR Fetcher 467 | 468 | This template ships with a default [SWR Fetcher](https://swr.vercel.app/docs/data-fetching#fetch) implementation based on above Fetch API wrapper. 469 | 470 | ```ts 471 | const swrFetcher = useSwrFetcherWithAccessToken(); 472 | const { data, error } = useSWR('/api/be/api/Values', swrFetcher); 473 | ``` 474 | 475 | > :warning: `useSwrFetcherWithAccessToken` and `useSWR` are hooks and they can ONLY be called from the top-level code block of function components. https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level 476 | 477 | > :bulb: Contrary to the function name, **it is safe to use `useSwrFetcherWithAccessToken` outside `` component context.** 478 | 479 | ## API Gateway 480 | 481 | HTTP requests initiated from a browser are restricted to the same domain ([Same-Origin Policy](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy)) and the same protocol (HTTPS requests must be performed from web pages with HTTPS URL). 482 | 483 | > For example, `https://front-end.app` accessing `http://back-end.app/api/data` will fail by default. 484 | 485 | To ease development against microservices, this template ships an implementation of API Gateway which allows bypassing Same-Origin Policy by proxying HTTP requests through the Next.js server. The API Gateway is implemented using [API Routes for Next.js](https://nextjs.org/docs/api-routes/introduction). 486 | 487 | > The content `/pages/api/be/[...apiGateway].ts` file: 488 | 489 | ```ts 490 | import Proxy from 'http-proxy'; 491 | import type { NextApiRequest, NextApiResponse } from 'next'; 492 | import { AppSettings } from '../../../functions/AppSettings'; 493 | 494 | // Great way to avoid using CORS and making API calls from HTTPS pages to back-end HTTP servers 495 | // Recommendation for projects in Kubernetes cluster: set target to Service DNS name instead of public DNS name 496 | const server = Proxy.createProxyServer({ 497 | target: AppSettings.current.backendApiHost, 498 | // changeOrigin to support name-based virtual hosting 499 | changeOrigin: true, 500 | xfwd: true, 501 | // https://github.com/http-party/node-http-proxy#proxying-websockets 502 | ws: false, 503 | }); 504 | 505 | server.on('proxyReq', (proxyReq, req) => { 506 | // Proxy requests from /api/be/... to http://my-web-api.com/... 507 | const urlRewrite = req.url?.replace(new RegExp('^/api/be'), ''); 508 | if (urlRewrite) { 509 | proxyReq.path = urlRewrite; 510 | } else { 511 | proxyReq.path = '/'; 512 | } 513 | proxyReq.removeHeader('cookie'); 514 | // console.log(JSON.stringify(proxyReq.getHeaders(), null, 4)); 515 | console.log('HTTP Proxy:', req.url, '-->', AppSettings.current.backendApiHost + urlRewrite); 516 | }); 517 | 518 | const apiGateway = async (req: NextApiRequest, res: NextApiResponse) => { 519 | const startTime = new Date().getTime(); 520 | 521 | server.web(req, res, {}, (err) => { 522 | if (err instanceof Error) { 523 | throw err; 524 | } 525 | 526 | throw new Error(`Failed to proxy request: '${req.url}'`); 527 | }); 528 | 529 | res.on('finish', () => { 530 | const endTime = new Date().getTime(); 531 | console.log(`HTTP Proxy: Finished ${res.req.url} in ${endTime - startTime}ms `); 532 | }) 533 | } 534 | 535 | export default apiGateway; 536 | 537 | export const config = { 538 | api: { 539 | externalResolver: true, 540 | bodyParser: false 541 | }, 542 | } 543 | ``` 544 | 545 | The above implementation allows forwarding from the Next.js API Route to the actual back-end API URL. For example: `/api/be/api/Values` is forwarded to the `http://back-end/api/Values` 546 | 547 | ```tsx 548 | // Fetch data from http://back-end/api/Values 549 | const { data, error } = useSWR('/api/be/api/Values', swrFetcher); 550 | ``` 551 | 552 | For clarity, it is recommended to create separate API Routes for different back-end microservices. (e.g. `/api/employees`, `/api/products`, etc.) 553 | 554 | When deployed in Kubernetes, the target host can be declared as a valid [RFC 1035 label name](https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#rfc-1035-label-names) instead of a public DNS to enable managing microservices using Kubernetes CoreDNS. 555 | 556 | For example, if the target host name is `my-service`, then the back-end web API can be [declared as a ClusterIP or LoadBalancer Service](https://kubernetes.io/docs/concepts/services-networking/service/#defining-a-service) with the same name and is reachable from the Next.js API Gateway via `http://my-service`: 557 | 558 | ```yaml 559 | apiVersion: v1 560 | kind: Service 561 | metadata: 562 | name: my-service 563 | spec: 564 | selector: 565 | app: DemoBackEndWebApi 566 | ports: 567 | - protocol: TCP 568 | port: 80 569 | ``` 570 | 571 | ## OpenID Connect Integrations 572 | 573 | > TODO 574 | 575 | ## Authorize Component and AuthorizationContext 576 | 577 | > TODO 578 | 579 | ## Sidebar Menu Programming 580 | 581 | > TODO 582 | 583 | ## Security Headers 584 | 585 | > TODO 586 | 587 | ## Step Debugging with Visual Studio Code 588 | 589 | This template ships with Visual Studio Code step debugging support. Simply press F5 to start debugging. 590 | 591 | When only client-side debugging is required, **ensure `npm run dev` is already running** and choose the `Next.js: Debug Client-Side` launch configuration. Breakpoint can now be placed in source code lines which run in the browser-side. 592 | 593 | When server-side debugging is required, **ensure `npm run dev` is NOT running** and choose the `Next.js: Debug Full-Stack` launch configuration. Breakpoint can now be placed in source code lines which runs in the server-side, in addition to the browser-side. 594 | 595 | > The debug configuration can be selected from the Run & Debug Sidebar (CTRL + SHIFT + D) 596 | 597 | The debugging experience is set to use the new Chromium-based Microsoft Edge by default (which should be installed by default in newer Windows 10 and Windows 11). If this is not desirable, feel free to modify the `.vscode/launch.json` file. 598 | 599 | To enrich the React development experience, install the official [React Developer Tools extension](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi?hl=en) in the browser used for debugging. 600 | 601 | ## GitHub CI Integration 602 | 603 | This project template ships with GitHub Action workflow for Docker Images enabled. Example: https://github.com/accelist/nextjs-starter/actions 604 | 605 | When a commit is pushed or a merge request is performed against the `master` or `main` branch, a container image will be built. If a commit is pushed, then the container image will also be pushed into the GitHub Container Registry of the project as `master` or `main` tag. 606 | 607 | Upon tagging a commit (if using GitHub web, go to [Releases](https://github.com/accelist/nextjs-starter/releases) page then [draft a new release](https://github.com/accelist/nextjs-starter/releases/new)) with version number string such as `v1.0.0` (notice the mandatory `v` prefix), a new container image will be built and tagged as the version number (in this example, resulting in `1.0.0` image tag, notice the lack of `v` prefix) and `latest`. 608 | 609 | The container images are available via the project [GitHub Container Registry](https://github.com/accelist/nextjs-starter/pkgs/container/nextjs-starter). For example: 610 | 611 | ```sh 612 | docker pull ghcr.io/accelist/nextjs-starter:master 613 | ``` 614 | 615 | If working with private repository (hence private container registry), [create a new GitHub personal access token](https://github.com/settings/tokens) with `read:packages` scope to allow downloading container images from Kubernetes cluster. 616 | 617 | > Read more: https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry 618 | 619 | ## Deploying Container to Kubernetes 620 | 621 | > TODO add guide for adding GitHub access token to Kubernetes for pulling from private registry: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ 622 | 623 | > TODO add Deployment and Services `yaml` here with Environment Variables 624 | 625 | ## Git Pre-Commit Compile Check 626 | 627 | Upon launching development server via `npm run dev`, git pre-commit hook will be installed into the local repository. 628 | 629 | This hook will perform TypeScript and ESLint checks when a developer attempts to commit into the git repository and fail the commit if any errors are detected. 630 | --------------------------------------------------------------------------------