This commit is contained in:
2024-09-27 22:24:33 -04:00
parent 7f6116450f
commit bb60182164
58 changed files with 2414 additions and 681 deletions

View File

@@ -1,33 +1,35 @@
index.html,1725860405793,6310d84ffdca1cba888ffec82dec1bd853728243a85f931c7bbb1ea501234ed9 robots.txt,1726876647544,391d14b3c2f8c9143a27a28c7399585142228d4d1bdbe2c87ac946de411fa9a2
robots.txt,1725860392506,391d14b3c2f8c9143a27a28c7399585142228d4d1bdbe2c87ac946de411fa9a2 manifest.json,1726876647544,4e1d3adad9ce7e17c0d13fac197f1c27091906f4ecf3909c9f51247bff345118
logo192.png,1725860392504,f66564670b5bfe49f3f04a5b63107bae60f4a7736f1d3b83aa0115f7acf45150 index.html,1726876659230,354b28bc9760fd2b17eab90a8d858fc8e52f9564c0c63c37be8a99d181e133e7
favicon.ico,1725860392503,1463be9bedc82e16f9364eec7a1f5152cbe56229401381eed65c1496cb884407 asset-manifest.json,1726876659241,afa5940bbd1bc504018bd4f1fddfedc7ec8465288067dd8d8f933e89e71b170d
asset-manifest.json,1725860405804,4e32ba450098f872b3a581873dc770a8b5c91d6d56f58e07f8ffe31b70c53ef7 favicon.ico,1726876647541,6048ca490d966978cc92b22151024057c5643b3bc69f6cdf9856fba38e8877fa
logo512.png,1725860392505,2356e0b814e58a349935c8de17226873d615d088a867d591fef48a64c80c9a2f static/media/exit.5b9b641b90012b1192056a8c7e1b8c45.svg,1726876659228,4ff12475566874f3f3d3be8e8c97ca14a7b9292a04481947610725c16bb1599d
static/media/exit.5b9b641b90012b1192056a8c7e1b8c45.svg,1725860405791,4ff12475566874f3f3d3be8e8c97ca14a7b9292a04481947610725c16bb1599d static/media/infinity.d099870a8639593717e3b17aea138c92.svg,1726876659228,70485bc15570a561994193d7e3e6b08a1f2de236248676255b871c8697e4c577
static/media/coffee.46aafd6dd9e4a1d3460e80ff9969dc6e.svg,1725860405791,f27933e7a7e8113c7a2be9eba7492922dd5918b77575f02a8ad595cb079e5547 static/media/coffee.46aafd6dd9e4a1d3460e80ff9969dc6e.svg,1726876659228,f27933e7a7e8113c7a2be9eba7492922dd5918b77575f02a8ad595cb079e5547
static/media/infinity.d099870a8639593717e3b17aea138c92.svg,1725860405791,70485bc15570a561994193d7e3e6b08a1f2de236248676255b871c8697e4c577 static/media/cardo-latin-400-normal.4911b85d7a3867b3911f.woff2,1726876659228,8f43ac21350311e2925bf981b9fd8da34935abad28c7b1028c2ca2756af003f3
manifest.json,1725860392505,4e1d3adad9ce7e17c0d13fac197f1c27091906f4ecf3909c9f51247bff345118 static/media/cardo-greek-400-normal.79d37e879083ce714bd7.woff2,1726876659228,074bc81d8ca5a2111b391b121b56d17a67601361e97404d3d7792dbda1dbe5bd
static/media/cardo-greek-ext-400-normal.134b821611b16e279388.woff2,1725860405791,f47df12ed94c7e7bb8a2e37e21d10cde4a47fd41eeda611d924a55187ad01466 static/media/cardo-greek-400-normal.82ddcf833fd34281b75d.woff,1726876659228,3b4c19d6c84e7a572ee345b68fc874deeec43d4690c7d590a9029fddf3dbddf5
static/media/cardo-latin-400-normal.4911b85d7a3867b3911f.woff2,1725860405791,8f43ac21350311e2925bf981b9fd8da34935abad28c7b1028c2ca2756af003f3 static/media/cardo-greek-ext-400-normal.134b821611b16e279388.woff2,1726876659228,f47df12ed94c7e7bb8a2e37e21d10cde4a47fd41eeda611d924a55187ad01466
static/media/cardo-greek-400-normal.79d37e879083ce714bd7.woff2,1725860405791,074bc81d8ca5a2111b391b121b56d17a67601361e97404d3d7792dbda1dbe5bd static/media/bracket.910c02ed0a238012f80f22086064c5d9.svg,1726876659228,fc713f945dd7cd2ca06c86d392ebdea3a6cf04a3e009e72877aec5e3be787486
static/media/cardo-greek-400-normal.82ddcf833fd34281b75d.woff,1725860405791,3b4c19d6c84e7a572ee345b68fc874deeec43d4690c7d590a9029fddf3dbddf5 static/media/cardo-greek-ext-400-normal.f2008e9d17e15badfffd.woff,1726876659228,f3e91ac60b6103eb6065b6d12d6adb656d95b1ab9904f98d15916b49849385ec
static/media/bracket.910c02ed0a238012f80f22086064c5d9.svg,1725860405791,fc713f945dd7cd2ca06c86d392ebdea3a6cf04a3e009e72877aec5e3be787486 static/js/main.cce1692b.js.LICENSE.txt,1726876659241,fd222b36f412805419e026feba067f18ca36cf439c430ae6dfd4bf072d541304
static/media/cardo-greek-ext-400-normal.f2008e9d17e15badfffd.woff,1725860405791,f3e91ac60b6103eb6065b6d12d6adb656d95b1ab9904f98d15916b49849385ec static/js/453.18d94f3f.chunk.js,1726876659241,0c85c1b7dd1771b07c17867a1b4cc9147aac500e2761b78e5f43c3af5a990b97
static/js/main.4e103dc0.js.LICENSE.txt,1725860405805,fd222b36f412805419e026feba067f18ca36cf439c430ae6dfd4bf072d541304 static/js/453.18d94f3f.chunk.js.map,1726876659242,01852c42a7189dcf6095a7437716308dcf1058fbf9afeee17e1d113d8fc0016f
static/js/453.18d94f3f.chunk.js,1725860405805,0c85c1b7dd1771b07c17867a1b4cc9147aac500e2761b78e5f43c3af5a990b97 static/css/main.59a52c37.css,1726876659242,cb1aee4c30f148e3e41a58b1f43c2b03814942da517af2d341db473565473725
static/js/453.18d94f3f.chunk.js.map,1725860405805,01852c42a7189dcf6095a7437716308dcf1058fbf9afeee17e1d113d8fc0016f static/css/main.59a52c37.css.map,1726876659241,bcdda2feb46a3e91c65dcaabd8cc9bab2ee623d259e7fee95565b88ef3e7aa1d
static/css/main.f0edc1a0.css,1725860405805,1c87f7cfa7da9415d7b6abe70cf41d8705f1d751ee0f4d6b406baafbb9bcca8b static/media/cardo-latin-ext-400-normal.d90efa9a5c52b57c81be.woff2,1726876659228,ac5c40b8f0f0170763306cbd56736ea93f3bc8a46bd69691097be496e3b9a604
static/css/main.f0edc1a0.css.map,1725860405805,1a40319fdab387a87e11b7c3aadf554d86722ada29a129ba316b832bcdbaefa0 static/media/cardo-latin-400-normal.a3fc903c20de90e51735.woff,1726876659228,cf7c3f21e093e6af8f4490da55d497398a38ff9485afae511311013338b8fe82
static/media/cardo-latin-ext-400-normal.d90efa9a5c52b57c81be.woff2,1725860405792,ac5c40b8f0f0170763306cbd56736ea93f3bc8a46bd69691097be496e3b9a604 static/media/cardo-latin-ext-400-normal.448cff0b9a0afb700151.woff,1726876659228,b285c4d668a7c861ca15b7dd3e126f26f6c06449c41a2c823a10146b0550089c
static/media/cardo-latin-400-normal.a3fc903c20de90e51735.woff,1725860405791,cf7c3f21e093e6af8f4490da55d497398a38ff9485afae511311013338b8fe82 static/media/CyarenLogo.d899a3de152d4020e373.png,1726876659241,06d88a03004c107e8170e6995d20c27ef9383ff8ac1fd3c8432f119eb3b6431a
static/media/cardo-latin-ext-400-normal.448cff0b9a0afb700151.woff,1725860405791,b285c4d668a7c861ca15b7dd3e126f26f6c06449c41a2c823a10146b0550089c static/media/Blackmild Regular.5b441560e0d850038d0f.otf,1726876659227,60300d3ccf8a22a528b216703a9677022b27e7f05fe71f9768e58220c132a4c9
static/media/Blackmild Regular.5b441560e0d850038d0f.otf,1725860405791,60300d3ccf8a22a528b216703a9677022b27e7f05fe71f9768e58220c132a4c9 logo192.png,1726876647542,1a626b8669164ea8df1bc1b55b3fba2844f6e258f86204ebcadc2e283d8d7d0a
static/media/Cinzel-VariableFont_wght.2d087b545bf495f3d86d.ttf,1725860405791,3394f58fb0427fbe8d8dcc90863079661330c57c41d22309fabdda6f9bbbf7e3 static/media/Cinzel-VariableFont_wght.2d087b545bf495f3d86d.ttf,1726876659227,3394f58fb0427fbe8d8dcc90863079661330c57c41d22309fabdda6f9bbbf7e3
static/media/Cardo-Regular.4ad75d4da97e1cf6655b.ttf,1725860405791,9441d454efe51434f20f9f848652531e4caa3ffa57a3c7d2caa08370b01b2c63 static/media/logo.9ad0b2edab4ef5fc261b.png,1726876659241,cf3180ffbfd0da385f809b161518dd1f9ae74b33b803ae92ab1f689f518620a5
static/media/card.1d7fa489536497609c42.png,1725860405805,aed32aacda18dd89590e88b93da24764ec4c8d3a94f9390d90b241fb88f20788 static/media/Cardo-Regular.4ad75d4da97e1cf6655b.ttf,1726876659228,9441d454efe51434f20f9f848652531e4caa3ffa57a3c7d2caa08370b01b2c63
static/js/main.4e103dc0.js,1725860405805,20a9e0c99d6ba25a839ea31b3fcceb3d71c1e6835061ddb862bc51a56f0cf0ca static/media/card.1d7fa489536497609c42.png,1726876659241,aed32aacda18dd89590e88b93da24764ec4c8d3a94f9390d90b241fb88f20788
static/media/applogo.9a90eed8f247993fa372.png,1725860405805,162fdef5335b42be3160731c6f9472f17d8a47b1a91f0f14f2a4fcf3b46559cd logo512.png,1726876647543,3d55e276f6674ba35ec7f6da0170e3e7b3c0016d0a84ee80f11954f7ab341862
static/media/titlecard.420823b8e1f5db1380e4.png,1725860405806,77bccf483368ffe9622e590756dac4c2011d125589802b193a9d9ac79f1ddf0b static/js/main.cce1692b.js,1726876659243,38fa9b797c454b23fa57226e91c0299f19185c48629de59381d4937bd38f98ab
static/media/cardback.6c07d542c2853cbf82d3.png,1725860405806,4cd2749fe35df98d738b7504afe8469dbdd2ba23101544b81c9592be8c5321f4 static/media/applogo.9a90eed8f247993fa372.png,1726876659241,162fdef5335b42be3160731c6f9472f17d8a47b1a91f0f14f2a4fcf3b46559cd
static/js/main.4e103dc0.js.map,1725860405807,61fad40e9da546f6b8440539286164bb2d5d301525c6149d772184ed61d7dc04 static/media/titlecard.420823b8e1f5db1380e4.png,1726876659243,77bccf483368ffe9622e590756dac4c2011d125589802b193a9d9ac79f1ddf0b
static/media/cardback.6c07d542c2853cbf82d3.png,1726876659242,4cd2749fe35df98d738b7504afe8469dbdd2ba23101544b81c9592be8c5321f4
static/js/main.cce1692b.js.map,1726876659242,c5543305e62b653f85b5c8d20534d0d3766509eecec9ea27d6e93ae218dfadea

