add settings page, refactor session handling

maxime 1 year ago
parent 5a92cd77d1
commit cb3c629c38

@ -5,9 +5,21 @@
<link rel="icon" type="image/svg+xml" href="/src/assets/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/src/assets/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>IQBall</title> <title>IQBall</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script src="https://cdn.jsdelivr.net/npm/react/umd/react.production.min.js" crossorigin></script>
<script
src="https://cdn.jsdelivr.net/npm/react-dom/umd/react-dom.production.min.js"
crossorigin></script>
<script
src="https://cdn.jsdelivr.net/npm/react-bootstrap@next/dist/react-bootstrap.min.js"
crossorigin></script>
<script>var Alert = ReactBootstrap.Alert;</script>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

@ -9,8 +9,10 @@
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"@types/react": "^18.2.31", "@types/react": "^18.2.31",
"@types/react-dom": "^18.2.14", "@types/react-dom": "^18.2.14",
"bootstrap": "^5.3.3",
"eslint-plugin-react-refresh": "^0.4.5", "eslint-plugin-react-refresh": "^0.4.5",
"react": "^18.2.0", "react": "^18.2.0",
"react-bootstrap": "^2.10.2",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-draggable": "^4.4.6", "react-draggable": "^4.4.6",
"react-router-dom": "^6.22.0", "react-router-dom": "^6.22.0",

