Implement authentication features with login, registration, and user management; update service configurations for development and production environments.
This commit is contained in:
@@ -17,6 +17,8 @@ Environment="HOST=0.0.0.0"
|
|||||||
Environment="WDS_SOCKET_PROTOCOL=wss"
|
Environment="WDS_SOCKET_PROTOCOL=wss"
|
||||||
Environment="WDS_SOCKET_HOST=dev.leagues.tools"
|
Environment="WDS_SOCKET_HOST=dev.leagues.tools"
|
||||||
Environment="WDS_SOCKET_PORT=443"
|
Environment="WDS_SOCKET_PORT=443"
|
||||||
|
Environment="REACT_APP_RELDO_URL=https://api.leagues.tools"
|
||||||
|
|
||||||
|
|
||||||
# Start the dev server with hot reload
|
# Start the dev server with hot reload
|
||||||
ExecStart=/usr/bin/npm run dev
|
ExecStart=/usr/bin/npm run dev
|
||||||
|
|||||||
74
os-league-tools-master/src/client/auth-client.js
Normal file
74
os-league-tools-master/src/client/auth-client.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
255
os-league-tools-master/src/components/AuthModal.js
Normal file
255
os-league-tools-master/src/components/AuthModal.js
Normal file
@@ -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 (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
setIsOpen={setIsOpen}
|
||||||
|
onClose={handleClose}
|
||||||
|
className='w-96 shadow shadow-primary rounded-md bg-primary-alt'
|
||||||
|
>
|
||||||
|
<Modal.Header className='text-center small-caps tracking-wide text-xl text-accent font-semibold'>
|
||||||
|
{view === VIEW.LOGIN ? 'Login' : 'Create Account'}
|
||||||
|
</Modal.Header>
|
||||||
|
|
||||||
|
<Modal.Body className='text-primary text-sm'>
|
||||||
|
{view === VIEW.LOGIN ? (
|
||||||
|
<form onSubmit={handleLogin} className='m-4 flex flex-col gap-3'>
|
||||||
|
<label htmlFor='login-username' className='flex flex-col gap-1'>
|
||||||
|
<span className='text-secondary text-xs'>Username</span>
|
||||||
|
<input
|
||||||
|
id='login-username'
|
||||||
|
name='username'
|
||||||
|
type='text'
|
||||||
|
className='input-primary text-sm form-input'
|
||||||
|
placeholder='Enter username'
|
||||||
|
value={loginUsername}
|
||||||
|
onChange={e => setLoginUsername(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
autoComplete='username'
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label htmlFor='login-password' className='flex flex-col gap-1'>
|
||||||
|
<span className='text-secondary text-xs'>Password</span>
|
||||||
|
<input
|
||||||
|
id='login-password'
|
||||||
|
name='password'
|
||||||
|
type='password'
|
||||||
|
className='input-primary text-sm form-input'
|
||||||
|
placeholder='Enter password'
|
||||||
|
value={loginPassword}
|
||||||
|
onChange={e => setLoginPassword(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
autoComplete='current-password'
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <div className='text-error text-xs text-center'>{error}</div>}
|
||||||
|
|
||||||
|
<button type='submit' className='button-filled py-2 mt-2' disabled={isLoading}>
|
||||||
|
{isLoading ? (
|
||||||
|
<span className='flex items-center justify-center gap-2'>
|
||||||
|
<Spinner size={Spinner.SIZE.sm} /> Logging in...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Login'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleRegister} className='m-4 flex flex-col gap-3'>
|
||||||
|
<label htmlFor='register-username' className='flex flex-col gap-1'>
|
||||||
|
<span className='text-secondary text-xs'>Username</span>
|
||||||
|
<input
|
||||||
|
id='register-username'
|
||||||
|
name='username'
|
||||||
|
type='text'
|
||||||
|
className='input-primary text-sm form-input'
|
||||||
|
placeholder='Choose a username'
|
||||||
|
value={registerUsername}
|
||||||
|
onChange={e => setRegisterUsername(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
autoComplete='username'
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label htmlFor='register-email' className='flex flex-col gap-1'>
|
||||||
|
<span className='text-secondary text-xs'>Email</span>
|
||||||
|
<input
|
||||||
|
id='register-email'
|
||||||
|
name='email'
|
||||||
|
type='email'
|
||||||
|
className='input-primary text-sm form-input'
|
||||||
|
placeholder='Enter your email'
|
||||||
|
value={registerEmail}
|
||||||
|
onChange={e => setRegisterEmail(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
autoComplete='email'
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label htmlFor='register-password' className='flex flex-col gap-1'>
|
||||||
|
<span className='text-secondary text-xs'>Password</span>
|
||||||
|
<input
|
||||||
|
id='register-password'
|
||||||
|
name='new-password'
|
||||||
|
type='password'
|
||||||
|
className='input-primary text-sm form-input'
|
||||||
|
placeholder='Create a password'
|
||||||
|
value={registerPassword}
|
||||||
|
onChange={e => setRegisterPassword(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
autoComplete='new-password'
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label htmlFor='register-confirm-password' className='flex flex-col gap-1'>
|
||||||
|
<span className='text-secondary text-xs'>Confirm Password</span>
|
||||||
|
<input
|
||||||
|
id='register-confirm-password'
|
||||||
|
name='confirm-password'
|
||||||
|
type='password'
|
||||||
|
className='input-primary text-sm form-input'
|
||||||
|
placeholder='Confirm your password'
|
||||||
|
value={registerConfirmPassword}
|
||||||
|
onChange={e => setRegisterConfirmPassword(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
autoComplete='new-password'
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <div className='text-error text-xs text-center'>{error}</div>}
|
||||||
|
|
||||||
|
<button type='submit' className='button-filled py-2 mt-2' disabled={isLoading}>
|
||||||
|
{isLoading ? (
|
||||||
|
<span className='flex items-center justify-center gap-2'>
|
||||||
|
<Spinner size={Spinner.SIZE.sm} /> Creating account...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Create Account'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Modal.Body>
|
||||||
|
|
||||||
|
<Modal.Footer className='text-center text-sm'>
|
||||||
|
{view === VIEW.LOGIN ? (
|
||||||
|
<p className='text-secondary py-2'>
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<button type='button' className='text-accent hover:underline' onClick={() => switchView(VIEW.REGISTER)}>
|
||||||
|
Register
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className='text-secondary py-2'>
|
||||||
|
Already have an account?{' '}
|
||||||
|
<button type='button' className='text-accent hover:underline' onClick={() => switchView(VIEW.LOGIN)}>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -7,9 +7,9 @@ import FeedbackModal from './FeedbackModal';
|
|||||||
import ManageDataModal from './ManageDataModal';
|
import ManageDataModal from './ManageDataModal';
|
||||||
import images from '../assets/images';
|
import images from '../assets/images';
|
||||||
import useQueryString from '../hooks/useQueryString';
|
import useQueryString from '../hooks/useQueryString';
|
||||||
// import ManageData from './nav/ManageData';
|
|
||||||
import Feedback from './nav/Feedback';
|
import Feedback from './nav/Feedback';
|
||||||
// import Character from './nav/Character';
|
import AuthButton from './nav/AuthButton';
|
||||||
|
import Character from './nav/Character';
|
||||||
import ManageCharactersModal from './ManageCharactersModal';
|
import ManageCharactersModal from './ManageCharactersModal';
|
||||||
|
|
||||||
export default function PageWrapper({ children }) {
|
export default function PageWrapper({ children }) {
|
||||||
@@ -18,9 +18,6 @@ export default function PageWrapper({ children }) {
|
|||||||
const [isFeedbackModalOpen, setFeedbackModalOpen] = useState(false);
|
const [isFeedbackModalOpen, setFeedbackModalOpen] = useState(false);
|
||||||
const [isCharacterModalOpen, setCharacterModalOpen] = useState(false);
|
const [isCharacterModalOpen, setCharacterModalOpen] = useState(false);
|
||||||
const [manageDataModalType, setManageDataModalType] = useQueryString('open');
|
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 = [
|
const navItems = [
|
||||||
new NavItem('Stats', 'primary', 0, 0).withRouterLink('/stats').withIconFont('query_stats'),
|
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('Account', 'footer', 10, 0).withCustomRenderFn(
|
||||||
new NavItem('User', 'footer', 10, 1).withCustomRenderFn(
|
(isCollapsed, onNavigate) => (
|
||||||
(isCollapsed, onNavigate) => (
|
<AuthButton.SideBarItem
|
||||||
<div
|
key='auth'
|
||||||
key='footer-user'
|
isCollapsed={isCollapsed}
|
||||||
className='sidebar-nav-link'
|
onNavigate={onNavigate}
|
||||||
onClick={() => {
|
/>
|
||||||
// optional: if you want clicking the row to go to profile
|
)
|
||||||
// navigate('/profile') or use a NavLink style row
|
),
|
||||||
onNavigate?.();
|
new NavItem('Character', 'footer', 10, 1).withCustomRenderFn(
|
||||||
}}
|
(isCollapsed, onNavigate) => (
|
||||||
title={isCollapsed ? (user?.name ?? user?.email ?? 'Profile') : undefined}
|
<Character.SideBarItem
|
||||||
>
|
key='character'
|
||||||
<span className='sidebar-nav-link-icon icon-base'>person</span>
|
setCharacterModalOpen={setCharacterModalOpen}
|
||||||
{!isCollapsed && (
|
isCollapsed={isCollapsed}
|
||||||
<span className='sidebar-nav-link-label'>
|
onNavigate={onNavigate}
|
||||||
{user?.name ?? user?.email ?? 'Unknown user'}
|
/>
|
||||||
</span>
|
)
|
||||||
)}
|
),
|
||||||
</div>
|
|
||||||
)
|
|
||||||
),
|
|
||||||
new NavItem('Selected Character', 'footer', 10, 2).withCustomRenderFn(
|
|
||||||
(isCollapsed, onNavigate) => (
|
|
||||||
<button
|
|
||||||
key='footer-selected-character'
|
|
||||||
type='button'
|
|
||||||
className='sidebar-nav-link w-full text-left'
|
|
||||||
onClick={() => {
|
|
||||||
setCharacterModalOpen(true);
|
|
||||||
onNavigate?.();
|
|
||||||
}}
|
|
||||||
title={isCollapsed ? (selectedCharacter?.name ?? 'Select character') : undefined}
|
|
||||||
>
|
|
||||||
<span className='sidebar-nav-link-icon icon-base'>badge</span>
|
|
||||||
{!isCollapsed && (
|
|
||||||
<span className='sidebar-nav-link-label'>
|
|
||||||
{selectedCharacter?.name ?? 'Select character'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
),
|
|
||||||
// new NavItem('Tip Jar', 'overflow', 4, 3)
|
// new NavItem('Tip Jar', 'overflow', 4, 3)
|
||||||
// .withHref('https://ko-fi.com/osleaguetools', '_blank')
|
// .withHref('https://ko-fi.com/osleaguetools', '_blank')
|
||||||
// .withIconFont('savings'),
|
// .withIconFont('savings'),
|
||||||
|
|||||||
@@ -1,9 +1,26 @@
|
|||||||
import React from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import Spinner from '../common/Spinner';
|
import Spinner from '../common/Spinner';
|
||||||
|
import Dropdown from '../common/Dropdown';
|
||||||
import useAccount from '../../hooks/useAccount';
|
import useAccount from '../../hooks/useAccount';
|
||||||
|
import useClickListener from '../../hooks/useClickListener';
|
||||||
|
import AuthModal from '../AuthModal';
|
||||||
|
|
||||||
function NavBarItem() {
|
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) {
|
if (isAuthenticating) {
|
||||||
return (
|
return (
|
||||||
@@ -14,41 +31,198 @@ function NavBarItem() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { label, icon, action } = getButtonValues(isLoggedIn, login, logout);
|
if (!isLoggedIn) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className='text-primary md:inline hidden navbar-link-alt bg-hover px-2 py-1'
|
||||||
|
onClick={openAuthModal}
|
||||||
|
type='button'
|
||||||
|
>
|
||||||
|
<span className='icon-base mr-1 align-bottom'>login</span>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
<button className='md:hidden inline navbar-icon-link' onClick={openAuthModal} type='button'>
|
||||||
|
<span className='text-primary-alt icon-lg leading-tight align-middle'>login</span>
|
||||||
|
</button>
|
||||||
|
<AuthModal isOpen={authModalOpen} setIsOpen={closeAuthModal} onAuthSuccess={handleAuthSuccess} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='relative'>
|
||||||
|
<button
|
||||||
|
className='text-primary md:inline hidden navbar-link-alt bg-hover py-1 px-2'
|
||||||
|
type='button'
|
||||||
|
onClick={() => setExpanded(true)}
|
||||||
|
>
|
||||||
|
<span className='text-primary-alt icon-lg inline align-middle mr-1'>account_circle</span>
|
||||||
|
{username}
|
||||||
|
</button>
|
||||||
|
<button className='md:hidden inline navbar-icon-link' onClick={() => setExpanded(true)} type='button'>
|
||||||
|
<span className='text-primary-alt icon-lg leading-tight align-middle'>account_circle</span>
|
||||||
|
</button>
|
||||||
|
<div className='mt-1 absolute right-0 text-center'>
|
||||||
|
<Dropdown show={isExpanded} innerRef={menuRef}>
|
||||||
|
<Dropdown.Text isHeading>
|
||||||
|
<span className='text-accent'>{username}</span>
|
||||||
|
{userEmail && <p className='text-xs text-secondary font-normal'>{userEmail}</p>}
|
||||||
|
</Dropdown.Text>
|
||||||
|
<Dropdown.Separator />
|
||||||
|
<Dropdown.Button
|
||||||
|
className='text-left'
|
||||||
|
icon='logout'
|
||||||
|
onClick={() => {
|
||||||
|
logout();
|
||||||
|
setExpanded(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</Dropdown.Button>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsedMenu() {
|
||||||
|
const {
|
||||||
|
isLoggedIn,
|
||||||
|
isAuthenticating,
|
||||||
|
username,
|
||||||
|
openAuthModal,
|
||||||
|
logout,
|
||||||
|
authModalOpen,
|
||||||
|
closeAuthModal,
|
||||||
|
handleAuthSuccess,
|
||||||
|
} = useAccount();
|
||||||
|
|
||||||
|
if (isAuthenticating) {
|
||||||
|
return (
|
||||||
|
<div className='text-primary bg-hover py-1 text-left'>
|
||||||
|
<Spinner size={Spinner.SIZE.sm} />
|
||||||
|
<p className='h-4 inline pl-1 font-sans-alt'>Loading...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button className='text-primary bg-hover py-1 text-left' onClick={openAuthModal} type='button'>
|
||||||
|
<span className='text-primary-alt icon-lg inline align-middle mr-1'>login</span>
|
||||||
|
<p className='h-4 inline pl-1 font-sans-alt'>Login</p>
|
||||||
|
</button>
|
||||||
|
<AuthModal isOpen={authModalOpen} setIsOpen={closeAuthModal} onAuthSuccess={handleAuthSuccess} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<div className='text-primary py-1 text-left'>
|
||||||
className='text-primary md:inline hidden navbar-link-alt bg-hover px-2 py-1'
|
<span className='text-primary-alt icon-lg inline align-middle mr-1'>account_circle</span>
|
||||||
onClick={() => action()}
|
<p className='h-4 inline pl-1 font-sans-alt text-accent'>{username}</p>
|
||||||
type='button'
|
</div>
|
||||||
>
|
<button className='text-primary bg-hover py-1 text-left' onClick={logout} type='button'>
|
||||||
{label}
|
<span className='text-primary-alt icon-lg inline align-middle mr-1'>logout</span>
|
||||||
</button>
|
<p className='h-4 inline pl-1 font-sans-alt'>Logout</p>
|
||||||
<button className='md:hidden inline navbar-icon-link' onClick={action} type='button'>
|
|
||||||
<span className='text-primary-alt icon-lg leading-tight align-middle'>{icon}</span>
|
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CollapsedMenu() {
|
function SideBarItem({ isCollapsed, onNavigate }) {
|
||||||
const { isLoggedIn, login, logout } = useAccount({ redirectReturnToUrl: window.location.origin });
|
const [isExpanded, setExpanded] = useState(false);
|
||||||
const { label, icon, action } = getButtonValues(isLoggedIn, login, logout);
|
const menuRef = useRef(null);
|
||||||
|
const {
|
||||||
|
isLoggedIn,
|
||||||
|
isAuthenticating,
|
||||||
|
username,
|
||||||
|
userEmail,
|
||||||
|
openAuthModal,
|
||||||
|
logout,
|
||||||
|
authModalOpen,
|
||||||
|
closeAuthModal,
|
||||||
|
handleAuthSuccess,
|
||||||
|
} = useAccount();
|
||||||
|
|
||||||
|
useClickListener(menuRef, () => setExpanded(false), true);
|
||||||
|
|
||||||
|
if (isAuthenticating) {
|
||||||
|
return (
|
||||||
|
<div className='sidebar-nav-link w-full text-left'>
|
||||||
|
<Spinner size={Spinner.SIZE.sm} />
|
||||||
|
{!isCollapsed && <span className='sidebar-nav-link-label'>Loading...</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className='sidebar-nav-link w-full text-left'
|
||||||
|
type='button'
|
||||||
|
onClick={() => {
|
||||||
|
openAuthModal();
|
||||||
|
if (onNavigate) {
|
||||||
|
onNavigate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={isCollapsed ? 'Login' : undefined}
|
||||||
|
>
|
||||||
|
<span className='sidebar-nav-link-icon icon-base'>login</span>
|
||||||
|
{!isCollapsed && <span className='sidebar-nav-link-label'>Login</span>}
|
||||||
|
</button>
|
||||||
|
<AuthModal isOpen={authModalOpen} setIsOpen={closeAuthModal} onAuthSuccess={handleAuthSuccess} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button className='text-primary bg-hover py-1 text-left' onClick={() => action()} type='button'>
|
<div className='relative' ref={menuRef}>
|
||||||
<span className='text-primary-alt icon-lg inline align-middle mr-1'>{icon}</span>
|
<button
|
||||||
<p className='h-4 inline pl-1 font-sans-alt'>{label}</p>
|
className='sidebar-nav-link w-full text-left'
|
||||||
</button>
|
type='button'
|
||||||
|
onClick={() => setExpanded(!isExpanded)}
|
||||||
|
title={isCollapsed ? username : undefined}
|
||||||
|
>
|
||||||
|
<span className='sidebar-nav-link-icon icon-base'>account_circle</span>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<>
|
||||||
|
<span className='sidebar-nav-link-label'>{username}</span>
|
||||||
|
<span className='ml-auto icon-sm text-primary-alt'>{isExpanded ? 'expand_less' : 'expand_more'}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{isExpanded && !isCollapsed && (
|
||||||
|
<div className='bg-secondary-alt pl-4'>
|
||||||
|
{userEmail && (
|
||||||
|
<div className='sidebar-nav-link w-full text-left text-sm text-secondary'>
|
||||||
|
<span className='sidebar-nav-link-icon icon-base text-primary-alt'>mail</span>
|
||||||
|
<span className='sidebar-nav-link-label text-xs'>{userEmail}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className='sidebar-nav-link w-full text-left text-sm'
|
||||||
|
onClick={() => {
|
||||||
|
logout();
|
||||||
|
setExpanded(false);
|
||||||
|
if (onNavigate) {
|
||||||
|
onNavigate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type='button'
|
||||||
|
>
|
||||||
|
<span className='sidebar-nav-link-icon icon-base text-primary-alt'>logout</span>
|
||||||
|
<span className='sidebar-nav-link-label'>Logout</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getButtonValues(isLoggedIn, login, logout) {
|
export default { NavBarItem, CollapsedMenu, SideBarItem };
|
||||||
return {
|
|
||||||
label: isLoggedIn ? 'Logout' : 'Login',
|
|
||||||
icon: isLoggedIn ? 'logout' : 'login',
|
|
||||||
action: isLoggedIn ? logout : login,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default { NavBarItem, CollapsedMenu };
|
|
||||||
|
|||||||
@@ -1,101 +1,68 @@
|
|||||||
import { useAuth0 } from '@auth0/auth0-react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useEffect } from 'react';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { useDispatch, useSelector, batch } from 'react-redux';
|
|
||||||
import { resetState } from '../store/common';
|
import { resetState } from '../store/common';
|
||||||
import { load as loadSettingsState, loadState as loadSettingsLocalState } from '../store/settings/settings';
|
import { setLoggedIn, setLoggedOut } from '../store/user/account';
|
||||||
import { load as loadTasksState, loadState as loadTasksLocalState } from '../store/tasks/tasks';
|
import { getAuthStatus, getCurrentUser, logout as logoutApi } from '../client/auth-client';
|
||||||
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';
|
|
||||||
|
|
||||||
export default function useAccount({ redirectReturnToUrl }) {
|
export default function useAccount() {
|
||||||
const {
|
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||||
isLoading,
|
const isLoggedIn = useSelector(state => state.account.accountCache.isLoggedIn);
|
||||||
isAuthenticated,
|
const isChecking = useSelector(state => state.account.accountCache.isChecking);
|
||||||
user,
|
const username = useSelector(state => state.account.accountCache.username);
|
||||||
loginWithRedirect,
|
const userEmail = useSelector(state => state.account.accountCache.userEmail);
|
||||||
logout: logoutWithRedirect,
|
|
||||||
getAccessTokenSilently,
|
|
||||||
} = useAuth0();
|
|
||||||
const isLoginCache = useSelector(state => state.account.accountCache.isLoggedIn);
|
|
||||||
const accessToken = useSelector(state => state.account.accountCache.accessToken);
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
getAuthStatus().then(result => {
|
||||||
getAccessTokenSilently().then(token => {
|
if (result.success && result.value?.authenticated) {
|
||||||
dispatch(updateAccountCache({ isAuthenticated, user, accessToken: token }));
|
getCurrentUser().then(userResult => {
|
||||||
});
|
if (userResult.success) {
|
||||||
} else {
|
dispatch(setLoggedIn({
|
||||||
updateAccountCache({ isAuthenticated, user, accessToken: undefined });
|
username: userResult.value.username,
|
||||||
}
|
email: userResult.value.email,
|
||||||
}, [isAuthenticated]);
|
}));
|
||||||
|
} else {
|
||||||
|
dispatch(setLoggedOut());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
dispatch(setLoggedOut());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
const isLoggedIn = isLoginCache || isAuthenticated;
|
const handleAuthSuccess = userData => {
|
||||||
useEffect(() => {
|
dispatch(setLoggedIn({
|
||||||
if (isLoginCache && accessToken) {
|
username: userData.username,
|
||||||
getUser(user.email, accessToken).then(res => {
|
email: userData.email,
|
||||||
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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,15 @@
|
|||||||
/* eslint-disable no-param-reassign */
|
/* eslint-disable no-param-reassign */
|
||||||
import { createSlice } from '@reduxjs/toolkit';
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
export const CURRENT_VERSION = 3;
|
export const CURRENT_VERSION = 4;
|
||||||
|
|
||||||
const INITIAL_STATE = {
|
const INITIAL_STATE = {
|
||||||
version: CURRENT_VERSION,
|
version: CURRENT_VERSION,
|
||||||
accountCache: {
|
accountCache: {
|
||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
|
isChecking: true,
|
||||||
|
username: undefined,
|
||||||
userEmail: undefined,
|
userEmail: undefined,
|
||||||
accessToken: undefined,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -17,18 +18,28 @@ export const accountSlice = createSlice({
|
|||||||
name: 'account',
|
name: 'account',
|
||||||
initialState: INITIAL_STATE,
|
initialState: INITIAL_STATE,
|
||||||
reducers: {
|
reducers: {
|
||||||
updateAccountCache: (state, action) => {
|
setAuthChecking: (state, action) => {
|
||||||
state.accountCache.isLoggedIn = action.payload.isAuthenticated;
|
state.accountCache.isChecking = action.payload;
|
||||||
state.accountCache.userEmail = action.payload.isAuthenticated ? action.payload.user.email : undefined;
|
},
|
||||||
state.accountCache.accessToken = action.payload.isAuthenticated ? action.payload.accessToken : undefined;
|
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,
|
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 loadState = () => INITIAL_STATE;
|
||||||
|
|
||||||
export const { updateAccountCache, reset } = accountSlice.actions;
|
export const { setAuthChecking, setLoggedIn, setLoggedOut, reset } = accountSlice.actions;
|
||||||
|
|
||||||
export default accountSlice.reducer;
|
export default accountSlice.reducer;
|
||||||
|
|||||||
@@ -3528,6 +3528,9 @@ select[multiple]:focus option:checked {
|
|||||||
.font-medium {
|
.font-medium {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
.font-normal {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
.font-semibold {
|
.font-semibold {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=OS League Tools - OSRS Leagues Hub
|
Description=OS League Tools - Prod Environment
|
||||||
After=network.target
|
After=network.target
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ WorkingDirectory=/home/sonder/leagues-tools/os-league-tools-master
|
|||||||
Environment="NODE_ENV=production"
|
Environment="NODE_ENV=production"
|
||||||
Environment="PORT=3000"
|
Environment="PORT=3000"
|
||||||
# Uncomment and set if you have a backend API:
|
# 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)
|
# Start the application (serves the pre-built static files)
|
||||||
ExecStart=/usr/bin/serve -s build -l tcp://0.0.0.0:3000
|
ExecStart=/usr/bin/serve -s build -l tcp://0.0.0.0:3000
|
||||||
|
|||||||
Reference in New Issue
Block a user