1
.firebaseignore Normal file
View File

@@ -0,0 +1 @@
**/*.xcf

2
.gitignore vendored
View File

@@ -25,3 +25,5 @@ yarn-error.log*
# storybook # storybook
*storybook.log *storybook.log
**/*.xcf

Binary file not shown.

View File

@@ -1,9 +1,9 @@
21:13:22.421 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO akka.event.slf4j.Slf4jLogger - Slf4jLogger started 22:11:37.220 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO akka.event.slf4j.Slf4jLogger - Slf4jLogger started
21:13:22.572 [main] INFO com.firebase.server.forge.App$ - Listening at 127.0.0.1:9000 22:11:37.437 [main] INFO com.firebase.server.forge.App$ - Listening at 127.0.0.1:9000
21:13:31.975 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO com.firebase.core.namespace.NamespaceActor - fibonacci-fold-default-rtdb successfully activated FBKV (SurveyIdle(0)) wait: 121ms, init: 0ms 22:11:46.657 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO com.firebase.core.namespace.NamespaceActor - fibonacci-fold-default-rtdb successfully activated FBKV (SurveyIdle(0)) wait: 152ms, init: 0ms
21:13:32.035 [NamespaceSystem-blocking-namespace-operation-dispatcher-6] INFO com.firebase.core.namespace.StateManager - Namespace fibonacci-fold-default-rtdb status Active to Active 22:11:46.748 [NamespaceSystem-blocking-namespace-operation-dispatcher-6] INFO com.firebase.core.namespace.StateManager - Namespace fibonacci-fold-default-rtdb status Active to Active
21:14:00.571 [FirebaseWorkerPool-1-2] WARN com.firebase.core.persistence.backend.dummy.EmulatorMetadataPersistence - Multiple projectIds are not recommended in single project mode. Requested 22:11:51.139 [FirebaseWorkerPool-1-3] WARN com.firebase.core.persistence.backend.dummy.EmulatorMetadataPersistence - Multiple projectIds are not recommended in single project mode. Requested
namespace fake-project-id, but the emulator is configured for fibonacci-fold-default-rtdb. To namespace fake-project-id, but the emulator is configured for fibonacci-fold-default-rtdb. To
opt-out of single project mode add/set the singleProjectMode: false property in opt-out of single project mode add/set the singleProjectMode: false property in
the firebase.json emulators config. the firebase.json emulators config.
21:14:00.575 [NamespaceSystem-akka.actor.default-dispatcher-10] INFO com.firebase.core.namespace.NamespaceActor - fake-project-id successfully activated FBKV (SurveyIdle(0)) wait: 1ms, init: 0ms 22:11:51.147 [NamespaceSystem-akka.actor.default-dispatcher-5] INFO com.firebase.core.namespace.NamespaceActor - fake-project-id successfully activated FBKV (SurveyIdle(0)) wait: 3ms, init: 0ms

View File

