First working version of EVE-PI

This commit is contained in:
Calli
2023-06-23 12:07:45 +03:00
commit 5429ff7969
46 changed files with 28631 additions and 0 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.env*
.git
.gitignore
.github
*.md
node_modules
npm-debug.log
docker
scripts
.next

4
.env.example Normal file
View File

@@ -0,0 +1,4 @@
# Populate these env variables from the app you create at https://developers.eveonline.com/
EVE_SSO_CLIENT_ID=Client ID
EVE_SSO_SECRET=Secret Key
EVE_SSO_CALLBACK_URL=Callback URL (This should be the domain you are hosting at or if run locally it should be http://localhost:3000)

3
.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

36
.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

51
Dockerfile Normal file
View File

@@ -0,0 +1,51 @@
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
CMD ["node", "server.js"]

49
README.md Normal file
View File

@@ -0,0 +1,49 @@
# Callis PI tool
Simple tool to track your PI planet extractors. Login with your characters and hit refresh.
## [Avanto hosted PI tool](https://pi.avanto.tk)
![Screenshot of PI tool](https://github.com/calli-eve/eve-pi/blob/main/images/eve-pi.png)
Features:
- Group characters to account groups by clickin on the character icon
- Track amount of planets
- Track extractor status
- When the cycle will end
- Highlight the planet if extractor has stopped or has not been started.
## Security
All eve sso information is stored in your browser and refresh token is encrypted with apps EVE SSO secret. Backend processes only the token exchange, refresh and revoke that need the EVE_SSO_SECRET. Everything else is handled in frontend.
## EVE SSO Callback
Callback is handled by the SPA so when running you should point to the domain. There is no separate callback path.
## Running
To run the app you need to create a EVE SSO application here: https://developers.eveonline.com/
You will need these env variables from the application settings:
```
EVE_SSO_CLIENT_ID=Client ID
EVE_SSO_SECRET=Secret Key
EVE_SSO_CALLBACK_URL=Callback URL (This should be the domain you are hosting at or if run locally it should be http://localhost:3000)
```
## Run locally
1. Create .env file in the directory root and populate with env variables you get from the EVE app you created. Example env file: .env.example
2. run `npm run dev`
## Run the container
1. Populate the environment variables in .env file
2. Run 'docker-compose up
## Hosting
Easiest way to host is deploy the app through Vercel https://vercel.com. Login with github, point to eve-pi repository, setup the env variables and the app should work out of the box.

13
docker-compose.yml Normal file
View File

@@ -0,0 +1,13 @@
---
version: "2.1"
services:
eve-pi:
build: .
container_name: eve-pi
environment:
- EVE_SSO_CLIENT_ID=${EVE_SSO_CLIENT_ID}
- EVE_SSO_CALLBACK_URL=${EVE_SSO_CALLBACK_URL}
- EVE_SSO_SECRET=${EVE_SSO_SECRET}
ports:
- 3000:3000
restart: unless-stopped

BIN
images/eve-pi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 792 KiB

16
next.config.js Normal file
View File

@@ -0,0 +1,16 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "images.evetech.net",
port: "",
pathname: "/**",
},
],
},
output: "standalone",
};
module.exports = nextConfig;

8066
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "eve-pi",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"esi-swagger": "npx swagger-typescript-api -p https://esi.evetech.net/latest/swagger.json -o ./src -n esi-api.ts"
},
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@fontsource/roboto": "^5.0.3",
"@mui/icons-material": "^5.11.16",
"@mui/material": "^5.13.5",
"@types/node": "20.3.1",
"@types/react": "18.2.12",
"@types/react-dom": "18.2.5",
"autoprefixer": "10.4.14",
"crypto-js": "^4.1.1",
"eslint": "8.42.0",
"eslint-config-next": "13.4.5",
"luxon": "^3.3.0",
"next": "13.4.5",
"react": "18.2.0",
"react-countdown": "^2.3.5",
"react-dom": "18.2.0",
"sharp": "^0.32.1",
"typescript": "5.1.3"
},
"devDependencies": {
"@types/crypto-js": "^4.1.1",
"@types/luxon": "^3.3.0"
}
}

