commit 1e2681bd081fc686d0520e51f3d9b6ee5ee391d6
parent 779345ef79ce0502b7b1940e95c539fd1d6b8d5b
Author: Andreas Gruhler <andreas.gruhler@adfinis.com>
Date: Sun, 12 Mar 2023 23:06:53 +0100
feat: add auth and router
Diffstat:
8 files changed, 425 insertions(+), 210 deletions(-)
diff --git a/README.md b/README.md
@@ -70,6 +70,8 @@ create trigger on_auth_user_created
Authentication of judges is performed using [Supabase Magic Links](https://supabase.com/docs/guides/auth/auth-magic-link).
+The basic code for the authentication flow was taken from the [official React Getting Started Tutorial](https://supabase.com/docs/guides/getting-started/tutorials/with-react).
+
Sign up process for new judges:
* The judge is required to have a valid email address
* Judges can be invied to the rating backend by invite only (Click ["Invite"](https://app.supabase.com/project/_/auth/users) in Supabase backend)
diff --git a/package-lock.json b/package-lock.json
@@ -14,6 +14,7 @@
"@testing-library/user-event": "^13.5.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
+ "react-router-dom": "^6.9.0",
"react-scripts": "5.0.1",
"react-select": "^5.7.0"
}
@@ -3227,6 +3228,14 @@
}
}
},
+ "node_modules/@remix-run/router": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.4.0.tgz",
+ "integrity": "sha512-BJ9SxXux8zAg991UmT8slpwpsd31K1dHHbD3Ba4VzD+liLQ4WAMSxQp2d2ZPRPfN0jN2NPRowcSSoM7lCaF08Q==",
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/@rollup/plugin-babel": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
@@ -14694,6 +14703,36 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-router": {
+ "version": "6.9.0",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.9.0.tgz",
+ "integrity": "sha512-51lKevGNUHrt6kLuX3e/ihrXoXCa9ixY/nVWRLlob4r/l0f45x3SzBvYJe3ctleLUQQ5fVa4RGgJOTH7D9Umhw==",
+ "dependencies": {
+ "@remix-run/router": "1.4.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "6.9.0",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.9.0.tgz",
+ "integrity": "sha512-/seUAPY01VAuwkGyVBPCn1OXfVbaWGGu4QN9uj0kCPcTyNYgL1ldZpxZUpRU7BLheKQI4Twtl/OW2nHRF1u26Q==",
+ "dependencies": {
+ "@remix-run/router": "1.4.0",
+ "react-router": "6.9.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
"node_modules/react-scripts": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz",
@@ -19844,6 +19883,11 @@
"source-map": "^0.7.3"
}
},
+ "@remix-run/router": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.4.0.tgz",
+ "integrity": "sha512-BJ9SxXux8zAg991UmT8slpwpsd31K1dHHbD3Ba4VzD+liLQ4WAMSxQp2d2ZPRPfN0jN2NPRowcSSoM7lCaF08Q=="
+ },
"@rollup/plugin-babel": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
@@ -28063,6 +28107,23 @@
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
"integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A=="
},
+ "react-router": {
+ "version": "6.9.0",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.9.0.tgz",
+ "integrity": "sha512-51lKevGNUHrt6kLuX3e/ihrXoXCa9ixY/nVWRLlob4r/l0f45x3SzBvYJe3ctleLUQQ5fVa4RGgJOTH7D9Umhw==",
+ "requires": {
+ "@remix-run/router": "1.4.0"
+ }
+ },
+ "react-router-dom": {
+ "version": "6.9.0",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.9.0.tgz",
+ "integrity": "sha512-/seUAPY01VAuwkGyVBPCn1OXfVbaWGGu4QN9uj0kCPcTyNYgL1ldZpxZUpRU7BLheKQI4Twtl/OW2nHRF1u26Q==",
+ "requires": {
+ "@remix-run/router": "1.4.0",
+ "react-router": "6.9.0"
+ }
+ },
"react-scripts": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz",
diff --git a/package.json b/package.json
@@ -9,6 +9,7 @@
"@testing-library/user-event": "^13.5.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
+ "react-router-dom": "^6.9.0",
"react-scripts": "5.0.1",
"react-select": "^5.7.0"
},
diff --git a/src/App.js b/src/App.js
@@ -1,226 +1,69 @@
-import './App.css';
-import { Fragment, useEffect, useState } from 'react';
-import Select from 'react-select'
-import { createClient } from '@supabase/supabase-js'
+import './App.css'
+import { Suspense, lazy, useState, useEffect } from 'react'
+import { BrowserRouter as Router, Routes, Route, Outlet, Link } from 'react-router-dom'
+import { supabase } from './supabaseClient'
-const supabaseUrl = 'https://aaxkgqazjhwumoljibld.supabase.co'
-const supabaseKey = process.env.REACT_APP_SUPABASE_KEY
-const supabase = createClient(supabaseUrl, supabaseKey)
-document.title = process.env.REACT_APP_DOC_TITLE ? process.env.REACT_APP_DOC_TITLE : 'My Heats'
-
-async function getStartlistForHeats(heatIds) {
- return supabase.rpc('distinct_startlist', { 'heat_ids': heatIds })
-}
-
-async function getRatingForHeatAndAthlete(heatId, athleteId) {
- return supabase.from('ratings').select(`
- id,
- athlete,
- judge,
- rating
- `)
- .eq('heat', heatId)
- .eq('athlete', athleteId)
-}
-
-async function getRatingSummary(heatIds) {
- let startlist = {data: []}
- startlist = await getStartlistForHeats(heatIds)
-
- if (startlist.error !== null) {
- return {data: []}
- }
-
- let startListWithRatings = []
-
- for (const i of startlist.data) {
- i.heats = []
-
- for (const h of heatIds) {
- let ratings = await getRatingForHeatAndAthlete(h, i.athlete)
-
- let summary = {data: []}
- summary = await supabase.from('rating_summary').select('rating_summary')
- .eq('heat_id', h)
- .eq('athlete_id', i.athlete)
-
- // add heat results of athlete to startlist entry
- i.heats.push({
- heatId: h,
- ratings: ratings.data,
- summary: summary.data.length > 0 ? summary.data[0].rating_summary : 0
- })
-
- // find best/worst heat
- i.bestHeat = Math.max(...i.heats.map(h => h.summary))
- i.worstHeat = Math.min(...i.heats.map(h => h.summary))
+const Rate = lazy(() => import('./Rate'));
+const Auth = lazy(() => import('./Auth'));
+const Leaderboard = lazy(() => import('./Leaderboard'));
- // sum up all totals across heats
- i.sum = i.heats.map(h => h.summary).reduce((a, b) => a + b, 0)
- }
-
- startListWithRatings.push(i)
- }
+document.title = process.env.REACT_APP_DOC_TITLE ? process.env.REACT_APP_DOC_TITLE : 'My Heats'
- return startListWithRatings
+function Layout() {
+ return (
+ <div>
+ <nav>
+ <ul>
+ <li><Link to="/leaderboard">Leaderboard</Link></li>
+ <li><Link to="/rate">Rate</Link></li>
+ <li><button onClick={() => supabase.auth.signOut()}>Sign out</button></li>
+ </ul>
+ </nav>
+ <Outlet />
+ </div>
+ )
}
-function rankByHeat(rankingComp) {
- return function(a, b) {
- // rank by chosen heat or ranking comparator
- for (const r of rankingComp) {
- switch(r.value) {
- case 'best':
- if (b.bestHeat - a.bestHeat !== 0) {
- // rank by best heat first
- return b.bestHeat - a.bestHeat
- }
- // rank by least worst heat for identical best heats
- return b.worstHeat - a.worstHeat
- case 'worst':
- // rank by worst heat
- return b.worstHeat - a.worstHeat
- case 'total':
- // rank by total sum across heats
- return b.sum - a.sum
- default:
- // rank by heat totals
- if (b.heats.find(h => h.heatId === r.value)?.summary - a.heats.find(h => h.heatId === r.value)?.summary !== 0) {
- return b.heats.find(h => h.heatId === r.value)?.summary - a.heats.find(h => h.heatId === r.value)?.summary
- }
- }
- }
- }
+function NoMatch() {
+ return (
+ <div className="NoMatch">
+ Nothing to see here, <Link to="/leaderboard">go to leaderboard</Link>
+ </div>
+ )
}
function App() {
- const [leaderboard, setLeaderboard] = useState([]);
- const [heatSelection, setHeatSelection] = useState([{value: 0, label: ''}]);
- const [heats, setHeats] = useState([]);
- const [rankingComp, setRankingComp] = useState([{value: 0, label: ''}]);
-
- // add options to select or rank by heat
- let heatOpts = heats.map(h => {
- return {
- value: h.id,
- label: h.name
- }
- })
-
- // add options to rank by best/worst heat
- let rankOpts = heatOpts.concat([
- {
- value: 'best',
- label: 'Best Heat'
- }, {
- value: 'worst',
- label: 'Worst Heat'
- }, {
- value: 'total',
- label: 'Total Sum (all heats)'
- }
- ])
+ const [session, setSession] = useState(null)
useEffect(() => {
- (async () => {
- let heatList = {data: []}
- heatList = await supabase.from('heats').select()
- setHeats(heatList.data)
-
- let ratingSummary = await getRatingSummary(heatSelection.map(h => h.value))
- setLeaderboard(ratingSummary)
- })();
- }, [heatSelection]);
-
- // subscribe to ratings from judges and
- // reload all ratings to refresh leaderboard
- const channel = supabase.channel('ratings')
- channel.on(
- 'postgres_changes',
- {
- event: '*',
- table: 'ratings',
- },
- async (payload) => {
- let ratingSummary = await getRatingSummary(heatSelection.map(h => h.value))
- setLeaderboard(ratingSummary)
- }
- ).subscribe()
-
+ supabase.auth.getSession().then(({ data: { session } }) => {
+ setSession(session)
+ // const { data, error } = supabase.auth.setSession({
+ // access_token: session.access_token,
+ // refresh_token: session.refresh_token
+ // })
+ })
+
+ supabase.auth.onAuthStateChange((_event, session) => {
+ setSession(session)
+ })
+ }, [])
+
return (
<div className="App">
- <header>
- <div>
- Startlist:
- <Select />
- Heats to display:
- <Select
- closeMenuOnSelect={false}
- isMulti
- options={heatOpts}
- onChange={h => setHeatSelection(h)}
- />
- Rank by (in this order):
- <Select
- closeMenuOnSelect={false}
- isMulti
- options={rankOpts}
- onChange={h => setRankingComp(h)}
- />
- Top N to new startlist: <input type="number" size="5" /><button>Level up!</button>
- </div>
- <h1>Leaderboard</h1>
- </header>
- <table>
- <thead>
- <tr>
- <th>Rank</th>
- <th>Start Nr.</th>
- <th>Firstname</th>
- <th>Lastname</th>
- <th>Birthday</th>
- <th>School</th>
- <th colSpan={heatSelection.length * 2}>Heat Ratings & Sum</th>
- <th colSpan={3}>Summary (all heats)</th>
- </tr>
- <tr>
- <th></th>
- <th></th>
- <th></th>
- <th></th>
- <th></th>
- <th></th>
- {heatSelection.map(h => (
- <th key={h.value} colSpan={2}>{h.label}</th>
- ))}
- <th>👍 Best</th>
- <th>👎 Worst</th>
- <th>Total</th>
- </tr>
- </thead>
- <tbody>
- {leaderboard.sort(rankByHeat(rankingComp)).map(i => (
- <tr key={i.id}>
- <td></td>
- <td>{i.nr}</td>
- <td>{i.firstname}</td>
- <td>{i.lastname}</td>
- <td>{i.birthday}</td>
- <td>{i.school}</td>
- {heatSelection.map(h => (
- <Fragment key={h.value}>
- <td className='right'>{i.heats.find(heat => heat.heatId === h.value)?.ratings?.map(r => r.rating).join(" / ")}</td>
- <td className='right'>{i.heats.find(heat => heat.heatId === h.value)?.summary}</td>
- </Fragment>
- ))}
- <td>{i.bestHeat}</td>
- <td>{i.worstHeat}</td>
- <td>{i.sum}</td>
- </tr>
- ))}
- </tbody>
- </table>
+ <Router>
+ <Suspense fallback={<div>Loading...</div>}>
+ <Routes>
+ <Route path="/" element={<Layout />}>
+ <Route path="/leaderboard" element={<Leaderboard />} />
+ <Route path="/rate" element={<Rate session={session} />} />
+ <Route path="/auth" element={<Auth />} />
+ <Route path="*" element={<NoMatch />} />
+ </Route>
+ </Routes>
+ </Suspense>
+ </Router>
</div>
);
}
diff --git a/src/Auth.js b/src/Auth.js
@@ -0,0 +1,55 @@
+import { useState } from 'react'
+import { supabase } from './supabaseClient'
+
+function Auth() {
+ const [loading, setLoading] = useState(false)
+ const [email, setEmail] = useState('')
+
+ const handleLogin = async (e) => {
+ e.preventDefault()
+
+ try {
+ setLoading(true)
+ const { error } = await supabase.auth.signInWithOtp({
+ email: email,
+ options: {
+ shouldCreateUser: false,
+ // emailRedirectTo: 'http://127.0.0.1:3000/rate'
+ }})
+ if (error) throw error
+ alert('Check your email for the login link!')
+ } catch (error) {
+ alert(error.error_description || error.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+ <div className="Auth">
+ <div>
+ <h1>Rate</h1>
+ <p>Sign in via magic link with your email below</p>
+ {loading ? (
+ 'Sending magic link...'
+ ) : (
+ <form onSubmit={handleLogin}>
+ <label htmlFor="email">Email</label>
+ <input
+ id="email"
+ type="email"
+ placeholder="Your email"
+ value={email}
+ onChange={(e) => setEmail(e.target.value)}
+ />
+ <button>
+ Send magic link
+ </button>
+ </form>
+ )}
+ </div>
+ </div>
+ )
+}
+
+export default Auth;
diff --git a/src/Leaderboard.js b/src/Leaderboard.js
@@ -0,0 +1,221 @@
+import { supabase } from './supabaseClient'
+import { Fragment, lazy, useEffect, useState } from 'react'
+import Select from 'react-select'
+
+async function getStartlistForHeats(heatIds) {
+ return supabase.rpc('distinct_startlist', { 'heat_ids': heatIds })
+}
+
+async function getRatingForHeatAndAthlete(heatId, athleteId) {
+ return supabase.from('ratings').select(`
+ id,
+ athlete,
+ judge,
+ rating
+ `)
+ .eq('heat', heatId)
+ .eq('athlete', athleteId)
+}
+
+async function getRatingSummary(heatIds) {
+ let startlist = {data: []}
+ startlist = await getStartlistForHeats(heatIds)
+
+ if (startlist.error !== null) {
+ return {data: []}
+ }
+
+ let startListWithRatings = []
+
+ for (const i of startlist.data) {
+ i.heats = []
+
+ for (const h of heatIds) {
+ let ratings = await getRatingForHeatAndAthlete(h, i.athlete)
+
+ let summary = {data: []}
+ summary = await supabase.from('rating_summary').select('rating_summary')
+ .eq('heat_id', h)
+ .eq('athlete_id', i.athlete)
+
+ // add heat results of athlete to startlist entry
+ i.heats.push({
+ heatId: h,
+ ratings: ratings.data,
+ summary: summary.data.length > 0 ? summary.data[0].rating_summary : 0
+ })
+
+ // find best/worst heat
+ i.bestHeat = Math.max(...i.heats.map(h => h.summary))
+ i.worstHeat = Math.min(...i.heats.map(h => h.summary))
+
+ // sum up all totals across heats
+ i.sum = i.heats.map(h => h.summary).reduce((a, b) => a + b, 0)
+ }
+
+ startListWithRatings.push(i)
+ }
+
+ return startListWithRatings
+}
+
+function rankByHeat(rankingComp) {
+ return function(a, b) {
+ // rank by chosen heat or ranking comparator
+ for (const r of rankingComp) {
+ switch(r.value) {
+ case 'best':
+ if (b.bestHeat - a.bestHeat !== 0) {
+ // rank by best heat first
+ return b.bestHeat - a.bestHeat
+ }
+ // rank by least worst heat for identical best heats
+ return b.worstHeat - a.worstHeat
+ case 'worst':
+ // rank by worst heat
+ return b.worstHeat - a.worstHeat
+ case 'total':
+ // rank by total sum across heats
+ return b.sum - a.sum
+ default:
+ // rank by heat totals
+ if (b.heats.find(h => h.heatId === r.value)?.summary - a.heats.find(h => h.heatId === r.value)?.summary !== 0) {
+ return b.heats.find(h => h.heatId === r.value)?.summary - a.heats.find(h => h.heatId === r.value)?.summary
+ }
+ }
+ }
+ }
+}
+
+function Leaderboard() {
+ const [leaderboard, setLeaderboard] = useState([]);
+ const [heatSelection, setHeatSelection] = useState([{value: 0, label: ''}]);
+ const [heats, setHeats] = useState([]);
+ const [rankingComp, setRankingComp] = useState([{value: 0, label: ''}]);
+
+ // add options to select or rank by heat
+ let heatOpts = heats.map(h => {
+ return {
+ value: h.id,
+ label: h.name
+ }
+ })
+
+ // add options to rank by best/worst heat
+ let rankOpts = heatOpts.concat([
+ {
+ value: 'best',
+ label: 'Best Heat'
+ }, {
+ value: 'worst',
+ label: 'Worst Heat'
+ }, {
+ value: 'total',
+ label: 'Total Sum (all heats)'
+ }
+ ])
+
+ useEffect(() => {
+ (async () => {
+ let heatList = {data: []}
+ heatList = await supabase.from('heats').select()
+ setHeats(heatList.data)
+
+ let ratingSummary = await getRatingSummary(heatSelection.map(h => h.value))
+ setLeaderboard(ratingSummary)
+ })();
+ }, [heatSelection]);
+
+ // subscribe to ratings from judges and
+ // reload all ratings to refresh leaderboard
+ const channel = supabase.channel('ratings')
+ channel.on(
+ 'postgres_changes',
+ {
+ event: '*',
+ table: 'ratings',
+ },
+ async (payload) => {
+ let ratingSummary = await getRatingSummary(heatSelection.map(h => h.value))
+ setLeaderboard(ratingSummary)
+ }
+ ).subscribe()
+
+ return (
+ <div className="Leaderboard">
+ <header>
+ <div>
+ Startlist:
+ <Select />
+ Heats to display:
+ <Select
+ closeMenuOnSelect={false}
+ isMulti
+ options={heatOpts}
+ onChange={h => setHeatSelection(h)}
+ />
+ Rank by (in this order):
+ <Select
+ closeMenuOnSelect={false}
+ isMulti
+ options={rankOpts}
+ onChange={h => setRankingComp(h)}
+ />
+ Top N to new startlist: <input type="number" size="5" /><button>Level up!</button>
+ </div>
+ <h1>Leaderboard</h1>
+ </header>
+ <table>
+ <thead>
+ <tr>
+ <th>Rank</th>
+ <th>Start Nr.</th>
+ <th>Firstname</th>
+ <th>Lastname</th>
+ <th>Birthday</th>
+ <th>School</th>
+ <th colSpan={heatSelection.length * 2}>Heat Ratings & Sum</th>
+ <th colSpan={3}>Summary (all heats)</th>
+ </tr>
+ <tr>
+ <th></th>
+ <th></th>
+ <th></th>
+ <th></th>
+ <th></th>
+ <th></th>
+ {heatSelection.map(h => (
+ <th key={h.value} colSpan={2}>{h.label}</th>
+ ))}
+ <th>👍 Best</th>
+ <th>👎 Worst</th>
+ <th>Total</th>
+ </tr>
+ </thead>
+ <tbody>
+ {leaderboard.sort(rankByHeat(rankingComp)).map(i => (
+ <tr key={i.id}>
+ <td></td>
+ <td>{i.nr}</td>
+ <td>{i.firstname}</td>
+ <td>{i.lastname}</td>
+ <td>{i.birthday}</td>
+ <td>{i.school}</td>
+ {heatSelection.map(h => (
+ <Fragment key={h.value}>
+ <td className='right'>{i.heats.find(heat => heat.heatId === h.value)?.ratings?.map(r => r.rating).join(" / ")}</td>
+ <td className='right'>{i.heats.find(heat => heat.heatId === h.value)?.summary}</td>
+ </Fragment>
+ ))}
+ <td>{i.bestHeat}</td>
+ <td>{i.worstHeat}</td>
+ <td>{i.sum}</td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ );
+}
+
+export default Leaderboard;
diff --git a/src/Rate.js b/src/Rate.js
@@ -0,0 +1,25 @@
+import { lazy } from 'react'
+
+const Auth = lazy(() => import('./Auth'));
+
+function RatingForm(session) {
+ return (
+ <div>
+ <h1>Rate Athletes</h1>
+ <p>Welcome <i>{session.session.user.email}</i></p>
+ </div>
+ )
+}
+
+function Rate(session) {
+
+ console.log(session)
+
+ return (
+ <div>
+ {!session.session ? <Auth /> : <RatingForm key={session.session.user.id} session={session.session} />}
+ </div>
+ )
+}
+
+export default Rate;
diff --git a/src/supabaseClient.js b/src/supabaseClient.js
@@ -0,0 +1,6 @@
+import { createClient } from '@supabase/supabase-js'
+
+const supabaseUrl = 'https://aaxkgqazjhwumoljibld.supabase.co'
+const supabaseKey = process.env.REACT_APP_SUPABASE_KEY
+
+export const supabase = createClient(supabaseUrl, supabaseKey)
+\ No newline at end of file