diff --git a/.gitignore b/.gitignore index 74d544686..02a3dabd2 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,9 @@ apps/promisetracker/public/data/** storybook-static mongo-keyfile + +#Google credentials +credentials.json + +# Sentry Config File +.sentryclirc diff --git a/Dockerfile.vpnmanager b/Dockerfile.vpnmanager new file mode 100644 index 000000000..31ba72d4c --- /dev/null +++ b/Dockerfile.vpnmanager @@ -0,0 +1,68 @@ +FROM node:18-alpine as node-alpine + +# Always install security updated e.g. https://pythonspeed.com/articles/security-updates-in-docker/ +# Update local cache so that other stages don't need to update cache +RUN apk update \ + && apk upgrade + + +FROM node-alpine as base + +RUN apk add --no-cache libc6-compat + +ARG PNPM_VERSION=8.5.0 + +RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate + +WORKDIR /workspace + +COPY pnpm-lock.yaml . + +RUN pnpm fetch + + +FROM base as builder + + +WORKDIR /workspace + +COPY *.yaml *.json ./ +COPY packages ./packages +COPY apps/vpnmanager ./apps/vpnmanager + +# Use virtual store: https://pnpm.io/cli/fetch#usage-scenario +RUN pnpm install --recursive --offline --frozen-lockfile + +# NOTE: ARG values are only available **after** ARG statement & hence we need +# to separate NEXT_PUBLIC_APP_URL and PAYLOAD_PUBLIC_APP_URL into +# multiple ARG statements so that PAYLOAD can use the value defined +# in NEXT. +ARG PORT=3000 \ + # Sentry config for source maps upload (needed at build time only) + SENTRY_AUTH_TOKEN="" \ + SENTRY_ENV="local" \ + SENTRY_ORG="" \ + SENTRY_PROJECT="" \ + SENTRY_DSN="" +RUN pnpm build --filter=vpnmanager + +FROM builder as runner + +RUN rm -rf /var/cache/apk/* + +ARG PORT \ + SENTRY_ENV + +ENV NODE_ENV=production \ + PORT=${PORT} \ + SENTRY_ENV=${SENTRY_ENV} \ + SENTRY_DSN=${SENTRY_DSN} \ + SENTRY_ORG=${SENTRY_ORG} \ + SENTRY_PROJECT=${SENTRY_PROJECT} \ + SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN} + +WORKDIR /workspace/apps/vpnmanager + +EXPOSE ${PORT} + +CMD [ "pnpm", "run", "start" ] diff --git a/Makefile b/Makefile index 84ddc764d..1c3ba1c13 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,13 @@ # Makefile -.PHONY: charterafrica mongodb mongodb-keyfile +.PHONY: charterafrica mongodb mongodb-keyfile vpnmanager charterafrica: docker compose --env-file apps/charterafrica/.env.local up charterafrica --build -d +vpnmanager: + docker compose --env-file apps/vpnmanager/.env.local up vpnmanager --build -d + mongodb: docker compose --env-file apps/charterafrica/.env.local up --wait mongodb diff --git a/apps/vpnmanager/.env.template b/apps/vpnmanager/.env.template new file mode 100644 index 000000000..91538d77e --- /dev/null +++ b/apps/vpnmanager/.env.template @@ -0,0 +1,6 @@ +NEXT_APP_GOOGLE_CREDENTIALS=/path/to/credentials.json +NEXT_APP_GOOGLE_SHEET_ID= +NEXT_APP_GOOGLE_SHEET_RANGE=New Hires!A:Z +NEXT_APP_VPN_API_URL= +SENTRY_AUTH_TOKEN= +SENTRY_DSN= diff --git a/apps/vpnmanager/README.md b/apps/vpnmanager/README.md new file mode 100644 index 000000000..d2a5df1fc --- /dev/null +++ b/apps/vpnmanager/README.md @@ -0,0 +1,45 @@ +# VPN Manager + +This is the cfa Outline VPN Manager + +### Development + +## Getting Started + +First create `.env.local` file in the root directory of the project. + +```bash +cp env.template .env.local +``` + +and modify the `.env.local` file according to your needs. + +#### Note + +The default `.env` file is for the 'Publicly' visible environment variables. + +## Script + +```bash + pnpm process-new-hires +``` + +## Web + +Run the development server: + +```bash +pnpm dev +``` + +### Deployment. + +```bash + docker-compose up --build vpnmanager +``` + +or + +```bash +make vpnmanager +``` diff --git a/apps/vpnmanager/next-env.d.ts b/apps/vpnmanager/next-env.d.ts new file mode 100644 index 000000000..4f11a03dc --- /dev/null +++ b/apps/vpnmanager/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/vpnmanager/next.config.mjs b/apps/vpnmanager/next.config.mjs new file mode 100644 index 000000000..ea793582e --- /dev/null +++ b/apps/vpnmanager/next.config.mjs @@ -0,0 +1,38 @@ +import { withSentryConfig } from "@sentry/nextjs"; + +/** @type {import('next').NextConfig} */ +const nextConfig = { + transpilePackages: ["@commons-ui/core", "@commons-ui/next"], + eslint: { + ignoreDuringBuilds: true, + }, + webpack: (config) => { + config.module.rules.push( + { + test: /\.svg$/i, + type: "asset", + resourceQuery: /url/, // *.svg?url + }, + { + test: /\.svg$/i, + issuer: /\.[jt]sx?$/, + resourceQuery: { not: [/url/] }, // exclude react component if *.svg?url + use: ["@svgr/webpack"], + }, + { + test: /\.md$/, + loader: "frontmatter-markdown-loader", + }, + ); + config.experiments = { ...config.experiments, topLevelAwait: true }; // eslint-disable-line no-param-reassign + return config; + }, +}; + +export default withSentryConfig(nextConfig, { + silent: true, + hideSourceMaps: true, + org: process.env.SENTRY_ORG, + authToken: process.env.SENTRY_AUTH_TOKEN, + project: process.env.SENTRY_PROJECT, +}); diff --git a/apps/vpnmanager/package.json b/apps/vpnmanager/package.json new file mode 100644 index 000000000..573837c07 --- /dev/null +++ b/apps/vpnmanager/package.json @@ -0,0 +1,45 @@ +{ + "name": "vpnmanager", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "pnpm run build-ts && next build", + "start": "next start", + "build-ts": "tsc --project tsconfig.server.json && tsc-alias -p tsconfig.server.json", + "process-new-hires": "node dist/src/lib/processNewHires.js" + }, + "dependencies": { + "@babel/core": "^7.23.6", + "@babel/preset-react": "^7.23.3", + "@commons-ui/core": "workspace:*", + "@commons-ui/next": "workspace:*", + "@emotion/cache": "^11.11.0", + "@emotion/react": "^11.11.1", + "@emotion/server": "^11.11.0", + "@emotion/styled": "^11.11.0", + "@mui/material": "^5.14.20", + "@mui/utils": "^5.14.20", + "@sentry/nextjs": "^7.105.0", + "@svgr/webpack": "^8.1.0", + "@types/jest": "^29.5.12", + "googleapis": "^133.0.0", + "jest": "^29.7.0", + "next": "14.1.3", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "tsc-alias": "^1.8.8", + "tsconfig-paths": "^4.2.0" + }, + "devDependencies": { + "@commons-ui/testing-library": "workspace:*", + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "eslint": "^8.55.0", + "eslint-config-commons-ui": "workspace:*", + "eslint-import-resolver-webpack": "^0.13.8", + "eslint-plugin-import": "^2.29.0", + "typescript": "^5" + } +} diff --git a/apps/vpnmanager/public/android-chrome-192x192.png b/apps/vpnmanager/public/android-chrome-192x192.png new file mode 100644 index 000000000..b6b1c195a Binary files /dev/null and b/apps/vpnmanager/public/android-chrome-192x192.png differ diff --git a/apps/vpnmanager/public/android-chrome-512x512.png b/apps/vpnmanager/public/android-chrome-512x512.png new file mode 100644 index 000000000..994c5f68a Binary files /dev/null and b/apps/vpnmanager/public/android-chrome-512x512.png differ diff --git a/apps/vpnmanager/public/apple-touch-icon.png b/apps/vpnmanager/public/apple-touch-icon.png new file mode 100644 index 000000000..bc086d518 Binary files /dev/null and b/apps/vpnmanager/public/apple-touch-icon.png differ diff --git a/apps/vpnmanager/public/browserconfig.xml b/apps/vpnmanager/public/browserconfig.xml new file mode 100644 index 000000000..f9c2e67fe --- /dev/null +++ b/apps/vpnmanager/public/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #2b5797 + + + diff --git a/apps/vpnmanager/public/favicon-16x16.png b/apps/vpnmanager/public/favicon-16x16.png new file mode 100644 index 000000000..aeecce01f Binary files /dev/null and b/apps/vpnmanager/public/favicon-16x16.png differ diff --git a/apps/vpnmanager/public/favicon-32x32.png b/apps/vpnmanager/public/favicon-32x32.png new file mode 100644 index 000000000..3dd2a39a8 Binary files /dev/null and b/apps/vpnmanager/public/favicon-32x32.png differ diff --git a/apps/vpnmanager/public/favicon.ico b/apps/vpnmanager/public/favicon.ico new file mode 100644 index 000000000..886942077 Binary files /dev/null and b/apps/vpnmanager/public/favicon.ico differ diff --git a/apps/vpnmanager/public/mstile-150x150.png b/apps/vpnmanager/public/mstile-150x150.png new file mode 100644 index 000000000..f47cd2c07 Binary files /dev/null and b/apps/vpnmanager/public/mstile-150x150.png differ diff --git a/apps/vpnmanager/public/safari-pinned-tab.svg b/apps/vpnmanager/public/safari-pinned-tab.svg new file mode 100644 index 000000000..dcae051b6 --- /dev/null +++ b/apps/vpnmanager/public/safari-pinned-tab.svg @@ -0,0 +1,46 @@ + + + + +Created by potrace 1.14, written by Peter Selinger 2001-2017 + + + + + + + + + diff --git a/apps/vpnmanager/public/site.webmanifest b/apps/vpnmanager/public/site.webmanifest new file mode 100644 index 000000000..96ca8fa01 --- /dev/null +++ b/apps/vpnmanager/public/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "Code for Africa", + "short_name": "CfA", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/apps/vpnmanager/sentry.client.config.ts b/apps/vpnmanager/sentry.client.config.ts new file mode 100644 index 000000000..d4ca649c7 --- /dev/null +++ b/apps/vpnmanager/sentry.client.config.ts @@ -0,0 +1,16 @@ +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + tracesSampleRate: 1, + environment: process.env.SENTRY_ENV, + debug: false, + replaysOnErrorSampleRate: 1.0, + replaysSessionSampleRate: 0.1, + integrations: [ + Sentry.replayIntegration({ + maskAllText: true, + blockAllMedia: true, + }), + ], +}); diff --git a/apps/vpnmanager/sentry.edge.config.ts b/apps/vpnmanager/sentry.edge.config.ts new file mode 100644 index 000000000..458a9f886 --- /dev/null +++ b/apps/vpnmanager/sentry.edge.config.ts @@ -0,0 +1,8 @@ +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + environment: process.env.SENTRY_ENV, + tracesSampleRate: 1, + debug: false, +}); diff --git a/apps/vpnmanager/sentry.server.config.ts b/apps/vpnmanager/sentry.server.config.ts new file mode 100644 index 000000000..458a9f886 --- /dev/null +++ b/apps/vpnmanager/sentry.server.config.ts @@ -0,0 +1,8 @@ +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + environment: process.env.SENTRY_ENV, + tracesSampleRate: 1, + debug: false, +}); diff --git a/apps/vpnmanager/src/assets/icons/Type=github, Size=24, Color=CurrentColor.svg b/apps/vpnmanager/src/assets/icons/Type=github, Size=24, Color=CurrentColor.svg new file mode 100644 index 000000000..33fc4f5fb --- /dev/null +++ b/apps/vpnmanager/src/assets/icons/Type=github, Size=24, Color=CurrentColor.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/vpnmanager/src/assets/icons/Type=x, Size=24, Color=CurrentColor.svg b/apps/vpnmanager/src/assets/icons/Type=x, Size=24, Color=CurrentColor.svg new file mode 100644 index 000000000..93fbad2f7 --- /dev/null +++ b/apps/vpnmanager/src/assets/icons/Type=x, Size=24, Color=CurrentColor.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/vpnmanager/src/assets/icons/menu-icon.svg b/apps/vpnmanager/src/assets/icons/menu-icon.svg new file mode 100644 index 000000000..2a4912de7 --- /dev/null +++ b/apps/vpnmanager/src/assets/icons/menu-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/vpnmanager/src/components/DesktopNavBar/DesktopNavBar.tsx b/apps/vpnmanager/src/components/DesktopNavBar/DesktopNavBar.tsx new file mode 100644 index 000000000..4346ab9e7 --- /dev/null +++ b/apps/vpnmanager/src/components/DesktopNavBar/DesktopNavBar.tsx @@ -0,0 +1,57 @@ +import React, { FC } from "react"; + +import NavBarNavList from "@/vpnmanager/components/NavBarNavList"; +import NextImageButton from "@/vpnmanager/components/NextImageButton"; +import { Box, Grid, Grid2Props } from "@mui/material"; + +interface SocialLinks { + platform: string; + url: string; +} + +interface Menu { + label: string; + href: string; +} +interface Props extends Grid2Props { + logo: any; + menus: Menu[]; + socialLinks: SocialLinks[]; +} +const DesktopNavBar: FC = React.forwardRef( + function DesktopNavBar(props, ref) { + const { logo, menus, socialLinks, sx } = props; + + return ( + + + + + + + + + + + ); + }, +); + +export default DesktopNavBar; diff --git a/apps/vpnmanager/src/components/DesktopNavBar/index.ts b/apps/vpnmanager/src/components/DesktopNavBar/index.ts new file mode 100644 index 000000000..3919164ee --- /dev/null +++ b/apps/vpnmanager/src/components/DesktopNavBar/index.ts @@ -0,0 +1,3 @@ +import DesktopNavBar from "./DesktopNavBar"; + +export default DesktopNavBar; diff --git a/apps/vpnmanager/src/components/MobileNavBar/MobileNavBar.tsx b/apps/vpnmanager/src/components/MobileNavBar/MobileNavBar.tsx new file mode 100644 index 000000000..147c844b9 --- /dev/null +++ b/apps/vpnmanager/src/components/MobileNavBar/MobileNavBar.tsx @@ -0,0 +1,141 @@ +import React, { FC, ForwardedRef } from "react"; + +import menuIcon from "@/vpnmanager/assets/icons/menu-icon.svg"; +import CloseIcon from "@/vpnmanager/assets/icons/Type=x, Size=24, Color=CurrentColor.svg"; +import NavBarNavList from "@/vpnmanager/components/NavBarNavList"; +import NextImageButton from "@/vpnmanager/components/NextImageButton"; +import { + Dialog, + DialogContent, + Grid, + Grid2Props, + IconButton, + Slide, + SlideProps, + SvgIcon, +} from "@mui/material"; +import { styled } from "@mui/material/styles"; + +interface SocialLinks { + platform: string; + url: string; +} + +interface Menu { + label: string; + href: string; +} +interface Props extends Grid2Props { + logo: any; + menus: Menu[]; + socialLinks: SocialLinks[]; +} + +const DialogContainer = styled(Dialog)(({ theme: { palette, spacing } }) => ({ + "& .MuiDialog-container": { + height: "100%", + }, + "& .MuiBackdrop-root": { + background: "transparent", + }, + "& .MuiDialogContent-root": { + padding: spacing(5), + color: palette.text.secondary, + background: palette.primary.main, + }, +})); + +const Transition: FC = React.forwardRef(function Transition( + { children, ...props }, + ref, +) { + return ( + + {children} + + ); +}); + +const MobileNavBar: FC = React.forwardRef(function MobileNavBar( + props, + ref: ForwardedRef, +) { + const { logo, menus, socialLinks, sx } = props; + const [open, setOpen] = React.useState(false); + + const handleClickOpen = () => { + setOpen(true); + }; + const handleClose = () => { + setOpen(false); + }; + + return ( + + + + + + + + + + + + + + + + + + + ); +}); + +export default MobileNavBar; diff --git a/apps/vpnmanager/src/components/MobileNavBar/index.ts b/apps/vpnmanager/src/components/MobileNavBar/index.ts new file mode 100644 index 000000000..b19184643 --- /dev/null +++ b/apps/vpnmanager/src/components/MobileNavBar/index.ts @@ -0,0 +1,3 @@ +import MobileNavBar from "./MobileNavBar"; + +export default MobileNavBar; diff --git a/apps/vpnmanager/src/components/NavBar/NavBar.tsx b/apps/vpnmanager/src/components/NavBar/NavBar.tsx new file mode 100644 index 000000000..a120d8bc4 --- /dev/null +++ b/apps/vpnmanager/src/components/NavBar/NavBar.tsx @@ -0,0 +1,46 @@ +import React from "react"; + +import DesktopNavBar from "@/vpnmanager/components/DesktopNavBar"; +import MobileNavBar from "@/vpnmanager/components/MobileNavBar"; +import { NavBar as NavigationBar, Section } from "@commons-ui/core"; + +interface SocialLinks { + platform: string; + url: string; +} + +interface Menu { + label: string; + href: string; +} +interface Props { + logo: any; + menus: Menu[]; + socialLinks: SocialLinks[]; +} +function NavBar({ logo, menus, socialLinks }: Props) { + return ( + +
+ + +
+
+ ); +} + +export default NavBar; diff --git a/apps/vpnmanager/src/components/NavBar/index.ts b/apps/vpnmanager/src/components/NavBar/index.ts new file mode 100644 index 000000000..085b6b525 --- /dev/null +++ b/apps/vpnmanager/src/components/NavBar/index.ts @@ -0,0 +1,3 @@ +import NavBar from "./NavBar"; + +export default NavBar; diff --git a/apps/vpnmanager/src/components/NavBarNavList/NavBarNavList.tsx b/apps/vpnmanager/src/components/NavBarNavList/NavBarNavList.tsx new file mode 100644 index 000000000..7f5983949 --- /dev/null +++ b/apps/vpnmanager/src/components/NavBarNavList/NavBarNavList.tsx @@ -0,0 +1,103 @@ +import React, { ElementType, FC } from "react"; + +import GitHubIcon from "@/vpnmanager/assets/icons/Type=github, Size=24, Color=CurrentColor.svg"; +import NavListItem from "@/vpnmanager/components/NavListItem"; +import { NavList } from "@commons-ui/core"; +import { Link } from "@commons-ui/next"; +import { LinkProps, SvgIcon } from "@mui/material"; + +const platformToIconMap: { + [key: string]: ElementType; +} = { + Github: GitHubIcon, +}; + +interface NavListItemProps extends LinkProps {} + +interface SocialLinks { + platform: string; + url: string; +} + +interface Menu { + label: string; + href: string; +} + +interface Props { + NavListItemProps?: NavListItemProps; + direction?: string; + menus?: Menu[]; + socialLinks?: SocialLinks[]; +} + +const NavBarNavList: FC = React.forwardRef( + function NavBarNavList(props, ref) { + const { NavListItemProps, direction, menus, socialLinks, ...other } = props; + + if (!menus?.length) { + return null; + } + return ( + + {menus.map((item) => ( + + + {item.label} + + + ))} + {socialLinks?.map(({ platform, url }) => { + const Icon = platformToIconMap[platform]; + if (!Icon) { + return null; + } + return ( + + + + + + ); + })} + + ); + }, +); + +export default NavBarNavList; diff --git a/apps/vpnmanager/src/components/NavBarNavList/index.ts b/apps/vpnmanager/src/components/NavBarNavList/index.ts new file mode 100644 index 000000000..f261c9be0 --- /dev/null +++ b/apps/vpnmanager/src/components/NavBarNavList/index.ts @@ -0,0 +1,3 @@ +import NavBarNavList from "./NavBarNavList"; + +export default NavBarNavList; diff --git a/apps/vpnmanager/src/components/NavListItem/NavListItem.tsx b/apps/vpnmanager/src/components/NavListItem/NavListItem.tsx new file mode 100644 index 000000000..5cde403fc --- /dev/null +++ b/apps/vpnmanager/src/components/NavListItem/NavListItem.tsx @@ -0,0 +1,19 @@ +import { styled, SxProps } from "@mui/material/styles"; +import React, { FC, ForwardedRef, HTMLAttributes } from "react"; + +const NavListItemRoot = styled("li")({ + listStyle: "none", +}); + +interface Props extends HTMLAttributes { + sx?: SxProps; +} + +const NavListItem: FC = React.forwardRef(function NavListItem( + props, + ref: ForwardedRef, +) { + return ; +}); + +export default NavListItem; diff --git a/apps/vpnmanager/src/components/NavListItem/index.ts b/apps/vpnmanager/src/components/NavListItem/index.ts new file mode 100644 index 000000000..abc33a899 --- /dev/null +++ b/apps/vpnmanager/src/components/NavListItem/index.ts @@ -0,0 +1,3 @@ +import NavListItem from "./NavListItem"; + +export default NavListItem; diff --git a/apps/vpnmanager/src/components/NextImageButton/NextImageButton.tsx b/apps/vpnmanager/src/components/NextImageButton/NextImageButton.tsx new file mode 100644 index 000000000..e118ada07 --- /dev/null +++ b/apps/vpnmanager/src/components/NextImageButton/NextImageButton.tsx @@ -0,0 +1,40 @@ +import { ImageButton } from "@commons-ui/core"; +import { Link } from "@commons-ui/next"; +import Image from "next/image"; +import React, { FC } from "react"; + +interface Props { + src?: string; + href?: string; + alt: string; + width?: number; + height?: number; + priority?: boolean; + onClick?: () => void; +} + +const NextImageButton: FC = React.forwardRef(function Logo(props, ref) { + const { alt, height, href, priority, src, width, ...other } = props; + + if (!src) { + return null; + } + return ( + + {alt} + + ); +}); + +export default NextImageButton; diff --git a/apps/vpnmanager/src/components/NextImageButton/index.ts b/apps/vpnmanager/src/components/NextImageButton/index.ts new file mode 100644 index 000000000..9e6d32a68 --- /dev/null +++ b/apps/vpnmanager/src/components/NextImageButton/index.ts @@ -0,0 +1,3 @@ +import NextImageButton from "./NextImageButton"; + +export default NextImageButton; diff --git a/apps/vpnmanager/src/components/Page/Page.tsx b/apps/vpnmanager/src/components/Page/Page.tsx new file mode 100644 index 000000000..578741907 --- /dev/null +++ b/apps/vpnmanager/src/components/Page/Page.tsx @@ -0,0 +1,39 @@ +import React from "react"; + +import NavBar from "@/vpnmanager/components/NavBar"; + +interface SocialLinks { + platform: string; + url: string; +} + +interface Menu { + label: string; + href: string; +} + +interface Menu { + label: string; + href: string; +} + +interface Navbar { + logo: any; + menus: Menu[]; + socialLinks: SocialLinks[]; +} + +interface Props { + children?: React.ReactNode; + navbar?: Navbar; +} +function Page({ children, navbar }: Props) { + return ( + <> + {navbar ? : null} + {children ?
{children}
: null} + + ); +} + +export default Page; diff --git a/apps/vpnmanager/src/components/Page/index.ts b/apps/vpnmanager/src/components/Page/index.ts new file mode 100644 index 000000000..39d0be934 --- /dev/null +++ b/apps/vpnmanager/src/components/Page/index.ts @@ -0,0 +1,3 @@ +import Page from "./Page"; + +export default Page; diff --git a/apps/vpnmanager/src/lib/data/spreadsheet.ts b/apps/vpnmanager/src/lib/data/spreadsheet.ts new file mode 100644 index 000000000..1c02307bb --- /dev/null +++ b/apps/vpnmanager/src/lib/data/spreadsheet.ts @@ -0,0 +1,59 @@ +import { google } from "googleapis"; + +import { SheetRow } from "@/vpnmanager/types"; +import { toCamelCase } from "@/vpnmanager/utils"; + +function gSheet() { + const auth = new google.auth.GoogleAuth({ + keyFile: process.env.NEXT_APP_GOOGLE_CREDENTIALS, + scopes: ["https://www.googleapis.com/auth/spreadsheets.readonly"], + }); + return google.sheets({ version: "v4", auth }); +} + +async function list( + spreadsheetId?: string, + range?: string, +): Promise { + if (!spreadsheetId || !range) { + return []; + } + const sheets = gSheet(); + const response = await sheets.spreadsheets.values.get({ + spreadsheetId, + range, + }); + + const rows = response.data.values; + if (!rows || !rows?.length) { + return []; + } + const titles = rows[0]; + + const data = rows.slice(1).map((row: any) => { + return titles.reduce((acc, curr, index) => { + const key = toCamelCase(curr); + const value = row[index]; + return { + ...acc, + [key]: value, + }; + }, {}); + }); + + return data; +} + +async function newHires() { + const spreadsheetId = process.env.NEXT_APP_GOOGLE_SHEET_ID; + const range = process.env.NEXT_APP_GOOGLE_SHEET_RANGE; + const data = await list(spreadsheetId, range); + return data.filter( + (row: SheetRow) => row.emailAddress && row.keySent !== "Yes", + ); +} + +export default { + list, + newHires, +}; diff --git a/apps/vpnmanager/src/lib/processNewHires.ts b/apps/vpnmanager/src/lib/processNewHires.ts new file mode 100644 index 000000000..4db039a2c --- /dev/null +++ b/apps/vpnmanager/src/lib/processNewHires.ts @@ -0,0 +1,17 @@ +import { SheetRow } from "@/vpnmanager/types"; +import * as Sentry from "@sentry/nextjs"; + +import spreadsheet from "./data/spreadsheet"; + +export async function processEmployee(item: SheetRow) { + // Capture to test that it works + Sentry.captureException(item); +} + +export async function processNewHires() { + const newHires = await spreadsheet.newHires(); + const promises = newHires.map((item) => processEmployee(item)); + Promise.allSettled(promises); +} + +processNewHires(); diff --git a/apps/vpnmanager/src/pages/_app.tsx b/apps/vpnmanager/src/pages/_app.tsx new file mode 100644 index 000000000..4f06c88d3 --- /dev/null +++ b/apps/vpnmanager/src/pages/_app.tsx @@ -0,0 +1,37 @@ +import React, { ReactNode } from "react"; + +import { AppProps } from "next/app"; +import Head from "next/head"; + +import Page from "@/vpnmanager/components/Page"; +import theme from "@/vpnmanager/theme"; +import createEmotionCache from "@/vpnmanager/utils/createEmotionCache"; +import { CacheProvider } from "@emotion/react"; +import { CssBaseline, ThemeProvider } from "@mui/material"; + +const clientSideEmotionCache = createEmotionCache(); + +function getDefaultLayout(page: ReactNode, pageProps: any) { + return {page}; +} + +function MyApp(props: AppProps | any) { + const { Component, emotionCache = clientSideEmotionCache, pageProps } = props; + const getLayout = Component.getLayout || getDefaultLayout; + + return ( + <> + + + + + + + {getLayout(, pageProps)} + + + + ); +} + +export default MyApp; diff --git a/apps/vpnmanager/src/pages/_document.tsx b/apps/vpnmanager/src/pages/_document.tsx new file mode 100644 index 000000000..d996f2c88 --- /dev/null +++ b/apps/vpnmanager/src/pages/_document.tsx @@ -0,0 +1,105 @@ +import React from "react"; + +import Document, { Head, Html, Main, NextScript } from "next/document"; + +import createEmotionCache from "@/vpnmanager/utils/createEmotionCache"; +import createEmotionServer from "@emotion/server/create-instance"; + +class MyDocument extends Document { + render() { + return ( + + + + + + + + + + + {this.props.emotionStyleTags} + + +
+ + + + ); + } +} + +// `getInitialProps` belongs to `_document` (instead of `_app`), +// it's compatible with static-site generation (SSG). +MyDocument.getInitialProps = async (ctx) => { + // Resolution order + // + // On the server: + // 1. app.getInitialProps + // 2. page.getInitialProps + // 3. document.getInitialProps + // 4. app.render + // 5. page.render + // 6. document.render + // + // On the server with error: + // 1. document.getInitialProps + // 2. app.render + // 3. page.render + // 4. document.render + // + // On the client + // 1. app.getInitialProps + // 2. page.getInitialProps + // 3. app.render + // 4. page.render + + const originalRenderPage = ctx.renderPage; + + // You can consider sharing the same Emotion cache between all the SSR requests to speed up performance. + // However, be aware that it can have global side effects. + const cache = createEmotionCache(); + const { extractCriticalToChunks } = createEmotionServer(cache); + + ctx.renderPage = () => + originalRenderPage({ + enhanceApp: (App: any) => + function EnhanceApp(props) { + return ; + }, + }); + + const initialProps = await Document.getInitialProps(ctx); + // This is important. It prevents Emotion to render invalid HTML. + // See https://github.com/mui/material-ui/issues/26561#issuecomment-855286153 + const emotionStyles = extractCriticalToChunks(initialProps.html); + const emotionStyleTags = emotionStyles.styles.map((style: any) => ( +