@@ -1,11 +1,11 @@
{ {
"rules": { "rules": {
".read": "auth != null", ".read": "auth != null && auth.provider == 'anonymous'",
".write": "auth != null", ".write": "auth != null && auth.provider == 'anonymous'",
"users": { "users": {
"$uid": { "$uid": {
".read": "$uid === auth.uid", ".read": "$uid === auth.uid && auth.provider == 'anonymous'",
".write": "$uid === auth.uid" ".write": "$uid === auth.uid && auth.provider == 'anonymous'"
} }
} }
} }

View File

@@ -7,6 +7,7 @@
"@testing-library/jest-dom": "^5.17.0", "@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0", "@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"axios": "^1.7.7",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"firebase": "^10.13.0", "firebase": "^10.13.0",
"react": "^18.3.1", "react": "^18.3.1",

30
public/.htaccess Normal file
View File

@@ -0,0 +1,30 @@
# DO NOT REMOVE. CLOUDLINUX PASSENGER CONFIGURATION BEGIN
PassengerAppRoot "/home/cyarsflu/nodejs_app"
PassengerBaseURI "/"
PassengerNodejs "/home/cyarsflu/nodevenv/nodejs_app/20/bin/node"
PassengerAppType node
PassengerStartupFile server.js
# DO NOT REMOVE. CLOUDLINUX PASSENGER CONFIGURATION END
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
# Allow API requests to be handled by the Node.js app directly
RewriteCond %{REQUEST_URI} ^/api/ [NC]
RewriteRule ^ - [L]
# Redirect all other routes to index.html for React frontend routing
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ /index.html [L]
</IfModule>
# DO NOT REMOVE OR MODIFY. CLOUDLINUX ENV VARS CONFIGURATION BEGIN
<IfModule Litespeed>
</IfModule>
# DO NOT REMOVE OR MODIFY. CLOUDLINUX ENV VARS CONFIGURATION END

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -5,13 +5,10 @@
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<meta <meta name="description" content="A Fibonacci Poker Voting App" />
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>React App</title> <title>Fibonacci's Fold</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 420 KiB

25
server/app.js Normal file
View File

@@ -0,0 +1,25 @@
const express = require('express');
const session = require('express-session');
const app = express();
const dotenv = require('dotenv');
// Load environment variables
dotenv.config();
// Ensure that SESSION_SECRET is defined
if (!process.env.SESSIONS_SECRET) {
console.error('FATAL ERROR: SESSIONS_SECRET is not defined.');
process.exit(1);
}
// Middleware
app.use(express.json());
app.use(
session({
secret: process.env.SESSIONS_SECRET,
resave: false,
saveUninitialized: true,
})
);
module.exports = app;

151
server/authRoutes.js Normal file
View File

@@ -0,0 +1,151 @@
const express = require('express');
const router = express.Router();
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const jwt = require('jsonwebtoken');
const db = require('./db');
const { generateUniqueId } = require('../shared/utils/crypto');
// Check user session (GET)
router.get('/api/auth/session', (req, res) => {
const token = req.cookies.token;
if (!token) {
return res.json({ userId: null });
}
try {
// Verify the token
const decoded = jwt.verify(token, process.env.JWT_SECRET);
res.json({ userId: decoded.userId });
} catch (error) {
console.error('Invalid token:', error);
res.status(401).json({ userId: null, error: 'Invalid token' });
}
});
// Sign in anonymously (POST)
router.post('/api/auth/signin-anonymous', async (req, res) => {
const userId = generateUniqueId(); // Generate unique ID for anonymous user
try {
// Insert the anonymous user into the database (no sensitive data, just ID)
await db.query('INSERT INTO users (id, provider) VALUES (?, ?)', [
userId,
'anonymous',
]);
// Generate JWT token for the anonymous user
const token = jwt.sign(
{ userId, provider: 'anonymous' },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
// Set the JWT in an httpOnly cookie for better security
res.cookie('token', token, { httpOnly: true });
res.status(200).json({ message: 'Anonymous user signed in' });
} catch (error) {
console.error('Error creating anonymous user:', error);
res.status(500).json({ error: 'Server error' });
}
});
router.post('/api/auth/refresh-token', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) return res.status(403).send('Refresh token required');
// Verify the refresh token and issue a new JWT
try {
const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
const newToken = jwt.sign(
{ userId: decoded.userId },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
res.cookie('token', newToken, { httpOnly: true });
res.json({ message: 'Token refreshed' });
} catch (error) {
res.status(403).send('Invalid refresh token');
}
});
// Initialize Passport
router.use(passport.initialize());
// Google OAuth Strategy
passport.use(
new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: '/api/auth/google/callback',
},
async (accessToken, refreshToken, profile, done) => {
const googleId = profile.id; // This is the unique ID from Google
try {
// Insert the Google user into the database if they don't exist
await db.query(
'INSERT INTO users (id, provider) VALUES (?, ?) ON DUPLICATE KEY UPDATE id = id',
[googleId, 'google']
);
// Generate JWT token for the Google user
const token = jwt.sign(
{ userId: googleId, provider: 'google' },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
// Pass the token back
return done(null, token);
} catch (error) {
return done(error);
}
}
)
);
// Start Google OAuth login
router.get(
'/api/auth/google',
passport.authenticate('google', { scope: ['profile', 'email'] })
);
// Callback route for Google OAuth
router.get(
'/api/auth/google/callback',
passport.authenticate('google', { failureRedirect: '/' }),
(req, res) => {
// After successful authentication, generate the JWT (which is in req.user)
const token = req.user; // Passport's Google strategy should return the JWT
// Set the JWT in an httpOnly cookie
res.cookie('token', token, { httpOnly: true });
// Redirect to the frontend dashboard or a secure page without the token in the URL
res.redirect('/dashboard');
}
);
// Example of a protected route (no need for query token)
router.get('/dashboard', (req, res) => {
// Extract token from cookies (JWT is automatically sent as a cookie)
const token = req.cookies.token;
if (!token) {
return res.status(403).send('Token is required');
}
try {
// Verify the token and decode it
const decoded = jwt.verify(token, process.env.JWT_SECRET);
res.send(`Welcome, user with ID: ${decoded.userId}`);
} catch (err) {
res.status(403).send('Invalid token');
}
});
module.exports = router;

15
server/db.js Normal file
View File

@@ -0,0 +1,15 @@
const mysql = require('mysql2/promise');
const dotenv = require('dotenv');
// Load environment variables
dotenv.config();
// Create a MySQL connection pool with promises
const db = mysql.createPool({
host: process.env.MYSQL_HOST,
user: process.env.MYSQL_USER,
password: process.env.MYSQL_PASSWORD,
database: process.env.MYSQL_DATABASE,
});
module.exports = db;

19
server/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "fibonacci-fold-server",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"crypto-js": "^4.2.0",
"dotenv": "^8.2.0",
"express": "^4.17.1",
"express-session": "^1.18.0",
"jsonwebtoken": "^9.0.2",
"mysql2": "^2.3.3",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"uuid": "^10.0.0"
}
}

113
server/server.js Normal file
View File

@@ -0,0 +1,113 @@
const authRoutes = require('./authRoutes');
const app = require('./app');
const db = require('./db');
// Routes
app.use(authRoutes); // Use the auth routes
// Create new session
app.post('/api/session', async (req, res) => {
const sessionId = req.body.sessionId;
const query = 'INSERT INTO sessions (id) VALUES (?)';
await db.query(query, [sessionId], (err, result) => {
if (err) return res.status(500).json({ error: err });
res.status(200).json({ sessionId });
});
});
// Add user to session
app.post('/api/session/:sessionId/user', async (req, res) => {
const { sessionId } = req.params;
const { userId, provider } = req.body;
// Insert the user into the `users` table if they don't already exist
const userQuery = `
INSERT INTO users (id, provider)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE id = id`; // If the user already exists, do nothing
// Insert the user into the `session_users` table for this session
const sessionUserQuery = `
INSERT INTO session_users (session_id, user_id, provider)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE session_id = session_id`; // If the user is already in the session, do nothing
// Insert the initial vote (-1) for the user in this session
const voteQuery = `
INSERT INTO votes (session_id, user_id, vote)
VALUES (?, ?, -1)
ON DUPLICATE KEY UPDATE vote = -1`; // If a vote already exists, reset it to -1
try {
// Insert the user into the `users` table
await db.query(userQuery, [userId, provider]);
// Add the user to the `session_users` table
await db.query(sessionUserQuery, [sessionId, userId, provider]);
// Set the initial vote for the user in the session
await db.query(voteQuery, [sessionId, userId]);
res.status(200).json({ userId });
} catch (err) {
console.error('Error adding user to session:', err);
res.status(500).json({ error: 'Server error' });
}
});
// Get users in a session
app.get('/api/session/:sessionId/users', async (req, res) => {
const { sessionId } = req.params;
const query = `
SELECT users.id, users.username
FROM votes
JOIN users ON votes.user_id = users.id
WHERE session_id = ?
`;
try {
const [results] = await db.query(query, [sessionId]);
console.log('Query results:', results); // Log query result
res.status(200).json(results);
} catch (err) {
console.error('Error fetching session users:', err);
res.status(500).json({ error: 'Server error' });
}
});
app.delete('/api/session/:sessionId/votes/:userId', async (req, res) => {
const { sessionId, userId } = req.params;
const query = 'DELETE FROM votes WHERE session_id = ? AND user_id = ?';
try {
const [result] = await db.query(query, [sessionId, userId]);
res
.status(200)
.json({ success: true, message: 'Vote removed successfully' });
} catch (error) {
console.error('Error removing vote:', error);
res.status(500).json({ error: 'Server error' });
}
});
app.delete('/api/session/:sessionId/users/:userId', async (req, res) => {
const { sessionId, userId } = req.params;
const query =
'DELETE FROM session_users WHERE session_id = ? AND user_id = ?';
try {
const [result] = await db.query(query, [sessionId, userId]);
res
.status(200)
.json({ success: true, message: 'User removed successfully' });
} catch (error) {
console.error('Error removing user:', error);
res.status(500).json({ error: 'Server error' });
}
});
// Start the server
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});

1267
server/stderr.log Normal file

File diff suppressed because it is too large Load Diff

0
server/tmp/restart.txt Normal file
View File

14
shared/utils/crypto.js Normal file
View File

@@ -0,0 +1,14 @@
const { v4: uuidv4 } = require('uuid');
const CryptoJS = require('crypto-js');
// Generate a session ID hash
const generateUniqueId = () => {
const uuid = uuidv4();
const hash = CryptoJS.SHA256(uuid).toString();
// Use the first 28 characters of the hash
return hash.substring(0, 28);
};
module.exports = {
generateUniqueId,
};

View File

