From 15741176fe924c50228c2a7d59a4eff84f5d7c09 Mon Sep 17 00:00:00 2001 From: Kevin Koech Date: Thu, 19 Sep 2024 13:51:09 +0300 Subject: [PATCH 1/9] - Updated Readme with deployment commands - Made process G sheet more RESTful . - Renamed processGsheet endpoint to users. --- apps/vpnmanager/README.md | 28 +++++++++++++++++++ apps/vpnmanager/contrib/dokku/app.json | 4 +++ .../contrib/dokku/scripts/apiClient.mjs | 26 +++++++++++++++++ .../contrib/dokku/scripts/processGsheet.mjs | 13 +++------ .../contrib/dokku/scripts/processStats.mjs | 9 ++++++ apps/vpnmanager/src/lib/processUsers.ts | 1 + .../vpnmanager/src/pages/api/processGsheet.ts | 12 -------- apps/vpnmanager/src/pages/api/users.ts | 21 ++++++++++++++ 8 files changed, 93 insertions(+), 21 deletions(-) create mode 100644 apps/vpnmanager/contrib/dokku/scripts/apiClient.mjs create mode 100644 apps/vpnmanager/contrib/dokku/scripts/processStats.mjs delete mode 100644 apps/vpnmanager/src/pages/api/processGsheet.ts create mode 100644 apps/vpnmanager/src/pages/api/users.ts diff --git a/apps/vpnmanager/README.md b/apps/vpnmanager/README.md index 461c00a78..f3e3979c8 100644 --- a/apps/vpnmanager/README.md +++ b/apps/vpnmanager/README.md @@ -43,3 +43,31 @@ or ```bash make vpnmanager ``` + +### Deployment to Dokku + +1. Install and setup new application on dokku. [Here is the documentation of how to install and create an app on dokku](https://dokku.com/docs~v0.6.5/deployment/application-deployment/). + +2. Build docker image and tag. + +```bash +docker build --target vpnmanager-runner \ + --build-arg SENTRY_ORG=$SENTRY_ORG \ + --build-arg SENTRY_PROJECT=$SENTRY_PROJECT \ + --build-arg SENTRY_DSN=$SENTRY_DSN \ + --build-arg API_SECRET_KEY=$API_SECRET_KEY \ + -t codeforafrica/vpnmanager:latest . +``` + +3. Deploy to dokku. + +```bash +dokku git:from-image vpnmanager codeforafrica/vpnmanager:latest +``` + +4. Persist storage database. + Docker in their [best practices](https://docs.docker.com/build/building/best-practices/#containers-should-be-ephemeral) that containers be treated as ephemeral. In order to manage persistent storage for database, a directory outside the container should be mounted. Vpnmanager uses sqlite to locally store data as obtained from Outline VPN API. To persist this data, run the command below + +```bash +dokku storage:mount vpnmanager /var/lib/dokku/data/storage/vpnmanager/data:/workspace/apps/vpnmanager/data +``` diff --git a/apps/vpnmanager/contrib/dokku/app.json b/apps/vpnmanager/contrib/dokku/app.json index e509d641e..7a39c7191 100644 --- a/apps/vpnmanager/contrib/dokku/app.json +++ b/apps/vpnmanager/contrib/dokku/app.json @@ -4,6 +4,10 @@ { "command": "node contrib/dokku/scripts/processGsheet.mjs", "schedule": "@hourly" + }, + { + "command": "node contrib/dokku/scripts/processStats.mjs", + "schedule": "0 23 * * *" } ] } diff --git a/apps/vpnmanager/contrib/dokku/scripts/apiClient.mjs b/apps/vpnmanager/contrib/dokku/scripts/apiClient.mjs new file mode 100644 index 000000000..6e3e12e17 --- /dev/null +++ b/apps/vpnmanager/contrib/dokku/scripts/apiClient.mjs @@ -0,0 +1,26 @@ +export async function apiClient(endpoint, method = "POST", body = null) { + const NEXT_PUBLIC_APP_URL = process.env.NEXT_PUBLIC_APP_URL; + const API_SECRET_KEY = process.env.API_SECRET_KEY; + + const headers = { + "Content-Type": "application/json", + "x-api-key": API_SECRET_KEY ?? "", + }; + + const options = { + method, + headers, + }; + + if (body) { + options.body = JSON.stringify(body); + } + + const res = await fetch(`${NEXT_PUBLIC_APP_URL}${endpoint}`, options); + + if (!res.ok) { + throw new Error(`API call failed with status ${res.status}`); + } + + return res.json(); +} diff --git a/apps/vpnmanager/contrib/dokku/scripts/processGsheet.mjs b/apps/vpnmanager/contrib/dokku/scripts/processGsheet.mjs index a58d27af8..ad3dadb53 100644 --- a/apps/vpnmanager/contrib/dokku/scripts/processGsheet.mjs +++ b/apps/vpnmanager/contrib/dokku/scripts/processGsheet.mjs @@ -1,13 +1,8 @@ +import { apiClient } from "./apiClient.mjs"; + async function main() { - const NEXT_PUBLIC_APP_URL = process.env.NEXT_PUBLIC_APP_URL; - const API_SECRET_KEY = process.env.API_SECRET_KEY; - const headers = { - "x-api-key": API_SECRET_KEY ?? "", - }; - const res = await fetch(`${NEXT_PUBLIC_APP_URL}/api/processGsheet`, { - headers, - }); - return res.json(); + const response = await apiClient("/api/users"); + return response; } const responseJson = await main(); diff --git a/apps/vpnmanager/contrib/dokku/scripts/processStats.mjs b/apps/vpnmanager/contrib/dokku/scripts/processStats.mjs new file mode 100644 index 000000000..d5d3b9a39 --- /dev/null +++ b/apps/vpnmanager/contrib/dokku/scripts/processStats.mjs @@ -0,0 +1,9 @@ +import { apiClient } from "./apiClient.mjs"; + +async function main() { + const response = await apiClient("/api/statistics"); + return response; +} + +const responseJson = await main(); +console.log(responseJson); diff --git a/apps/vpnmanager/src/lib/processUsers.ts b/apps/vpnmanager/src/lib/processUsers.ts index 680b92123..271000dd2 100644 --- a/apps/vpnmanager/src/lib/processUsers.ts +++ b/apps/vpnmanager/src/lib/processUsers.ts @@ -50,4 +50,5 @@ export async function processNewUsers() { if (fulfilled.length) { updateSheet(fulfilled); } + return fulfilled; } diff --git a/apps/vpnmanager/src/pages/api/processGsheet.ts b/apps/vpnmanager/src/pages/api/processGsheet.ts deleted file mode 100644 index 35d5597a2..000000000 --- a/apps/vpnmanager/src/pages/api/processGsheet.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { NextApiResponse, NextApiRequest } from "next"; -import { processNewUsers } from "@/vpnmanager/lib/processUsers"; - -export async function handler(req: NextApiRequest, res: NextApiResponse) { - try { - processNewUsers(); - return res.status(200).json({ message: "Process Started" }); - } catch (error) { - return res.status(500).json(error); - } -} -export default handler; diff --git a/apps/vpnmanager/src/pages/api/users.ts b/apps/vpnmanager/src/pages/api/users.ts new file mode 100644 index 000000000..57cb42bfc --- /dev/null +++ b/apps/vpnmanager/src/pages/api/users.ts @@ -0,0 +1,21 @@ +import { NextApiResponse, NextApiRequest } from "next"; +import { processNewUsers } from "@/vpnmanager/lib/processUsers"; +import { RestMethodFunctions, RestMethods } from "@/vpnmanager/types"; + +const methodToFunction: RestMethodFunctions = { + POST: processNewUsers, +}; + +export async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + const statFunc = methodToFunction[req.method as RestMethods]; + if (!statFunc) { + return res.status(404).json({ message: "Requested path not found" }); + } + const data = await statFunc(req); + return res.status(200).json(data); + } catch (error) { + return res.status(500).json(error); + } +} +export default handler; From a9e5629683c8fccfa686825e46a6fb5d14c09478 Mon Sep 17 00:00:00 2001 From: Kevin Koech Date: Thu, 19 Sep 2024 14:03:01 +0300 Subject: [PATCH 2/9] Update `.env.template` --- apps/vpnmanager/.env.template | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/vpnmanager/.env.template b/apps/vpnmanager/.env.template index b049edec6..d296f1f73 100644 --- a/apps/vpnmanager/.env.template +++ b/apps/vpnmanager/.env.template @@ -8,3 +8,5 @@ SENTRY_DSN= VPN_MANAGER_SENDGRID_API_KEY= VPN_MANAGER_SENDGRID_FROM_EMAIL=security@codeforafrica.org VPN_MANAGER_SENDGRID_FROM_NAME=CfA Security + +API_SECRET_KEY= From e28aa32fe3979b94bb33438c8d22d0b2720b9751 Mon Sep 17 00:00:00 2001 From: Kevin Koech Date: Fri, 20 Sep 2024 13:49:30 +0300 Subject: [PATCH 3/9] vpn manager add auth --- apps/vpnmanager/.env.template | 3 + apps/vpnmanager/package.json | 1 + ...pe=google, Size=24, Color=currentColor.svg | 7 ++ .../NavBarNavList/NavBarNavList.tsx | 4 + .../src/components/UserAvatar/UserAvatar.tsx | 58 +++++++++++++ .../src/components/UserAvatar/index.ts | 3 + apps/vpnmanager/src/middleware.ts | 2 +- apps/vpnmanager/src/pages/_app.tsx | 11 ++- .../src/pages/api/auth/[...nextauth].ts | 32 +++++++ apps/vpnmanager/src/pages/login.tsx | 86 +++++++++++++++++++ pnpm-lock.yaml | 13 +-- 11 files changed, 210 insertions(+), 10 deletions(-) create mode 100644 apps/vpnmanager/src/assets/icons/Type=google, Size=24, Color=currentColor.svg create mode 100644 apps/vpnmanager/src/components/UserAvatar/UserAvatar.tsx create mode 100644 apps/vpnmanager/src/components/UserAvatar/index.ts create mode 100644 apps/vpnmanager/src/pages/api/auth/[...nextauth].ts create mode 100644 apps/vpnmanager/src/pages/login.tsx diff --git a/apps/vpnmanager/.env.template b/apps/vpnmanager/.env.template index d296f1f73..086c82775 100644 --- a/apps/vpnmanager/.env.template +++ b/apps/vpnmanager/.env.template @@ -10,3 +10,6 @@ VPN_MANAGER_SENDGRID_FROM_EMAIL=security@codeforafrica.org VPN_MANAGER_SENDGRID_FROM_NAME=CfA Security API_SECRET_KEY= +NEXT_APP_GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +ALLOWED_EMAILS= diff --git a/apps/vpnmanager/package.json b/apps/vpnmanager/package.json index a28fabf3f..f28a9cb84 100644 --- a/apps/vpnmanager/package.json +++ b/apps/vpnmanager/package.json @@ -32,6 +32,7 @@ "googleapis": "catalog:", "jest": "catalog:", "next": "catalog:", + "next-auth": "catalog:", "react": "catalog:", "react-dom": "catalog:", "tsc-alias": "catalog:" diff --git a/apps/vpnmanager/src/assets/icons/Type=google, Size=24, Color=currentColor.svg b/apps/vpnmanager/src/assets/icons/Type=google, Size=24, Color=currentColor.svg new file mode 100644 index 000000000..3c1f0c341 --- /dev/null +++ b/apps/vpnmanager/src/assets/icons/Type=google, Size=24, Color=currentColor.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/vpnmanager/src/components/NavBarNavList/NavBarNavList.tsx b/apps/vpnmanager/src/components/NavBarNavList/NavBarNavList.tsx index f75bb15db..0abcb4ae0 100644 --- a/apps/vpnmanager/src/components/NavBarNavList/NavBarNavList.tsx +++ b/apps/vpnmanager/src/components/NavBarNavList/NavBarNavList.tsx @@ -2,6 +2,7 @@ import { NavList, NavListItem, SocialMediaIconLink } from "@commons-ui/core"; import { Link } from "@commons-ui/next"; import type { LinkProps } from "@mui/material"; import React from "react"; +import UserAvatar from "../UserAvatar"; interface NavListItemProps extends LinkProps {} @@ -88,6 +89,9 @@ const NavBarNavList = React.forwardRef(function NavBarNavList( ); })} + + + ); }); diff --git a/apps/vpnmanager/src/components/UserAvatar/UserAvatar.tsx b/apps/vpnmanager/src/components/UserAvatar/UserAvatar.tsx new file mode 100644 index 000000000..bcf635442 --- /dev/null +++ b/apps/vpnmanager/src/components/UserAvatar/UserAvatar.tsx @@ -0,0 +1,58 @@ +import React, { useEffect } from "react"; +import { useState } from "react"; +import { Avatar, Menu, MenuItem, IconButton, Typography } from "@mui/material"; +import { signOut, useSession } from "next-auth/react"; +import { useRouter } from "next/router"; + +export default function UserAvatar() { + const [anchorEl, setAnchorEl] = useState(null); + const { data: session } = useSession(); + const open = Boolean(anchorEl); + const router = useRouter(); + + const handleMenuClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleMenuClose = () => { + setAnchorEl(null); + }; + + const handleLogout = () => { + handleMenuClose(); + signOut(); + }; + useEffect(() => { + if (!session) { + router.push("/login"); + } + }, [router, session]); + + if (!session) { + return null; + } + + return ( + <> + + + + + + + Logout + + + + ); +} diff --git a/apps/vpnmanager/src/components/UserAvatar/index.ts b/apps/vpnmanager/src/components/UserAvatar/index.ts new file mode 100644 index 000000000..7edaa54ee --- /dev/null +++ b/apps/vpnmanager/src/components/UserAvatar/index.ts @@ -0,0 +1,3 @@ +import UserAvatar from "./UserAvatar"; + +export default UserAvatar; diff --git a/apps/vpnmanager/src/middleware.ts b/apps/vpnmanager/src/middleware.ts index 7a482002a..7464ed51c 100644 --- a/apps/vpnmanager/src/middleware.ts +++ b/apps/vpnmanager/src/middleware.ts @@ -2,7 +2,7 @@ import type { NextRequest } from "next/server"; // Limit the middleware to paths starting with `/api/` export const config = { - matcher: "/api/:function*", + matcher: ["/api/statistics/:path*", "/api/users/:path*"], }; export function middleware(req: NextRequest) { diff --git a/apps/vpnmanager/src/pages/_app.tsx b/apps/vpnmanager/src/pages/_app.tsx index ea2db2c97..18eb76bc3 100644 --- a/apps/vpnmanager/src/pages/_app.tsx +++ b/apps/vpnmanager/src/pages/_app.tsx @@ -1,4 +1,5 @@ import { CacheProvider } from "@emotion/react"; +import { SessionProvider } from "next-auth/react"; import { CssBaseline, ThemeProvider } from "@mui/material"; import { AppProps } from "next/app"; import Head from "next/head"; @@ -23,10 +24,12 @@ function MyApp(props: AppProps | any) { - - - {getLayout(, pageProps)} - + + + + {getLayout(, pageProps)} + + ); } diff --git a/apps/vpnmanager/src/pages/api/auth/[...nextauth].ts b/apps/vpnmanager/src/pages/api/auth/[...nextauth].ts new file mode 100644 index 000000000..b2fa672c3 --- /dev/null +++ b/apps/vpnmanager/src/pages/api/auth/[...nextauth].ts @@ -0,0 +1,32 @@ +import NextAuth from "next-auth"; +import GoogleProvider from "next-auth/providers/google"; + +export default NextAuth({ + secret: process.env.SECRET, + debug: true, + providers: [ + GoogleProvider({ + clientId: process.env.NEXT_APP_GOOGLE_CLIENT_ID ?? "", + clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "", + authorization: { + params: { + scope: "openid email profile", + }, + }, + }), + ], + pages: { + signIn: "/login", + error: "/login", + }, + callbacks: { + async signIn({ profile }) { + const allowedEmails = (process.env.ALLOWED_EMAILS ?? "").split(","); + if (allowedEmails.includes(profile?.email || "")) { + return true; + } else { + return false; + } + }, + }, +}); diff --git a/apps/vpnmanager/src/pages/login.tsx b/apps/vpnmanager/src/pages/login.tsx new file mode 100644 index 000000000..147d1b8d1 --- /dev/null +++ b/apps/vpnmanager/src/pages/login.tsx @@ -0,0 +1,86 @@ +import React, { useEffect } from "react"; +import { Box, Button, Typography, SvgIcon } from "@mui/material"; +import { signIn, useSession } from "next-auth/react"; +import GoogleIcon from "@/vpnmanager/assets/icons/Type=google, Size=24, Color=currentColor.svg"; +import { useRouter } from "next/router"; + +export default function LoginPage() { + const { data: session } = useSession(); + const router = useRouter(); + useEffect(() => { + if (session) { + router.push("/"); + } + }, [session, router]); + const handleGoogleLogin = () => { + signIn("google"); + }; + if (router.query?.error) { + return ( + + + Unauthorized Access + + + You are not allowed to sign in with this account. Please contact + administrator and try again + + + + ); + } + + return ( + + + Welcome to VPN Manager + + + + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a956399cf..288ea0983 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -662,7 +662,7 @@ importers: version: 1.0.7(@swc/core@1.7.23(@swc/helpers@0.5.5))(ajv@8.17.1)(payload@2.28.0(@swc/helpers@0.5.5)(@types/react@18.3.5)(encoding@0.1.13)(typescript@5.5.4)(webpack@5.93.0(@swc/core@1.7.23(@swc/helpers@0.5.5))(webpack-cli@4.10.0)))(sass@1.69.4) '@payloadcms/db-mongodb': specifier: 'catalog:' - version: 1.7.2(@aws-sdk/client-sso-oidc@3.645.0(@aws-sdk/client-sts@3.645.0))(payload@2.28.0(@swc/helpers@0.5.5)(@types/react@18.3.5)(encoding@0.1.13)(typescript@5.5.4)(webpack@5.93.0(@swc/core@1.7.23(@swc/helpers@0.5.5))(webpack-cli@4.10.0))) + version: 1.7.2(@aws-sdk/client-sso-oidc@3.645.0(@aws-sdk/client-sts@3.621.0))(payload@2.28.0(@swc/helpers@0.5.5)(@types/react@18.3.5)(encoding@0.1.13)(typescript@5.5.4)(webpack@5.93.0(@swc/core@1.7.23(@swc/helpers@0.5.5))(webpack-cli@4.10.0))) '@payloadcms/plugin-cloud-storage': specifier: 'catalog:' version: 1.1.3(@aws-sdk/client-s3@3.645.0)(@aws-sdk/lib-storage@3.645.0(@aws-sdk/client-s3@3.645.0))(payload@2.28.0(@swc/helpers@0.5.5)(@types/react@18.3.5)(encoding@0.1.13)(typescript@5.5.4)(webpack@5.93.0(@swc/core@1.7.23(@swc/helpers@0.5.5))(webpack-cli@4.10.0))) @@ -698,7 +698,7 @@ importers: version: 1.9.4 migrate-mongo: specifier: 'catalog:' - version: 11.0.0(mongodb@4.17.1(@aws-sdk/client-sso-oidc@3.645.0(@aws-sdk/client-sts@3.645.0))) + version: 11.0.0(mongodb@4.17.1(@aws-sdk/client-sso-oidc@3.645.0(@aws-sdk/client-sts@3.621.0))) monaco-editor: specifier: 'catalog:' version: 0.51.0 @@ -2346,6 +2346,9 @@ importers: next: specifier: 'catalog:' version: 14.2.8(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(@playwright/test@1.46.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.69.4) + next-auth: + specifier: 'catalog:' + version: 4.24.7(next@14.2.8(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(@playwright/test@1.46.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.69.4))(nodemailer@6.9.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: 'catalog:' version: 18.3.1 @@ -15428,7 +15431,7 @@ snapshots: '@aws-sdk/token-providers@3.614.0(@aws-sdk/client-sso-oidc@3.645.0(@aws-sdk/client-sts@3.645.0))': dependencies: - '@aws-sdk/client-sso-oidc': 3.645.0(@aws-sdk/client-sts@3.645.0) + '@aws-sdk/client-sso-oidc': 3.645.0(@aws-sdk/client-sts@3.621.0) '@aws-sdk/types': 3.609.0 '@smithy/property-provider': 3.1.3 '@smithy/shared-ini-file-loader': 3.1.4 @@ -18356,7 +18359,7 @@ snapshots: - utf-8-validate - webpack-dev-server - '@payloadcms/db-mongodb@1.7.2(@aws-sdk/client-sso-oidc@3.645.0(@aws-sdk/client-sts@3.645.0))(payload@2.28.0(@swc/helpers@0.5.5)(@types/react@18.3.5)(encoding@0.1.13)(typescript@5.5.4)(webpack@5.93.0(@swc/core@1.7.23(@swc/helpers@0.5.5))(webpack-cli@4.10.0)))': + '@payloadcms/db-mongodb@1.7.2(@aws-sdk/client-sso-oidc@3.645.0(@aws-sdk/client-sts@3.621.0))(payload@2.28.0(@swc/helpers@0.5.5)(@types/react@18.3.5)(encoding@0.1.13)(typescript@5.5.4)(webpack@5.93.0(@swc/core@1.7.23(@swc/helpers@0.5.5))(webpack-cli@4.10.0)))': dependencies: bson-objectid: 2.0.4 deepmerge: 4.3.1 @@ -26251,7 +26254,7 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 - migrate-mongo@11.0.0(mongodb@4.17.1(@aws-sdk/client-sso-oidc@3.645.0(@aws-sdk/client-sts@3.645.0))): + migrate-mongo@11.0.0(mongodb@4.17.1(@aws-sdk/client-sso-oidc@3.645.0(@aws-sdk/client-sts@3.621.0))): dependencies: cli-table3: 0.6.5 commander: 9.5.0 From 62155f8e4ba5d37d6a1bd823d6b47b9ef533811d Mon Sep 17 00:00:00 2001 From: Kevin Koech Date: Mon, 23 Sep 2024 11:51:37 +0300 Subject: [PATCH 4/9] Use Get Server side props --- .../src/components/UserAvatar/UserAvatar.tsx | 9 +-------- apps/vpnmanager/src/pages/index.tsx | 14 +++++++++++++- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/apps/vpnmanager/src/components/UserAvatar/UserAvatar.tsx b/apps/vpnmanager/src/components/UserAvatar/UserAvatar.tsx index bcf635442..d4a9f9481 100644 --- a/apps/vpnmanager/src/components/UserAvatar/UserAvatar.tsx +++ b/apps/vpnmanager/src/components/UserAvatar/UserAvatar.tsx @@ -1,14 +1,12 @@ -import React, { useEffect } from "react"; +import React from "react"; import { useState } from "react"; import { Avatar, Menu, MenuItem, IconButton, Typography } from "@mui/material"; import { signOut, useSession } from "next-auth/react"; -import { useRouter } from "next/router"; export default function UserAvatar() { const [anchorEl, setAnchorEl] = useState(null); const { data: session } = useSession(); const open = Boolean(anchorEl); - const router = useRouter(); const handleMenuClick = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); @@ -22,11 +20,6 @@ export default function UserAvatar() { handleMenuClose(); signOut(); }; - useEffect(() => { - if (!session) { - router.push("/login"); - } - }, [router, session]); if (!session) { return null; diff --git a/apps/vpnmanager/src/pages/index.tsx b/apps/vpnmanager/src/pages/index.tsx index 31b9a57d9..0fcb795c6 100644 --- a/apps/vpnmanager/src/pages/index.tsx +++ b/apps/vpnmanager/src/pages/index.tsx @@ -1,6 +1,7 @@ import Statistics from "@/vpnmanager/components/Statistics"; import { Data } from "@/vpnmanager/components/Statistics/Statistics"; import { getStats } from "@/vpnmanager/lib/statistics"; +import { GetSessionParams, getSession } from "next-auth/react"; interface Props { data: Data[]; @@ -10,8 +11,19 @@ export default function Home(props: Props) { return ; } -export async function getStaticProps() { +export async function getServerSideProps( + context: GetSessionParams | undefined, +) { const data = await getStats({ query: { orderBy: "date DESC" } }); + const session = await getSession(context); + if (!session) { + return { + redirect: { + destination: "/login", + permanent: false, + }, + }; + } return { props: { data, From 3d3a819780c6bcd92a46556e31de81648ad33a27 Mon Sep 17 00:00:00 2001 From: Kevin Koech Date: Mon, 23 Sep 2024 15:41:28 +0300 Subject: [PATCH 5/9] Update readme with order of deployment --- apps/vpnmanager/README.md | 18 +++++++++--------- .../contrib/dokku/scripts/processGsheet.mjs | 3 +-- .../contrib/dokku/scripts/processStats.mjs | 3 +-- ...ype=google, Size=24, Color=currentColor.svg | 4 ++-- apps/vpnmanager/src/middleware.ts | 2 +- 5 files changed, 14 insertions(+), 16 deletions(-) diff --git a/apps/vpnmanager/README.md b/apps/vpnmanager/README.md index f3e3979c8..3f422afc8 100644 --- a/apps/vpnmanager/README.md +++ b/apps/vpnmanager/README.md @@ -48,7 +48,14 @@ make vpnmanager 1. Install and setup new application on dokku. [Here is the documentation of how to install and create an app on dokku](https://dokku.com/docs~v0.6.5/deployment/application-deployment/). -2. Build docker image and tag. +2. Persist storage database. + Docker in their [best practices](https://docs.docker.com/build/building/best-practices/#containers-should-be-ephemeral) opine that containers be treated as ephemeral. In order to manage persistent storage for database, a directory outside the container should be mounted. Vpnmanager uses sqlite to locally store data as obtained from Outline VPN API. To persist this data, run the command below + +```bash +dokku storage:mount vpnmanager /var/lib/dokku/data/storage/vpnmanager/data:/workspace/apps/vpnmanager/data +``` + +3. Build docker image and tag. ```bash docker build --target vpnmanager-runner \ @@ -59,15 +66,8 @@ docker build --target vpnmanager-runner \ -t codeforafrica/vpnmanager:latest . ``` -3. Deploy to dokku. +4. Deploy to dokku. ```bash dokku git:from-image vpnmanager codeforafrica/vpnmanager:latest ``` - -4. Persist storage database. - Docker in their [best practices](https://docs.docker.com/build/building/best-practices/#containers-should-be-ephemeral) that containers be treated as ephemeral. In order to manage persistent storage for database, a directory outside the container should be mounted. Vpnmanager uses sqlite to locally store data as obtained from Outline VPN API. To persist this data, run the command below - -```bash -dokku storage:mount vpnmanager /var/lib/dokku/data/storage/vpnmanager/data:/workspace/apps/vpnmanager/data -``` diff --git a/apps/vpnmanager/contrib/dokku/scripts/processGsheet.mjs b/apps/vpnmanager/contrib/dokku/scripts/processGsheet.mjs index ad3dadb53..07d74b60a 100644 --- a/apps/vpnmanager/contrib/dokku/scripts/processGsheet.mjs +++ b/apps/vpnmanager/contrib/dokku/scripts/processGsheet.mjs @@ -5,5 +5,4 @@ async function main() { return response; } -const responseJson = await main(); -console.log(responseJson); +await main(); diff --git a/apps/vpnmanager/contrib/dokku/scripts/processStats.mjs b/apps/vpnmanager/contrib/dokku/scripts/processStats.mjs index d5d3b9a39..35954cf3f 100644 --- a/apps/vpnmanager/contrib/dokku/scripts/processStats.mjs +++ b/apps/vpnmanager/contrib/dokku/scripts/processStats.mjs @@ -5,5 +5,4 @@ async function main() { return response; } -const responseJson = await main(); -console.log(responseJson); +await main(); diff --git a/apps/vpnmanager/src/assets/icons/Type=google, Size=24, Color=currentColor.svg b/apps/vpnmanager/src/assets/icons/Type=google, Size=24, Color=currentColor.svg index 3c1f0c341..2b1839ca2 100644 --- a/apps/vpnmanager/src/assets/icons/Type=google, Size=24, Color=currentColor.svg +++ b/apps/vpnmanager/src/assets/icons/Type=google, Size=24, Color=currentColor.svg @@ -1,5 +1,5 @@ - - + + diff --git a/apps/vpnmanager/src/middleware.ts b/apps/vpnmanager/src/middleware.ts index 7464ed51c..a7c8258c2 100644 --- a/apps/vpnmanager/src/middleware.ts +++ b/apps/vpnmanager/src/middleware.ts @@ -1,7 +1,7 @@ import type { NextRequest } from "next/server"; -// Limit the middleware to paths starting with `/api/` export const config = { + // We are adding an authentication [API KEY] to endpoints that involve writing to the database to ensure the APIs are not misused. matcher: ["/api/statistics/:path*", "/api/users/:path*"], }; From a9921ce1a8d6061dd769735f857c6d9b770226e9 Mon Sep 17 00:00:00 2001 From: Kevin Koech Date: Tue, 24 Sep 2024 08:59:06 +0300 Subject: [PATCH 6/9] Rename api client to fetch Api --- .../contrib/dokku/scripts/{apiClient.mjs => fetchApi.mjs} | 2 +- apps/vpnmanager/contrib/dokku/scripts/processGsheet.mjs | 4 ++-- apps/vpnmanager/contrib/dokku/scripts/processStats.mjs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) rename apps/vpnmanager/contrib/dokku/scripts/{apiClient.mjs => fetchApi.mjs} (87%) diff --git a/apps/vpnmanager/contrib/dokku/scripts/apiClient.mjs b/apps/vpnmanager/contrib/dokku/scripts/fetchApi.mjs similarity index 87% rename from apps/vpnmanager/contrib/dokku/scripts/apiClient.mjs rename to apps/vpnmanager/contrib/dokku/scripts/fetchApi.mjs index 6e3e12e17..62632dc61 100644 --- a/apps/vpnmanager/contrib/dokku/scripts/apiClient.mjs +++ b/apps/vpnmanager/contrib/dokku/scripts/fetchApi.mjs @@ -1,4 +1,4 @@ -export async function apiClient(endpoint, method = "POST", body = null) { +export async function fetchApi(endpoint, method = "POST", body = null) { const NEXT_PUBLIC_APP_URL = process.env.NEXT_PUBLIC_APP_URL; const API_SECRET_KEY = process.env.API_SECRET_KEY; diff --git a/apps/vpnmanager/contrib/dokku/scripts/processGsheet.mjs b/apps/vpnmanager/contrib/dokku/scripts/processGsheet.mjs index 07d74b60a..6b5d48223 100644 --- a/apps/vpnmanager/contrib/dokku/scripts/processGsheet.mjs +++ b/apps/vpnmanager/contrib/dokku/scripts/processGsheet.mjs @@ -1,7 +1,7 @@ -import { apiClient } from "./apiClient.mjs"; +import { fetchApi } from "./fetchApi.mjs"; async function main() { - const response = await apiClient("/api/users"); + const response = await fetchApi("/api/users"); return response; } diff --git a/apps/vpnmanager/contrib/dokku/scripts/processStats.mjs b/apps/vpnmanager/contrib/dokku/scripts/processStats.mjs index 35954cf3f..9875a0a4c 100644 --- a/apps/vpnmanager/contrib/dokku/scripts/processStats.mjs +++ b/apps/vpnmanager/contrib/dokku/scripts/processStats.mjs @@ -1,7 +1,7 @@ -import { apiClient } from "./apiClient.mjs"; +import { fetchApi } from "./fetchApi.mjs"; async function main() { - const response = await apiClient("/api/statistics"); + const response = await fetchApi("/api/statistics"); return response; } From ee16f99fdfaebdabe9aa391a633b0f2b9939a847 Mon Sep 17 00:00:00 2001 From: Kevin Koech Date: Tue, 24 Sep 2024 14:59:47 +0300 Subject: [PATCH 7/9] Update readme and fix improve load --- apps/vpnmanager/README.md | 55 ++++++++++++++++--- .../src/components/Statistics/Statistics.tsx | 24 +++++++- apps/vpnmanager/src/pages/index.tsx | 22 +++----- 3 files changed, 77 insertions(+), 24 deletions(-) diff --git a/apps/vpnmanager/README.md b/apps/vpnmanager/README.md index 3f422afc8..c17bc6ebb 100644 --- a/apps/vpnmanager/README.md +++ b/apps/vpnmanager/README.md @@ -1,10 +1,39 @@ # VPN Manager -This is the cfa Outline VPN Manager +VPN Manager is designed to manage and track usage statistics for Outline VPN users. -### Development +- The app retrieves usage statistics from Outline VPN and stores them in a local database for efficient querying and analysis. +- Users can access a user-friendly UI to query and analyze their VPN usage data over specific time periods. +- VPN Manager automatically generates VPN keys for new hires and sends them an email with detailed setup instructions for configuring their VPN access. -## Getting Started +## Development + +### Configuring Google Provider for Authentication + +1. Visit the Google Cloud Console. +2. Select or create a new project. +3. In the navigation menu, go to APIs & Services > Credentials. +4. Click on Create Credentials and choose OAuth 2.0 Client IDs. +5. Set the Application type to Web Application. +6. In the Authorized redirect URIs, add the following URIs: + +- `http://localhost:3000/login` (for local development) +- Any other production URLs such as `https://vpnmanager.dev.codeforafrica.org/login` + +Google requires certain scopes to retrieve the necessary user information for authentication. You must explicitly set the following scopes: + +- `openid`: To obtain information about the authenticated user's identity. +- `email`: To retrieve the user's email address. +- `profile`: To get basic profile information, such as the user's name and profile picture. + +After the app is created, take note of the Client ID and Client Secret. These will be used in your environment variables(.env.local). + +```bash + NEXT_APP_GOOGLE_CLIENT_ID= + GOOGLE_CLIENT_SECRET= +``` + +### Getting Started First create `.env.local` file in the root directory of the project. @@ -18,21 +47,29 @@ and modify the `.env.local` file according to your needs. The default `.env` file is for the 'Publicly' visible environment variables. -## Script +## Run the development server + +- Install dependancies ```bash -pnpm process-new-hires +pnpm install ``` -## Web - -Run the development server: +- if you are in the `apps/vpnmanager` directory ```bash pnpm dev ``` -### Deployment. +or + +```bash +pnpm dev --filter=vpnmanager +``` + +if you are executing from ui directory. + +### Deployment ```bash docker-compose up --build vpnmanager diff --git a/apps/vpnmanager/src/components/Statistics/Statistics.tsx b/apps/vpnmanager/src/components/Statistics/Statistics.tsx index 5710aca64..8bf56b1dc 100644 --- a/apps/vpnmanager/src/components/Statistics/Statistics.tsx +++ b/apps/vpnmanager/src/components/Statistics/Statistics.tsx @@ -151,12 +151,16 @@ const Statistics: React.FC = ({ data: result }) => { label="Date" name="date" type="date" - InputLabelProps={{ shrink: true }} variant="outlined" value={filters["date"]} onChange={handleFilterChange} placeholder="Date Start" size="small" + sx={{ + "& .MuiFormLabel-root": { + color: "inherit", + }, + }} fullWidth /> @@ -168,6 +172,12 @@ const Statistics: React.FC = ({ data: result }) => { onChange={handleFilterChange} InputLabelProps={{ shrink: true }} placeholder="Email" + label="Email" + sx={{ + "& .MuiFormLabel-root": { + color: "inherit", + }, + }} size="small" fullWidth /> @@ -176,12 +186,18 @@ const Statistics: React.FC = ({ data: result }) => { @@ -189,6 +205,7 @@ const Statistics: React.FC = ({ data: result }) => { = ({ data: result }) => { size="small" placeholder="Date End" fullWidth + sx={{ + "& .MuiFormLabel-root": { + color: "inherit", + }, + }} /> diff --git a/apps/vpnmanager/src/pages/index.tsx b/apps/vpnmanager/src/pages/index.tsx index 0fcb795c6..9b9bd6e78 100644 --- a/apps/vpnmanager/src/pages/index.tsx +++ b/apps/vpnmanager/src/pages/index.tsx @@ -2,6 +2,7 @@ import Statistics from "@/vpnmanager/components/Statistics"; import { Data } from "@/vpnmanager/components/Statistics/Statistics"; import { getStats } from "@/vpnmanager/lib/statistics"; import { GetSessionParams, getSession } from "next-auth/react"; +import { format, startOfYesterday } from "date-fns"; interface Props { data: Data[]; @@ -14,7 +15,11 @@ export default function Home(props: Props) { export async function getServerSideProps( context: GetSessionParams | undefined, ) { - const data = await getStats({ query: { orderBy: "date DESC" } }); + const yesterday = startOfYesterday(); + + const data = await getStats({ + query: { orderBy: "date DESC", date: format(yesterday, "yyyy-MM-dd") }, + }); const session = await getSession(context); if (!session) { return { @@ -39,19 +44,8 @@ export async function getServerSideProps( url: "https://cfa.dev.codeforafrica.org/media/cfa-logo.svg", src: "https://cfa.dev.codeforafrica.org/media/cfa-logo.svg", }, - menus: [ - { - label: "Our Work", - href: "/", - }, - ], - socialLinks: [ - { - platform: "Github", - url: "https://github.com/CodeForAfrica", - id: "651e89dec938b817cab85676", - }, - ], + menus: [], + socialLinks: [], }, }, }; From 1c67429063886118032e47368160feb322c67248 Mon Sep 17 00:00:00 2001 From: Kevin Koech Date: Tue, 24 Sep 2024 16:24:15 +0300 Subject: [PATCH 8/9] Fixed file names, refactored a bit --- apps/vpnmanager/README.md | 22 +++++++++---------- .../scripts/{fetchApi.mjs => fetchJson.mjs} | 8 ++----- .../contrib/dokku/scripts/processGsheet.mjs | 5 ++--- .../contrib/dokku/scripts/processStats.mjs | 5 ++--- .../NavBarNavList/NavBarNavList.tsx | 3 +-- .../src/pages/api/auth/[...nextauth].ts | 9 ++++---- apps/vpnmanager/src/pages/api/statistics.ts | 2 +- apps/vpnmanager/src/pages/api/users.ts | 2 +- apps/vpnmanager/src/pages/index.tsx | 11 +++++----- 9 files changed, 30 insertions(+), 37 deletions(-) rename apps/vpnmanager/contrib/dokku/scripts/{fetchApi.mjs => fetchJson.mjs} (74%) diff --git a/apps/vpnmanager/README.md b/apps/vpnmanager/README.md index c17bc6ebb..217ed86a4 100644 --- a/apps/vpnmanager/README.md +++ b/apps/vpnmanager/README.md @@ -28,7 +28,7 @@ Google requires certain scopes to retrieve the necessary user information for au After the app is created, take note of the Client ID and Client Secret. These will be used in your environment variables(.env.local). -```bash +```sh NEXT_APP_GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= ``` @@ -37,7 +37,7 @@ After the app is created, take note of the Client ID and Client Secret. These wi First create `.env.local` file in the root directory of the project. -```bash +```sh cp env.template .env.local ``` @@ -51,33 +51,33 @@ The default `.env` file is for the 'Publicly' visible environment variables. - Install dependancies -```bash +```sh pnpm install ``` - if you are in the `apps/vpnmanager` directory -```bash +```sh pnpm dev ``` or -```bash -pnpm dev --filter=vpnmanager +```sh +pnpm --filter=vpnmanager dev ``` if you are executing from ui directory. ### Deployment -```bash +```sh docker-compose up --build vpnmanager ``` or -```bash +```sh make vpnmanager ``` @@ -88,13 +88,13 @@ make vpnmanager 2. Persist storage database. Docker in their [best practices](https://docs.docker.com/build/building/best-practices/#containers-should-be-ephemeral) opine that containers be treated as ephemeral. In order to manage persistent storage for database, a directory outside the container should be mounted. Vpnmanager uses sqlite to locally store data as obtained from Outline VPN API. To persist this data, run the command below -```bash +```sh dokku storage:mount vpnmanager /var/lib/dokku/data/storage/vpnmanager/data:/workspace/apps/vpnmanager/data ``` 3. Build docker image and tag. -```bash +```sh docker build --target vpnmanager-runner \ --build-arg SENTRY_ORG=$SENTRY_ORG \ --build-arg SENTRY_PROJECT=$SENTRY_PROJECT \ @@ -105,6 +105,6 @@ docker build --target vpnmanager-runner \ 4. Deploy to dokku. -```bash +```sh dokku git:from-image vpnmanager codeforafrica/vpnmanager:latest ``` diff --git a/apps/vpnmanager/contrib/dokku/scripts/fetchApi.mjs b/apps/vpnmanager/contrib/dokku/scripts/fetchJson.mjs similarity index 74% rename from apps/vpnmanager/contrib/dokku/scripts/fetchApi.mjs rename to apps/vpnmanager/contrib/dokku/scripts/fetchJson.mjs index 62632dc61..d24a5874b 100644 --- a/apps/vpnmanager/contrib/dokku/scripts/fetchApi.mjs +++ b/apps/vpnmanager/contrib/dokku/scripts/fetchJson.mjs @@ -1,4 +1,4 @@ -export async function fetchApi(endpoint, method = "POST", body = null) { +export async function fetchJson(path, method = "POST", body = null) { const NEXT_PUBLIC_APP_URL = process.env.NEXT_PUBLIC_APP_URL; const API_SECRET_KEY = process.env.API_SECRET_KEY; @@ -11,16 +11,12 @@ export async function fetchApi(endpoint, method = "POST", body = null) { method, headers, }; - if (body) { options.body = JSON.stringify(body); } - - const res = await fetch(`${NEXT_PUBLIC_APP_URL}${endpoint}`, options); - + const res = await fetch(`${NEXT_PUBLIC_APP_URL}${path}`, options); if (!res.ok) { throw new Error(`API call failed with status ${res.status}`); } - return res.json(); } diff --git a/apps/vpnmanager/contrib/dokku/scripts/processGsheet.mjs b/apps/vpnmanager/contrib/dokku/scripts/processGsheet.mjs index 6b5d48223..f2a3d7783 100644 --- a/apps/vpnmanager/contrib/dokku/scripts/processGsheet.mjs +++ b/apps/vpnmanager/contrib/dokku/scripts/processGsheet.mjs @@ -1,8 +1,7 @@ -import { fetchApi } from "./fetchApi.mjs"; +import { fetchJson } from "./fetchJson.mjs"; async function main() { - const response = await fetchApi("/api/users"); - return response; + return fetchJson("/api/users"); } await main(); diff --git a/apps/vpnmanager/contrib/dokku/scripts/processStats.mjs b/apps/vpnmanager/contrib/dokku/scripts/processStats.mjs index 9875a0a4c..676c13ccc 100644 --- a/apps/vpnmanager/contrib/dokku/scripts/processStats.mjs +++ b/apps/vpnmanager/contrib/dokku/scripts/processStats.mjs @@ -1,8 +1,7 @@ -import { fetchApi } from "./fetchApi.mjs"; +import { fetchJson } from "./fetchJson.mjs"; async function main() { - const response = await fetchApi("/api/statistics"); - return response; + return fetchJson("/api/statistics"); } await main(); diff --git a/apps/vpnmanager/src/components/NavBarNavList/NavBarNavList.tsx b/apps/vpnmanager/src/components/NavBarNavList/NavBarNavList.tsx index 0abcb4ae0..ae1576878 100644 --- a/apps/vpnmanager/src/components/NavBarNavList/NavBarNavList.tsx +++ b/apps/vpnmanager/src/components/NavBarNavList/NavBarNavList.tsx @@ -2,7 +2,7 @@ import { NavList, NavListItem, SocialMediaIconLink } from "@commons-ui/core"; import { Link } from "@commons-ui/next"; import type { LinkProps } from "@mui/material"; import React from "react"; -import UserAvatar from "../UserAvatar"; +import UserAvatar from "@/vpnmanager/components/UserAvatar"; interface NavListItemProps extends LinkProps {} @@ -21,7 +21,6 @@ type SocialMediaPlatform = interface SocialMediaLink { platform: SocialMediaPlatform; - // TODO(koech): Confirm why we chose url instead of href in the CMS url: string; } diff --git a/apps/vpnmanager/src/pages/api/auth/[...nextauth].ts b/apps/vpnmanager/src/pages/api/auth/[...nextauth].ts index b2fa672c3..9c32ebb66 100644 --- a/apps/vpnmanager/src/pages/api/auth/[...nextauth].ts +++ b/apps/vpnmanager/src/pages/api/auth/[...nextauth].ts @@ -3,7 +3,7 @@ import GoogleProvider from "next-auth/providers/google"; export default NextAuth({ secret: process.env.SECRET, - debug: true, + debug: process.env.NODE_ENV === "development", providers: [ GoogleProvider({ clientId: process.env.NEXT_APP_GOOGLE_CLIENT_ID ?? "", @@ -21,12 +21,11 @@ export default NextAuth({ }, callbacks: { async signIn({ profile }) { - const allowedEmails = (process.env.ALLOWED_EMAILS ?? "").split(","); - if (allowedEmails.includes(profile?.email || "")) { - return true; - } else { + if (!profile?.email) { return false; } + const allowedEmails = process.env.ALLOWED_EMAILS?.split?.(",") ?? []; + return allowedEmails?.includes(profile.email); }, }, }); diff --git a/apps/vpnmanager/src/pages/api/statistics.ts b/apps/vpnmanager/src/pages/api/statistics.ts index 607d6d1b6..ec3518cc6 100644 --- a/apps/vpnmanager/src/pages/api/statistics.ts +++ b/apps/vpnmanager/src/pages/api/statistics.ts @@ -11,7 +11,7 @@ export async function handler(req: NextApiRequest, res: NextApiResponse) { try { const statFunc = methodToFunction[req.method as RestMethods]; if (!statFunc) { - return res.status(404).json({ message: "Requested path not found" }); + return res.status(405).json({ message: "Method not allowed" }); } const data = await statFunc(req); return res.status(200).json(data); diff --git a/apps/vpnmanager/src/pages/api/users.ts b/apps/vpnmanager/src/pages/api/users.ts index 57cb42bfc..30c26469b 100644 --- a/apps/vpnmanager/src/pages/api/users.ts +++ b/apps/vpnmanager/src/pages/api/users.ts @@ -10,7 +10,7 @@ export async function handler(req: NextApiRequest, res: NextApiResponse) { try { const statFunc = methodToFunction[req.method as RestMethods]; if (!statFunc) { - return res.status(404).json({ message: "Requested path not found" }); + return res.status(405).json({ message: "Method not allowed" }); } const data = await statFunc(req); return res.status(200).json(data); diff --git a/apps/vpnmanager/src/pages/index.tsx b/apps/vpnmanager/src/pages/index.tsx index 9b9bd6e78..94af543a8 100644 --- a/apps/vpnmanager/src/pages/index.tsx +++ b/apps/vpnmanager/src/pages/index.tsx @@ -15,11 +15,6 @@ export default function Home(props: Props) { export async function getServerSideProps( context: GetSessionParams | undefined, ) { - const yesterday = startOfYesterday(); - - const data = await getStats({ - query: { orderBy: "date DESC", date: format(yesterday, "yyyy-MM-dd") }, - }); const session = await getSession(context); if (!session) { return { @@ -29,6 +24,12 @@ export async function getServerSideProps( }, }; } + + const yesterday = startOfYesterday(); + + const data = await getStats({ + query: { orderBy: "date DESC", date: format(yesterday, "yyyy-MM-dd") }, + }); return { props: { data, From 4bcd784fdea256d6d36733a0f617e09fc54d5029 Mon Sep 17 00:00:00 2001 From: Kevin Koech Date: Tue, 24 Sep 2024 16:55:49 +0300 Subject: [PATCH 9/9] Fix spacing --- apps/vpnmanager/src/pages/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/vpnmanager/src/pages/index.tsx b/apps/vpnmanager/src/pages/index.tsx index 94af543a8..d250ac0dd 100644 --- a/apps/vpnmanager/src/pages/index.tsx +++ b/apps/vpnmanager/src/pages/index.tsx @@ -26,7 +26,6 @@ export async function getServerSideProps( } const yesterday = startOfYesterday(); - const data = await getStats({ query: { orderBy: "date DESC", date: format(yesterday, "yyyy-MM-dd") }, });