diff --git a/os-league-tools-dev.service b/os-league-tools-dev.service index 1b879d30..7cf5af82 100644 --- a/os-league-tools-dev.service +++ b/os-league-tools-dev.service @@ -17,6 +17,8 @@ Environment="HOST=0.0.0.0" Environment="WDS_SOCKET_PROTOCOL=wss" Environment="WDS_SOCKET_HOST=dev.leagues.tools" Environment="WDS_SOCKET_PORT=443" +Environment="REACT_APP_RELDO_URL=https://api.leagues.tools" + # Start the dev server with hot reload ExecStart=/usr/bin/npm run dev diff --git a/os-league-tools-master/src/client/auth-client.js b/os-league-tools-master/src/client/auth-client.js new file mode 100644 index 00000000..659dcb7d --- /dev/null +++ b/os-league-tools-master/src/client/auth-client.js @@ -0,0 +1,74 @@ +const BASE_URL = process.env.REACT_APP_RELDO_URL || 'http://localhost:8080'; +const DEFAULT_HEADERS = { + 'Content-type': 'application/json', +}; + +function handleResponse(response) { + if (!response.ok) { + return response.json().then(data => ({ + success: false, + error: data.message || data.error || 'An error occurred', + })); + } + return response.json().then(data => ({ + success: true, + value: data, + })); +} + +function handleError(error) { + console.warn(error); + return { success: false, error: error.message || 'Network error' }; +} + +export function login(username, password) { + return fetch(`${BASE_URL}/api/login`, { + method: 'POST', + headers: DEFAULT_HEADERS, + credentials: 'include', + body: JSON.stringify({ username, password }), + }) + .then(handleResponse) + .catch(handleError); +} + +export function register(username, email, password) { + return fetch(`${BASE_URL}/api/register`, { + method: 'POST', + headers: DEFAULT_HEADERS, + credentials: 'include', + body: JSON.stringify({ username, email, password }), + }) + .then(handleResponse) + .catch(handleError); +} + +export function logout() { + return fetch(`${BASE_URL}/api/logout`, { + method: 'POST', + headers: DEFAULT_HEADERS, + credentials: 'include', + }) + .then(handleResponse) + .catch(handleError); +} + +export function getAuthStatus() { + return fetch(`${BASE_URL}/api/auth/status`, { + method: 'GET', + headers: DEFAULT_HEADERS, + credentials: 'include', + }) + .then(handleResponse) + .catch(handleError); +} + +export function getCurrentUser() { + return fetch(`${BASE_URL}/api/me`, { + method: 'GET', + headers: DEFAULT_HEADERS, + credentials: 'include', + }) + .then(handleResponse) + .catch(handleError); +} diff --git a/os-league-tools-master/src/components/AuthModal.js b/os-league-tools-master/src/components/AuthModal.js new file mode 100644 index 00000000..a74700a2 --- /dev/null +++ b/os-league-tools-master/src/components/AuthModal.js @@ -0,0 +1,255 @@ +import React, { useState } from 'react'; +import Modal from './Modal'; +import Spinner from './common/Spinner'; +import { login, register } from '../client/auth-client'; + +const VIEW = { + LOGIN: 'login', + REGISTER: 'register', +}; + +export default function AuthModal({ isOpen, setIsOpen, onAuthSuccess }) { + const [view, setView] = useState(VIEW.LOGIN); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + + const [loginUsername, setLoginUsername] = useState(''); + const [loginPassword, setLoginPassword] = useState(''); + + const [registerUsername, setRegisterUsername] = useState(''); + const [registerEmail, setRegisterEmail] = useState(''); + const [registerPassword, setRegisterPassword] = useState(''); + const [registerConfirmPassword, setRegisterConfirmPassword] = useState(''); + + const resetForm = () => { + setLoginUsername(''); + setLoginPassword(''); + setRegisterUsername(''); + setRegisterEmail(''); + setRegisterPassword(''); + setRegisterConfirmPassword(''); + setError(''); + }; + + const handleClose = () => { + resetForm(); + setView(VIEW.LOGIN); + }; + + const switchView = newView => { + resetForm(); + setView(newView); + }; + + const handleLogin = async e => { + e.preventDefault(); + setError(''); + + if (!loginUsername || !loginPassword) { + setError('Please fill in all fields'); + return; + } + + setIsLoading(true); + const result = await login(loginUsername, loginPassword); + setIsLoading(false); + + if (result.success) { + resetForm(); + setIsOpen(false); + if (onAuthSuccess) { + onAuthSuccess(result.value); + } + } else { + setError(result.error || 'Login failed'); + } + }; + + const handleRegister = async e => { + e.preventDefault(); + setError(''); + + if (!registerUsername || !registerEmail || !registerPassword || !registerConfirmPassword) { + setError('Please fill in all fields'); + return; + } + + if (registerPassword !== registerConfirmPassword) { + setError('Passwords do not match'); + return; + } + + if (registerPassword.length < 8) { + setError('Password must be at least 8 characters'); + return; + } + + setIsLoading(true); + const result = await register(registerUsername, registerEmail, registerPassword); + setIsLoading(false); + + if (result.success) { + resetForm(); + setIsOpen(false); + if (onAuthSuccess) { + onAuthSuccess(result.value); + } + } else { + setError(result.error || 'Registration failed'); + } + }; + + return ( + + + {view === VIEW.LOGIN ? 'Login' : 'Create Account'} + + + + {view === VIEW.LOGIN ? ( +
+ + + + + {error &&
{error}
} + + +
+ ) : ( +
+ + + + + + + + + {error &&
{error}
} + + +
+ )} +
+ + + {view === VIEW.LOGIN ? ( +

+ Don't have an account?{' '} + +

+ ) : ( +

+ Already have an account?{' '} + +

+ )} +
+
+); + +} diff --git a/os-league-tools-master/src/components/PageWrapper.js b/os-league-tools-master/src/components/PageWrapper.js index fa366f93..5e6d6788 100644 --- a/os-league-tools-master/src/components/PageWrapper.js +++ b/os-league-tools-master/src/components/PageWrapper.js @@ -7,9 +7,9 @@ import FeedbackModal from './FeedbackModal'; import ManageDataModal from './ManageDataModal'; import images from '../assets/images'; import useQueryString from '../hooks/useQueryString'; -// import ManageData from './nav/ManageData'; import Feedback from './nav/Feedback'; -// import Character from './nav/Character'; +import AuthButton from './nav/AuthButton'; +import Character from './nav/Character'; import ManageCharactersModal from './ManageCharactersModal'; export default function PageWrapper({ children }) { @@ -18,9 +18,6 @@ export default function PageWrapper({ children }) { const [isFeedbackModalOpen, setFeedbackModalOpen] = useState(false); const [isCharacterModalOpen, setCharacterModalOpen] = useState(false); const [manageDataModalType, setManageDataModalType] = useQueryString('open'); - const user = useSelector(state => state.auth?.user); // adjust to your store - const selectedCharacter = useSelector(state => state.character?.selected); // adjust - const navItems = [ new NavItem('Stats', 'primary', 0, 0).withRouterLink('/stats').withIconFont('query_stats'), @@ -45,49 +42,25 @@ export default function PageWrapper({ children }) { /> ) ), - new NavItem('Profile', 'footer', 10, 0).withRouterLink('/profile').withIconFont('account_circle'), - new NavItem('User', 'footer', 10, 1).withCustomRenderFn( - (isCollapsed, onNavigate) => ( -
{ - // optional: if you want clicking the row to go to profile - // navigate('/profile') or use a NavLink style row - onNavigate?.(); - }} - title={isCollapsed ? (user?.name ?? user?.email ?? 'Profile') : undefined} - > - person - {!isCollapsed && ( - - {user?.name ?? user?.email ?? 'Unknown user'} - - )} -
- ) -), -new NavItem('Selected Character', 'footer', 10, 2).withCustomRenderFn( - (isCollapsed, onNavigate) => ( - - ) -), + new NavItem('Account', 'footer', 10, 0).withCustomRenderFn( + (isCollapsed, onNavigate) => ( + + ) + ), + new NavItem('Character', 'footer', 10, 1).withCustomRenderFn( + (isCollapsed, onNavigate) => ( + + ) + ), // new NavItem('Tip Jar', 'overflow', 4, 3) // .withHref('https://ko-fi.com/osleaguetools', '_blank') // .withIconFont('savings'), diff --git a/os-league-tools-master/src/components/nav/AuthButton.js b/os-league-tools-master/src/components/nav/AuthButton.js index 4c3d366f..34759737 100644 --- a/os-league-tools-master/src/components/nav/AuthButton.js +++ b/os-league-tools-master/src/components/nav/AuthButton.js @@ -1,9 +1,26 @@ -import React from 'react'; +import React, { useRef, useState } from 'react'; import Spinner from '../common/Spinner'; +import Dropdown from '../common/Dropdown'; import useAccount from '../../hooks/useAccount'; +import useClickListener from '../../hooks/useClickListener'; +import AuthModal from '../AuthModal'; function NavBarItem() { - const { isLoggedIn, isAuthenticating, login, logout } = useAccount({ redirectReturnToUrl: window.location.origin }); + const [isExpanded, setExpanded] = useState(false); + const menuRef = useRef(null); + const { + isLoggedIn, + isAuthenticating, + username, + userEmail, + openAuthModal, + logout, + authModalOpen, + closeAuthModal, + handleAuthSuccess, + } = useAccount(); + + useClickListener(menuRef, () => setExpanded(false), true); if (isAuthenticating) { return ( @@ -14,41 +31,198 @@ function NavBarItem() { ); } - const { label, icon, action } = getButtonValues(isLoggedIn, login, logout); + if (!isLoggedIn) { + return ( + <> + + + + + ); + } + + return ( +
+ + +
+ + + {username} + {userEmail &&

{userEmail}

} +
+ + { + logout(); + setExpanded(false); + }} + > + Logout + +
+
+
+ ); +} + +function CollapsedMenu() { + const { + isLoggedIn, + isAuthenticating, + username, + openAuthModal, + logout, + authModalOpen, + closeAuthModal, + handleAuthSuccess, + } = useAccount(); + + if (isAuthenticating) { + return ( +
+ +

Loading...

+
+ ); + } + + if (!isLoggedIn) { + return ( + <> + + + + ); + } + return ( <> - - ); } -function CollapsedMenu() { - const { isLoggedIn, login, logout } = useAccount({ redirectReturnToUrl: window.location.origin }); - const { label, icon, action } = getButtonValues(isLoggedIn, login, logout); +function SideBarItem({ isCollapsed, onNavigate }) { + const [isExpanded, setExpanded] = useState(false); + const menuRef = useRef(null); + const { + isLoggedIn, + isAuthenticating, + username, + userEmail, + openAuthModal, + logout, + authModalOpen, + closeAuthModal, + handleAuthSuccess, + } = useAccount(); + + useClickListener(menuRef, () => setExpanded(false), true); + + if (isAuthenticating) { + return ( +
+ + {!isCollapsed && Loading...} +
+ ); + } + + if (!isLoggedIn) { + return ( + <> + + + + ); + } return ( - +
+ + {isExpanded && !isCollapsed && ( +
+ {userEmail && ( +
+ mail + {userEmail} +
+ )} + +
+ )} +
); } -function getButtonValues(isLoggedIn, login, logout) { - return { - label: isLoggedIn ? 'Logout' : 'Login', - icon: isLoggedIn ? 'logout' : 'login', - action: isLoggedIn ? logout : login, - }; -} - -export default { NavBarItem, CollapsedMenu }; +export default { NavBarItem, CollapsedMenu, SideBarItem }; diff --git a/os-league-tools-master/src/hooks/useAccount.js b/os-league-tools-master/src/hooks/useAccount.js index 267ebf65..8d4cb3a4 100644 --- a/os-league-tools-master/src/hooks/useAccount.js +++ b/os-league-tools-master/src/hooks/useAccount.js @@ -1,101 +1,68 @@ -import { useAuth0 } from '@auth0/auth0-react'; -import { useEffect } from 'react'; -import { useDispatch, useSelector, batch } from 'react-redux'; +import { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { resetState } from '../store/common'; -import { load as loadSettingsState, loadState as loadSettingsLocalState } from '../store/settings/settings'; -import { load as loadTasksState, loadState as loadTasksLocalState } from '../store/tasks/tasks'; -import { load as loadUnlocksState, loadState as loadUnlocksLocalState } from '../store/unlocks/unlocks'; -import { - fetchHiscores, - load as loadCharacterState, - loadState as loadCharacterLocalState, -} from '../store/user/character'; -import { updateAccountCache } from '../store/user/account'; -import { createUserIfNeeded, getUser } from '../client/user-data-client'; -import { INITIAL_STATE as INITIAL_TASKS_STATE } from '../store/tasks/constants'; -import { INITIAL_STATE as INITIAL_UNLOCKS_STATE } from '../store/unlocks/constants'; -import { INITIAL_STATE as INITIAL_CHARACTER_STATE , INITIAL_STATE as INITIAL_SETTINGS_STATE } from '../store/user/constants'; -import updateTasksVersion from '../store/tasks/updateTasksVersion'; -import updateCharacterVersion from '../store/user/updateCharacterVersion'; -import updateUnlocksVersion from '../store/unlocks/updateUnlocksVersion'; +import { setLoggedIn, setLoggedOut } from '../store/user/account'; +import { getAuthStatus, getCurrentUser, logout as logoutApi } from '../client/auth-client'; -export default function useAccount({ redirectReturnToUrl }) { - const { - isLoading, - isAuthenticated, - user, - loginWithRedirect, - logout: logoutWithRedirect, - getAccessTokenSilently, - } = useAuth0(); - const isLoginCache = useSelector(state => state.account.accountCache.isLoggedIn); - const accessToken = useSelector(state => state.account.accountCache.accessToken); +export default function useAccount() { + const [authModalOpen, setAuthModalOpen] = useState(false); + const isLoggedIn = useSelector(state => state.account.accountCache.isLoggedIn); + const isChecking = useSelector(state => state.account.accountCache.isChecking); + const username = useSelector(state => state.account.accountCache.username); + const userEmail = useSelector(state => state.account.accountCache.userEmail); const dispatch = useDispatch(); useEffect(() => { - if (isAuthenticated) { - getAccessTokenSilently().then(token => { - dispatch(updateAccountCache({ isAuthenticated, user, accessToken: token })); - }); - } else { - updateAccountCache({ isAuthenticated, user, accessToken: undefined }); - } - }, [isAuthenticated]); + getAuthStatus().then(result => { + if (result.success && result.value?.authenticated) { + getCurrentUser().then(userResult => { + if (userResult.success) { + dispatch(setLoggedIn({ + username: userResult.value.username, + email: userResult.value.email, + })); + } else { + dispatch(setLoggedOut()); + } + }); + } else { + dispatch(setLoggedOut()); + } + }); + }, [dispatch]); - const isLoggedIn = isLoginCache || isAuthenticated; - useEffect(() => { - if (isLoginCache && accessToken) { - getUser(user.email, accessToken).then(res => { - if (res.success) { - // user exists already, load their data and overwrite existing - batch(() => { - const settingsState = res.value.settings?.S ? JSON.parse(res.value.settings?.S) : INITIAL_SETTINGS_STATE; - const characterState = updateCharacterVersion( - res.value.character?.S ? JSON.parse(res.value.character?.S) : INITIAL_CHARACTER_STATE - ); - const activeCharacter = characterState.characters[characterState.activeCharacter] ?? 'DEFAULT'; - const taskState = updateTasksVersion( - res.value[`tasks_${activeCharacter}`]?.S - ? JSON.parse(res.value[`tasks_${activeCharacter}`].S) - : INITIAL_TASKS_STATE - ); - const unlocksState = updateUnlocksVersion( - res.value[`unlocks_${activeCharacter}`]?.S - ? JSON.parse(res.value[`unlocks_${activeCharacter}`].S) - : INITIAL_UNLOCKS_STATE - ); - dispatch(loadTasksState({ forceOverwrite: true, newState: taskState, skipDbUpdate: true })); - dispatch(loadSettingsState({ forceOverwrite: true, newState: settingsState, skipDbUpdate: true })); - dispatch(loadUnlocksState({ forceOverwrite: true, newState: unlocksState, skipDbUpdate: true })); - dispatch(loadCharacterState({ forceOverwrite: true, newState: characterState, skipDbUpdate: true })); - dispatch(fetchHiscores(characterState, null, true)); - }); - } else { - // user does not exist, create them and reload the current local state to save it to the DB - createUserIfNeeded(user.email, accessToken).then(result => { - if (result.success) { - batch(() => { - dispatch(loadTasksState({ forceOverwrite: true, newState: loadTasksLocalState() })); - dispatch(loadSettingsState({ forceOverwrite: true, newState: loadSettingsLocalState() })); - dispatch(loadUnlocksState({ forceOverwrite: true, newState: loadUnlocksLocalState() })); - dispatch(loadCharacterState({ forceOverwrite: true, newState: loadCharacterLocalState() })); - }); - } - }); - } - }); - } - }, [isLoginCache, accessToken]); - - const isAuthenticating = isLoading; - - const login = () => { - loginWithRedirect({ redirect_uri: redirectReturnToUrl }); - }; - const logout = () => { - logoutWithRedirect({ returnTo: redirectReturnToUrl }); - resetState(dispatch, true); + const handleAuthSuccess = userData => { + dispatch(setLoggedIn({ + username: userData.username, + email: userData.email, + })); }; - return { isLoggedIn, isAuthenticating, login, logout }; + const openAuthModal = () => { + setAuthModalOpen(true); + }; + + const closeAuthModal = () => { + setAuthModalOpen(false); + }; + + const logout = async () => { + const result = await logoutApi(); + if (result.success) { + dispatch(setLoggedOut()); + resetState(dispatch, true); + } + }; + + return { + isLoggedIn, + isAuthenticating: isChecking, + username, + userEmail, + authModalOpen, + openAuthModal, + closeAuthModal, + handleAuthSuccess, + logout, + }; } diff --git a/os-league-tools-master/src/store/user/account.js b/os-league-tools-master/src/store/user/account.js index a1c2ca4f..2bd67726 100644 --- a/os-league-tools-master/src/store/user/account.js +++ b/os-league-tools-master/src/store/user/account.js @@ -2,14 +2,15 @@ /* eslint-disable no-param-reassign */ import { createSlice } from '@reduxjs/toolkit'; -export const CURRENT_VERSION = 3; +export const CURRENT_VERSION = 4; const INITIAL_STATE = { version: CURRENT_VERSION, accountCache: { isLoggedIn: false, + isChecking: true, + username: undefined, userEmail: undefined, - accessToken: undefined, }, }; @@ -17,18 +18,28 @@ export const accountSlice = createSlice({ name: 'account', initialState: INITIAL_STATE, reducers: { - updateAccountCache: (state, action) => { - state.accountCache.isLoggedIn = action.payload.isAuthenticated; - state.accountCache.userEmail = action.payload.isAuthenticated ? action.payload.user.email : undefined; - state.accountCache.accessToken = action.payload.isAuthenticated ? action.payload.accessToken : undefined; + setAuthChecking: (state, action) => { + state.accountCache.isChecking = action.payload; + }, + setLoggedIn: (state, action) => { + state.accountCache.isLoggedIn = true; + state.accountCache.isChecking = false; + state.accountCache.username = action.payload.username; + state.accountCache.userEmail = action.payload.email; + }, + setLoggedOut: state => { + state.accountCache.isLoggedIn = false; + state.accountCache.isChecking = false; + state.accountCache.username = undefined; + state.accountCache.userEmail = undefined; }, reset: () => INITIAL_STATE, }, }); -// Don't cache anything across sessions, let auth0 handle it +// Don't cache anything across sessions, session cookie handles it export const loadState = () => INITIAL_STATE; -export const { updateAccountCache, reset } = accountSlice.actions; +export const { setAuthChecking, setLoggedIn, setLoggedOut, reset } = accountSlice.actions; export default accountSlice.reducer; diff --git a/os-league-tools-master/src/styles/compiled.css b/os-league-tools-master/src/styles/compiled.css index 34691c1b..3ae5724a 100644 --- a/os-league-tools-master/src/styles/compiled.css +++ b/os-league-tools-master/src/styles/compiled.css @@ -3528,6 +3528,9 @@ select[multiple]:focus option:checked { .font-medium { font-weight: 500; } +.font-normal { + font-weight: 400; +} .font-semibold { font-weight: 600; } diff --git a/os-league-tools.service b/os-league-tools.service index c2e2cba1..c2bc0d59 100644 --- a/os-league-tools.service +++ b/os-league-tools.service @@ -1,5 +1,5 @@ [Unit] -Description=OS League Tools - OSRS Leagues Hub +Description=OS League Tools - Prod Environment After=network.target Wants=network-online.target @@ -13,7 +13,7 @@ WorkingDirectory=/home/sonder/leagues-tools/os-league-tools-master Environment="NODE_ENV=production" Environment="PORT=3000" # Uncomment and set if you have a backend API: -# Environment="REACT_APP_RELDO_URL=http://localhost:8080" +Environment="REACT_APP_RELDO_URL=https://api.leagues.tools" # Start the application (serves the pre-built static files) ExecStart=/usr/bin/serve -s build -l tcp://0.0.0.0:3000