BIN
public/barren.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

BIN
public/gas.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
public/ice.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

BIN
public/lava.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
public/noplanet.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
public/oceanic.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
public/plasma.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
public/stopped.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
public/storm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
public/temperate.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View File

@@ -0,0 +1,35 @@
import { AccessToken } from "@/types";
import { Box, Stack, Typography } from "@mui/material";
import { CharacterRow } from "../Characters/CharacterRow";
import { PlanetaryInteractionRow } from "../PlanetaryInteraction/PlanetaryInteractionRow";
export const AccountCard = ({
characters,
sessionReady,
}: {
characters: AccessToken[];
sessionReady: boolean;
}) => {
return (
<Box
sx={{
background: "#262626",
padding: 1,
borderRadius: 1,
margin: 1,
}}
>
<Typography paddingLeft={2}>Account: {characters[0].account}</Typography>
{characters.map((c) => (
<Stack
key={c.character.characterId}
direction="row"
alignItems="flex-start"
>
<CharacterRow character={c} />
{sessionReady && <PlanetaryInteractionRow character={c} />}
</Stack>
))}
</Box>
);
};

View File

@@ -0,0 +1,70 @@
import { Button, Dialog, DialogActions, DialogTitle } from "@mui/material";
import { AccessToken, CharacterUpdate } from "../../../types";
import { useEffect, useState } from "react";
import TextField from "@mui/material/TextField";
import { revokeToken } from "@/esi-sso";
export const CharacterDialog = ({
character,
closeDialog,
deleteCharacter,
updateCharacter,
}: {
character: AccessToken | undefined;
closeDialog: () => void;
deleteCharacter: (character: AccessToken) => void;
updateCharacter: (characer: AccessToken, update: CharacterUpdate) => void;
}) => {
const [account, setAccount] = useState("");
useEffect(() => {
if (character?.account) setAccount(character.account);
}, [character]);
const logout = (character: AccessToken) => {
revokeToken(character)
.then()
.catch((e) => console.log("Logout failed"));
};
return (
<Dialog open={character !== undefined} onClose={closeDialog}>
<DialogTitle>{character && character.character.name}</DialogTitle>
<TextField
id="outlined-basic"
label="Account name"
variant="outlined"
value={account ?? ""}
sx={{ margin: 1 }}
onChange={(event) => setAccount(event.target.value)}
/>
<DialogActions>
<Button
onClick={() => {
character && updateCharacter(character, { account });
closeDialog();
}}
variant="contained"
>
Save
</Button>
<Button
onClick={() => {
character && deleteCharacter(character);
character && logout(character);
}}
variant="contained"
>
Delete
</Button>
<Button
onClick={() => {
closeDialog();
}}
>
Cancel
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -0,0 +1,57 @@
"use client";
import { useContext, useState } from "react";
import Image from "next/image";
import Stack from "@mui/material/Stack";
import { styled } from "@mui/material/styles";
import React from "react";
import { CharacterDialog } from "./CharacterDialog";
import { AccessToken } from "@/types";
import { Box } from "@mui/material";
import { EVE_IMAGE_URL } from "@/const";
import { CharacterContext } from "@/app/context/Context";
const StackItem = styled(Stack)(({ theme }) => ({
...theme.typography.body2,
padding: theme.spacing(2),
textAlign: "left",
justifyContent: "center",
alignItems: "center",
}));
export const CharacterRow = ({ character }: { character: AccessToken }) => {
const [selectedCharacter, setSelectedCharacter] = useState<
AccessToken | undefined
>(undefined);
const { deleteCharacter, updateCharacter } = useContext(CharacterContext);
return (
<StackItem
key={character.character.characterId}
alignItems="flex-start"
justifyContent="flex-start"
>
<CharacterDialog
character={selectedCharacter}
deleteCharacter={deleteCharacter}
updateCharacter={updateCharacter}
closeDialog={() => setSelectedCharacter(undefined)}
/>
<Box
onClick={() => setSelectedCharacter(character)}
display="flex"
flexDirection="column"
>
<Image
src={`${EVE_IMAGE_URL}/characters/${character.character.characterId}/portrait?size=64`}
alt=""
width={120}
height={120}
style={{ marginBottom: "0.2rem" }}
/>
{character.character.name}
</Box>
</StackItem>
);
};

View File

@@ -0,0 +1,22 @@
import { Button } from "@mui/material";
import { useState } from "react";
import { LoginDialog } from "./LoginDialog";
export const LoginButton = () => {
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
return (
<>
<Button
style={{ width: "100%" }}
variant="contained"
onClick={() => setLoginDialogOpen(true)}
>
Login
</Button>
<LoginDialog
open={loginDialogOpen}
closeDialog={() => setLoginDialogOpen(false)}
/>
</>
);
};

View File

@@ -0,0 +1,71 @@
import DialogTitle from "@mui/material/DialogTitle";
import Dialog from "@mui/material/Dialog";
import Button from "@mui/material/Button";
import { Box, DialogActions } from "@mui/material";
import { useContext, useEffect, useState } from "react";
import { eveSwagger, loginParameters } from "@/esi-sso";
import { SessionContext } from "@/app/context/Context";
export const LoginDialog = ({
open,
closeDialog,
}: {
open: boolean;
closeDialog: () => void;
}) => {
const [scopes] = useState<string[]>(["esi-planets.manage_planets.v1"]);
const [selectedScopes, setSelectedScopes] = useState<string[]>([
"esi-planets.manage_planets.v1",
]);
const [ssoUrl, setSsoUrl] = useState<string | undefined>(undefined);
const [loginUrl, setLoginUrl] = useState<string | undefined>(undefined);
const { EVE_SSO_CLIENT_ID, EVE_SSO_CALLBACK_URL } =
useContext(SessionContext);
useEffect(() => {
eveSwagger().then((json) => {
setSsoUrl(json.securityDefinitions.evesso.authorizationUrl);
});
}, []);
useEffect(() => {
if (!ssoUrl || selectedScopes.length === 0) return;
loginParameters(
selectedScopes,
EVE_SSO_CLIENT_ID,
EVE_SSO_CALLBACK_URL
).then((res) => setLoginUrl(ssoUrl + "?" + res));
}, [selectedScopes, ssoUrl, EVE_SSO_CLIENT_ID, EVE_SSO_CALLBACK_URL]);
return (
<Dialog open={open} onClose={closeDialog}>
<DialogTitle>Select scopes to login with</DialogTitle>
{scopes.map((scope) => (
<Box key={scope} padding={1}>
<input
type="checkbox"
checked={selectedScopes.some((v) => v === scope)}
onChange={() => {
selectedScopes.some((v) => v === scope)
? setSelectedScopes(selectedScopes.filter((s) => s !== scope))
: setSelectedScopes([...selectedScopes, scope]);
}}
/>
{scope}
</Box>
))}
<DialogActions>
<Button
variant="contained"
onClick={() => {
window.open(loginUrl, "_self");
}}
>
Login
</Button>
<Button onClick={closeDialog}>Close</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -0,0 +1,16 @@
import { SessionContext } from "@/app/context/Context";
import { Button } from "@mui/material";
import { useContext } from "react";
export const RefreshButton = () => {
const { refreshSession } = useContext(SessionContext);
return (
<Button
style={{ width: "100%" }}
variant="contained"
onClick={refreshSession}
>
Refresh
</Button>
);
};

View File

@@ -0,0 +1,45 @@
import { useContext } from "react";
import { Box, Grid, Stack } from "@mui/material";
import { LoginButton } from "./Login/LoginButton";
import { RefreshButton } from "./Login/RefreshButton";
import { AccountCard } from "./Account/AccountCard";
import { AccessToken } from "@/types";
import { CharacterContext } from "../context/Context";
interface Grouped {
[key: string]: AccessToken[];
}
export const MainGrid = ({ sessionReady }: { sessionReady: boolean }) => {
const { characters } = useContext(CharacterContext);
const groupByAccount = characters.reduce<Grouped>((group, character) => {
const { account } = character;
group[account ?? ""] = group[account ?? ""] ?? [];
group[account ?? ""].push(character);
return group;
}, {});
return (
<Box sx={{ flexGrow: 1 }}>
<Grid container spacing={1}>
<Grid item xs={2}>
<Stack direction="row" spacing={1}>
<LoginButton />
<RefreshButton />
</Stack>
</Grid>
</Grid>
<Grid container spacing={1}>
<Grid item xs={12}>
{Object.values(groupByAccount).map((g, id) => (
<AccountCard
key={`account-${id}-${g[0].account}`}
characters={g}
sessionReady={sessionReady}
/>
))}
</Grid>
</Grid>
</Box>
);
};

View File

@@ -0,0 +1,32 @@
import { Stack, Typography, styled } from "@mui/material";
import Image from "next/image";
const StackItem = styled(Stack)(({ theme }) => ({
...theme.typography.body2,
padding: 0,
textAlign: "left",
justifyContent: "center",
alignItems: "center",
}));
export const NoPlanetCard = ({}: {}) => {
return (
<StackItem alignItems="flex-start" className="poop" height="100%">
<Image
src={`/noplanet.png`}
alt=""
width={120}
height={120}
style={{ marginBottom: "0.2rem" }}
/>
<Image
width={64}
height={64}
src={`/stopped.png`}
alt=""
style={{ position: "absolute" }}
/>
<Typography>No planet</Typography>
</StackItem>
);
};

View File

@@ -0,0 +1,146 @@
import { Stack, Typography, styled } from "@mui/material";
import Image from "next/image";
import { AccessToken, Planet } from "@/types";
import { Api } from "@/esi-api";
import { useEffect, useState } from "react";
import { DateTime } from "luxon";
import { EXTRACTOR_TYPE_IDS } from "@/const";
import Countdown from "react-countdown";
const StackItem = styled(Stack)(({ theme }) => ({
...theme.typography.body2,
padding: 0,
textAlign: "left",
justifyContent: "center",
alignItems: "center",
}));
export interface PlanetInfo {
links: {
destination_pin_id: number;
link_level: number;
source_pin_id: number;
}[];
pins: {
contents?: {
amount: number;
type_id: number;
}[];
expiry_time?: string;
extractor_details?: {
cycle_time?: number;
head_radius?: number;
heads: {
head_id: number;
latitude: number;
longitude: number;
}[];
product_type_id?: number;
qty_per_cycle?: number;
};
factory_details?: {
schematic_id: number;
};
install_time?: string;
last_cycle_start?: string;
latitude: number;
longitude: number;
pin_id: number;
schematic_id?: number;
type_id: number;
}[];
routes: {
content_type_id: number;
destination_pin_id: number;
quantity: number;
route_id: number;
source_pin_id: number;
waypoints?: number[];
}[];
}
export const PlanetCard = ({
planet,
character,
}: {
planet: Planet;
character: AccessToken;
}) => {
const [planetInfo, setPlanetInfo] = useState<PlanetInfo | undefined>(
undefined
);
const extractors =
(planetInfo &&
planetInfo.pins
.filter((p) => EXTRACTOR_TYPE_IDS.some((e) => e === p.type_id))
.map((p) => p.expiry_time)) ??
[];
const getPlanet = async (
character: AccessToken,
planet: Planet
): Promise<PlanetInfo> => {
const api = new Api();
const planetInfo = (
await api.characters.getCharactersCharacterIdPlanetsPlanetId(
character.character.characterId,
planet.planet_id,
{
token: character.access_token,
}
)
).data;
return planetInfo;
};
useEffect(() => {
getPlanet(character, planet).then(setPlanetInfo);
}, [planet, character]);
return (
<StackItem alignItems="flex-start" className="poop" height="100%">
<Image
src={`/${planet.planet_type}.png`}
alt=""
width={120}
height={120}
style={{ marginBottom: "0.2rem" }}
/>
{extractors.some((e) => {
if (!e) return true;
const dateExtractor = DateTime.fromISO(e);
const dateNow = DateTime.now();
return dateExtractor < dateNow;
}) && (
<Image
width={64}
height={64}
src={`/stopped.png`}
alt=""
style={{ position: "absolute" }}
/>
)}
{extractors.map((e, idx) => {
const inPast = () => {
if (!e) return true;
const dateExtractor = DateTime.fromISO(e);
const dateNow = DateTime.now();
return dateExtractor < dateNow;
};
return (
<Typography
key={`${e}-${idx}-${character.character.characterId}`}
color={inPast() ? "red" : "white"}
>
{e ? (
<Countdown
overtime={true}
date={DateTime.fromISO(e).toMillis()}
/>
) : (
"STOPPED"
)}
</Typography>
);
})}
</StackItem>
);
};

View File

@@ -0,0 +1,57 @@
import { Api } from "@/esi-api";
import { AccessToken, Planet } from "@/types";
import { Stack, styled } from "@mui/material";
import { useEffect, useState } from "react";
import { PlanetCard } from "./PlanetCard";
import { NoPlanetCard } from "./NoPlanetCard";
const StackItem = styled(Stack)(({ theme }) => ({
...theme.typography.body2,
padding: theme.spacing(2),
textAlign: "left",
justifyContent: "center",
alignItems: "center",
}));
const getPlanets = async (character: AccessToken): Promise<Planet[]> => {
const api = new Api();
const planets = (
await api.characters.getCharactersCharacterIdPlanets(
character.character.characterId,
{
token: character.access_token,
}
)
).data;
return planets;
};
export const PlanetaryInteractionRow = ({
character,
}: {
character: AccessToken;
}) => {
const [planets, setPlanets] = useState<Planet[]>([]);
useEffect(() => {
getPlanets(character).then(setPlanets).catch(console.log);
}, [character]);
return (
<StackItem>
<Stack spacing={2} direction="row" flexWrap="wrap">
{planets.map((planet) => (
<PlanetCard
key={`${character.character.characterId}-${planet.planet_id}`}
planet={planet}
character={character}
/>
))}
{Array.from(Array(6 - planets.length).keys()).map((i, id) => (
<NoPlanetCard
key={`${character.character.characterId}-no-planet-${id}`}
/>
))}
</Stack>
</StackItem>
);
};

View File

@@ -0,0 +1,26 @@
import { AccessToken, CharacterUpdate } from "@/types";
import { Dispatch, SetStateAction, createContext } from "react";
export const CharacterContext = createContext<{
characters: AccessToken[];
deleteCharacter: (character: AccessToken) => void;
updateCharacter: (character: AccessToken, update: CharacterUpdate) => void;
}>({
characters: [],
deleteCharacter: () => {},
updateCharacter: () => {},
});
export const SessionContext = createContext<{
sessionReady: boolean;
refreshSession: () => void;
setSessionReady: Dispatch<SetStateAction<boolean>>;
EVE_SSO_CALLBACK_URL: string;
EVE_SSO_CLIENT_ID: string;
}>({
sessionReady: false,
refreshSession: () => {},
setSessionReady: () => {},
EVE_SSO_CALLBACK_URL: "",
EVE_SSO_CLIENT_ID: "",
});

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

23
src/app/globals.css Normal file
View File

@@ -0,0 +1,23 @@
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
}

21
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,21 @@
import "./globals.css";
import { Inter } from "next/font/google";
const inter = Inter({ subsets: ["latin"] });
export const metadata = {
title: "EVE PI",
description: "Lets PI!",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
);
}

124
src/app/page.tsx Normal file
View File

@@ -0,0 +1,124 @@
"use client";
import "@fontsource/roboto/300.css";
import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css";
import { memo, useCallback, useEffect, useState } from "react";
import { AccessToken, CharacterUpdate, Env } from "../types";
import { MainGrid } from "./components/MainGrid";
import { refreshToken } from "@/esi-sso";
import { CharacterContext, SessionContext } from "./context/Context";
import { useSearchParams } from "next/navigation";
const Home = () => {
const [characters, setCharacters] = useState<AccessToken[]>([]);
const [sessionReady, setSessionReady] = useState(false);
const [environment, setEnvironment] = useState<Env | undefined>(undefined);
const searchParams = useSearchParams();
const code = searchParams && searchParams.get("code");
// Initialize SSO env
useEffect(() => {
fetch("api/env")
.then((r) => r.json())
.then((j) => {
setEnvironment({
EVE_SSO_CLIENT_ID: j.EVE_SSO_CLIENT_ID,
EVE_SSO_CALLBACK_URL: j.EVE_SSO_CALLBACK_URL,
});
});
}, []);
// Memoize chracter state manipulations
const addCharacter = useCallback((character: AccessToken) => {
setCharacters((chars) => [
...chars.filter(
(c) => c.character.characterId !== character.character.characterId
),
character,
]);
}, []);
const deleteCharacter = useCallback(
(character: AccessToken) => {
setCharacters(
characters.filter(
(c) => character.character.characterId !== c.character.characterId
)
);
},
[characters]
);
const updateCharacter = useCallback(
(character: AccessToken, updates: CharacterUpdate) => {
setCharacters(
characters.map((c) => {
if (c.character.characterId === character.character.characterId)
return {
...c,
...(updates.account ? { account: updates.account } : {}),
};
return c;
})
);
},
[characters]
);
// Handle EVE SSO callback
useEffect(() => {
if (code) {
window.history.replaceState(null, "", "/");
fetch(`api/token?code=${code}`)
.then((res) => res.json())
.then(addCharacter)
.catch();
}
}, [code, addCharacter]);
// Initialise saved characters
useEffect(() => {
const localStorageCharacters = localStorage.getItem("characters");
if (localStorageCharacters) {
const characterArray: AccessToken[] = JSON.parse(localStorageCharacters);
setCharacters(characterArray);
}
}, []);
// Update saved characters to local storage on state change
useEffect(() => {
localStorage.setItem("characters", JSON.stringify(characters));
}, [characters]);
const refreshSession = () => {
Promise.all(characters.map((c) => refreshToken(c)))
.then(setCharacters)
.finally(() => setSessionReady(true));
};
return (
<SessionContext.Provider
value={{
sessionReady,
setSessionReady,
refreshSession,
EVE_SSO_CALLBACK_URL: environment?.EVE_SSO_CALLBACK_URL ?? "",
EVE_SSO_CLIENT_ID: environment?.EVE_SSO_CLIENT_ID ?? "",
}}
>
<CharacterContext.Provider
value={{
characters,
deleteCharacter,
updateCharacter,
}}
>
<MainGrid sessionReady={sessionReady} />
</CharacterContext.Provider>
</SessionContext.Provider>
);
};
export default memo(Home);

4
src/const.ts Normal file
View File

@@ -0,0 +1,4 @@
export const EVE_IMAGE_URL = "https://images.evetech.net";
export const EXTRACTOR_TYPE_IDS = [
2848, 3060, 3061, 3062, 3063, 3064, 3067, 3068,
];

19278
src/esi-api.ts Normal file

File diff suppressed because it is too large Load Diff

55
src/esi-sso.ts Normal file
View File

@@ -0,0 +1,55 @@
import { AccessToken } from "./types";
export const refreshToken = async (
character: AccessToken
): Promise<AccessToken> => {
return fetch(`api/refresh`, {
method: "POST",
mode: "cors",
cache: "no-cache",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
},
redirect: "error",
referrerPolicy: "no-referrer",
body: JSON.stringify(character),
}).then((res) => res.json());
};
export const revokeToken = async (
character: AccessToken
): Promise<Response> => {
return fetch(`api/revoke`, {
method: "POST",
mode: "cors",
cache: "no-cache",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
},
redirect: "error",
referrerPolicy: "no-referrer",
body: JSON.stringify(character),
});
};
export const loginParameters = async (
selectedScopes: string[],
EVE_SSO_CLIENT_ID: string,
EVE_SSO_CALLBACK_URL: string
) => {
return new URLSearchParams({
response_type: "code",
redirect_uri: EVE_SSO_CALLBACK_URL,
client_id: EVE_SSO_CLIENT_ID,
scope: selectedScopes.join(" "),
state: "asfe",
}).toString();
};
export const eveSwagger = async () => {
return fetch("https://esi.evetech.net/latest/swagger.json").then((res) =>
res.json()
);
};

