diff --git a/package.json b/package.json index d09f7f9..01b0a50 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "license": "MIT", "dependencies": { "@eveshipfit/dogma-engine": "^2.2.1", - "clsx": "^2.0.0" + "clsx": "^2.0.0", + "jwt-decode": "^4.0.0" }, "devDependencies": { "@babel/preset-env": "^7.23.3", diff --git a/src/EsiCharacterSelection/EsiCharacterSelection.module.css b/src/EsiCharacterSelection/EsiCharacterSelection.module.css new file mode 100644 index 0000000..0551917 --- /dev/null +++ b/src/EsiCharacterSelection/EsiCharacterSelection.module.css @@ -0,0 +1,25 @@ +.character { + width: 100%; +} + +.character > select { + background-color: #1d1d1d; + color: #c5c5c5; + height: 24px; + width: calc(100% - 20px); +} + +.character > button { + background-color: #1d1d1d; + color: #c5c5c5; + cursor: pointer; + height: 24px; + text-align: center; + width: 20px; +} + +.character > button.noCharacter { + text-align: left; + padding-left: 5px; + width: 100%; +} diff --git a/src/EsiCharacterSelection/EsiCharacterSelection.stories.tsx b/src/EsiCharacterSelection/EsiCharacterSelection.stories.tsx new file mode 100644 index 0000000..5b31b68 --- /dev/null +++ b/src/EsiCharacterSelection/EsiCharacterSelection.stories.tsx @@ -0,0 +1,28 @@ +import type { Decorator, Meta, StoryObj } from '@storybook/react'; +import React from "react"; + +import { EsiProvider } from '../EsiProvider'; +import { EsiCharacterSelection } from './'; + +const meta: Meta = { + component: EsiCharacterSelection, + tags: ['autodocs'], + title: 'Component/EsiCharacterSelection', +}; + +export default meta; +type Story = StoryObj; + +const withEsiProvider: Decorator> = (Story) => { + return ( + + + + ); +} + +export const Default: Story = { + args: { + }, + decorators: [withEsiProvider], +}; diff --git a/src/EsiCharacterSelection/EsiCharacterSelection.tsx b/src/EsiCharacterSelection/EsiCharacterSelection.tsx new file mode 100644 index 0000000..7cd0a27 --- /dev/null +++ b/src/EsiCharacterSelection/EsiCharacterSelection.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +import { EsiContext } from "../EsiProvider"; + +import styles from "./EsiCharacterSelection.module.css"; + +/** + * Character selection for EsiProvider. + * + * It shows both a dropdown for all the characters that the EsiProvider knows, + * and a button to add another character. + */ +export const EsiCharacterSelection = () => { + const esi = React.useContext(EsiContext); + + if (Object.keys(esi.characters ?? {}).length === 0) { + return
+ +
+ } + + return
+ + +
+}; diff --git a/src/EsiCharacterSelection/index.ts b/src/EsiCharacterSelection/index.ts new file mode 100644 index 0000000..cf40bcb --- /dev/null +++ b/src/EsiCharacterSelection/index.ts @@ -0,0 +1 @@ +export { EsiCharacterSelection } from "./EsiCharacterSelection"; diff --git a/src/EsiProvider/EsiAccessToken.tsx b/src/EsiProvider/EsiAccessToken.tsx new file mode 100644 index 0000000..49c1f8f --- /dev/null +++ b/src/EsiProvider/EsiAccessToken.tsx @@ -0,0 +1,20 @@ +export async function getAccessToken(refreshToken: string): Promise { + let response; + try { + response = await fetch('https://esi.eveship.fit/', { + method: 'POST', + body: JSON.stringify({ + refresh_token: refreshToken, + }), + }); + } catch (e) { + return undefined; + } + + if (response.status !== 201) { + return undefined; + } + + const data = await response.json(); + return data.access_token; +}; diff --git a/src/EsiProvider/EsiProvider.stories.tsx b/src/EsiProvider/EsiProvider.stories.tsx new file mode 100644 index 0000000..3691686 --- /dev/null +++ b/src/EsiProvider/EsiProvider.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React from "react"; + +import { EsiContext, EsiProvider } from './'; + +const meta: Meta = { + component: EsiProvider, + tags: ['autodocs'], + title: 'Provider/EsiProvider', +}; + +export default meta; +type Story = StoryObj; + +const TestEsi = () => { + const esi = React.useContext(EsiContext); + + if (!esi.loaded) { + return ( +
+ Esi: loading
+
+ ); + } + + return ( +
+ Esi: loaded
+
{JSON.stringify(esi, null, 2)}
+
+ ); +} + +export const Default: Story = { + args: { + }, + render: (args) => ( + + + + ), +}; diff --git a/src/EsiProvider/EsiProvider.tsx b/src/EsiProvider/EsiProvider.tsx new file mode 100644 index 0000000..8c76dc9 --- /dev/null +++ b/src/EsiProvider/EsiProvider.tsx @@ -0,0 +1,277 @@ +import { jwtDecode } from "jwt-decode"; +import React from "react"; +import { getAccessToken } from "./EsiAccessToken"; +import { getSkills } from "./EsiSkills"; + +export interface EsiCharacter { + name: string; + skills?: Record; +} + +export interface Esi { + loaded?: boolean; + characters: Record; + currentCharacter?: string; + + changeCharacter: (character: string) => void; + login: () => void; +} + +interface EsiPrivate { + loaded?: boolean; + refreshTokens: Record; + accessTokens: Record; +} + +interface JwtPayload { + name: string; + sub: string; +} + +export const EsiContext = React.createContext({ + loaded: undefined, + characters: {}, + changeCharacter: () => {}, + login: () => {}, +}); + +export interface EsiProps { + /** Children that can use this provider. */ + children: React.ReactNode; +} + +const useLocalStorage = function (key: string, initialValue: T) { + const [storedValue, setStoredValue] = React.useState(() => { + if (typeof window === 'undefined') return initialValue; + + const item = window.localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + }); + + const setValue = React.useCallback((value: T | ((val: T) => T)) => { + if (typeof window === 'undefined') return; + if (storedValue == value) return; + + const valueToStore = value instanceof Function ? value(storedValue) : value; + setStoredValue(valueToStore); + + if (valueToStore === undefined) { + window.localStorage.removeItem(key); + return; + } + + window.localStorage.setItem(key, JSON.stringify(valueToStore)); + }, [key, storedValue]); + + return [ storedValue, setValue ] as const; +} + +/** + * Keeps track (in local storage) of ESI characters and their refresh token. + */ +export const EsiProvider = (props: EsiProps) => { + const [esi, setEsi] = React.useState({ + loaded: undefined, + characters: {}, + changeCharacter: () => {}, + login: () => {}, + }); + const [esiPrivate, setEsiPrivate] = React.useState({ + loaded: undefined, + refreshTokens: {}, + accessTokens: {}, + }); + + const [characters, setCharacters] = useLocalStorage>('characters', {}); + const [refreshTokens, setRefreshTokens] = useLocalStorage('refreshTokens', {}); + const [currentCharacter, setCurrentCharacter] = useLocalStorage('currentCharacter', undefined); + + const changeCharacter = React.useCallback((character: string) => { + setCurrentCharacter(character); + + setEsi((oldEsi: Esi) => { + return { + ...oldEsi, + currentCharacter: character, + }; + }); + }, [setCurrentCharacter]); + + const login = React.useCallback(() => { + if (typeof window === 'undefined') return; + window.location.href = "https://esi.eveship.fit/"; + }, []); + + const ensureAccessToken = React.useCallback(async (characterId: string): Promise => { + if (esiPrivate.accessTokens[characterId]) { + return esiPrivate.accessTokens[characterId]; + } + + const accessToken = await getAccessToken(esiPrivate.refreshTokens[characterId]); + if (accessToken === undefined) { + console.log('Failed to get access token'); + return undefined; + } + + /* New access token; store for later use. */ + setEsiPrivate((oldEsiPrivate: EsiPrivate) => { + return { + ...oldEsiPrivate, + accessToken: { + ...oldEsiPrivate.accessTokens, + [characterId]: accessToken, + }, + }; + }); + + return accessToken; + }, [esiPrivate.accessTokens, esiPrivate.refreshTokens]); + + React.useEffect(() => { + const characterId = esi.currentCharacter; + if (characterId === undefined) return; + /* Skills already fetched? We won't do it again till the user reloads. */ + if (esi.characters[characterId]?.skills !== undefined) return; + + ensureAccessToken(characterId).then((accessToken) => { + if (accessToken === undefined) return; + + getSkills(characterId, accessToken).then((skills) => { + if (skills === undefined) return; + + setEsi((oldEsi: Esi) => { + return { + ...oldEsi, + characters: { + ...oldEsi.characters, + [characterId]: { + ...oldEsi.characters[characterId], + skills, + }, + }, + }; + }); + }); + }); + + /* We only update when currentCharacter changes, and ignore all others. */ + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [esi.currentCharacter]); + + React.useEffect(() => { + if (typeof window === 'undefined') return; + + async function loginCharacter(code: string) { + let response; + try { + response = await fetch('https://esi.eveship.fit/', { + method: 'POST', + body: JSON.stringify({ + code: code, + }), + }); + } catch (e) { + return false; + } + + if (response.status !== 201) { + return false; + } + + const data = await response.json(); + + /* Decode the access-token as it contains the name and character id. */ + const jwt = jwtDecode(data.access_token); + if (!jwt.name || !jwt.sub?.startsWith("CHARACTER:EVE:")) { + return false; + } + + const accessToken = data.access_token; + const refreshToken = data.refresh_token; + const name = jwt.name; + const characterId = jwt.sub.slice("CHARACTER:EVE:".length); + + /* Update the local storage with the new character. */ + setCharacters((oldCharacters: Record) => { + return { + ...oldCharacters, + [characterId]: { + name: name, + }, + }; + }); + setRefreshTokens((oldRefreshTokens: Record) => { + return { + ...oldRefreshTokens, + [characterId]: refreshToken, + }; + }); + setCurrentCharacter(characterId); + + /* Update the current render with the new character. */ + setEsi((oldEsi: Esi) => { + return { + ...oldEsi, + characters: { + ...oldEsi.characters, + [characterId]: { + name: name, + }, + }, + currentCharacter: characterId, + }; + }); + setEsiPrivate((oldEsiPrivate: EsiPrivate) => { + return { + ...oldEsiPrivate, + refreshTokens: { + ...oldEsiPrivate.refreshTokens, + [characterId]: refreshToken, + }, + accessToken: { + ...oldEsiPrivate.accessTokens, + [characterId]: accessToken, + }, + }; + }); + + return true; + } + + async function startup() { + setEsi({ + loaded: true, + characters, + currentCharacter, + changeCharacter, + login, + }); + setEsiPrivate({ + loaded: true, + refreshTokens, + accessTokens: {}, + }); + + /* Check if this was a login request. */ + const urlParams = new URLSearchParams(window.location.search); + const code = urlParams.get('code'); + if (code) { + /* Remove the code from the URL. */ + window.history.replaceState(null, "", window.location.pathname + window.location.hash); + + if (!await loginCharacter(code)) { + console.log('Failed to login character'); + } + } + } + + startup(); + + /* This should only on first start. */ + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return + {props.children} + +}; diff --git a/src/EsiProvider/EsiSkills.tsx b/src/EsiProvider/EsiSkills.tsx new file mode 100644 index 0000000..0d52407 --- /dev/null +++ b/src/EsiProvider/EsiSkills.tsx @@ -0,0 +1,25 @@ + +export async function getSkills(characterId: string, accessToken: string): Promise | undefined> { + let response; + try { + response = await fetch(`https://esi.evetech.net/v4/characters/${characterId}/skills/`, { + headers: { + authorization: `Bearer ${accessToken}`, + 'content-type': 'application/json', + }, + }); + } catch (e) { + return undefined; + } + + if (response.status !== 200) return undefined; + + const data = await response.json(); + const skills: Record = {}; + + for (const skill of data.skills) { + skills[skill.skill_id] = skill.active_skill_level; + } + + return skills; +} diff --git a/src/EsiProvider/index.ts b/src/EsiProvider/index.ts new file mode 100644 index 0000000..26a1764 --- /dev/null +++ b/src/EsiProvider/index.ts @@ -0,0 +1,2 @@ +export { EsiContext, EsiProvider } from "./EsiProvider"; +export type { EsiCharacter, Esi } from "./EsiProvider"; diff --git a/src/index.ts b/src/index.ts index da3fe71..86007b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,6 @@ export * from './DogmaEngineProvider'; +export * from './EsiCharacterSelection'; +export * from './EsiProvider'; export * from './EveDataProvider'; export * from './EveShipFitHash'; export * from './EveShipFitLink';