Skip to content

Commit

Permalink
Merge pull request #900 from CodeForAfrica/vpnmanager-next-auth
Browse files Browse the repository at this point in the history
@vpnmanager auth
  • Loading branch information
koechkevin authored Sep 24, 2024
2 parents f86c8c0 + 4bcd784 commit daa0fa4
Show file tree
Hide file tree
Showing 22 changed files with 383 additions and 61 deletions.
5 changes: 5 additions & 0 deletions apps/vpnmanager/.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,8 @@ SENTRY_DSN=
VPN_MANAGER_SENDGRID_API_KEY=
[email protected]
VPN_MANAGER_SENDGRID_FROM_NAME=CfA Security

API_SECRET_KEY=
NEXT_APP_GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
ALLOWED_EMAILS=
93 changes: 79 additions & 14 deletions apps/vpnmanager/README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,43 @@
# 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).

```sh
NEXT_APP_GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
```

### Getting Started

First create `.env.local` file in the root directory of the project.

```bash
```sh
cp env.template .env.local
```

Expand All @@ -18,28 +47,64 @@ 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

```bash
pnpm process-new-hires
```
- Install dependancies

## Web
```sh
pnpm install
```

Run the development server:
- if you are in the `apps/vpnmanager` directory

```bash
```sh
pnpm dev
```

### Deployment.
or

```sh
pnpm --filter=vpnmanager dev
```

```bash
if you are executing from ui directory.

### Deployment

```sh
docker-compose up --build vpnmanager
```

or

```bash
```sh
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. 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

```sh
dokku storage:mount vpnmanager /var/lib/dokku/data/storage/vpnmanager/data:/workspace/apps/vpnmanager/data
```

3. Build docker image and tag.

```sh
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 .
```

4. Deploy to dokku.

```sh
dokku git:from-image vpnmanager codeforafrica/vpnmanager:latest
```
4 changes: 4 additions & 0 deletions apps/vpnmanager/contrib/dokku/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
{
"command": "node contrib/dokku/scripts/processGsheet.mjs",
"schedule": "@hourly"
},
{
"command": "node contrib/dokku/scripts/processStats.mjs",
"schedule": "0 23 * * *"
}
]
}
22 changes: 22 additions & 0 deletions apps/vpnmanager/contrib/dokku/scripts/fetchJson.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
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;

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}${path}`, options);
if (!res.ok) {
throw new Error(`API call failed with status ${res.status}`);
}
return res.json();
}
15 changes: 4 additions & 11 deletions apps/vpnmanager/contrib/dokku/scripts/processGsheet.mjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
import { fetchJson } from "./fetchJson.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();
return fetchJson("/api/users");
}

const responseJson = await main();
console.log(responseJson);
await main();
7 changes: 7 additions & 0 deletions apps/vpnmanager/contrib/dokku/scripts/processStats.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { fetchJson } from "./fetchJson.mjs";

async function main() {
return fetchJson("/api/statistics");
}

await main();
1 change: 1 addition & 0 deletions apps/vpnmanager/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"googleapis": "catalog:",
"jest": "catalog:",
"next": "catalog:",
"next-auth": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"tsc-alias": "catalog:"
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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 "@/vpnmanager/components/UserAvatar";

interface NavListItemProps extends LinkProps {}

Expand All @@ -20,7 +21,6 @@ type SocialMediaPlatform =

interface SocialMediaLink {
platform: SocialMediaPlatform;
// TODO(koech): Confirm why we chose url instead of href in the CMS
url: string;
}

Expand Down Expand Up @@ -88,6 +88,9 @@ const NavBarNavList = React.forwardRef(function NavBarNavList(
</NavListItem>
);
})}
<NavListItem>
<UserAvatar />
</NavListItem>
</NavList>
);
});
Expand Down
24 changes: 23 additions & 1 deletion apps/vpnmanager/src/components/Statistics/Statistics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,12 +151,16 @@ const Statistics: React.FC<Props> = ({ 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
/>
</Grid>
Expand All @@ -168,6 +172,12 @@ const Statistics: React.FC<Props> = ({ data: result }) => {
onChange={handleFilterChange}
InputLabelProps={{ shrink: true }}
placeholder="Email"
label="Email"
sx={{
"& .MuiFormLabel-root": {
color: "inherit",
},
}}
size="small"
fullWidth
/>
Expand All @@ -176,26 +186,38 @@ const Statistics: React.FC<Props> = ({ data: result }) => {
<TextField
name="date.start"
type="date"
label="Start Date"
InputLabelProps={{ shrink: true }}
variant="outlined"
value={filters["date.start"]}
onChange={handleFilterChange}
placeholder="Date Start"
size="small"
sx={{
"& .MuiFormLabel-root": {
color: "inherit",
},
}}
fullWidth
/>
</Grid>
<Grid item xs={12} sm={6} md={4} lg={3}>
<TextField
name="date.end"
type="date"
label="End Date"
InputLabelProps={{ shrink: true }}
variant="outlined"
value={filters["date.end"]}
onChange={handleFilterChange}
size="small"
placeholder="Date End"
fullWidth
sx={{
"& .MuiFormLabel-root": {
color: "inherit",
},
}}
/>
</Grid>
<Grid item xs={12} sm={6} md={4} lg={3}>
Expand Down
51 changes: 51 additions & 0 deletions apps/vpnmanager/src/components/UserAvatar/UserAvatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React from "react";
import { useState } from "react";
import { Avatar, Menu, MenuItem, IconButton, Typography } from "@mui/material";
import { signOut, useSession } from "next-auth/react";

export default function UserAvatar() {
const [anchorEl, setAnchorEl] = useState(null);
const { data: session } = useSession();
const open = Boolean(anchorEl);

const handleMenuClick = (event: React.MouseEvent<any>) => {
setAnchorEl(event.currentTarget);
};

const handleMenuClose = () => {
setAnchorEl(null);
};

const handleLogout = () => {
handleMenuClose();
signOut();
};

if (!session) {
return null;
}

return (
<>
<IconButton onClick={handleMenuClick} size="small" sx={{ p: 2 }}>
<Avatar
alt={session?.user?.name || "Name"}
src={session.user?.image ?? undefined}
/>
</IconButton>

<Menu
anchorEl={anchorEl}
open={open}
onClose={handleMenuClose}
onClick={handleMenuClose}
transformOrigin={{ horizontal: "right", vertical: "top" }}
anchorOrigin={{ horizontal: "right", vertical: "bottom" }}
>
<MenuItem onClick={handleLogout}>
<Typography variant="inherit">Logout</Typography>
</MenuItem>
</Menu>
</>
);
}
3 changes: 3 additions & 0 deletions apps/vpnmanager/src/components/UserAvatar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import UserAvatar from "./UserAvatar";

export default UserAvatar;
1 change: 1 addition & 0 deletions apps/vpnmanager/src/lib/processUsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,5 @@ export async function processNewUsers() {
if (fulfilled.length) {
updateSheet(fulfilled);
}
return fulfilled;
}
4 changes: 2 additions & 2 deletions apps/vpnmanager/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { NextRequest } from "next/server";

// Limit the middleware to paths starting with `/api/`
export const config = {
matcher: "/api/:function*",
// 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*"],
};

export function middleware(req: NextRequest) {
Expand Down
Loading

0 comments on commit daa0fa4

Please sign in to comment.