@@ -5,11 +5,13 @@ import {
Route, Route,
Navigate, Navigate,
} from 'react-router-dom'; } from 'react-router-dom';
import Lobby from './components/Lobby/Lobby'; import Lobby from './components/Lobby';
import VotingTable from './components/VotingTable/VotingTable'; import VotingTable from './components/VotingTable';
import UserSessionListener from './components/UserSessionListener/UserSessionListener'; import UserSessionListener from './components/UserSessionListener';
import { signInAnonymously, onAuthStateChanged } from 'firebase/auth'; import axios from 'axios';
import { auth } from './services/firebase'; import cyarenLogo from './assets/images/CyarenLogo.png';
import cardLogo from './assets/images/logo.png';
import styles from './App.module.css';
const App = () => { const App = () => {
const [userId, setUserId] = useState(''); const [userId, setUserId] = useState('');
@@ -21,18 +23,29 @@ const App = () => {
}; };
useEffect(() => { useEffect(() => {
// Sign in the user anonymously // Check if there's a user session by making a request to the backend
signInAnonymously(auth).catch((error) => { axios
console.error('Anonymous sign-in error:', error); .get('/api/auth/session')
}); .then((response) => {
const { userId } = response.data;
// Handle user state changes if (userId) {
onAuthStateChanged(auth, (user) => { setUserId(userId); // User is logged in
if (user) {
console.log('User is signed in:', user);
} else { } else {
console.log('User is signed out'); // If no user session exists, create an anonymous user session
axios
.post('/api/auth/signin-anonymous')
.then((response) => {
setUserId(response.data.userId);
})
.catch((error) => {
console.error('Error signing in anonymously:', error);
});
} }
})
.catch((error) => {
console.error('Error checking user session:', error);
})
.finally(() => {
setLoading(false); setLoading(false);
}); });
}, []); }, []);
@@ -40,6 +53,8 @@ const App = () => {
return ( return (
<Router> <Router>
<UserSessionListener /> <UserSessionListener />
<div className={styles.app}>
<section className={styles.header}>
<Routes> <Routes>
<Route <Route
path="/lobby/:sessionId?" path="/lobby/:sessionId?"
@@ -52,6 +67,23 @@ const App = () => {
/> />
<Route path="*" element={<Navigate to={`/lobby`} />} /> <Route path="*" element={<Navigate to={`/lobby`} />} />
</Routes> </Routes>
</section>
<section className={styles.footer}>
<div className={styles.logoEnvelope}>
<img className={styles.cyaren} src={cyarenLogo} alt="cyaren-logo" />
</div>
<img
className={`${styles.cardLogo} ${styles.moveLeft}`}
src={cardLogo}
alt="card-logo"
/>
<img
className={`${styles.cardLogo} ${styles.moveRight}`}
src={cardLogo}
alt="card-logo"
/>
</section>
</div>
</Router> </Router>
); );
}; };

69
src/App.module.css Normal file
View File

@@ -0,0 +1,69 @@
.app {
display: flex;
flex-flow: column nowrap;
align-items: center;
height: 100%;
}
.app section.header {
flex: 1 auto;
display: flex;
flex-flow: column nowrap;
align-items: center;
width: 100%;
}
.app section.footer {
display: flex;
flex-flow: column nowrap;
align-items: center;
margin: 0;
padding-bottom: 20px;
position: relative;
user-select: none;
width: 10%;
}
.app section.footer .logoEnvelope {
width: 0;
overflow: hidden;
opacity: 0;
transition:
opacity 0.2s ease-in-out,
width 0.2s ease-in-out;
display: flex;
align-items: center;
justify-content: center;
}
.app section.footer:hover .logoEnvelope {
width: 140px;
opacity: 1;
text-align: center;
}
.cyaren {
/* width: calc(10%); */
width: 140px;
min-width: 140px;
}
.cardLogo {
width: calc(2%);
min-width: 30px;
position: absolute;
opacity: 0.1;
transition:
margin 0.2s ease-in-out,
opacity 0.2s ease-in-out;
}
.app section.footer:hover .moveLeft {
margin-right: 120px;
opacity: 1;
}
.app section.footer:hover .moveRight {
margin-left: 120px;
opacity: 1;
}

View File

