diff --git a/src/api/login.ts b/src/api/login.ts new file mode 100644 index 0000000..d52bf70 --- /dev/null +++ b/src/api/login.ts @@ -0,0 +1,66 @@ +export type LoginResult = LoginSuccess | LoginFailure + +export interface LoginSuccess { + _tag: 'success' + loginView: LoginView +} +export interface LoginFailure { + _tag: 'failure' + error: string +} + +export interface LoginView { + username: string + accessToken: string +} + +const login = async ( + username: string, + password: string +): Promise => { + try { + const response = await fetch( + import.meta.env.VITE_MME_API_URL + '/auth/login', + { + body: JSON.stringify({ username, password }), + headers: { + 'Content-type': 'application/json' + }, + method: 'POST', + mode: 'cors' + } + ) + if (response.ok) { + const loginView = (await response.json()) as LoginView + return { + _tag: 'success', + loginView + } + } else { + let error: string + if (response.status === 401) { + error = 'Invalid username or password.' + } else if (response.status === 500) { + error = + 'There was an internal server error. Please try again later.' + } else { + error = 'Unknown error.' + console.error( + `Unknown error: ${response.status} ${response.statusText}` + ) + } + return { + _tag: 'failure', + error + } + } + } catch (fetchError) { + console.error(`Unknown error: ${fetchError}`) + return { + _tag: 'failure', + error: 'Network error. Please try again later.' + } + } +} + +export default login diff --git a/src/auth.tsx b/src/auth.tsx index 2784772..0ce75f9 100644 --- a/src/auth.tsx +++ b/src/auth.tsx @@ -1,70 +1,50 @@ -import React, { useContext, createContext, useState } from 'react' +import React, { createContext, useContext, useEffect, useState } from 'react' export interface AuthContextType { token: string | null - error: string | null - login(username: string, password: string): Promise - logout(): void + putToken(token: string, cb?: () => void): void + clearToken(cb?: () => void): void } -interface LoginView { - username: string - accessToken: string +interface AuthState { + token: string | null + putCb?: () => void + clearCb?: () => void } const AuthContext = createContext(null) export const AuthProvider = ({ children }: React.PropsWithChildren) => { - const [token, setToken] = useState(null) - const [error, setError] = useState(null) + const [authState, setAuthState] = useState({ token: null }) - const login: AuthContextType['login'] = async (username, password) => { - try { - const response = await fetch( - import.meta.env.VITE_MME_API_URL + '/auth/login', - { - body: JSON.stringify({ username, password }), - headers: { - 'Content-type': 'application/json' - }, - method: 'POST', - mode: 'cors' - } - ) - if (response.ok) { - const body = (await response.json()) as LoginView - setToken(body.accessToken) - setError(null) - return true - } else { - setToken(null) - if (response.status === 401) { - setError('Invalid username or password.') - } else if (response.status === 500) { - setError( - 'There was an internal server error. Please try again later.' - ) - } else { - setError('Unknown error.') - console.error( - `Unknown error: ${response.status} ${response.statusText}` - ) - } - return false - } - } catch (fetchError) { - setError('Network error. Please try again later.') - console.error(`Unknown error: ${fetchError}`) - return false + useEffect(() => { + if (authState.token === null && authState.clearCb !== undefined) { + authState.clearCb() + setAuthState({ ...authState, clearCb: undefined }) + } else if (authState.token !== null && authState.putCb !== undefined) { + authState.putCb() + setAuthState({ ...authState, putCb: undefined }) } - } - - const logout: AuthContextType['logout'] = () => { - setToken(null) - } + }, [authState.token]) return ( - + {children} ) diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 86917c3..59ef13d 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,23 +1,23 @@ import { Outlet, createRootRouteWithContext, - useRouteContext, + useNavigate, useRouter } from '@tanstack/react-router' import { TanStackRouterDevtools } from '@tanstack/router-devtools' import RouterContext from '../RouterContext' +import { useAuth } from '../auth' const RootLayout = () => { - const logout = useRouteContext({ - from: '__root__', - select: s => s.auth.logout - }) + const auth = useAuth() const router = useRouter() + const navigate = useNavigate() const onLogout = async () => { - logout() - await router.invalidate() - router.navigate({ to: '/login' }) + auth.clearToken(async () => { + await router.invalidate() + await navigate({ to: '/login' }) + }) } return ( diff --git a/src/routes/login.tsx b/src/routes/login.tsx index 602c48f..66ed74d 100644 --- a/src/routes/login.tsx +++ b/src/routes/login.tsx @@ -1,17 +1,21 @@ import { createFileRoute, redirect, - useRouteContext, + useNavigate, useRouter, useSearch } from '@tanstack/react-router' -import { FormEvent } from 'react' +import { FormEvent, useState } from 'react' import { z } from 'zod' +import login from '../api/login' +import { useAuth } from '../auth' const Login = () => { - const login = useRouteContext({ from: '/login', select: s => s.auth.login }) - const error = useRouteContext({ from: '/login', select: s => s.auth.error }) + const auth = useAuth() + const [error, setError] = useState(null) + const router = useRouter() + const navigate = useNavigate() const search = useSearch({ from: '/login' }) const onSubmit = async (event: FormEvent) => { @@ -19,10 +23,14 @@ const Login = () => { const formData = new FormData(event.currentTarget) const username = (formData.get('username') as string | null) ?? '' const password = (formData.get('password') as string | null) ?? '' - const success = await login(username, password) - if (success) { - await router.invalidate() - router.navigate({ to: search.redirect || '/' }) + const loginResult = await login(username, password) + if (loginResult._tag === 'success') { + auth.putToken(loginResult.loginView.accessToken, async () => { + await router.invalidate() + await navigate({ to: search.redirect ?? '/' }) + }) + } else { + setError(loginResult.error) } }