3box profiles (#9)

* Fix auth

* Added 3box profile info

* Added player names in list

* Added hasura action to fetch verified accounts on 3Box

* Added usernames

* fix router
This commit is contained in:
Pacien Boisson
2020-05-13 10:02:21 +02:00
committed by GitHub
parent 7747ff69d0
commit 214f3f65c2
37 changed files with 9547 additions and 331 deletions

1
packages/app-react/@types/3box.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module '3box';

View File

@@ -0,0 +1 @@
declare module 'react-router-dom';

View File

@@ -3,6 +3,7 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"3box": "^1.18.1",
"@apollo/client": "^3.0.0-beta.43",
"@apollo/react-hooks": "^3.1.5",
"@material-ui/core": "^4.9.10",
@@ -20,6 +21,7 @@
"graphql-codegen-hasura-core": "^4.8.4",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "^5.1.2",
"react-scripts": "3.4.1",
"typescript": "~3.7.2",
"uuid": "^7.0.3",

View File

@@ -1,12 +1,15 @@
import React from 'react';
import { ApolloProvider } from '@apollo/react-hooks';
import { CssBaseline } from '@material-ui/core';
import { BrowserRouter as Router } from "react-router-dom";
import { createApolloClient } from './apollo';
import { Home } from './containers/Home';
import Web3ContextProvider from './contexts/Web3';
import Header from './components/Header';
import Routes from './Routes';
const apolloClient = createApolloClient();
function App() {
@@ -14,7 +17,10 @@ function App() {
<ApolloProvider client={apolloClient}>
<Web3ContextProvider>
<CssBaseline/>
<Home/>
<Router>
<Header/>
<Routes/>
</Router>
</Web3ContextProvider>
</ApolloProvider>
);

View File

@@ -0,0 +1,19 @@
import React from 'react';
import { Switch, Route, Redirect } from "react-router-dom";
import { Home } from './containers/Home';
import { Player } from './containers/Player';
export default function Routes() {
return (
<Switch>
<Route exact path="/">
<Home />
</Route>
<Route path="/player/:playerId">
<Player />
</Route>
<Redirect to="/"/>
</Switch>
);
}

View File

@@ -24,7 +24,11 @@ export function loginLoading(client, loading = true) {
export async function login(client, token, ethAddress) {
loginLoading(client);
client.writeData({
data: {
authToken: token,
},
});
return client.query({
query: queries.get_MyAccount,
variables: { eth_address: ethAddress }
@@ -36,14 +40,13 @@ export async function login(client, token, ethAddress) {
client.writeData({
data: {
authState: 'logged',
authToken: token,
playerId: res.data.Account[0].Player.id,
},
});
setTokenInStore(token);
})
.catch(async error => {
logout();
logout(client);
throw error;
});
}

View File

@@ -0,0 +1,15 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Box } from '@material-ui/core';
import {Login} from "../containers/Login";
export default function Header() {
return (
<Box>
<Link to={`/`}><button>Home</button></Link>
<Login/>
</Box>
)
}

View File

@@ -1,11 +0,0 @@
import React from 'react';
import { Box } from '@material-ui/core';
export default function Player({ player }: { player: any }) {
return (
<Box>
{player.id}
</Box>
)
}

View File

@@ -0,0 +1,90 @@
import React, { useState, useEffect, useCallback } from 'react';
import {useMutation} from "@apollo/react-hooks";
import ThreeBox from '3box';
import { Box } from '@material-ui/core';
import {useMyPlayer} from "../graphql/hooks";
import {getPlayerETHAddress} from "../utils/players";
import mutations from '../graphql/mutations';
function getProfilePicture(boxProfile: any) {
const imageHash = boxProfile && boxProfile.image && boxProfile.image[0] && boxProfile.image[0].contentUrl && boxProfile.image[0].contentUrl['/'];
if(imageHash) {
return `https://ipfs.infura.io/ipfs/${imageHash}`;
} else {
return 'https://i.imgur.com/RXJO8FD.png';
}
}
export default function PlayerDetails({ player }: { player: any }) {
const myPlayer = useMyPlayer();
const isMyPlayer = myPlayer && myPlayer.id === player.id;
const [boxProfile, setBoxProfile] = useState<any>();
const [usernameInput, setUsernameInput] = useState<string>(player.username);
const [updateBoxProfiles] = useMutation(mutations.UpdateBoxProfiles);
const [updateUsername] = useMutation(mutations.UpdateUsername);
const ethAddress = getPlayerETHAddress(player);
useEffect(() => {
(async () => {
const boxProfile = await ThreeBox.getProfile(ethAddress);
setBoxProfile(boxProfile);
})();
}, [ethAddress]);
const goToEditBoxProfile = useCallback(() => {
window.open(`https://3box.io/${ethAddress}/edit`)
}, []);
const editUserName = useCallback(() => {
// TODO Apollo does not updates caches as it expects that the mutation returns an object with id, but hasura returns { returning: [{id}] }
updateUsername({
variables: {
username: usernameInput
}
}).then(res =>
console.log('updated username', res.data)
);
}, [usernameInput]);
const updateAccounts = useCallback(() => {
updateBoxProfiles().then(res =>
console.log('updated verified profiles', res.data.updateBoxProfile.updatedProfiles)
);
}, []);
return (
<Box>
<h3>{player.username}</h3>
<h4>{player.id}</h4>
{isMyPlayer && <button onClick={goToEditBoxProfile}>Edit profile</button>}
{isMyPlayer && <div>
<input value={usernameInput} onChange={e => setUsernameInput(e.target.value)} />
<button onClick={editUserName}>Change username</button>
</div>}
{boxProfile ?
<div>
<p><b>Name:</b> {boxProfile.name}</p>
<p><b>Description:</b> {boxProfile.description}</p>
<img src={getProfilePicture(boxProfile)} width={100} alt="profile-image"/>
</div>
:
<p>Loading box profile</p>
}
<div>
<h4>External accounts</h4>
<ul>
{player.Accounts.map((account: any) =>
<li key={account.type}><b>{account.type}</b>: {account.identifier}</li>
)}
</ul>
{isMyPlayer && <button onClick={updateAccounts}>Update accounts</button>}
</div>
</Box>
)
}

View File

@@ -0,0 +1,13 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Box } from '@material-ui/core';
export default function PlayerListItem({ player }: { player: any }) {
return (
<Box>
{player.username}
<Link to={`/player/${player.id}`}><button>View player</button></Link>
</Box>
)
}

View File

@@ -1,21 +1,13 @@
import React from 'react';
import { Box } from '@material-ui/core';
import { useQuery } from '@apollo/react-hooks';
import PlayerList from './PlayerList';
import { Login } from './Login';
import { MyPlayer } from './MyPlayer';
import {localQueries} from "../apollo";
export const Home: React.FC = () => {
const { data, loading } = useQuery(localQueries.get_authState);
return (
<Box>
<PlayerList/>
<Login/>
{!loading && data?.authState === 'logged' && <MyPlayer/>}
</Box>
);
};

View File

@@ -1,15 +1,20 @@
import React, {useContext} from 'react';
import React, {useContext, useCallback} from 'react';
import { Box } from '@material-ui/core';
import { Web3Context } from '../contexts/Web3';
import {localQueries} from "../apollo";
import { useQuery } from '@apollo/react-hooks';
import {Link} from "react-router-dom";
export const Login: React.FC = () => {
const { data, loading } = useQuery(localQueries.get_authState);
const { connectWeb3 } = useContext(Web3Context);
const { connectWeb3, disconnect } = useContext(Web3Context);
const connect = useCallback(() => {
connectWeb3().catch(console.error);
}, [connectWeb3]);
if(loading || data?.authState === 'loading') {
return (
@@ -18,13 +23,18 @@ export const Login: React.FC = () => {
</Box>
);
} else if(data?.authState === 'logged') {
const { playerId } = data;
return (
<Box>Connected</Box>
<Box>
Connected
<Link to={`/player/${playerId}`}><button>View my player</button></Link>
<button onClick={disconnect}>Logout</button>
</Box>
);
} else {
return (
<Box>
Unknown state
<button onClick={connect}>Connect</button>
</Box>
)
}

View File

@@ -1,26 +0,0 @@
import React from 'react';
import { Box } from '@material-ui/core';
import Player from '../components/Player';
import { useMyPlayer } from '../graphql/hooks';
export const MyPlayer: React.FC = () => {
const { data, called, loading, error } = useMyPlayer();
if(error) {
return <div>error</div>
}
if(loading || !called) {
return <div>loading</div>
}
const myPlayer = data.Player[0];
return (
<Box>
<h4>My player</h4>
<Player player={myPlayer} />
</Box>
)
};

View File

@@ -0,0 +1,33 @@
import React from 'react';
import {useQuery} from "@apollo/react-hooks";
import { useParams } from 'react-router-dom';
import { Box } from '@material-ui/core';
import PlayerDetails from '../components/PlayerDetails';
import queries from "../graphql/queries";
export const Player: React.FC = ({}) => {
const { playerId } = useParams();
const { data, loading, error } = useQuery(queries.get_Player, {
variables: {
player_id: playerId,
}
});
if(error) {
return <div>error</div>
}
if(loading) {
return <div>loading</div>
}
const myPlayer = data.Player[0];
return (
<Box>
<h4>Player</h4>
<PlayerDetails player={myPlayer} />
</Box>
)
};

View File

@@ -6,19 +6,23 @@ import {useQuery} from '@apollo/react-hooks';
import queries from '../graphql/queries';
import Player from '../components/Player';
import PlayerListItem from '../components/PlayerListItem';
export default function PlayerList() {
const { data, loading } = useQuery(queries.get_Player);
const { data, loading, error } = useQuery(queries.get_Player);
if(error) {
return <div>error: {error.message}</div>
}
if(loading) {
return <div>loading</div>
}
return (
<Box>
<h4>Player list</h4>
{data.Player.map((player: any) =>
<Player key={player.id} player={player} />
<PlayerListItem key={player.id} player={player} />
)}
</Box>
)

View File

@@ -8,16 +8,18 @@ import {useApolloClient} from '@apollo/react-hooks';
import config from '../config';
import { did } from '@the-game/utils';
import {loginLoading, login, getTokenFromStore} from '../apollo/auth';
import {loginLoading, login, logout, getTokenFromStore} from '../apollo/auth';
type Web3ContextType = {
ethersProvider: Web3Provider | null,
connectWeb3: () => void;
connectWeb3: () => Promise<void>;
disconnect: () => void,
}
export const Web3Context = createContext<Web3ContextType>({
ethersProvider: null,
connectWeb3: () => {},
connectWeb3: async () => {},
disconnect: () => undefined,
});
const providerOptions = {
@@ -75,6 +77,11 @@ const Web3ContextProvider: React.FC = props => {
}
}, [apolloClient, connectDID]);
const disconnect = useCallback(async () => {
web3Modal.clearCachedProvider();
logout(apolloClient);
}, [apolloClient]);
useEffect(() => {
if(web3Modal.cachedProvider) {
connectWeb3().catch(console.error);
@@ -82,7 +89,7 @@ const Web3ContextProvider: React.FC = props => {
}, [connectWeb3]);
return (
<Web3Context.Provider value={{ ethersProvider, connectWeb3 }}>
<Web3Context.Provider value={{ ethersProvider, connectWeb3, disconnect }}>
{props.children}
</Web3Context.Provider>
);

View File

@@ -3,11 +3,13 @@ const fragments: any = {};
fragments.PlayerFragment = `
fragment PlayerFragment on Player {
id
username
}
`;
fragments.AccountFragment = `
fragment AccountFragment on Account {
identifier
type
}
`;

View File

@@ -7,7 +7,7 @@ import queries from './queries';
export function useMyPlayer() {
const authStateQuery = useQuery(localQueries.get_authState);
const [getMyPlayer, myPlayerQuery] = useLazyQuery(queries.get_MyPlayer);
const [getMyPlayer, myPlayerQuery] = useLazyQuery(queries.get_Player);
const playerId = authStateQuery.data?.playerId;
@@ -21,5 +21,5 @@ export function useMyPlayer() {
}
}, [getMyPlayer, playerId]);
return myPlayerQuery;
return myPlayerQuery.data?.Player[0];
}

View File

@@ -0,0 +1,31 @@
import gql from 'graphql-tag';
const mutations: any = {};
mutations.UpdateBoxProfiles = gql`
mutation UpdateBoxProfiles {
updateBoxProfile {
success
updatedProfiles
}
}
`;
mutations.UpdateUsername = gql`
mutation UpdateUsername($username: String!) {
update_Player(
where: {},
_set: {
username: $username
}
) {
affected_rows
returning {
id
username
}
}
}
`;
export default mutations;

View File

@@ -5,23 +5,18 @@ import fragments from './fragments';
const queries: any = {};
queries.get_Player = gql`
query GetPlayer {
Player {
...PlayerFragment
}
}
${fragments.PlayerFragment}
`;
queries.get_MyPlayer = gql`
query GetPlayer($player_id: uuid) {
Player(
where: { id: { _eq: $player_id } }
) {
...PlayerFragment
Accounts {
...AccountFragment
}
}
}
${fragments.PlayerFragment}
${fragments.AccountFragment}
`;
queries.get_MyAccount = gql`

View File

@@ -0,0 +1,3 @@
export function getPlayerETHAddress(player: any) {
return player.Accounts.find((a: any) => a.type === "ETHEREUM").identifier;
}

1
packages/backend/@types/3box.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module '3box';

View File

@@ -13,6 +13,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"3box": "^1.18.1",
"body-parser": "^1.19.0",
"ethers": "^4.0.46",
"express": "^4.17.1",

View File

@@ -0,0 +1,11 @@
import express from 'express';
import { asyncHandlerWrapper } from '../../lib/apiHelpers';
import updateBoxProfileHandler from './updateBoxProfile/handler';
const router = express.Router();
router.post('/updateBoxProfile', asyncHandlerWrapper(updateBoxProfileHandler));
export default router;

View File

@@ -0,0 +1,107 @@
import { Request, Response } from 'express';
import {hasuraQuery} from "../../../lib/hasuraHelpers";
import {getPlayerETHAddress} from "../../../lib/playerHelpers";
import Box from '3box';
const getPlayerQuery = `
query GetPlayer ($playerId: uuid!) {
Player(
where: {
id: { _eq: $playerId }
}
) {
id
Accounts {
identifier
type
}
}
}
`;
const upsertAccount = `
mutation upsert_Account($objects: [Account_insert_input!]!) {
insert_Account (
objects: $objects,
on_conflict: {
constraint: Account_identifier_type_player_key,
update_columns: [identifier]
}
) {
affected_rows
}
}
`;
const handler = async (req: Request, res: Response) => {
const { session_variables } = req.body;
const role = session_variables['x-hasura-role'];
const playerId = session_variables['x-hasura-user-id'];
if(role !== 'player') {
throw new Error('expected role player');
}
const result = await hasuraQuery(getPlayerQuery, {
playerId,
});
const player = result.Player[0];
if(!player) {
throw new Error('unknown-player');
}
const ethAddress = getPlayerETHAddress(player);
const boxProfile = await Box.getProfile(ethAddress);
const verifiedProfile = await Box.getVerifiedAccounts(boxProfile);
const updatedProfiles = await updateVerifiedProfiles(playerId, verifiedProfile);
res.json({
success: true,
updatedProfiles,
});
};
async function updateVerifiedProfiles(playerId: string, verifiedProfiles: any) {
const updatedProfiles: string[] = [];
if(verifiedProfiles.github) {
const result = await hasuraQuery(upsertAccount, {
objects: [
{
player_id: playerId,
type: 'GITHUB',
identifier: verifiedProfiles.github.username,
linkToProof: verifiedProfiles.github.proof,
}
],
});
if(result.affected_rows === 0) {
throw new Error('Error while upserting github profile');
}
updatedProfiles.push('github');
}
if(verifiedProfiles.twitter) {
const result = await hasuraQuery(upsertAccount, {
objects: [
{
player_id: playerId,
type: 'TWITTER',
identifier: verifiedProfiles.twitter.username,
linkToProof: verifiedProfiles.twitter.proof,
}
],
});
if(result.affected_rows === 0) {
throw new Error('Error while upserting github profile');
}
updatedProfiles.push('twitter');
}
return updatedProfiles;
}
export default handler;

View File

@@ -1,6 +1,4 @@
import fetch from 'node-fetch';
import config from '../../config';
import { hasuraQuery } from '../../lib/hasuraHelpers';
const getPlayerQuery = `
query GetPlayerFromETH ($eth_address: String) {
@@ -18,40 +16,27 @@ query GetPlayerFromETH ($eth_address: String) {
`;
const createProfileMutation = `
mutation CreateAccountFromETH ($eth_address: String) {
mutation CreateAccountFromETH ($eth_address: String!, $username: String!) {
insert_Account(
objects: {
type: "ETHEREUM",
identifier: $eth_address
Player: {
data: {}
data: {
username: $username
}
}
}) {
returning {
identifier
}
affected_rows
returning {
identifier
Player {
id
}
}
}
}
`;
async function hasuraQuery(query: string, qv: any = {}) {
const result = await fetch(config.graphqlURL, {
method: 'POST',
body: JSON.stringify({ query: query, variables: qv }),
headers: {
'Content-Type': 'application/json',
'x-hasura-access-key': config.adminKey,
},
});
const { errors, data } = await result.json();
if(errors) {
throw new Error(JSON.stringify(errors));
}
return data;
}
interface IPlayer {
id: string
}
@@ -59,7 +44,11 @@ interface IPlayer {
export async function createPlayer(ethAddress: string): Promise<IPlayer> {
const resProfile = await hasuraQuery(createProfileMutation, {
eth_address: ethAddress,
username: ethAddress,
});
if(resProfile.insert_Account.affected_rows !== 2) {
throw new Error('error while creating profile');
}
return resProfile.insert_Account.returning[0].Player;
}

View File

@@ -2,6 +2,8 @@ import express from 'express';
import { asyncHandlerWrapper } from '../lib/apiHelpers';
import actionsRoutes from './actions/routes';
import authHandler from './auth-webhook/handler';
const router = express.Router();
@@ -11,5 +13,6 @@ router.get('/', function (req, res) {
});
router.get('/auth-webhook', asyncHandlerWrapper(authHandler));
router.use('/actions', actionsRoutes);
export default router;

View File

@@ -0,0 +1,20 @@
import fetch from 'node-fetch';
import config from '../config';
export async function hasuraQuery(query: string, qv: any = {}) {
const result = await fetch(config.graphqlURL, {
method: 'POST',
body: JSON.stringify({ query: query, variables: qv }),
headers: {
'Content-Type': 'application/json',
'x-hasura-access-key': config.adminKey,
},
});
const { errors, data } = await result.json();
if(errors) {
throw new Error(JSON.stringify(errors));
}
return data;
}

View File

@@ -0,0 +1,3 @@
export function getPlayerETHAddress(player: any) {
return player.Accounts.find((a: any) => a.type === "ETHEREUM").identifier;
}

View File

@@ -6,6 +6,11 @@
"sourceMap": true,
"outDir": "./dist",
"strict": true,
"esModuleInterop": true
}
"esModuleInterop": true,
"typeRoots": ["./@types"]
},
"include": [
"src",
"./@types"
]
}