@@ -1,14 +1,6 @@
@font-face {
font-family: 'blackmild-regular';
src: url('/src/assets/fonts/Blackmild\ Regular/Blackmild\ Regular.otf')
format('opentype');
font-weight: normal;
font-style: normal;
}
@font-face { @font-face {
font-family: 'cardo-regular'; font-family: 'cardo-regular';
src: url('/src/assets/fonts/Cardo/Cardo-Regular.ttf') format('opentype'); src: url('/src/assets/fonts/Cardo/Cardo-Regular.ttf') format('truetype');
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
} }
@@ -16,7 +8,7 @@
@font-face { @font-face {
font-family: 'cinzel'; font-family: 'cinzel';
src: url('/src/assets/fonts/Cinzel/Cinzel-VariableFont_wght.ttf') src: url('/src/assets/fonts/Cinzel/Cinzel-VariableFont_wght.ttf')
format('opentype'); format('truetype');
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

View File

@@ -0,0 +1,9 @@
<svg width="119" height="30" viewBox="0 0 119 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 21.5C9.13162 21.6574 8.56984 21.6774 8.56984 21.6774C8.56984 21.6774 10.8859 17.7503 14 22C16 25.5 11.0613 29.7046 6.70152 28.9176C-4.5 26.5 -0.12039 14.287 10 13.5C16.8508 13.3426 31.9248 23.4088 40.1768 23.7236C48.4287 24.0383 49.2074 18.5295 49.2074 17.4277C49.5 13 42.0001 11.2225 42 15C42.1558 17.3609 44.3807 17.8999 44.3807 17.8999C41.4224 19.3165 39.3983 16.3259 40.7995 13.0206C42.2008 9.7153 50.92 11.4467 50.92 16.3259C50.92 21.2052 49 25.5 40 26C31 26.5 20.403 17.1129 10.4382 16.0111C1.87485 15.2242 0 27 6.85715 27.5011C13.7143 28.0021 13.9583 21.5 11 21.5Z" fill="currentColor"/>
<path d="M38.3105 17.6692C38.3105 17.6692 34.7779 15.843 34.0833 12.5105C33.772 11.0171 35.3114 9.60623 36.3302 11.4808C37.1536 13.4492 36.2118 14.4557 38.3105 17.6692Z" fill="currentColor"/>
<path d="M35.4119 19.3831C35.4119 19.3831 31.4572 19.4494 29.2987 16.8551C28.3315 15.6925 29.0298 13.7285 30.7964 14.8914C32.4347 16.2287 32.0739 17.5559 35.4119 19.3831Z" fill="currentColor"/>
<path d="M108 21.5C109.868 21.6574 110.43 21.6774 110.43 21.6774C110.43 21.6774 108.114 17.7503 105 22C103 25.5 107.939 29.7046 112.298 28.9176C123.5 26.5 119.12 14.287 109 13.5C102.149 13.3426 87.0752 23.4088 78.8232 23.7236C70.5713 24.0383 69.7926 18.5295 69.7926 17.4277C69.5 13 76.9999 11.2225 77 15C76.8442 17.3609 74.6193 17.8999 74.6193 17.8999C77.5776 19.3165 79.6017 16.3259 78.2005 13.0206C76.7992 9.7153 68.08 11.4467 68.08 16.3259C68.08 21.2052 70 25.5 79 26C88 26.5 98.597 17.1129 108.562 16.0111C117.125 15.2242 119 27 112.143 27.5011C105.286 28.0021 105.042 21.5 108 21.5Z" fill="currentColor"/>
<path d="M80.6895 17.6692C80.6895 17.6692 84.2221 15.843 84.9167 12.5105C85.228 11.0171 83.6886 9.60623 82.6698 11.4808C81.8464 13.4492 82.7882 14.4557 80.6895 17.6692Z" fill="currentColor"/>
<path d="M83.5881 19.3831C83.5881 19.3831 87.5428 19.4494 89.7013 16.8551C90.6685 15.6925 89.9702 13.7285 88.2036 14.8914C86.5653 16.2287 86.9261 17.5559 83.5881 19.3831Z" fill="currentColor"/>
<path d="M65.5 18H54C54 18 57.8682 16.7591 58.9422 10.1231C49.8164 17.5555 47.4006 2.4253 57.8685 6.93779C53.037 -2.61808 67.5307 -2.08719 61.6261 7.20324C72.3622 2.15986 70.7518 18.3518 60.821 10.1231C61.6258 16.4937 65.5 18 65.5 18Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Binary file not shown.

BIN
src/assets/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

View File

@@ -0,0 +1,29 @@
import React, { useEffect, useState } from 'react';
import VoteValue from '../VoteValue';
import cardBack from '../../assets/images/cardback.png';
import cardFront from '../../assets/images/cardfront.png';
import styles from './BoardCard.module.css';
const BoardCard = ({ vote, isDimmed, isRevealed = true }) => {
const [isFlipped, setIsFlipped] = useState(false);
useEffect(() => {
if (isRevealed) {
setTimeout(() => setIsFlipped(true), 300);
} else if (isFlipped) {
setIsFlipped(false);
}
}, [isFlipped, isRevealed]);
return (
<div className={`${isDimmed ? styles.isDimmed : ''} ${styles.boardCard}`}>
<div className={`${styles.card} ${isFlipped ? styles.flip : ''}`}>
<img className={styles.cardBack} src={cardBack} alt="card-back" />
<img className={styles.cardFront} src={cardFront} alt="card-front" />
<VoteValue className={styles.vote} value={vote} />
</div>
</div>
);
};
export default BoardCard;

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import cardBack from '../../assets/images/cardback.png'; import cardBack from '../../assets/images/cardback.png';
import { ReactComponent as BracketIcon } from '../../assets/images/bracket.svg'; import { ReactComponent as BracketIcon } from '../../assets/images/bracket.svg';
import FrontCard from '../FrontCard/FrontCard'; import FrontCard from '../FrontCard';
import styles from './BoardCard.module.css'; import styles from './BoardCard.module.css';
const BoardCard = ({ username, vote, isRevealed, isDimmed, isHighlighted }) => { const BoardCard = ({ username, vote, isRevealed, isDimmed, isHighlighted }) => {

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import card from '../../assets/images/card.png'; import card from '../../assets/images/card.png';
import VoteValue from '../VoteValue/VoteValue'; import VoteValue from '../VoteValue';
import styles from './FrontCard.module.css'; import styles from './FrontCard.module.css';
const FrontCard = ({ value, className }) => { const FrontCard = ({ value, className }) => {

View File

@@ -0,0 +1,14 @@
import React, { Fragment } from 'react';
import { Route } from 'react-router-dom';
const LoadingRoute = ({ loading = false, element, ...rest }) => {
debugger;
console.log('this.type', this.type);
return <Route {...rest} element={!loading ? element : <Fragment />} />;
};
// const LoadingRoute = Route;
// console.log('LoadingRoute', LoadingRoute);
export default LoadingRoute;

View File

@@ -1,11 +1,9 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { ref, onValue, set } from 'firebase/database'; import { useNavigate, useParams } from 'react-router-dom';
import { realtimeDb } from '../../services/firebase';
import { generateUniqueId } from '../../utils/crypto';
import appLogo from '../../assets/images/applogo.png'; import appLogo from '../../assets/images/applogo.png';
import styles from './Lobby.module.css'; import styles from './Lobby.module.css';
import { useNavigate, useParams } from 'react-router-dom'; import api from '../../services/api';
import { auth } from '../../services/firebase'; import { generateUniqueId } from '../../shared/utils/crypto';
const Lobby = ({ onJoinSession = () => {} }) => { const Lobby = ({ onJoinSession = () => {} }) => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -18,60 +16,40 @@ const Lobby = ({ onJoinSession = () => {} }) => {
// Get or generate the session ID // Get or generate the session ID
if (!sessionId) { if (!sessionId) {
const newSessionId = generateUniqueId(); const newSessionId = generateUniqueId();
// Update the URL with the session ID if it wasn't already there
navigate(`/lobby/${newSessionId}`); navigate(`/lobby/${newSessionId}`);
} } else {
// Fetch users in the session
const sessionRef = ref(realtimeDb, `sessions/${sessionId}/users`); api
.get(`/api/session/${sessionId}/users`)
// Listen for changes to the session users data .then((response) => {
const unsubscribe = onValue(sessionRef, (snapshot) => { setUsers(response.data);
const users = snapshot.val(); })
if (!users) return; .catch((error) => {
console.error('Error fetching users:', error);
const userList = Object.entries(users).map(([uid, userInfo]) => ({
uid,
username: userInfo.username,
}));
setUsers(userList);
}); });
}
return () => {
unsubscribe();
};
}, [navigate, sessionId]); }, [navigate, sessionId]);
const handleJoinSession = async () => { const handleJoinSession = async () => {
const user = auth.currentUser; const userId = generateUniqueId(); // Generate unique ID for user
const userId = user ? user.uid : generateUniqueId();
// Check if the uid is already part of the session // Check if the username is already taken
if (users.includes(userId)) { if (users.some((user) => user.username === username)) {
setIsUsernameTaken(true); setIsUsernameTaken(true);
return; return;
} else { }
// Set the initial vote for the user
const voteRef = ref(realtimeDb, `sessions/${sessionId}/votes/${userId}`);
await set(voteRef, -1);
// Add the user to the session's user list with their uid and username // Add the user to the session
const userRef = ref(realtimeDb, `sessions/${sessionId}/users/${userId}`); try {
await set(userRef, { await api.post(`/api/session/${sessionId}/user`, { userId, username });
username: username,
uid: userId,
});
// Also save the current session ID under the user's record in the database
const userSessionRef = ref(realtimeDb, `users/${userId}`);
await set(userSessionRef, {
currentSessionId: `session/${sessionId}`,
});
// Call the onJoinSession callback with the username // Call the onJoinSession callback with the username
onJoinSession(userId); onJoinSession(userId);
// Navigate to the session // Navigate to the session
navigate(`/session/${sessionId}`); navigate(`/session/${sessionId}`);
} catch (error) {
console.error('Error joining session:', error);
} }
}; };
@@ -103,8 +81,8 @@ const Lobby = ({ onJoinSession = () => {} }) => {
<br /> <br />
<h3>Current Users In Session</h3> <h3>Current Users In Session</h3>
<section className={styles.list}> <section className={styles.list}>
{users.map(({ uid, username }) => ( {users.map(({ id, username }) => (
<section key={uid}>{username}</section> <section key={id}>{username}</section>
))} ))}
</section> </section>
</div> </div>

View File

@@ -0,0 +1,80 @@
.root {
position: absolute;
top: 10px;
right: 35px;
}
/* Container for the entire menu */
.menuContainer {
position: relative;
display: inline-block;
text-align: center;
}
/* Label with circular border */
.menuLabel {
width: 20px;
height: 20px;
padding: 0.5rem;
border: 2px solid #f0dcb1;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
user-select: none;
}
/* Container for the buttons */
.menuButtons {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
display: none;
background-color: transparent;
padding: 4px 0;
box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1);
z-index: 1000;
min-width: 100px;
}
/* Buttons styling */
.menuButtons button {
display: block;
width: 100%;
margin: 8px 0;
background-color: #f0dcb1;
border: 1px solid #1b191a;
border-radius: 4px;
color: #1b191a;
padding: 4px 4px;
cursor: pointer;
font-weight: 800;
user-select: none;
box-shadow: 2px 2px 5px rgba(0, 0, 0, 1);
transition: background-color 0.3s ease;
}
.menuButtons button:hover {
box-shadow: 2px 2px 5px rgba(0, 0, 0, 1);
background-color: #cdba95;
}
.menuButtons button:active {
box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.5);
background-color: #f0dcb1;
transform: scale(0.95);
}
/* Show buttons on hover */
.menuContainer:hover .menuButtons {
display: block;
}
.menuContainer .invite {
}
.menuContainer .invite .checkmark {
font-weight: 800;
}

View File

@@ -0,0 +1,49 @@
import React, { useState, useEffect, useRef } from 'react';
import styles from './ProfileMenuButton.module.css';
import { getAcronym } from '../../utils/strings';
const ProfileMenuButton = ({ username, className, onLogout, onInvite }) => {
const [isInviteCopied, setIsInviteCopied] = useState(false);
const acronym = getAcronym(username).slice(0, 2);
const timeoutIdRef = useRef(null);
async function handleInvite() {
try {
await onInvite();
setIsInviteCopied(true);
timeoutIdRef.current = setTimeout(() => {
setIsInviteCopied(false);
}, 2000);
} catch (err) {
console.log(err);
}
}
useEffect(() => {
// Cleanup the timeout if the component unmounts before the timeout is complete
return () => clearTimeout(timeoutIdRef.current);
}, []);
return (
<div className={`${styles.root} ${className}`}>
<div className={styles.menuContainer}>
<div className={styles.menuLabel}>{acronym}</div>
<div className={styles.menuButtons}>
<button onClick={handleInvite} className={styles.invite}>
{!isInviteCopied ? (
'Copy Invite'
) : (
<>
<span className={styles.checkmark}> </span>Copied!
</>
)}
</button>
<button onClick={onLogout}>Logout</button>
</div>
</div>
</div>
);
};
export default ProfileMenuButton;

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import styles from './Results.module.css'; import styles from './Results.module.css';
import VoteValue from '../VoteValue/VoteValue'; import VoteValue from '../VoteValue';
const Results = ({ votes }) => { const Results = ({ votes }) => {
const voteCounts = Object.values(votes).reduce((acc, vote) => { const voteCounts = Object.values(votes).reduce((acc, vote) => {

View File

@@ -1,46 +0,0 @@
import { useEffect, useState } from 'react';
import { ref, onValue, remove } from 'firebase/database';
import { realtimeDb } from '../../services/firebase';
import { useNavigate } from 'react-router-dom';
import { generateUniqueId } from '../../utils/crypto';
import { auth } from '../../services/firebase';
const UserSessionListener = () => {
const navigate = useNavigate();
const user = auth.currentUser;
const userId = user ? user.uid : generateUniqueId();
const [isRedirecting, setIsRedirecting] = useState(false);
useEffect(() => {
const userRef = ref(realtimeDb, `users/${userId}/currentSessionId`);
const unsubscribe = onValue(userRef, async (snapshot) => {
const currentSessionId = snapshot.val() || generateUniqueId();
// Only redirect if the user is not already being redirected
if (
!isRedirecting &&
currentSessionId &&
currentSessionId.startsWith('lobby')
) {
setIsRedirecting(true);
console.log(`Removing user from: users/${userId}`);
// Remove the entire user entry from the database
const fullUserRef = ref(realtimeDb, `users/${userId}`);
await remove(fullUserRef);
navigate(`/${currentSessionId}`);
setIsRedirecting(false);
}
});
return () => unsubscribe();
}, [userId, navigate, isRedirecting]);
return null;
};
export default UserSessionListener;

View File

@@ -0,0 +1,48 @@
import { useEffect, useState } from 'react';
import api from '../../services/api'; // Import the Axios instance with the interceptor
import { useNavigate } from 'react-router-dom';
import { generateUniqueId } from '../../shared/utils/crypto';
const UserSessionListener = () => {
const navigate = useNavigate();
const [isRedirecting, setIsRedirecting] = useState(false);
const userId = generateUniqueId(); // Use the unique ID generator for anonymous users
useEffect(() => {
const fetchUserSession = async () => {
try {
// Get the user's current session ID from the API
const response = await api.get(`/users/${userId}/session`);
const currentSessionId =
response.data.currentSessionId || generateUniqueId();
// Only redirect if the user is not already being redirected
if (
!isRedirecting &&
currentSessionId &&
currentSessionId.startsWith('lobby')
) {
setIsRedirecting(true);
// Delete the user from the session via API
await api.delete(`/users/${userId}`);
// Redirect to the session
navigate(`/${currentSessionId}`);
setIsRedirecting(false);
}
} catch (error) {
console.error('Error fetching user session:', error);
}
};
fetchUserSession();
}, [userId, navigate, isRedirecting]);
return null;
};
export default UserSessionListener;

View File

@@ -1,3 +1,9 @@
button.voteCard {
font-family: 'Cardo', serif;
font-weight: 400;
font-style: normal;
}
.voteCard { .voteCard {
width: 50px; width: 50px;
height: 75px; height: 75px;
@@ -31,12 +37,6 @@
z-index: 1; z-index: 1;
} }
.cardo-regular {
font-family: 'Cardo', serif;
font-weight: 400;
font-style: normal;
}
.shadow { .shadow {
position: absolute; position: absolute;
width: 50px; width: 50px;

View File

@@ -1,12 +1,12 @@
import React from 'react'; import React from 'react';
import styles from './VoteCard.module.css'; import styles from './VoteCard.module.css';
import FrontCard from '../FrontCard/FrontCard'; import FrontCard from '../FrontCard';
const VoteCard = ({ number, isSelected, onClick, className }) => { const VoteCard = ({ number, isSelected, onClick, className }) => {
return ( return (
<button <button
className={`${styles.voteCard} ${isSelected ? styles.selected : ''} ${styles['cardo-regular']} ${className}`} className={`${styles.voteCard} ${isSelected ? styles.selected : ''} ${className}`}
onClick={() => onClick(number)} onClick={() => onClick(number)}
> >
<FrontCard <FrontCard

View File

@@ -2,29 +2,27 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; /* or specify a specific width */ width: 100%;
height: 100%; /* or specify a specific height */ height: 100%;
padding: 0.25em; padding: 0.25em;
} }
.coffee, .coffee,
.infinity { .infinity {
width: 100%; /* Adjust this to the specific size you want */ width: 100%;
height: auto; /* Maintains aspect ratio */ height: auto;
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
} }
.value { .value {
font-size: 2rem; /* Adjust this to fit your design */ font-size: 2rem;
font-weight: 800; font-weight: 800;
line-height: 1; line-height: 1;
width: 100%; /* Makes the text conform to the container width */ width: 100%;
height: auto; height: auto;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
text-align: center; text-align: center;
/* text-shadow: none; */
/* color: #1b191a; */
} }

View File

@@ -3,9 +3,10 @@ import { ReactComponent as Coffee } from '../../assets/images/coffee.svg';
import { ReactComponent as Infinity } from '../../assets/images/infinity.svg'; import { ReactComponent as Infinity } from '../../assets/images/infinity.svg';
import styles from './VoteValue.module.css'; import styles from './VoteValue.module.css';
const VoteValue = ({ value, className }) => { function VoteValue({ value, className }) {
const isInfinity = `${value}` === '9999'; const isInfinity = `${value}` === '9999';
const isCoffee = `${value}` === '0'; const isCoffee = `${value}` === '0';
const isNoVote = `${value}` === '';
return ( return (
<div className={`${styles.voteValueContainer} ${className}`}> <div className={`${styles.voteValueContainer} ${className}`}>
@@ -13,11 +14,13 @@ const VoteValue = ({ value, className }) => {
<Coffee className={styles.coffee} /> <Coffee className={styles.coffee} />
) : isInfinity ? ( ) : isInfinity ? (
<Infinity className={styles.infinity} /> <Infinity className={styles.infinity} />
) : isNoVote ? (
<div className={styles.value}>?</div>
) : ( ) : (
<div className={styles.value}>{value >= 0 ? value : ''}</div> <div className={styles.value}>{value >= 0 ? value : ''}</div>
)} )}
</div> </div>
); );
}; }
export default VoteValue; export default VoteValue;

View File

@@ -1,65 +0,0 @@
import React, { useState } from 'react';
import { ref, set, get } from 'firebase/database';
import { realtimeDb } from '../../services/firebase';
import VoteCard from '../VoteCard/VoteCard';
import styles from './VotingBoard.module.css';
const fibonacciNumbers = [0, 1, 2, 3, 5, 8, 13, 21, 9999];
const VotingBoard = ({ sessionId, userId }) => {
const [selectedVote, setSelectedVote] = useState(null);
const handleVote = async (number) => {
setSelectedVote(number);
const userVoteRef = ref(
realtimeDb,
`sessions/${sessionId}/votes/${userId}`
);
// Get the current vote for this user (if any)
const snapshot = await get(userVoteRef);
const existingVote =
snapshot.exists() && snapshot.val() > -1 ? snapshot.val() : null;
// Set the new vote for the user
await set(userVoteRef, number);
const sessionRef = ref(realtimeDb, `sessions/${sessionId}`);
const sessionSnapshot = await get(sessionRef);
if (sessionSnapshot.exists()) {
const sessionData = sessionSnapshot.val() || {};
// Increment votedUsers only if the user hasn't voted before
if (existingVote === null) {
const votedUsers = sessionData.votedUsers || 0;
await set(sessionRef, {
...sessionData,
votedUsers: votedUsers + 1,
});
}
} else {
console.error("Session doesn't exist");
}
};
return (
<div className={styles.votingBoard}>
<h2>Select Your Vote</h2>
<div className={styles.voteCards}>
{fibonacciNumbers.map((num) => (
<VoteCard
key={num}
className={styles.voteCard}
number={num}
onClick={() => handleVote(num)}
isSelected={selectedVote === num}
/>
))}
</div>
</div>
);
};
export default VotingBoard;

View File

@@ -0,0 +1,45 @@
import React, { useState } from 'react';
import api from '../../services/api'; // Import your Axios instance from `api.js`
import VoteCard from '../VoteCard';
import styles from './VotingBoard.module.css';
const fibonacciNumbers = ['', 0, 1, 2, 3, 5, 8, 13, 21, 9999];
const VotingBoard = ({ sessionId, userId }) => {
const [selectedVote, setSelectedVote] = useState(null);
const handleVote = async (number) => {
setSelectedVote(number);
try {
// Send the vote to your server via POST request using the `api.js` instance
await api.post(`/session/${sessionId}/votes`, {
userId,
vote: number,
});
console.log('Vote submitted successfully');
} catch (error) {
console.error('Error submitting vote:', error);
}
};
return (
<div className={styles.votingBoard}>
<h2>Select Your Vote</h2>
<div className={styles.voteCards}>
{fibonacciNumbers.map((num) => (
<VoteCard
key={num}
className={styles.voteCard}
number={num}
onClick={() => handleVote(num)}
isSelected={selectedVote === num}
/>
))}
</div>
</div>
);
};
export default VotingBoard;

View File

@@ -1,294 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import { ref, onValue, get, set, remove } from 'firebase/database';
import { realtimeDb } from '../../services/firebase';
import VotingBoard from '../VotingBoard/VotingBoard';
import BoardCard from '../BoardCard/BoardCard';
import Results from '../Results/Results';
import styles from './VotingTable.module.css';
import titleCardSrc from '../../assets/images/titlecard.png';
import { ReactComponent as ExitIcon } from '../../assets/images/exit.svg';
import { useNavigate, useParams } from 'react-router-dom';
import { auth } from '../../services/firebase';
import ProfileMenuButton from '../ProfileMenuButton';
const VotingTable = ({ userId }) => {
const [users, setUsers] = useState([]);
const [votes, setVotes] = useState({});
const [, setVotedUsers] = useState(0);
const [allVoted, setAllVoted] = useState(false);
const [isFinal, setIsFinal] = useState(false);
const { sessionId } = useParams();
const [currentUID, setCurrentUID] = useState('');
const navigate = useNavigate();
const maxUsers = 32;
const username =
users.find((user) => user.uid === currentUID)?.username || '';
const goToLobby = useCallback(() => {
navigate(`/lobby/${sessionId}`);
}, [sessionId, navigate]);
function handleLogout() {
handleDropUser(currentUID);
}
const handleDropUser = async (userId) => {
const userVoteRef = ref(
realtimeDb,
`sessions/${sessionId}/votes/${userId}`
);
const userRef = ref(realtimeDb, `sessions/${sessionId}/users/${userId}`);
await remove(userVoteRef);
await remove(userRef);
const sessionRef = ref(realtimeDb, `sessions/${sessionId}`);
const snapshot = await get(sessionRef);
if (snapshot.exists()) {
const sessionData = snapshot.val() || {};
if (typeof sessionData.users === 'undefined') {
sessionData.users = {};
}
if (typeof sessionData.votes === 'undefined') {
sessionData.votes = {};
}
const userList = Object.entries(sessionData.users).reduce(
(res, [uid, userInfo]) => {
res[uid] = {
uid,
username: userInfo.username,
};
return res;
},
{}
);
if (sessionData.votes && sessionData.votes[userId]) {
delete sessionData.votes[userId];
}
// Update the session data
await set(sessionRef, {
...sessionData,
votedUsers: Object.entries(sessionData.votes).reduce(
(res, [uid, vote]) => (userId !== uid && vote >= 0 ? res + 1 : res),
0
),
users: userList,
});
// Redirect the dropped user to the lobby
const userLobbyRef = ref(realtimeDb, `users/${userId}`);
await set(userLobbyRef, {
currentSessionId: `lobby/${sessionId}`,
});
}
};
const handleFinalizeVoting = async () => {
const sessionRef = ref(realtimeDb, `sessions/${sessionId}`);
const snapshot = await get(sessionRef);
if (snapshot.exists()) {
const sessionData = snapshot.val() || {};
await set(sessionRef, {
...sessionData,
isFinal: true,
});
setIsFinal(true);
}
};
const handleResetVoting = async () => {
const sessionRef = ref(realtimeDb, `sessions/${sessionId}`);
// Get the current session data
const snapshot = await get(sessionRef);
if (snapshot.exists()) {
const sessionData = snapshot.val() || {};
// Reset the votes while retaining users
const resetVotes = Object.keys(sessionData.votes).reduce((acc, user) => {
acc[user] = -1;
return acc;
}, {});
// Update the session in the database with reset votes and reset isFinal
await set(sessionRef, {
...sessionData,
votes: resetVotes,
votedUsers: 0,
isFinal: false,
});
// Update the component state
setVotes(resetVotes);
setVotedUsers(0);
setIsFinal(false);
setAllVoted(false);
}
};
useEffect(() => {
const user = auth.currentUser;
if (!user) {
goToLobby();
return;
}
setCurrentUID(user.uid);
const sessionRef = ref(realtimeDb, `sessions/${sessionId}`);
get(sessionRef).then((snapshot) => {
if (!snapshot.exists()) {
// Initialize the session if it doesn't exist
set(sessionRef, {
votes: {},
maxUsers: maxUsers,
votedUsers: 0,
isFinal: false,
users: {},
});
} else {
// If the session exists, ensure all necessary fields are set
const sessionData = snapshot.val() || {};
// Check and add missing fields
if (!sessionData.maxUsers) {
sessionData.maxUsers = maxUsers;
}
if (!sessionData.votedUsers) {
sessionData.votedUsers = 0;
}
if (!sessionData.isFinal) {
sessionData.isFinal = false;
}
if (typeof sessionData.votes === 'undefined') {
sessionData.votes = {};
}
if (typeof sessionData.users === 'undefined') {
sessionData.users = {};
}
// Update the session with missing fields if needed
set(sessionRef, sessionData);
const userList = Object.entries(sessionData.users).map(
([uid, userInfo]) => ({
uid,
username: userInfo.username, // Ensure username is passed
})
);
// Load the data into the state
setUsers(userList);
setVotes(sessionData.votes);
setVotedUsers(sessionData.votedUsers);
setIsFinal(sessionData.isFinal);
}
});
// Listen for changes to the session data
const unsubscribe = onValue(sessionRef, (snapshot) => {
const sessionData = snapshot.val();
if (!sessionData) return;
if (typeof sessionData.users === 'undefined') {
sessionData.users = {};
}
if (typeof sessionData.votes === 'undefined') {
sessionData.votes = {};
}
const userList = Object.entries(sessionData.users).map(
([uid, userInfo]) => ({
uid,
username: userInfo.username,
})
);
setUsers(userList);
setVotes(sessionData.votes || {});
setVotedUsers(sessionData.votedUsers || 0);
setIsFinal(sessionData.isFinal || false);
// Check if all users have voted
setAllVoted(
sessionData.votedUsers ===
Object.keys(sessionData.votes || {}).length &&
Object.keys(sessionData.votes || {}).length > 0
);
});
return () => {
unsubscribe();
};
}, [sessionId, maxUsers, navigate, setCurrentUID, goToLobby]);
return (
<div className={styles.votingTable}>
{/* <ExitIcon
className={styles.logout}
onClick={() => handleDropUser(currentUID)}
title="Logout"
/> */}
<ProfileMenuButton username={username} onLogout={handleLogout} />
<img className={styles.titleCard} src={titleCardSrc} alt="round-table" />
<div className={styles.tableCards}>
{users.map(({ uid, username }) => (
<div key={uid} className={styles.userCardWrapper}>
<BoardCard
username={username}
vote={votes[uid]}
isRevealed={isFinal && votes[uid] !== -1}
isDimmed={votes[uid] === -1}
isHighlighted={currentUID === uid}
/>
{
<ExitIcon
className={styles.dropButton}
onClick={() => handleDropUser(uid)}
title="Drop User"
/>
}
</div>
))}
</div>
<div className={styles.form}>
{allVoted && !isFinal && (
<div
className={`${styles.reveal} ${styles.button}`}
onClick={handleFinalizeVoting}
>
Reveal Votes
</div>
)}
{isFinal && (
<div
className={`${styles.restart} ${styles.button}`}
onClick={handleResetVoting}
>
Restart Voting
</div>
)}
</div>
{!isFinal ? (
<VotingBoard
sessionId={sessionId}
userId={userId}
maxUsers={maxUsers}
/>
) : (
<Results votes={votes} />
)}
</div>
);
};
export default VotingTable;

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { MemoryRouter, Route, Routes } from 'react-router-dom';
import VotingTable from './VotingTable'; import VotingTable from './VotingTable';
import { generateUniqueId } from '../../utils/crypto'; import { generateUniqueId } from '../../shared/utils/crypto';
export default { export default {
title: 'Components/VotingTable', title: 'Components/VotingTable',

View File

@@ -0,0 +1,203 @@
import React, { useState, useEffect, useCallback } from 'react';
import VotingBoard from '../VotingBoard';
import BoardCard from '../BoardCard';
import Results from '../Results';
import styles from './VotingTable.module.css';
import titleCardSrc from '../../assets/images/titlecard.png';
import { ReactComponent as ExitIcon } from '../../assets/images/exit.svg';
import { useNavigate, useParams } from 'react-router-dom';
import ProfileMenuButton from '../ProfileMenuButton';
import api from '../../services/api';
const VotingTable = ({ userId }) => {
const [users, setUsers] = useState([]);
const [votes, setVotes] = useState({});
const [, setVotedUsers] = useState(0);
const [isFinal, setIsFinal] = useState(false);
const { sessionId } = useParams();
const [currentUID, setCurrentUID] = useState('');
const navigate = useNavigate();
const maxUsers = 32;
const username =
users.find((user) => user.uid === currentUID)?.username || '';
const goToLobby = useCallback(() => {
navigate(`/lobby/${sessionId}`);
}, [sessionId, navigate]);
async function handleLogout() {
await handleDropUser(currentUID);
}
async function handleInvite() {
try {
navigator.clipboard.writeText(window.location.href);
} catch (err) {
console.error('Failed to copy invite link: ', err);
}
}
const handleDropUser = async (userId) => {
try {
// Remove user vote and user from session via API calls
await api.delete(`/api/session/${sessionId}/votes/${userId}`);
await api.delete(`/api/session/${sessionId}/users/${userId}`);
// Fetch updated session data after user removal
const sessionResponse = await api.get(`/api/session/${sessionId}`);
const sessionData = sessionResponse.data;
if (!sessionData) return;
// Update state with the new session data
const userList = Object.entries(sessionData.users || {}).map(
([uid, userInfo]) => ({
uid,
username: userInfo.username,
})
);
setUsers(userList);
setVotes(sessionData.votes || {});
// Redirect the dropped user to the lobby
await api.post(`/api/users/${userId}`, {
currentSessionId: `lobby/${sessionId}`,
});
} catch (error) {
console.error('Error removing user:', error);
}
};
const handleFinalizeVoting = async () => {
try {
// Finalize voting by setting the isFinal flag in the session via API call
await api.post(`/api/session/${sessionId}/finalize`, {
isFinal: true,
});
setIsFinal(true);
} catch (error) {
console.error('Error finalizing voting:', error);
}
};
const handleResetVoting = async () => {
try {
// Reset the session's votes and isFinal flag via API call
await api.post(`/api/session/${sessionId}/reset`, {
votes: {},
isFinal: false,
});
setVotes({});
setVotedUsers(0);
setIsFinal(false);
} catch (error) {
console.error('Error resetting voting:', error);
}
};
useEffect(() => {
const fetchSessionData = async () => {
try {
// Fetch session data from the API
const sessionResponse = await api.get(`/api/session/${sessionId}`);
const sessionData = sessionResponse.data;
if (!sessionData) {
// Initialize the session if it doesn't exist
await api.post(`/api/session/${sessionId}`, {
votes: {},
maxUsers: maxUsers,
isFinal: false,
users: {},
});
} else {
// Load the existing session data
const userList = Object.entries(sessionData.users || {}).map(
([uid, userInfo]) => ({
uid,
username: userInfo.username,
})
);
setUsers(userList);
setVotes(sessionData.votes || {});
setIsFinal(sessionData.isFinal || false);
}
} catch (error) {
console.error('Error fetching session data:', error);
goToLobby(); // Redirect to lobby if the session doesn't exist
}
};
fetchSessionData();
// Polling or WebSocket could be used to listen for real-time updates
const intervalId = setInterval(fetchSessionData, 5000); // Poll every 5 seconds
return () => clearInterval(intervalId); // Clean up the interval
}, [sessionId, maxUsers, navigate, setCurrentUID, goToLobby]);
const allVoted = Object.values(votes).every((v) => v >= 0);
return (
<div className={styles.votingTable}>
<ProfileMenuButton
username={username}
onLogout={handleLogout}
onInvite={handleInvite}
/>
<img className={styles.titleCard} src={titleCardSrc} alt="title-card" />
<div className={styles.tableCards}>
{users.map(({ uid, username }) => (
<div key={uid} className={styles.userCardWrapper}>
<BoardCard
username={username}
vote={votes[uid]}
isRevealed={isFinal && votes[uid] !== -1}
isDimmed={votes[uid] === -1}
isHighlighted={currentUID === uid}
/>
{
<ExitIcon
className={styles.dropButton}
onClick={
uid === currentUID ? handleLogout : () => handleDropUser(uid)
}
title="Drop User"
/>
}
</div>
))}
</div>
<div className={styles.form}>
{allVoted && !isFinal && (
<div
className={`${styles.reveal} ${styles.button}`}
onClick={handleFinalizeVoting}
>
Reveal Votes
</div>
)}
{isFinal && (
<div
className={`${styles.restart} ${styles.button}`}
onClick={handleResetVoting}
>
Restart Voting
</div>
)}
</div>
{!isFinal ? (
<VotingBoard
sessionId={sessionId}
userId={userId}
maxUsers={maxUsers}
/>
) : (
<Results votes={votes} />
)}
</div>
);
};
export default VotingTable;

View File

@@ -25,6 +25,10 @@ body {
height: 100%; height: 100%;
} }
#root {
height: 100%;
}
input { input {
width: 150px; width: 150px;
} }

39
src/services/api.js Normal file
View File

@@ -0,0 +1,39 @@
import axios from 'axios';
// Create an Axios instance with base URL and credentials
const api = axios.create({
baseURL: '/api', // The base URL for your API
withCredentials: true, // Send cookies with requests
});
// Set up an Axios interceptor to handle token expiration (401 error)
api.interceptors.response.use(
(response) => response, // If the response is OK, just return it
async (error) => {
const originalRequest = error.config;
if (
error.response &&
error.response.status === 401 &&
!originalRequest._retry
) {
originalRequest._retry = true; // Prevent an infinite retry loop
// Try refreshing the token
try {
await api.post('/api/auth/refresh-token');
// Retry the original request with the new token
return api(originalRequest);
} catch (refreshError) {
console.log('originalRequest', originalRequest);
console.error('Token refresh failed:', refreshError);
// Handle failed refresh (e.g., redirect to login page)
window.location.href = '/lobby';
}
}
return Promise.reject(error); // Reject if it's not a 401 or after retry
}
);
export default api;

1
src/shared Symbolic link
View File

@@ -0,0 +1 @@
/Users/cmedina/Documents/Developer/Apps/reactApps/fibonacci-fold/shared

View File

@@ -1,9 +0,0 @@
import { v4 as uuidv4 } from 'uuid';
import CryptoJS from 'crypto-js';
// Generate a session ID hash
export const generateUniqueId = () => {
const uuid = uuidv4();
const hash = CryptoJS.SHA256(uuid).toString();
return hash.substring(0, 28); // Use the first 8 characters of the hash
};

View File

@@ -1,110 +0,0 @@
export function mockThis(input) {
const defaultValues = {
string: 'default string',
number: 0,
boolean: false,
object: {},
array: [],
function: () => {}, // Default empty function
};
const nativeTypes = new Set([
'Map',
'Set',
'WeakMap',
'WeakSet',
'Date',
'RegExp',
'ArrayBuffer',
'SharedArrayBuffer',
'DataView',
'Promise',
'Int8Array',
'Uint8Array',
'Uint8ClampedArray',
'Int16Array',
'Uint16Array',
'Int32Array',
'Uint32Array',
'Float32Array',
'Float64Array',
'BigInt64Array',
'BigUint64Array',
]);
function getDefaultValue(value) {
const valueType = Array.isArray(value) ? 'array' : typeof value;
return defaultValues[valueType] !== undefined
? defaultValues[valueType]
: null;
}
function createGenericMock() {
return new Proxy(() => {}, {
get(target, prop) {
return createGenericMock();
},
apply() {
return createGenericMock();
},
});
}
function mockFunction(func) {
return new Proxy(func, {
apply(target, thisArg, argumentsList) {
const returnValue = target.apply(thisArg, argumentsList);
return mockValue(returnValue);
},
get(target, prop) {
return createGenericMock();
},
});
}
function mockObject(obj) {
if (nativeTypes.has(obj.constructor.name)) {
return obj; // Return native types without modification
}
return new Proxy(obj, {
get(target, prop) {
if (prop === 'getProvider') {
return () => ({
getImmediate: (arg) => {
// Handle different argument types passed to getImmediate
if (arg && arg.identifier === '(default)') {
return {
triggerHeartbeat: () => ({
isInitialized: () => true,
}),
};
}
// Return a generic mock for other cases
return createGenericMock();
},
});
}
if (prop in target) {
return mockValue(target[prop]);
}
return createGenericMock();
},
});
}
function mockValue(value) {
if (typeof value === 'function') {
return mockFunction(value);
} else if (typeof value === 'object' && value !== null) {
return mockObject(value);
} else {
return getDefaultValue(value);
}
}
return mockValue(input);
}
export default mockThis;

5
src/utils/strings.js Normal file
View File

@@ -0,0 +1,5 @@
export const getAcronym = (str = '', separator = ' ', union = '') => {
const words = str.split(separator).filter((word) => word);
const letters = words.map((word) => word[0]);
return letters.join(union);
};

View File

@@ -5982,6 +5982,17 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"axios@npm:^1.7.7":
version: 1.7.7
resolution: "axios@npm:1.7.7"
dependencies:
follow-redirects: "npm:^1.15.6"
form-data: "npm:^4.0.0"
proxy-from-env: "npm:^1.1.0"
checksum: 10c0/4499efc89e86b0b49ffddc018798de05fab26e3bf57913818266be73279a6418c3ce8f9e934c7d2d707ab8c095e837fc6c90608fb7715b94d357720b5f568af7
languageName: node
linkType: hard
"axobject-query@npm:~3.1.1": "axobject-query@npm:~3.1.1":
version: 3.1.1 version: 3.1.1
resolution: "axobject-query@npm:3.1.1" resolution: "axobject-query@npm:3.1.1"
@@ -9566,6 +9577,7 @@ __metadata:
"@testing-library/jest-dom": "npm:^5.17.0" "@testing-library/jest-dom": "npm:^5.17.0"
"@testing-library/react": "npm:^13.4.0" "@testing-library/react": "npm:^13.4.0"
"@testing-library/user-event": "npm:^13.5.0" "@testing-library/user-event": "npm:^13.5.0"
axios: "npm:^1.7.7"
crypto-js: "npm:^4.2.0" crypto-js: "npm:^4.2.0"
dotenv: "npm:^16.4.5" dotenv: "npm:^16.4.5"
eslint: "npm:^8.52.0" eslint: "npm:^8.52.0"
@@ -9956,6 +9968,16 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"follow-redirects@npm:^1.15.6":
version: 1.15.9
resolution: "follow-redirects@npm:1.15.9"
peerDependenciesMeta:
debug:
optional: true
checksum: 10c0/5829165bd112c3c0e82be6c15b1a58fa9dcfaede3b3c54697a82fe4a62dd5ae5e8222956b448d2f98e331525f05d00404aba7d696de9e761ef6e42fdc780244f
languageName: node
linkType: hard
"for-each@npm:^0.3.3": "for-each@npm:^0.3.3":
version: 0.3.3 version: 0.3.3
resolution: "for-each@npm:0.3.3" resolution: "for-each@npm:0.3.3"