13
src/pages/api/env.ts Normal file
View File

@@ -0,0 +1,13 @@
import { NextApiRequest, NextApiResponse } from "next";
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "GET") {
const EVE_SSO_CALLBACK_URL = process.env.EVE_SSO_CALLBACK_URL;
const EVE_SSO_CLIENT_ID = process.env.EVE_SSO_CLIENT_ID;
res.json({ EVE_SSO_CLIENT_ID, EVE_SSO_CALLBACK_URL });
} else {
res.status(404).end();
}
};
export default handler;

62
src/pages/api/refresh.ts Normal file
View File

@@ -0,0 +1,62 @@
import { AccessToken } from "@/types";
import { extractCharacterFromToken } from "@/utils";
import { NextApiRequest, NextApiResponse } from "next";
import crypto from "crypto-js";
const EVE_SSO_TOKEN_URL = "https://login.eveonline.com/v2/oauth/token";
const EVE_SSO_CLIENT_ID = process.env.EVE_SSO_CLIENT_ID ?? "";
const EVE_SSO_SECRET = process.env.EVE_SSO_SECRET ?? "";
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "POST") {
const accessToken: AccessToken = req.body;
const params = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: crypto.AES.decrypt(
accessToken.refresh_token,
EVE_SSO_SECRET
).toString(crypto.enc.Utf8),
}).toString();
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from(
`${EVE_SSO_CLIENT_ID}:${EVE_SSO_SECRET}`
).toString("base64")}`,
Host: "login.eveonline.com",
};
try {
const response = await fetch(EVE_SSO_TOKEN_URL, {
method: "POST",
body: params,
headers,
}).then((res) => res.json());
const character = extractCharacterFromToken(response);
const token: AccessToken = {
access_token: response.access_token,
token_type: response.token_type,
refresh_token: crypto.AES.encrypt(
response.refresh_token,
EVE_SSO_SECRET
).toString(),
expires_at: Date.now() + response.expires_in * 1000,
character,
needsLogin: false,
account: accessToken.account,
};
console.log("Refresh", character.name, character.characterId);
return res.json(token);
} catch (e) {
console.log(e);
res.json({ ...accessToken, needsLogin: true });
}
} else {
res.status(404).end();
}
};
export default handler;

51
src/pages/api/revoke.ts Normal file
View File

@@ -0,0 +1,51 @@
import { AccessToken } from "@/types";
import { NextApiRequest, NextApiResponse } from "next";
import crypto from "crypto-js";
const EVE_SSO_REVOKE_URL = "https://login.eveonline.com/v2/oauth/revoke";
const EVE_SSO_CLIENT_ID = process.env.EVE_SSO_CLIENT_ID ?? "";
const EVE_SSO_SECRET = process.env.EVE_SSO_SECRET ?? "";
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "POST") {
const accessToken: AccessToken = req.body;
const params = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: crypto.AES.decrypt(
accessToken.refresh_token,
EVE_SSO_SECRET
).toString(crypto.enc.Utf8),
}).toString();
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from(
`${EVE_SSO_CLIENT_ID}:${EVE_SSO_SECRET}`
).toString("base64")}`,
Host: "login.eveonline.com",
};
try {
await fetch(EVE_SSO_REVOKE_URL, {
method: "POST",
body: params,
headers,
}).then((res) => res.json());
console.log(
"Revoke",
accessToken.character.name,
accessToken.character.characterId
);
return res.end();
} catch (e) {
console.log(e);
return res.status(500).end();
}
} else {
res.status(404).end();
}
};
export default handler;