@ -1,9 +1,11 @@
import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom" import { BrowserRouter, Navigate, Outlet, Route, Routes, useLocation } from "react-router-dom"
import { Header } from "./pages/template/Header.tsx" import { Header } from "./pages/template/Header.tsx"
import "./style/app.css" import "./style/app.css"
import { lazy, ReactNode, Suspense } from "react" import { createContext, lazy, ReactNode, Suspense, useCallback, useContext, useEffect, useMemo, useState } from "react"
import { BASE } from "./Constants.ts" import { BASE } from "./Constants.ts"
import { Authentication, Fetcher } from "./app/Fetcher.ts"
import { User } from "./model/User.ts"
const HomePage = lazy(() => import("./pages/HomePage.tsx")) const HomePage = lazy(() => import("./pages/HomePage.tsx"))
const LoginPage = lazy(() => import("./pages/LoginPage.tsx")) const LoginPage = lazy(() => import("./pages/LoginPage.tsx"))
@ -24,45 +26,103 @@ export default function App() {
) )
} }
const storedAuth = useMemo(() => getStoredAuthentication(), [])
const fetcher = useMemo(() => new Fetcher(storedAuth), [storedAuth])
const [user, setUser] = useState<User | null>(null)
const handleAuthSuccess = useCallback(async (auth: Authentication) => {
fetcher.updateAuthentication(auth)
const user = await fetchUser(fetcher)
setUser(user)
storeAuthentication(auth)
}, [fetcher])
return ( return (
<div id="app"> <div id="app">
<FetcherContext.Provider value={fetcher}>
<SignedInUserContext.Provider value={{
user,
setUser,
}}>
<BrowserRouter basename={BASE}> <BrowserRouter basename={BASE}>
<Outlet /> <Outlet />
<Routes> <Routes>
<Route path={"/login"} element={suspense(<LoginPage />)} /> <Route
path={"/login"}
element={suspense(<LoginPage onSuccess={handleAuthSuccess} />)}
/>
<Route <Route
path={"/register"} path={"/register"}
element={suspense(<RegisterPage />)} element={suspense(<RegisterPage onSuccess={handleAuthSuccess} />)}
/> />
<Route path={"/"} element={suspense(<AppLayout />)}> <Route path={"/"} element={suspense(<AppLayout />)}>
<Route path={"/"} element={suspense(<HomePage />)} /> <Route path={"/"} element={
suspense(
<LoggedInPage>
<HomePage />
</LoggedInPage>,
)
}
/>
<Route <Route
path={"/home"} path={"/home"}
element={suspense(<HomePage />)} element={
suspense(
<LoggedInPage>
<HomePage />
</LoggedInPage>,
)
}
/> />
<Route <Route
path={"/settings"} path={"/settings"}
element={suspense(<Settings/>)} element={
suspense(
<LoggedInPage>
<Settings />
</LoggedInPage>,
)
}
/> />
<Route <Route
path={"/team/new"} path={"/team/new"}
element={suspense(<CreateTeamPage />)} element={
suspense(<CreateTeamPage />)
}
/> />
<Route <Route
path={"/team/:teamId"} path={"/team/:teamId"}
element={suspense(<TeamPanelPage />)} element={
suspense(
<LoggedInPage>
<TeamPanelPage />
</LoggedInPage>,
)
}
/> />
<Route <Route
path={"/tactic/new"} path={"/tactic/new"}
element={suspense(<NewTacticPage />)} element={
suspense(
<LoggedInPage>
<NewTacticPage />
</LoggedInPage>,
)
}
/> />
<Route <Route
path={"/tactic/:tacticId/edit"} path={"/tactic/:tacticId/edit"}
element={suspense(<Editor guestMode={false} />)} element={
suspense(
<LoggedInPage>
<Editor guestMode={false} />
</LoggedInPage>,
)
}
/> />
<Route <Route
path={"/tactic/edit-guest"} path={"/tactic/edit-guest"}
@ -76,10 +136,75 @@ export default function App() {
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</SignedInUserContext.Provider>
</FetcherContext.Provider>
</div> </div>
) )
} }
async function fetchUser(fetcher: Fetcher): Promise<User> {
const response = await fetcher.fetchAPIGet("user")
if (!response.ok) {
throw Error("Could not retrieve user information : " + await response.text())
}
return await response.json()
}
const STORAGE_AUTH_KEY = "token"
function getStoredAuthentication(): Authentication {
const storedUser = localStorage.getItem(STORAGE_AUTH_KEY)
return storedUser == null ? null : JSON.parse(storedUser)
}
function storeAuthentication(auth: Authentication) {
localStorage.setItem(STORAGE_AUTH_KEY, JSON.stringify(auth))
}
interface LoggedInPageProps {
children: ReactNode
}
enum UserFetchingState {
FETCHING,
FETCHED,
ERROR
}
function LoggedInPage({ children }: LoggedInPageProps) {
const [user, setUser] = useUser()
const fetcher = useAppFetcher()
const [userFetchingState, setUserFetchingState] = useState(user === null ? UserFetchingState.FETCHING : UserFetchingState.FETCHED)
const location = useLocation()
useEffect(() => {
async function initUser() {
try {
const user = await fetchUser(fetcher)
setUser(user)
setUserFetchingState(UserFetchingState.FETCHED)
} catch (e) {
setUserFetchingState(UserFetchingState.ERROR)
}
}
if (userFetchingState === UserFetchingState.FETCHING)
initUser()
}, [fetcher, setUser, userFetchingState])
switch (userFetchingState) {
case UserFetchingState.ERROR:
return <Navigate to={"/login"} replace state={{ from: location.pathname }} />
case UserFetchingState.FETCHED:
return children
case UserFetchingState.FETCHING:
return <p>Fetching user...</p>
}
}
function AppLayout() { function AppLayout() {
return ( return (
<> <>
@ -88,3 +213,22 @@ function AppLayout() {
</> </>
) )
} }
interface UserContext {
user: User | null
setUser: (user: User) => void
}
const SignedInUserContext = createContext<UserContext | null>(null)
const FetcherContext = createContext(new Fetcher())
export function useAppFetcher() {
return useContext(FetcherContext)
}
export function useUser(): [User | null, (user: User) => void] {
const { user, setUser } = useContext(SignedInUserContext)!
return [user, setUser]
}

@ -1,68 +0,0 @@
import { API } from "./Constants"
import { getSession, saveSession, Session } from "./api/session.ts"
export async function fetchAPI(
url: string,
payload: unknown,
method = "POST",
): Promise<Response> {
const session = getSession()
const token = session?.auth?.token
const headers: HeadersInit = {
Accept: "application/json",
"Content-Type": "application/json",
}
if (token) {
headers.Authorization = token
}
const response = await fetch(`${API}/${url}`, {
method,
headers,
body: JSON.stringify(payload),
})
return await handleResponse(session, response)
}
export async function fetchAPIGet(url: string): Promise<Response> {
const session = getSession()
const token = session?.auth?.token
const headers: HeadersInit = {
Accept: "application/json",
"Content-Type": "application/json",
}
if (token) {
headers.Authorization = token
}
const response = await fetch(`${API}/${url}`, {
method: "GET",
headers,
})
return await handleResponse(session, response)
}
async function handleResponse(
session: Session,
response: Response,
): Promise<Response> {
// if we provided a token but still unauthorized, the token has expired
if (!response.ok) {
return response
}
const nextToken = response.headers.get("Next-Authorization")!
const expirationDate = new Date(
response.headers.get("Next-Authorization-Expiration-Date")!,
)
if (nextToken && expirationDate)
saveSession({ ...session, auth: { token: nextToken, expirationDate } })
return response
}

@ -1,23 +0,0 @@
export interface Session {
auth?: Authentication
urlTarget?: string
username?: string
}
export interface Authentication {
token: string
expirationDate: Date
}
const SESSION_KEY = "session"
// export const SessionContext = createContext<Session>(getSession())
export function saveSession(session: Session) {
localStorage.setItem(SESSION_KEY, JSON.stringify(session))
}
export function getSession(): Session {
const json = localStorage.getItem(SESSION_KEY)
return json ? JSON.parse(json) : {}
}

@ -0,0 +1,87 @@
import { API } from "../Constants.ts"
export interface Authentication {
token: string
expirationDate: Date
}
export class Fetcher {
private auth?: Authentication
public constructor(auth?: Authentication) {
this.auth = auth
}
async fetchAPI(
url: string,
payload: unknown,
method = "POST",
): Promise<Response> {
const token = this.auth?.token
const headers: HeadersInit = {
Accept: "application/json",
"Content-Type": "application/json",
}
if (token) {
headers.Authorization = token
}
const response = await fetch(`${API}/${url}`, {
method,
headers,
body: JSON.stringify(payload),
})
return await this.handleResponse(response)
}
async fetchAPIGet(url: string): Promise<Response> {
const token = this.auth?.token
const headers: HeadersInit = {
Accept: "application/json",
"Content-Type": "application/json",
}
if (token) {
headers.Authorization = token
}
const response = await fetch(`${API}/${url}`, {
method: "GET",
headers,
})
return await this.handleResponse(response)
}
updateAuthentication(auth: Authentication) {
this.auth = auth
}
async handleResponse(
response: Response,
): Promise<Response> {
// if we provided a token but still unauthorized, the token has expired
if (!response.ok) {
return response
}
const nextToken = response.headers.get("Next-Authorization")!
const expirationDate = new Date(
response.headers.get("Next-Authorization-Expiration-Date")!,
)
if (nextToken && expirationDate) {
this.auth = { token: nextToken, expirationDate }
}
return response
}
}

@ -1,13 +1,4 @@
import { import { CSSProperties, RefObject, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react"
CSSProperties,
RefObject,
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import "../style/editor.css" import "../style/editor.css"
import TitleInput from "../components/TitleInput" import TitleInput from "../components/TitleInput"
import PlainCourt from "../assets/court/full_court.svg?react" import PlainCourt from "../assets/court/full_court.svg?react"
@ -18,18 +9,9 @@ import { BallPiece } from "../components/editor/BallPiece"
import { Rack } from "../components/Rack" import { Rack } from "../components/Rack"
import { PlayerPiece } from "../components/editor/PlayerPiece" import { PlayerPiece } from "../components/editor/PlayerPiece"
import { import { ComponentId, CourtType, StepContent, StepInfoNode, TacticComponent } from "../model/tactic/Tactic"
ComponentId,
CourtType, import SavingState, { SaveState, SaveStates } from "../components/editor/SavingState"
StepContent,
StepInfoNode,
TacticComponent,
} from "../model/tactic/Tactic"
import SavingState, {
SaveState,
SaveStates,
} from "../components/editor/SavingState"
import { BALL_TYPE } from "../model/tactic/CourtObjects" import { BALL_TYPE } from "../model/tactic/CourtObjects"
import { CourtAction } from "../components/editor/CourtAction" import { CourtAction } from "../components/editor/CourtAction"
@ -52,19 +34,10 @@ import {
updateComponent, updateComponent,
} from "../editor/TacticContentDomains" } from "../editor/TacticContentDomains"
import { import { BallState, Player, PlayerInfo, PlayerLike, PlayerTeam } from "../model/tactic/Player"
BallState,
Player,
PlayerInfo,
PlayerLike,
PlayerTeam,
} from "../model/tactic/Player"
import { RackedCourtObject, RackedPlayer } from "../editor/RackedItems" import { RackedCourtObject, RackedPlayer } from "../editor/RackedItems"
import { import { CourtPlayer, EditableCourtPlayer } from "../components/editor/CourtPlayer.tsx"
CourtPlayer,
EditableCourtPlayer,
} from "../components/editor/CourtPlayer.tsx"
import { import {
createAction, createAction,
getActionKind, getActionKind,
@ -76,25 +49,17 @@ import ArrowAction from "../components/actions/ArrowAction"
import { middlePos, Pos, ratioWithinBase } from "../geo/Pos" import { middlePos, Pos, ratioWithinBase } from "../geo/Pos"
import { Action, ActionKind } from "../model/tactic/Action" import { Action, ActionKind } from "../model/tactic/Action"
import BallAction from "../components/actions/BallAction" import BallAction from "../components/actions/BallAction"
import { import { computePhantomPositioning, getOrigin, removePlayer } from "../editor/PlayerDomains"
computePhantomPositioning,
getOrigin,
removePlayer,
} from "../editor/PlayerDomains"
import { CourtBall } from "../components/editor/CourtBall" import { CourtBall } from "../components/editor/CourtBall"
import StepsTree from "../components/editor/StepsTree" import StepsTree from "../components/editor/StepsTree"
import { import { addStepNode, getParent, getStepNode, removeStepNode } from "../editor/StepsDomain"
addStepNode,
getParent,
getStepNode,
removeStepNode,
} from "../editor/StepsDomain"
import SplitLayout from "../components/SplitLayout.tsx" import SplitLayout from "../components/SplitLayout.tsx"
import { ServiceError, TacticService } from "../service/TacticService.ts" import { ServiceError, TacticService } from "../service/TacticService.ts"
import { LocalStorageTacticService } from "../service/LocalStorageTacticService.ts" import { LocalStorageTacticService } from "../service/LocalStorageTacticService.ts"
import { APITacticService } from "../service/APITacticService.ts" import { APITacticService } from "../service/APITacticService.ts"
import { useParams } from "react-router-dom" import { useParams } from "react-router-dom"
import { ContentVersions } from "../editor/ContentVersions.ts" import { ContentVersions } from "../editor/ContentVersions.ts"
import { useAppFetcher } from "../App.tsx"
const ERROR_STYLE: CSSProperties = { const ERROR_STYLE: CSSProperties = {
borderColor: "red", borderColor: "red",
@ -132,12 +97,12 @@ interface EditorService {
function EditorPortal({ guestMode }: EditorPageProps) { function EditorPortal({ guestMode }: EditorPageProps) {
const { tacticId: idStr } = useParams() const { tacticId: idStr } = useParams()
const fetcher = useAppFetcher()
if (guestMode || !idStr) { if (guestMode || !idStr) {
return <EditorPageWrapper service={LocalStorageTacticService.init()} /> return <EditorPageWrapper service={LocalStorageTacticService.init()} />
} }
return <EditorPageWrapper service={new APITacticService(parseInt(idStr))} /> return <EditorPageWrapper service={new APITacticService(fetcher, parseInt(idStr))} />
} }
function EditorPageWrapper({ service }: { service: TacticService }) { function EditorPageWrapper({ service }: { service: TacticService }) {
@ -850,7 +815,7 @@ function EditorStepsTree({
onAddChildren, onAddChildren,
onRemoveNode, onRemoveNode,
onStepSelected, onStepSelected,
}: EditorStepsTreeProps) { }: EditorStepsTreeProps) {
return ( return (
<div id="steps-div"> <div id="steps-div">
<StepsTree <StepsTree
@ -880,7 +845,7 @@ function PlayerRack({
setObjects, setObjects,
courtRef, courtRef,
setComponents, setComponents,
}: PlayerRackProps) { }: PlayerRackProps) {
const courtBounds = useCallback( const courtBounds = useCallback(
() => courtRef.current!.getBoundingClientRect(), () => courtRef.current!.getBoundingClientRect(),
[courtRef], [courtRef],
@ -942,7 +907,7 @@ function CourtPlayerArrowAction({
setContent, setContent,
setPreviewAction, setPreviewAction,
courtRef, courtRef,
}: CourtPlayerArrowActionProps) { }: CourtPlayerArrowActionProps) {
const courtBounds = useCallback( const courtBounds = useCallback(
() => courtRef.current!.getBoundingClientRect(), () => courtRef.current!.getBoundingClientRect(),
[courtRef], [courtRef],

@ -1,9 +1,8 @@
import "../style/home/home.css" import "../style/home/home.css"
import { getSession } from "../api/session.ts"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { User } from "../model/User.ts" import { User } from "../model/User.ts"
import { fetchAPIGet } from "../Fetcher.ts" import { useAppFetcher } from "../App.tsx"
interface Tactic { interface Tactic {
id: number id: number
@ -27,17 +26,11 @@ export default function HomePage() {
}) })
const navigate = useNavigate() const navigate = useNavigate()
const fetcher = useAppFetcher()
useEffect(() => { useEffect(() => {
const session = getSession() async function initUserData() {
const response = await fetcher.fetchAPIGet("user-data")
if (!session.auth) {
navigate("/login")
return
}
async function getUser() {
const response = await fetchAPIGet("user-data")
if (response.status == 401) { if (response.status == 401) {
navigate("/login") navigate("/login")
return // if unauthorized return // if unauthorized
@ -45,8 +38,8 @@ export default function HomePage() {
setInfo(await response.json()) setInfo(await response.json())
} }
getUser() initUserData()
}, [navigate]) }, [fetcher, navigate])
tactics!.sort((a, b) => b.creationDate - a.creationDate) tactics!.sort((a, b) => b.creationDate - a.creationDate)

@ -1,14 +1,20 @@
import { FormEvent, useState } from "react" import { FormEvent, useState } from "react"
import { fetchAPI } from "../Fetcher.ts" import { Failure } from "../app/Failure.ts"
import { Failure } from "../api/failure.ts"
import { getSession, saveSession } from "../api/session.ts"
import "../style/form.css" import "../style/form.css"
import { Link, useNavigate } from "react-router-dom" import { Link, useLocation, useNavigate } from "react-router-dom"
import { useAppFetcher } from "../App.tsx"
import { Authentication } from "../app/Fetcher.ts"
export default function LoginApp() { export interface LoginAppProps {
onSuccess: (auth: Authentication) => void
}
export default function LoginApp({onSuccess}: LoginAppProps) {
const [errors, setErrors] = useState<Failure[]>([]) const [errors, setErrors] = useState<Failure[]>([])
const fetcher = useAppFetcher()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
async function handleSubmit(e: FormEvent) { async function handleSubmit(e: FormEvent) {
e.preventDefault() e.preventDefault()
@ -17,21 +23,18 @@ export default function LoginApp() {
new FormData(e.target as HTMLFormElement), new FormData(e.target as HTMLFormElement),
) )
const response = await fetchAPI( const response = await fetcher.fetchAPI(
"auth/token", "auth/token",
{ email, password }, { email, password },
"POST", "POST",
) )
if (response.ok) { if (response.ok) {
const session = getSession()
const { token, expirationDate } = await response.json() const { token, expirationDate } = await response.json()
saveSession({ const auth = { token, expirationDate: new Date(expirationDate) }
...session, onSuccess(auth)
auth: { token, expirationDate: new Date(expirationDate) }, console.log(location)
urlTarget: undefined, navigate(location.state?.from || "/")
})
navigate(session.urlTarget ?? "/")
return return
} }

@ -5,9 +5,8 @@ import plainCourt from "../assets/court/full_court.svg"
import halfCourt from "../assets/court/half_court.svg" import halfCourt from "../assets/court/half_court.svg"
import { CourtType } from "../model/tactic/Tactic.ts" import { CourtType } from "../model/tactic/Tactic.ts"
import { useCallback } from "react" import { useCallback } from "react"
import { fetchAPI } from "../Fetcher.ts" import { useAppFetcher, useUser } from "../App.tsx"
import { getSession, saveSession } from "../api/session.ts" import { useNavigate } from "react-router-dom"
import { useLocation, useNavigate } from "react-router-dom"
export const DEFAULT_TACTIC_NAME = "Nouvelle tactique" export const DEFAULT_TACTIC_NAME = "Nouvelle tactique"
@ -45,18 +44,19 @@ function CourtKindButton({
courtType: CourtType courtType: CourtType
}) { }) {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const fetcher = useAppFetcher()
const [user] = useUser()
return ( return (
<div <div
className="court-kind-button" className="court-kind-button"
onClick={useCallback(async () => { onClick={useCallback(async () => {
// if user is not authenticated // if user is not authenticated
if (!getSession().auth) { if (!user) {
navigate(`/tactic/edit-guest`) navigate(`/tactic/edit-guest`)
} }
const response = await fetchAPI( const response = await fetcher.fetchAPI(
"tactics", "tactics",
{ {
name: DEFAULT_TACTIC_NAME, name: DEFAULT_TACTIC_NAME,
@ -66,10 +66,6 @@ function CourtKindButton({
) )
if (response.status === 401) { if (response.status === 401) {
saveSession({
...getSession(),
urlTarget: location.pathname,
})
// if unauthorized // if unauthorized
navigate("/login") navigate("/login")
return return
@ -77,7 +73,7 @@ function CourtKindButton({
const { id } = await response.json() const { id } = await response.json()
navigate(`/tactic/${id}/edit`) navigate(`/tactic/${id}/edit`)
}, [courtType, location.pathname, navigate])}> }, [courtType, fetcher, navigate, user])}>
<div className="court-kind-button-top"> <div className="court-kind-button-top">
<div className="court-kind-button-image-div"> <div className="court-kind-button-image-div">
<img <img

@ -1,20 +1,26 @@
import { FormEvent, useRef, useState } from "react" import { FormEvent, useRef, useState } from "react"
import "../style/form.css" import "../style/form.css"
import { Failure } from "../api/failure.ts" import { Failure } from "../app/Failure.ts"
import { fetchAPI } from "../Fetcher.ts" import { Link, useLocation, useNavigate } from "react-router-dom"
import { getSession, saveSession } from "../api/session.ts" import { useAppFetcher } from "../App.tsx"
import { Link, useNavigate } from "react-router-dom" import { Authentication } from "../app/Fetcher.ts"
export default function RegisterPage() { export interface RegisterPageProps {
onSuccess: (auth: Authentication) => void
}
export default function RegisterPage({ onSuccess }: RegisterPageProps) {
const usernameField = useRef<HTMLInputElement>(null) const usernameField = useRef<HTMLInputElement>(null)
const passwordField = useRef<HTMLInputElement>(null) const passwordField = useRef<HTMLInputElement>(null)
const confirmpasswordField = useRef<HTMLInputElement>(null) const confirmpasswordField = useRef<HTMLInputElement>(null)
const emailField = useRef<HTMLInputElement>(null) const emailField = useRef<HTMLInputElement>(null)
const [errors, setErrors] = useState<Failure[]>([]) const [errors, setErrors] = useState<Failure[]>([])
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const fetcher = useAppFetcher()
async function handleSubmit(e: FormEvent) { async function handleSubmit(e: FormEvent) {
e.preventDefault() e.preventDefault()
@ -36,7 +42,7 @@ export default function RegisterPage() {
return return
} }
const response = await fetchAPI("auth/register", { const response = await fetcher.fetchAPI("auth/register", {
username, username,
password, password,
email, email,
@ -44,13 +50,10 @@ export default function RegisterPage() {
if (response.ok) { if (response.ok) {
const { token, expirationDate } = await response.json() const { token, expirationDate } = await response.json()
const session = getSession() const auth = { token, expirationDate: new Date(expirationDate) }
saveSession({ navigate(location.state?.from || "/")
...session,
auth: { token, expirationDate: new Date(expirationDate) }, onSuccess(auth)
urlTarget: undefined,
})
navigate(session.urlTarget ?? "/")
return return
} }

@ -1,5 +1,222 @@
import "bootstrap/dist/css/bootstrap.min.css"
import Button from "react-bootstrap/Button"
import Form from "react-bootstrap/Form"
import Image from "react-bootstrap/Image"
import Container from "react-bootstrap/Container"
import Row from "react-bootstrap/Row"
import Col from "react-bootstrap/Col"
import Modal from "react-bootstrap/Modal"
import { Stack } from "react-bootstrap"
import { useRef, useState } from "react"
import "../style/settings.css"
import { useAppFetcher, useUser } from "../App.tsx"
import { Fetcher } from "../app/Fetcher.ts"
export default function Settings() { export default function Settings() {
return <p>Hey !</p> return (
<div id="settings-page">
<div id="content">
<AccountSettings />
</div>
</div>
)
}
function AccountSettings() {
return (
<div id="account">
<ProfilSettings/>
</div>
)
}
function ProfilSettings() {
const nameRef = useRef<HTMLInputElement>(null)
const emailRef = useRef<HTMLInputElement>(null)
const passwordRef = useRef<HTMLInputElement>(null)
const confirmPasswordRef = useRef<HTMLInputElement>(null)
const fetcher = useAppFetcher()
const [user, setUser] = useUser()
const [errorMessages, setErrorMessages] = useState<string[]>([])
const [modalShow, setModalShow] = useState(false)
const width = 150
console.log(user)
const profilePicture = user!.profilePicture
return (
<Container>
{errorMessages.map(msg => (<div key={msg} className={"error-message"}>{msg}</div>))}
<Row>
<Col>
<Stack>
<div id="profile-picture">
<Image src={profilePicture} width={width} height={width} roundedCircle />
</div>
<Button variant="outline-primary" onClick={() => setModalShow(true)}>
Changer photo de profil
</Button>
<ProfileImageInputPopup
show={modalShow}
onHide={() => setModalShow(false)}
/>
</Stack>
</Col>
<Col>
<Form>
<Form.Group className="mb-3" controlId="formUsername">
<Form.Label className="content">Nom d'utilisateur</Form.Label>
<Form.Control ref={nameRef} size="sm" defaultValue={user!.name} />
</Form.Group>
<Form.Group className="mb-3" controlId="formEmail">
<Form.Label className="content">Adresse mail</Form.Label>
<Form.Control ref={emailRef} size="sm" defaultValue={user!.email} type="email"
placeholder="Addresse email" />
</Form.Group>
<Form.Group className="mb-3" controlId="formPassword">
<Form.Label className="content">Mot de passe</Form.Label>
<Form.Control ref={passwordRef}
size="sm"
type="password"
placeholder="Mot de passe" />
</Form.Group>
<Form.Group className="mb-3" controlId="formConfirmPassword">
<Form.Label className="content">Confirmez le mot de passe</Form.Label>
<Form.Control ref={confirmPasswordRef}
size="sm"
type="password"
placeholder="Confirmation du mot de passe" />
</Form.Group>
<Button variant="outline-primary" type="button"
onClick={async () => {
const name = nameRef.current!.value
const email = emailRef.current!.value
const password = passwordRef.current?.value
const confirmPassword = confirmPasswordRef.current?.value
console.log(password, confirmPassword, name, email)
if (password !== confirmPassword) {
setErrorMessages(["Les mots de passe ne correspondent pas !"])
return
}
const req: AccountUpdateRequest = {
name,
email,
}
if (password && password.length !== 0) {
req.password = password
}
const errors = await updateAccount(fetcher, req)
if (errors.length !== 0) {
setErrorMessages(errors)
return
}
setUser({...user!, email, name})
}}>Mettre
à jour</Button>
</Form>
</Col>
</Row>
</Container>
)
} }
interface AccountUpdateRequest {
name?: string
email?: string
profilePicture?: string
password?: string
}
async function updateAccount(fetcher: Fetcher, req: AccountUpdateRequest): Promise<string[]> {
const response = await fetcher.fetchAPI("user", req, "PUT")
if (response.ok)
return []
const body = await response.json()
return Object.entries(body)
.flatMap(([kind, messages]) => (messages as string[]).map(msg => `${kind}: ${msg}`))
}
interface ProfileImageInputPopupProps {
show: boolean
onHide: () => void
}
function ProfileImageInputPopup({ show, onHide }: ProfileImageInputPopupProps) {
const urlRef = useRef<HTMLInputElement>(null)
const [errorMessages, setErrorMessages] = useState<string[]>()
const fetcher = useAppFetcher()
const [user, setUser] = useUser()
return (
<Modal
show={show}
onHide={onHide}
size="lg"
aria-labelledby="title-modal"
centered
>
<Modal.Header>
<Modal.Title id="title-modal">
Nouvelle photo de profil
</Modal.Title>
</Modal.Header>
<Modal.Body>
{errorMessages?.map(msg => <div key={msg} className="error-message">{msg}</div>)}
<Form.Label>Saisissez le lien vers votre nouvelle photo de profil</Form.Label>
<Form.Control isInvalid={errorMessages?.length !== 0} ref={urlRef} type="input"
placeholder={"lien vers une image"} />
</Modal.Body>
<Modal.Footer>
<Button onClick={onHide}>Annuler</Button>
<Button onClick={async () => {
const url = urlRef.current!.value
const exists = await imageExists(url)
setErrorMessages(["Le lien ne renvoie vers aucune image !"])
if (!exists) {
return
}
const errors = await updateAccount(fetcher, { profilePicture: url })
if (errors.length !== 0) {
setErrorMessages(errors)
return
}
setUser({...user!, profilePicture: url})
onHide()
}
}>Valider</Button>
</Modal.Footer>
</Modal>
)
}
async function imageExists(imageLink: string) {
try {
const response = await fetch(imageLink)
console.log(response)
if (response.ok) {
const contentType = response.headers.get("content-type")
return contentType?.startsWith("image/") ?? false
}
return false
} catch (error) {
console.error(error)
return false
}
}

@ -1,38 +1,15 @@
import AccountSvg from "../../assets/account.svg?react" import AccountSvg from "../../assets/account.svg?react"
import "../../style/template/header.css" import "../../style/template/header.css"
import { useEffect, useState } from "react" import { useUser } from "../../App.tsx"
import { fetchAPIGet } from "../../Fetcher.ts" import { useNavigate } from "react-router-dom"
import { getSession, saveSession } from "../../api/session.ts"
import { useLocation, useNavigate } from "react-router-dom"
export function Header() { export function Header() {
const session = getSession()
const [username, setUsername] = useState<string | null>(
session.username ?? null,
)
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const [user, ] = useUser()
useEffect(() => {
async function loadUsername() {
const response = await fetchAPIGet("user")
if (response.status === 401) {
//if unauthorized, the header will display a login button instead,
//based on the nullity of username
return
}
//TODO check if the response is ok and handle errors
const { name: username } = await response.json()
saveSession({ ...session, username })
setUsername(username)
}
// if the user is authenticated and the username is not already present in the session, const accountImage = user?.profilePicture
if (session.auth && !session.username) loadUsername() ? <img id="img-account" src={user.profilePicture} alt={"profile-picture"} />
}, [session]) : <AccountSvg id="img-account" />
return ( return (
<div id="header"> <div id="header">
@ -50,20 +27,15 @@ export function Header() {
className="clickable" className="clickable"
id="clickable-header-right" id="clickable-header-right"
onClick={() => { onClick={() => {
if (username) { if (user) {
navigate("/settings") navigate("/settings")
return return
} }
saveSession({
...session,
urlTarget: location.pathname,
})
navigate("/login") navigate("/login")
}}> }}>
{/* <AccountSvg id="img-account" /> */} {accountImage}
<AccountSvg id="img-account" /> <p id="username">{user?.name ?? "Log In"}</p>
<p id="username">{username ?? "Log In"}</p>
</div> </div>
</div> </div>
</div> </div>

@ -1,19 +1,21 @@
import { TacticService, ServiceError, TacticContext } from "./TacticService.ts" import { TacticService, ServiceError, TacticContext } from "./TacticService.ts"
import { StepContent, StepInfoNode } from "../model/tactic/Tactic.ts" import { StepContent, StepInfoNode } from "../model/tactic/Tactic.ts"
import { fetchAPI, fetchAPIGet } from "../Fetcher.ts" import { Fetcher } from "../app/Fetcher.ts"
export class APITacticService implements TacticService { export class APITacticService implements TacticService {
private readonly tacticId: number private readonly tacticId: number
private readonly fetcher: Fetcher
constructor(tacticId: number) { constructor(fetcher: Fetcher, tacticId: number) {
this.tacticId = tacticId this.tacticId = tacticId
this.fetcher = fetcher
} }
async getContext(): Promise<TacticContext | ServiceError> { async getContext(): Promise<TacticContext | ServiceError> {
const infoResponsePromise = fetchAPIGet(`tactics/${this.tacticId}`) const infoResponsePromise = this.fetcher.fetchAPIGet(`tactics/${this.tacticId}`)
const infoResponse = await infoResponsePromise const infoResponse = await infoResponsePromise
const treeResponsePromise = fetchAPIGet(`tactics/${this.tacticId}/tree`) const treeResponsePromise = this.fetcher.fetchAPIGet(`tactics/${this.tacticId}/tree`)
const treeResponse = await treeResponsePromise const treeResponse = await treeResponsePromise
if (infoResponse.status == 401 || treeResponse.status == 401) { if (infoResponse.status == 401 || treeResponse.status == 401) {
@ -29,7 +31,7 @@ export class APITacticService implements TacticService {
parent: StepInfoNode, parent: StepInfoNode,
content: StepContent, content: StepContent,
): Promise<StepInfoNode | ServiceError> { ): Promise<StepInfoNode | ServiceError> {
const response = await fetchAPI(`tactics/${this.tacticId}/steps`, { const response = await this.fetcher.fetchAPI(`tactics/${this.tacticId}/steps`, {
parentId: parent.id, parentId: parent.id,
content, content,
}) })
@ -41,7 +43,7 @@ export class APITacticService implements TacticService {
} }
async removeStep(id: number): Promise<void | ServiceError> { async removeStep(id: number): Promise<void | ServiceError> {
const response = await fetchAPI( const response = await this.fetcher.fetchAPI(
`tactics/${this.tacticId}/steps/${id}`, `tactics/${this.tacticId}/steps/${id}`,
{}, {},
"DELETE", "DELETE",
@ -51,7 +53,7 @@ export class APITacticService implements TacticService {
} }
async setName(name: string): Promise<void | ServiceError> { async setName(name: string): Promise<void | ServiceError> {
const response = await fetchAPI( const response = await this.fetcher.fetchAPI(
`tactics/${this.tacticId}/name`, `tactics/${this.tacticId}/name`,
{ name }, { name },
"PUT", "PUT",
@ -64,7 +66,7 @@ export class APITacticService implements TacticService {
step: number, step: number,
content: StepContent, content: StepContent,
): Promise<void | ServiceError> { ): Promise<void | ServiceError> {
const response = await fetchAPI( const response = await this.fetcher.fetchAPI(
`tactics/${this.tacticId}/steps/${step}`, `tactics/${this.tacticId}/steps/${step}`,
{ content }, { content },
"PUT", "PUT",
@ -74,7 +76,7 @@ export class APITacticService implements TacticService {
} }
async getContent(step: number): Promise<StepContent | ServiceError> { async getContent(step: number): Promise<StepContent | ServiceError> {
const response = await fetchAPIGet( const response = await this.fetcher.fetchAPIGet(
`tactics/${this.tacticId}/steps/${step}`, `tactics/${this.tacticId}/steps/${step}`,
) )
if (response.status == 404) return ServiceError.NOT_FOUND if (response.status == 404) return ServiceError.NOT_FOUND

@ -0,0 +1,16 @@
.error-message {
color: red
}
#settings-page {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
#profile-picture {
display: flex;
justify-content: center;
}

@ -16,6 +16,9 @@
#img-account { #img-account {
cursor: pointer; cursor: pointer;
margin-right: 5px; margin-right: 5px;
width: 50px;
height: 50px;
border-radius: 20%;
} }
#header-left, #header-left,
@ -29,14 +32,20 @@
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: end; align-items: end;
margin-right: 30px;
color: white; color: white;
margin-right: 5px;
} }
#clickable-header-right:hover #username { #clickable-header-right:hover #username {
color: var(--accent-color); color: var(--accent-color);
} }
#username {
text-align: center;
vertical-align: center;
margin: 0;
}
#clickable-header-right { #clickable-header-right {
border-radius: 1cap; border-radius: 1cap;

Loading…
Cancel
Save