56
src/pages/api/token.ts Normal file
View File

@@ -0,0 +1,56 @@
import { AccessToken } from "@/types";
import { extractCharacterFromToken } from "@/utils";
import { NextApiRequest, NextApiResponse } from "next";
import crypto from "crypto-js";
const EVE_SSO_TOKEN_URL = "https://login.eveonline.com/v2/oauth/token";
const EVE_SSO_CLIENT_ID = process.env.EVE_SSO_CLIENT_ID ?? "";
const EVE_SSO_SECRET = process.env.EVE_SSO_SECRET ?? "";
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "GET") {
const code = req.query.code as string;
if (!code || code === undefined) return res.status(404).end();
const params = new URLSearchParams({
grant_type: "authorization_code",
code: code,
}).toString();
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from(
`${EVE_SSO_CLIENT_ID}:${EVE_SSO_SECRET}`
).toString("base64")}`,
Host: "login.eveonline.com",
};
const response = await fetch(EVE_SSO_TOKEN_URL, {
method: "POST",
body: params,
headers,
}).then((res) => res.json());
const character = extractCharacterFromToken(response);
console.log("Login", character.name, character.characterId);
const token: AccessToken = {
access_token: response.access_token,
token_type: response.token_type,
refresh_token: crypto.AES.encrypt(
response.refresh_token,
EVE_SSO_SECRET
).toString(),
expires_at: Date.now() + response.expires_in * 1000,
character,
needsLogin: false,
account: "-",
};
res.json(token);
} else {
res.status(404).end();
}
};
export default handler;

41
src/types.ts Normal file
View File

@@ -0,0 +1,41 @@
export interface AccessToken {
access_token: string;
expires_at: number;
token_type: "Bearer";
refresh_token: string;
character: Character;
account: string;
needsLogin: boolean;
}
export interface Character {
name: string;
characterId: number;
}
export interface CharacterUpdate {
account?: string;
}
export interface Planet {
last_update: string;
num_pins: number;
owner_id: number;
planet_id: number;
planet_type:
| "temperate"
| "barren"
| "oceanic"
| "ice"
| "gas"
| "lava"
| "storm"
| "plasma";
solar_system_id: number;
upgrade_level: number;
}
export interface Env {
EVE_SSO_CALLBACK_URL: string;
EVE_SSO_CLIENT_ID: string;
}

13
src/utils.ts Normal file
View File

@@ -0,0 +1,13 @@
import { AccessToken, Character } from "./types";
export const extractCharacterFromToken = (token: AccessToken): Character => {
const decodedToken = parseJwt(token.access_token);
return {
name: decodedToken.name,
characterId: decodedToken.sub.split(":")[2],
};
};
const parseJwt = (token: string) => {
return JSON.parse(Buffer.from(token.split(".")[1], "base64").toString());
};

28
tsconfig.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}