Compare commits

...

2 commits

Author SHA1 Message Date
4c58da37f4 begin impl of todo endpoints 2021-06-27 14:44:09 -04:00
093a679b6d login flow 2021-06-17 17:00:56 -04:00
36 changed files with 2523 additions and 519 deletions

3
.gitignore vendored
View file

@ -122,4 +122,5 @@ static/
elm-stuff/
yarn.lock
yarn.lock
backend/config.json

16
backend/.prettierrc.yaml Normal file
View file

@ -0,0 +1,16 @@
arrowParens: 'always'
bracketSpacing: true
endOfLine: 'lf'
htmlWhitespaceSensitivity: 'css'
insertPragma: false
jsxBracketSameLine: true
jsxSingleQuote: true
printWidth: 120
proseWrap: 'preserve'
quoteProps: 'consistent'
requirePragma: false
semi: true
singleQuote: true
tabWidth: 2
trailingComma: 'none'
useTabs: false

View file

@ -1,9 +0,0 @@
{
"secret": "TEST_SECRET",
"https": false,
"alter_db": true,
"port": 8080,
"db_url": "postgres://postgres:@127.0.0.1/todo",
"cert": "",
"cert_key": ""
}

View file

@ -0,0 +1,13 @@
{
"secret": "TEST_SECRET",
"https": true,
"alter_db": true,
"port": 8080,
"db_url": "postgres://postgres:@127.0.0.1/todo",
"cert": "",
"cert_key": "",
"mail_host": "",
"mail_port": 465,
"mail_username": "",
"mail_password": ""
}

View file

@ -5,9 +5,6 @@
"main": "src/index.js",
"scripts": {
"who": "pwd",
"build": "cd frontend; elm make src/Main.elm --output=../static/elm.js",
"debug": "cd frontend; elm make src/Main.elm --debug --output=../static/elm.js",
"prod": "cd frontend; elm make src/Main.elm --optimize --output=../static/elm.js",
"start": "node src/index.js"
},
"repository": {
@ -20,7 +17,12 @@
"cookie-parser": "^1.4.5",
"cors": "^2.8.5",
"express": "^4.17.1",
"express-paginate": "^1.0.2",
"nodemailer": "^6.6.1",
"pg": "^8.6.0",
"sequelize": "^6.6.2"
},
"devDependencies": {
"sequelize-cli": "^6.2.0"
}
}

File diff suppressed because it is too large Load diff

View file

@ -2,12 +2,35 @@ const Sequelize = require('sequelize');
const Config = require('./config.js');
if (!Config.config.db_url) {
console.error("No database url found. please set `db_url` in config.json");
console.error('No database url found. please set `db_url` in config.json');
process.exit();
}
const db = new Sequelize(Config.config.db_url);
const UnverifiedUser = db.define('UnverifiedUser', {
id: {
type: Sequelize.DataTypes.UUID,
defaultValue: Sequelize.UUIDV4,
allowNull: false,
primaryKey: true,
unique: true
},
verificationToken: {
type: Sequelize.DataTypes.STRING,
allowNull: false
},
email: {
type: Sequelize.DataTypes.STRING,
allowNull: false,
unique: true
},
password_hash: {
type: Sequelize.DataTypes.STRING,
allowNull: true
}
});
const User = db.define('User', {
id: {
type: Sequelize.DataTypes.UUID,
@ -35,13 +58,30 @@ const Todo = db.define('Todo', {
primaryKey: true,
unique: true
},
user: {
type: Sequelize.DataTypes.UUID,
allowNull: false
},
content: {
type: Sequelize.DataTypes.TEXT,
allowNull: false
},
tags: {
type: Sequelize.DataTypes.ARRAY(Sequelize.DataTypes.STRING),
allowNull: true
},
complete: {
type: Sequelize.DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
deadline: {
type: Sequelize.DataTypes.DATE,
allowNull: true
}
});
const Tag = db.define('Tag', {
const Grouping = db.define('Grouping', {
id: {
type: Sequelize.DataTypes.UUID,
defaultValue: Sequelize.UUIDV4,
@ -49,29 +89,48 @@ const Tag = db.define('Tag', {
primaryKey: true,
unique: true
},
content: {
type: Sequelize.DataTypes.STRING,
allowNull: false
complete: {
type: Sequelize.DataTypes.BOOLEAN,
allowNull: true
},
manually_added: {
type: Sequelize.DataTypes.ARRAY(Sequelize.DataTypes.UUID),
allowNull: true
},
required: {
type: Sequelize.DataTypes.ARRAY(Sequelize.DataTypes.STRING),
allowNull: true
},
exclusions: {
type: Sequelize.DataTypes.ARRAY(Sequelize.DataTypes.STRING),
allowNull: true
}
});
User.hasMany(Todo);
Todo.hasMany(Tag);
let options = {
alter: false
};
if (Config.config.alter_db) {
options.alter = true;
}
User.sync(options);
let start = async () => {
await UnverifiedUser.sync(options);
await User.sync(options);
await Todo.sync(options);
await Grouping.sync(options);
};
start();
module.exports = {
db: db,
constructors: {
user: () => { return User.build(); }
user: () => {
return User.build();
}
},
schemas: {
user: User
user: User,
unverifiedUser: UnverifiedUser,
todo: Todo,
grouping: Grouping
}
}
};

View file

@ -1,19 +1,17 @@
const http = require("http");
const https = require("https");
const cors = require("cors");
const express = require("express");
const cookieParser = require("cookie-parser");
const Config = require("./config.js");
const http = require('http');
const https = require('https');
const cors = require('cors');
const express = require('express');
const cookieParser = require('cookie-parser');
const Config = require('./config.js');
const UserInterface = require("./user.js");
const UserInterface = require('./user.js');
const TodoInterface = require('./todo.js');
let credentials = {};
if (Config.config.https) {
if (
fs.existsSync(Config.config.cert) &&
fs.existsSync(Config.config.cert_key)
) {
if (fs.existsSync(Config.config.cert) && fs.existsSync(Config.config.cert_key)) {
credentials.key = fs.readFileSync(Config.config.cert_key);
credentials.cert = fs.readFileSync(Config.config.cert);
}
@ -27,7 +25,7 @@ app.use(cookieParser());
// force https
app.use((req, res, next) => {
if (Config.config.https) {
if (req.headers["x-forwarded-proto"] !== "https") {
if (req.headers['x-forwarded-proto'] !== 'https') {
return res.redirect(`https://${req.headers.host}${req.url}`);
}
}
@ -35,14 +33,15 @@ app.use((req, res, next) => {
});
if (!Config.config.secret) {
console.error("No password secret found. please set `secret` in config.json");
console.error('No password secret found. please set `secret` in config.json');
process.exit();
} else if (Config.config.https && Config.config.secret == "TEST_SECRET") {
console.error("please do not use the testing secret in production.");
} else if (Config.config.https && Config.config.secret == 'TEST_SECRET') {
console.error('please do not use the testing secret in production.');
process.exit();
}
app.use("/api/user", UserInterface.router);
app.use('/api/user', UserInterface.router);
app.use('/api/todo', TodoInterface.router);
// serve static files last
// app.use(express.static('./static'));
@ -57,6 +56,5 @@ if (Config.config.https) {
server.listen(Config.config.port || 8080);
}
console.log(
`listening on port ${Config.config.port || 8080}` +
` with https ${Config.config.https ? "enabled" : "disabled"}`
`listening on port ${Config.config.port || 8080}` + ` with https ${Config.config.https ? 'enabled' : 'disabled'}`
);

52
backend/src/mail.js Normal file
View file

@ -0,0 +1,52 @@
const Config = require('./config.js');
const nodemailer = require('nodemailer');
class Mailer {
sender;
started = false;
mailer;
constructor(host, port, email, password) {
this.mailer = nodemailer.createTransport({
host: host,
port: port,
secure: true,
auth: {
user: email,
pass: password
}
});
this.sender = email;
this.started = true;
}
async sendMail(recipients, subject, content, contentStripped) {
console.log(`sending mail to ${recipients}`);
let info = await this.mailer.sendMail({
from: `"Todo App" <${this.sender}>`,
to: Array.isArray(recipients) ? recipients.join(', ') : recipients,
subject: subject,
text: contentStripped,
html: content
});
}
}
if (!global.mailer || !global.mailer.started) {
if (
!Config.config['mail_host'] ||
!Config.config['mail_port'] ||
!Config.config['mail_username'] ||
!Config.config['mail_password']
) {
console.error(`could not create email account as
mail_host, mail_port, mail_username or mail_password is not set.`);
process.exit();
}
global.mailer = new Mailer(
Config.config['mail_host'],
Config.config['mail_port'],
Config.config['mail_username'],
Config.config['mail_password']
);
}
module.exports = global.mailer;

146
backend/src/todo.js Normal file
View file

@ -0,0 +1,146 @@
const express = require('express');
const paginate = require('express-paginate');
const Database = require('./db_interface.js');
const User = require('./user.js');
const { Op } = require('sequelize');
let router = express.Router();
router.use(express.json());
function map_todo(result) {
return {
id: result.id,
content: result.content,
tags: result.tags
};
}
function parse_tags(tags) {
result = {
complete: undefined,
required: [],
excluded: []
};
tags.map((tag) => {
if (tag === 'complete') {
complete = true;
} else if (tag === '~complete') {
complete = false;
} else if (tag.startsWith('~')) {
excluded.push(tag);
} else {
required.push(tag);
}
});
return result;
}
const todo_fields = ['currentPage', 'limit'];
router.use(paginate.middleware(10, 50));
router.use('/todos', User.enforce_session_login);
router.get('/todos', async (req, res) => {
if (!req.query) {
return res.status(400).json({
error: `query must include the fields: ${todo_fields.join(', ')}}`
});
} else {
let error = [];
for (let field of todo_fields) {
if (!req.query[field]) {
error.push(field);
}
}
if (error.length > 0) {
return res.status(400).json({
error: `query must include the fields: ${error.join(', ')}}`
});
}
}
let tag_options = {};
if (req.query.tags) {
let parsed = parse_tags(req.query.tags.split(','));
tag_options['tags'] = {
[Op.and]: parsed.required,
[Op.not]: parsed.excluded
};
if (parsed.complete !== undefined) {
tag_options['complete'] = {
[Op.is]: parsed.complete
};
}
}
console.log(tag_options);
let all_todos = await Database.schemas.todo.findAndCountAll({
where: {
user: req.get('id'),
...tag_options
},
limit: req.query.limit,
offset: req.skip
});
const item_count = all_todos.count;
const page_count = Math.ceil(item_count / req.query.limit);
res.json({
result: all_todos.map(map_todo),
currentPage: req.query.currentPage,
pageCount: page_count,
itemCount: item_count,
pages: paginate.getArrayPages(req)(5, page_count, req.query.currentPage)
});
});
router.use('/todo', User.enforce_session_login);
router.get('/todo/:id([a-f0-9-]+)', async (req, res) => {
let userid = req.get('id');
let id = req.params?.id;
let match = await Database.schemas.todo.findOne({
where: {
user: userid,
id: id
}
});
if (!match) {
return res.sendStatus(404);
}
return res.json({
result: map_todo(match),
tags: get_tags(match.id)
});
});
router.use('/todo', User.enforce_session_login);
router.post('/todo/:id([a-f0-9-]+)', async (req, res) => {
let userid = req.get('id');
let id = req.params?.id;
let body = req.body;
if (!body) {
return res.sendStatus(400);
}
let match = await Database.schemas.todo.findOne({
where: {
user: userid,
id: id
}
});
if (!match) {
return res.sendStatus(404);
}
//
return res.json({
result: map_todo(match),
tags: get_tags(match.id)
});
});
module.exports = {
router: router
};

View file

@ -2,6 +2,7 @@ const express = require('express');
const crypto = require('crypto');
const Config = require('./config.js');
const Database = require('./db_interface.js');
const Mail = require('./mail.js');
let router = express.Router();
@ -13,19 +14,19 @@ user_cache = {};
email_cache = {};
async function get_user_details(id) {
if (!id) {
if (!id || id === 'undefined') {
return undefined;
}
console.log(`search for user with id ${id}`);
if (!user_cache[id]) {
let user = await Database.schemas.user.findOne({where: {id: id}});
if (!user) {
let user = await Database.schemas.user.findOne({ where: { id: id } });
if (user === null) {
return undefined;
}
user_cache[user.id] = {
id: user.id,
email: user.email,
password_hash: user.password_hash,
password_hash: user.password_hash
};
email_cache[user.email] = user.id;
}
@ -34,19 +35,19 @@ async function get_user_details(id) {
}
async function get_user_details_by_email(email) {
if (!email) {
if (!email || email === 'undefined') {
return undefined;
}
console.log(`search for user with email ${email}}`);
if (!email_cache[email] || !user_cache[email_cache[email]]) {
let user = await Database.schemas.user.findOne({where: {email: email}});
if (!user) {
let user = await Database.schemas.user.findOne({ where: { email: email } });
if (user === null) {
return undefined;
}
user_cache[user.id] = {
id: user.id,
email: user.email,
password_hash: user.password_hash,
password_hash: user.password_hash
};
email_cache[user.email] = user.id;
}
@ -57,22 +58,22 @@ async function get_user_details_by_email(email) {
router.get('/byEmail/:email', async (req, res) => {
if (!req.params?.email) {
res.status(400).json({
error: 'email is a required parameter',
error: 'email is a required parameter'
});
}
let user = get_user_details_by_email(req.params.email);
console.log(user);
if (user != null) {
if (user !== undefined && user !== {}) {
res.json({
id: user.id,
email: user.email,
email: user.email
});
} else {
res.sendStatus(404);
}
});
function hash(secret, password) {
function hash(secret, password, base64 = true) {
let pw_hash = crypto.pbkdf2Sync(
password,
secret,
@ -81,7 +82,7 @@ function hash(secret, password) {
'sha512'
);
return pw_hash.toString('base64');
return pw_hash.toString(base64 ? 'base64' : 'hex');
}
function verify(secret, password, hash) {
@ -104,9 +105,9 @@ function verify_password(password, hash) {
return verify(Config.config.secret, password, hash);
}
function get_session_token(id, token) {
function get_session_token(id, password_hash, base64 = true) {
session_entropy[id] = crypto.randomBytes(Config.config.session_entropy || 32);
return hash(session_entropy[id], token);
return hash(session_entropy[id], password_hash, base64);
}
function verify_session_token(id, hash, token) {
@ -118,8 +119,8 @@ function verify_session_token(id, hash, token) {
}
async function enforce_session_login(req, res, next) {
let userid = req.cookies?.userid;
let session_token = req.cookies?._session;
let userid = req.get('id');
let session_token = req.get('authorization');
console.log('a', userid, session_token);
if (!userid || !session_token) {
return res.sendStatus(401);
@ -128,70 +129,146 @@ async function enforce_session_login(req, res, next) {
if (!user) {
return res.sendStatus(401);
}
let verified_session = verify_session_token(
userid,
user.password_hash,
session_token
);
let verified_session = verify_session_token(userid, user.password_hash, session_token);
if (!verified_session) {
return res.sendStatus(401);
}
return next();
}
router.post('/new', async (req, res) => {
router.post('/signup', async (req, res) => {
if (!req.body?.email || !req.body?.password) {
return res.status(400).json({
error: 'must have email and password fields',
error: 'must have email and password fields'
});
}
let user = await get_user_details_by_email(req.body.email);
console.log(user);
if (user != null) {
let user = await get_user_details_by_email(req.body?.email);
if (user !== undefined && user !== {}) {
console.warn(`user already found: ${JSON.stringify(user)}`);
return res.status(403).json({
error: `email ${req.body.email} is already in use.`,
error: `email ${req.body.email} is already in use.`
});
} else {
let user = await Database.schemas.user.create({
let match = await Database.schemas.unverifiedUser.findOne({ where: { email: req.body.email } });
if (!!match) {
await Database.schemas.unverifiedUser.destroy({ where: { email: match.email } });
}
let randomString = 'Signup';
for (let i = 0; i < 16; i++) {
randomString += Math.floor(Math.random() * 10);
}
let password_hash = hash_password(req.body.password);
let user = await Database.schemas.unverifiedUser.create({
email: String(req.body.email),
password_hash: hash_password(req.body.password),
password_hash: password_hash,
verificationToken: get_session_token(randomString, password_hash, false)
});
const link = `${Config.config.https ? 'https://' : 'http://'}${req.headers.host}/api/user/verify?verification=${
user.verificationToken
}`;
const content = `Click here to verify your sign-up:
${link}`;
const contentHtml = `<h1>Click here to verify your sign-up:</h1>
<p><a href=${link}>${link}</a></p>`;
await Mail.sendMail([String(req.body.email)], 'Verify Your Account', contentHtml, content);
return res.sendStatus(204);
}
});
router.get('/verify', async (req, res) => {
if (!req.query?.verification) {
return res.status(400).send(
`<html>
<body>
<h1>No Verification Link</h1>
</body>
</html>`
);
}
let verification = req.query?.verification;
let user = await Database.schemas.unverifiedUser.findOne({ where: { verificationToken: verification } });
if (user !== undefined && user !== {}) {
if (user.verificationToken != verification) {
return res.status(404).send(
`<html>
<body>
<h1>Unknown Verification Link</h1>
</body>
</html>`
);
}
let newUser = await Database.schemas.user.create({
email: user.email,
password_hash: user.password_hash
});
return res.json({
id: user.id,
email: user.email,
});
return res.send(`<html>
<body>
<h1>Sign up complete.</h1>
</body>
</html>`);
} else {
return res.status(404).send(`<html>
<body>
<h1>Unknown Verification Link</h1>
</body>
</html>`);
}
});
router.post('/login', async (req, res) => {
if (!req.body?.email || !req.body?.password) {
return res.status(400).json({
error: 'must have email and password fields',
error: 'must have email and password fields'
});
}
let user = await get_user_details_by_email(req.body.email);
if (!user) {
return res.status(401).json({
error: 'incorrect email or password',
error: 'incorrect email or password'
});
}
let verified = verify_password(req.body.password, user.password_hash);
if (!verified) {
return res.status(401).json({
error: 'incorrect email or password',
error: 'incorrect email or password'
});
}
res.cookie('userid', user.id, {
httpOnly: true,
secure: true,
});
res.cookie('_session', get_session_token(user.id, user.password_hash), {
httpOnly: true,
secure: true,
return res.json({
userid: user.id,
session_token: get_session_token(user.id, user.password_hash)
});
});
router.post('/logout', async (req, res) => {
if (!req.body?.session_token || !req.body?.userid) {
return res.status(400).json({
error: 'must include user id and session token to log out'
});
}
let userid = req.body?.userid;
let session_token = req.body?.session_token;
let user = await get_user_details(userid);
if (!user) {
return res.status(401).json({
error: 'invalid user data'
});
}
let verified = verify_session_token(user.id, user.password_hash, session_token);
if (!verified) {
return res.status(401).json({
error: 'invalid user data'
});
}
delete session_entropy[user.id];
return res.sendStatus(204);
});
@ -199,17 +276,17 @@ router.get('/:id([a-f0-9-]+)', async (req, res) => {
console.log(req.params);
if (!req.params?.id) {
return res.status(400).json({
error: 'must have id parameter',
error: 'must have id parameter'
});
}
let id = req.params?.id;
console.log(id);
let user = await get_user_details(id);
console.log(user);
if (user != null) {
if (user !== undefined && user !== {}) {
return res.json({
id: user.id,
email: user.email,
email: user.email
});
} else {
return res.sendStatus(404);
@ -218,18 +295,20 @@ router.get('/:id([a-f0-9-]+)', async (req, res) => {
router.use('/authorized', enforce_session_login);
router.get('/authorized', async (req, res) => {
let userid = req.cookies?.userid;
let userid = req.get('id');
let user = await get_user_details(userid);
return res.json({
authorized: true,
user: {
id: user.id,
email: user.email,
},
email: user.email
}
});
});
module.exports = {
router: router,
enforce_session_login: enforce_session_login,
get_user_details: get_user_details,
get_user_details_by_email: get_user_details_by_email
};

16
frontend/.prettierrc.yaml Normal file
View file

@ -0,0 +1,16 @@
arrowParens: 'always'
bracketSpacing: true
endOfLine: 'lf'
htmlWhitespaceSensitivity: 'css'
insertPragma: false
jsxBracketSameLine: true
jsxSingleQuote: true
printWidth: 120
proseWrap: 'preserve'
quoteProps: 'consistent'
requirePragma: false
semi: true
singleQuote: true
tabWidth: 2
trailingComma: 'none'
useTabs: false

View file

@ -7,12 +7,20 @@
"@emotion/styled": "^11.3.0",
"@material-ui/core": "^5.0.0-alpha.36",
"@material-ui/icons": "^4.11.2",
"@material-ui/styles": "^4.11.4",
"@reduxjs/toolkit": "^1.6.0",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"axios": "^0.21.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-redux": "^7.2.4",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.3",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.3.0",
"web-vitals": "^1.0.1"
},
"scripts": {

View file

@ -3,12 +3,20 @@ dependencies:
'@emotion/styled': 11.3.0_c94ce1a602d602ceca4c9cb32dbce62d
'@material-ui/core': 5.0.0-alpha.36_0cc86b8010d608d52ef25836204eb4f5
'@material-ui/icons': 4.11.2_90b48a22f51b8440edf141ccfea311d4
'@material-ui/styles': 4.11.4_react-dom@17.0.2+react@17.0.2
'@reduxjs/toolkit': 1.6.0_react-redux@7.2.4+react@17.0.2
'@testing-library/jest-dom': 5.14.1
'@testing-library/react': 11.2.7_react-dom@17.0.2+react@17.0.2
'@testing-library/user-event': 12.8.3
axios: 0.21.1
react: 17.0.2
react-dom: 17.0.2_react@17.0.2
react-redux: 7.2.4_react-dom@17.0.2+react@17.0.2
react-router: 5.2.0_react@17.0.2
react-router-dom: 5.2.0_react@17.0.2
react-scripts: 4.0.3_react@17.0.2
redux-logger: 3.0.6
redux-thunk: 2.3.0
web-vitals: 1.1.2
lockfileVersion: 5.2
packages:
@ -2030,6 +2038,38 @@ packages:
optional: true
resolution:
integrity: sha512-1j+4tIxS6x3McJ+3O9mxwzjkci/uu09nnON7ZDgqX9O3f15D8CP8cmAy0PDm47M4utMwIqj+EaS4Y6d2PZWF5Q==
/@material-ui/styles/4.11.4_react-dom@17.0.2+react@17.0.2:
dependencies:
'@babel/runtime': 7.14.6
'@emotion/hash': 0.8.0
'@material-ui/types': 5.1.0
'@material-ui/utils': 4.11.2_react-dom@17.0.2+react@17.0.2
clsx: 1.1.1
csstype: 2.6.17
hoist-non-react-statics: 3.3.2
jss: 10.6.0
jss-plugin-camel-case: 10.6.0
jss-plugin-default-unit: 10.6.0
jss-plugin-global: 10.6.0
jss-plugin-nested: 10.6.0
jss-plugin-props-sort: 10.6.0
jss-plugin-rule-value-function: 10.6.0
jss-plugin-vendor-prefixer: 10.6.0
prop-types: 15.7.2
react: 17.0.2
react-dom: 17.0.2_react@17.0.2
dev: false
engines:
node: '>=8.0.0'
peerDependencies:
'@types/react': ^16.8.6 || ^17.0.0
react: ^16.8.0 || ^17.0.0
react-dom: ^16.8.0 || ^17.0.0
peerDependenciesMeta:
'@types/react':
optional: true
resolution:
integrity: sha512-KNTIZcnj/zprG5LW0Sao7zw+yG3O35pviHzejMdcSGCdWbiO8qzRgOYL8JAxAsWBKOKYwVZxXtHWaB5T2Kvxew==
/@material-ui/system/5.0.0-alpha.36_7874f5fd290ce587db5f5f81e8a37ea9:
dependencies:
'@babel/runtime': 7.14.6
@ -2059,6 +2099,15 @@ packages:
optional: true
resolution:
integrity: sha512-1QqINDfNv5y+5TY/p66qkWAZsQGO+HDPDFpTELXtSC9F7zzuosPdJGMM8YFD7vecY4KdDWhCvA4f2G/vI6x8nw==
/@material-ui/types/5.1.0:
dev: false
peerDependencies:
'@types/react': '*'
peerDependenciesMeta:
'@types/react':
optional: true
resolution:
integrity: sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A==
/@material-ui/types/6.0.1:
dev: false
peerDependencies:
@ -2089,6 +2138,21 @@ packages:
optional: true
resolution:
integrity: sha512-iTlwlftnH/3w4hU/xaJePMDVsL/JtYJhruJxZ2Tt/2eVzGTvYq88PLQ5+o2VBRROnxQGKRjpNuX3nss+/RudPg==
/@material-ui/utils/4.11.2_react-dom@17.0.2+react@17.0.2:
dependencies:
'@babel/runtime': 7.14.6
prop-types: 15.7.2
react: 17.0.2
react-dom: 17.0.2_react@17.0.2
react-is: 17.0.2
dev: false
engines:
node: '>=8.0.0'
peerDependencies:
react: ^16.8.0 || ^17.0.0
react-dom: ^16.8.0 || ^17.0.0
resolution:
integrity: sha512-Uul8w38u+PICe2Fg2pDKCaIG7kOyhowZ9vjiC1FsVwPABTW8vPPKfF6OvxRq3IiBaI1faOJmgdvMG7rMJARBhA==
/@material-ui/utils/5.0.0-alpha.35_react@17.0.2:
dependencies:
'@babel/runtime': 7.14.6
@ -2179,6 +2243,25 @@ packages:
dev: false
resolution:
integrity: sha512-VZMYa7+fXHdwIq1TDhSXoVmSPEGM/aa+6Aiq3nVVJ9bXr24zScr+NlKFKC3iPljA7ho/GAZr+d2jOf5GIRC30Q==
/@reduxjs/toolkit/1.6.0_react-redux@7.2.4+react@17.0.2:
dependencies:
immer: 9.0.3
react: 17.0.2
react-redux: 7.2.4_react-dom@17.0.2+react@17.0.2
redux: 4.1.0
redux-thunk: 2.3.0
reselect: 4.0.0
dev: false
peerDependencies:
react: ^16.14.0 || ^17.0.0
react-redux: ^7.2.1
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
resolution:
integrity: sha512-eGL50G+Vj5AG5uD0lineb6rRtbs96M8+hxbcwkHpZ8LQcmt0Bm33WyBSnj5AweLkjQ7ZP+KFRDHiLMznljRQ3A==
/@rollup/plugin-node-resolve/7.1.3_rollup@1.32.1:
dependencies:
'@rollup/pluginutils': 3.1.0_rollup@1.32.1
@ -2478,6 +2561,13 @@ packages:
dev: false
resolution:
integrity: sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw==
/@types/hoist-non-react-statics/3.3.1:
dependencies:
'@types/react': 17.0.11
hoist-non-react-statics: 3.3.2
dev: false
resolution:
integrity: sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==
/@types/html-minifier-terser/5.1.1:
dev: false
resolution:
@ -2547,6 +2637,15 @@ packages:
dev: false
resolution:
integrity: sha512-X6jVqDIibL2sY0Qtth5EzNeUgPyoCWeBZdmE5xKr7hI4zaQDwN0VaQd7pJnlOB0mDGnOVH0cZZVXg9cnWhztQg==
/@types/react-redux/7.1.16:
dependencies:
'@types/hoist-non-react-statics': 3.3.1
'@types/react': 17.0.11
hoist-non-react-statics: 3.3.2
redux: 4.1.0
dev: false
resolution:
integrity: sha512-f/FKzIrZwZk7YEO9E1yoxIuDNRiDducxkFlkw/GNMGEnK9n4K8wJzlJBghpSuOVDgEUHoDkDF7Gi9lHNQR4siw==
/@types/react-transition-group/4.4.1:
dependencies:
'@types/react': 17.0.11
@ -3311,6 +3410,12 @@ packages:
node: '>=4'
resolution:
integrity: sha512-OKRkKM4ojMEZRJ5UNJHmq9tht7cEnRnqKG6KyB/trYws00Xtkv12mHtlJ0SK7cmuNbrU8dPUova3ELTuilfBbw==
/axios/0.21.1:
dependencies:
follow-redirects: 1.14.1_debug@4.3.1
dev: false
resolution:
integrity: sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==
/axobject-query/2.2.0:
dev: false
resolution:
@ -4547,6 +4652,13 @@ packages:
node: '>=8.0.0'
resolution:
integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==
/css-vendor/2.0.8:
dependencies:
'@babel/runtime': 7.14.6
is-in-browser: 1.1.3
dev: false
resolution:
integrity: sha512-x9Aq0XTInxrkuFeHKbYC7zWY8ai7qJ04Kxd9MnvbC1uO5DagxoHQjm4JvG+vCdXOoFtCjbL2XSZfxmoYa9uQVQ==
/css-what/3.4.2:
dev: false
engines:
@ -4696,6 +4808,10 @@ packages:
node: '>=8'
resolution:
integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==
/csstype/2.6.17:
dev: false
resolution:
integrity: sha512-u1wmTI1jJGzCJzWndZo8mk4wnPTZd1eOIYTYvuEyOQGfmDl3TrabCCfKnOC86FZwW/9djqTl933UF/cS425i9A==
/csstype/3.0.8:
dev: false
resolution:
@ -4784,6 +4900,10 @@ packages:
dev: false
resolution:
integrity: sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=
/deep-diff/0.3.8:
dev: false
resolution:
integrity: sha1-wB3mPvsO7JeYgB1Ax+Da4ltYLIQ=
/deep-equal/1.1.1:
dependencies:
is-arguments: 1.1.0
@ -6404,6 +6524,17 @@ packages:
dev: false
resolution:
integrity: sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==
/history/4.10.1:
dependencies:
'@babel/runtime': 7.14.6
loose-envify: 1.4.0
resolve-pathname: 3.0.0
tiny-invariant: 1.1.0
tiny-warning: 1.0.3
value-equal: 1.0.1
dev: false
resolution:
integrity: sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==
/hmac-drbg/1.0.1:
dependencies:
hash.js: 1.1.7
@ -6601,6 +6732,10 @@ packages:
node: '>=8.12.0'
resolution:
integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==
/hyphenate-style-name/1.0.4:
dev: false
resolution:
integrity: sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==
/iconv-lite/0.4.24:
dependencies:
safer-buffer: 2.1.2
@ -6649,6 +6784,10 @@ packages:
dev: false
resolution:
integrity: sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA==
/immer/9.0.3:
dev: false
resolution:
integrity: sha512-mONgeNSMuyjIe0lkQPa9YhdmTv8P19IeHV0biYhcXhbd5dhdB9HSK93zBpyKjp6wersSUgT5QyU0skmejUVP2A==
/import-cwd/2.1.0:
dependencies:
import-from: 2.1.0
@ -6709,6 +6848,12 @@ packages:
node: '>=0.8.19'
resolution:
integrity: sha1-khi5srkoojixPcT7a21XbyMUU+o=
/indefinite-observable/2.0.1:
dependencies:
symbol-observable: 1.2.0
dev: false
resolution:
integrity: sha512-G8vgmork+6H9S8lUAg1gtXEj2JxIQTo0g2PbFiYOdjkziSI0F7UYBiVwhZRuixhBCNGczAls34+5HJPyZysvxQ==
/indent-string/4.0.0:
dev: false
engines:
@ -6997,6 +7142,10 @@ packages:
node: '>=0.10.0'
resolution:
integrity: sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==
/is-in-browser/1.1.3:
dev: false
resolution:
integrity: sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU=
/is-module/1.0.0:
dev: false
resolution:
@ -7154,6 +7303,10 @@ packages:
node: '>=8'
resolution:
integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==
/isarray/0.0.1:
dev: false
resolution:
integrity: sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=
/isarray/1.0.0:
dev: false
resolution:
@ -7837,6 +7990,69 @@ packages:
graceful-fs: 4.2.6
resolution:
integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==
/jss-plugin-camel-case/10.6.0:
dependencies:
'@babel/runtime': 7.14.6
hyphenate-style-name: 1.0.4
jss: 10.6.0
dev: false
resolution:
integrity: sha512-JdLpA3aI/npwj3nDMKk308pvnhoSzkW3PXlbgHAzfx0yHWnPPVUjPhXFtLJzgKZge8lsfkUxvYSQ3X2OYIFU6A==
/jss-plugin-default-unit/10.6.0:
dependencies:
'@babel/runtime': 7.14.6
jss: 10.6.0
dev: false
resolution:
integrity: sha512-7y4cAScMHAxvslBK2JRK37ES9UT0YfTIXWgzUWD5euvR+JR3q+o8sQKzBw7GmkQRfZijrRJKNTiSt1PBsLI9/w==
/jss-plugin-global/10.6.0:
dependencies:
'@babel/runtime': 7.14.6
jss: 10.6.0
dev: false
resolution:
integrity: sha512-I3w7ji/UXPi3VuWrTCbHG9rVCgB4yoBQLehGDTmsnDfXQb3r1l3WIdcO8JFp9m0YMmyy2CU7UOV6oPI7/Tmu+w==
/jss-plugin-nested/10.6.0:
dependencies:
'@babel/runtime': 7.14.6
jss: 10.6.0
tiny-warning: 1.0.3
dev: false
resolution:
integrity: sha512-fOFQWgd98H89E6aJSNkEh2fAXquC9aZcAVjSw4q4RoQ9gU++emg18encR4AT4OOIFl4lQwt5nEyBBRn9V1Rk8g==
/jss-plugin-props-sort/10.6.0:
dependencies:
'@babel/runtime': 7.14.6
jss: 10.6.0
dev: false
resolution:
integrity: sha512-oMCe7hgho2FllNc60d9VAfdtMrZPo9n1Iu6RNa+3p9n0Bkvnv/XX5San8fTPujrTBScPqv9mOE0nWVvIaohNuw==
/jss-plugin-rule-value-function/10.6.0:
dependencies:
'@babel/runtime': 7.14.6
jss: 10.6.0
tiny-warning: 1.0.3
dev: false
resolution:
integrity: sha512-TKFqhRTDHN1QrPTMYRlIQUOC2FFQb271+AbnetURKlGvRl/eWLswcgHQajwuxI464uZk91sPiTtdGi7r7XaWfA==
/jss-plugin-vendor-prefixer/10.6.0:
dependencies:
'@babel/runtime': 7.14.6
css-vendor: 2.0.8
jss: 10.6.0
dev: false
resolution:
integrity: sha512-doJ7MouBXT1lypLLctCwb4nJ6lDYqrTfVS3LtXgox42Xz0gXusXIIDboeh6UwnSmox90QpVnub7au8ybrb0krQ==
/jss/10.6.0:
dependencies:
'@babel/runtime': 7.14.6
csstype: 3.0.8
indefinite-observable: 2.0.1
is-in-browser: 1.1.3
tiny-warning: 1.0.3
dev: false
resolution:
integrity: sha512-n7SHdCozmxnzYGXBHe0NsO0eUf9TvsHVq2MXvi4JmTn3x5raynodDVE/9VQmBdWFyyj9HpHZ2B4xNZ7MMy7lkw==
/jsx-ast-utils/3.2.0:
dependencies:
array-includes: 3.1.3
@ -8273,6 +8489,18 @@ packages:
node: '>=4'
resolution:
integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==
/mini-create-react-context/0.4.1_prop-types@15.7.2+react@17.0.2:
dependencies:
'@babel/runtime': 7.14.6
prop-types: 15.7.2
react: 17.0.2
tiny-warning: 1.0.3
dev: false
peerDependencies:
prop-types: ^15.0.0
react: ^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
resolution:
integrity: sha512-YWCYEmd5CQeHGSAKrYvXgmzzkrvssZcuuQDDeqkT+PziKGMgE+0MCCtcKbROzocGBG1meBLl2FotlRwf4gAzbQ==
/mini-css-extract-plugin/0.11.3_webpack@4.44.2:
dependencies:
loader-utils: 1.4.0
@ -9054,6 +9282,12 @@ packages:
dev: false
resolution:
integrity: sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=
/path-to-regexp/1.8.0:
dependencies:
isarray: 0.0.1
dev: false
resolution:
integrity: sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==
/path-type/3.0.0:
dependencies:
pify: 3.0.0
@ -10216,12 +10450,67 @@ packages:
dev: false
resolution:
integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
/react-redux/7.2.4_react-dom@17.0.2+react@17.0.2:
dependencies:
'@babel/runtime': 7.14.6
'@types/react-redux': 7.1.16
hoist-non-react-statics: 3.3.2
loose-envify: 1.4.0
prop-types: 15.7.2
react: 17.0.2
react-dom: 17.0.2_react@17.0.2
react-is: 16.13.1
dev: false
peerDependencies:
react: ^16.8.3 || ^17
react-dom: '*'
react-native: '*'
peerDependenciesMeta:
react-dom:
optional: true
react-native:
optional: true
resolution:
integrity: sha512-hOQ5eOSkEJEXdpIKbnRyl04LhaWabkDPV+Ix97wqQX3T3d2NQ8DUblNXXtNMavc7DpswyQM6xfaN4HQDKNY2JA==
/react-refresh/0.8.3:
dev: false
engines:
node: '>=0.10.0'
resolution:
integrity: sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg==
/react-router-dom/5.2.0_react@17.0.2:
dependencies:
'@babel/runtime': 7.14.6
history: 4.10.1
loose-envify: 1.4.0
prop-types: 15.7.2
react: 17.0.2
react-router: 5.2.0_react@17.0.2
tiny-invariant: 1.1.0
tiny-warning: 1.0.3
dev: false
peerDependencies:
react: '>=15'
resolution:
integrity: sha512-gxAmfylo2QUjcwxI63RhQ5G85Qqt4voZpUXSEqCwykV0baaOTQDR1f0PmY8AELqIyVc0NEZUj0Gov5lNGcXgsA==
/react-router/5.2.0_react@17.0.2:
dependencies:
'@babel/runtime': 7.14.6
history: 4.10.1
hoist-non-react-statics: 3.3.2
loose-envify: 1.4.0
mini-create-react-context: 0.4.1_prop-types@15.7.2+react@17.0.2
path-to-regexp: 1.8.0
prop-types: 15.7.2
react: 17.0.2
react-is: 16.13.1
tiny-invariant: 1.1.0
tiny-warning: 1.0.3
dev: false
peerDependencies:
react: '>=15'
resolution:
integrity: sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw==
/react-scripts/4.0.3_react@17.0.2:
dependencies:
'@babel/core': 7.12.3
@ -10418,6 +10707,22 @@ packages:
node: '>=8'
resolution:
integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==
/redux-logger/3.0.6:
dependencies:
deep-diff: 0.3.8
dev: false
resolution:
integrity: sha1-91VZZvMJjzyIYExEnPC69XeCdL8=
/redux-thunk/2.3.0:
dev: false
resolution:
integrity: sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw==
/redux/4.1.0:
dependencies:
'@babel/runtime': 7.14.6
dev: false
resolution:
integrity: sha512-uI2dQN43zqLWCt6B/BMGRMY6db7TTY4qeHHfGeKb3EOhmOKjU3KdWvNLJyqaHRksv/ErdNH7cFZWg9jXtewy4g==
/regenerate-unicode-properties/8.2.0:
dependencies:
regenerate: 1.4.2
@ -10548,6 +10853,10 @@ packages:
dev: false
resolution:
integrity: sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=
/reselect/4.0.0:
dev: false
resolution:
integrity: sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA==
/resolve-cwd/2.0.0:
dependencies:
resolve-from: 3.0.0
@ -10582,6 +10891,10 @@ packages:
node: '>=8'
resolution:
integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==
/resolve-pathname/3.0.0:
dev: false
resolution:
integrity: sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==
/resolve-url-loader/3.1.3:
dependencies:
adjust-sourcemap-loader: 3.0.0
@ -11561,6 +11874,12 @@ packages:
hasBin: true
resolution:
integrity: sha512-yhy/sQYxR5BkC98CY7o31VGsg014AKLEPxdfhora76l36hD9Rdy5NZA/Ocn6yayNPgSamYdtX2rFJdcv07AYVw==
/symbol-observable/1.2.0:
dev: false
engines:
node: '>=0.10.0'
resolution:
integrity: sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==
/symbol-tree/3.2.4:
dev: false
resolution:
@ -11723,6 +12042,14 @@ packages:
dev: false
resolution:
integrity: sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=
/tiny-invariant/1.1.0:
dev: false
resolution:
integrity: sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw==
/tiny-warning/1.0.3:
dev: false
resolution:
integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
/tmpl/1.0.4:
dev: false
resolution:
@ -12158,6 +12485,10 @@ packages:
dev: false
resolution:
integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==
/value-equal/1.0.1:
dev: false
resolution:
integrity: sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==
/vary/1.1.2:
dev: false
engines:
@ -12740,10 +13071,18 @@ specifiers:
'@emotion/styled': ^11.3.0
'@material-ui/core': ^5.0.0-alpha.36
'@material-ui/icons': ^4.11.2
'@material-ui/styles': ^4.11.4
'@reduxjs/toolkit': ^1.6.0
'@testing-library/jest-dom': ^5.11.4
'@testing-library/react': ^11.1.0
'@testing-library/user-event': ^12.1.10
axios: ^0.21.1
react: ^17.0.2
react-dom: ^17.0.2
react-redux: ^7.2.4
react-router: ^5.2.0
react-router-dom: ^5.2.0
react-scripts: 4.0.3
redux-logger: ^3.0.6
redux-thunk: ^2.3.0
web-vitals: ^1.0.1

View file

@ -0,0 +1,11 @@
{
"hosts": {
"localhost:3000": "LOCAL",
"todo.j4.pm": "prod"
},
"defaultConfig": {},
"configs": {
"LOCAL": {},
"prod": {}
}
}

View file

@ -1,38 +0,0 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View file

@ -1,25 +1,71 @@
import logo from './logo.svg';
import './App.css';
import { BrowserRouter as Router, Switch, Route, Redirect } from 'react-router-dom';
import RootPage from './modules/Root';
import AboutPage from './modules/About';
import AccountPage from './modules/Account';
import LoginPage from './modules/Login';
import SignupPage from './modules/Signup';
import TodoPage from './modules/TodoList';
import { connect } from 'react-redux';
import ThemeProvider from './ThemeProvider';
import Navbar from './Navbar';
function App() {
const App = (props) => {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.js</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
<ThemeProvider>
<Navbar />
<Router>
<Switch>
<Route path='/about'>
<AboutPage />
</Route>
<Route path='/login'>
<LoginPage />
</Route>
<Route path='/signup'>
<SignupPage />
</Route>
<PRoute path='/todos'>
<TodoPage />
</PRoute>
<PRoute path='/account'>
<AccountPage />
</PRoute>
<PRoute path='/'>
<RootPage />
</PRoute>
</Switch>
</Router>
</ThemeProvider>
);
}
};
const PRoute = connect(
(state) => {
return {
token: state.login.token
};
},
(dispatch, props) => {
return {};
}
)(({ children, ...props }) => {
return (
<Route
{...props}
render={({ location }) => {
return props.token !== undefined ? (
children
) : (
<Redirect
to={{
pathname: '/login',
state: { from: location }
}}
/>
);
}}
/>
);
});
export default App;

View file

@ -1,8 +0,0 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

122
frontend/src/Navbar.js Normal file
View file

@ -0,0 +1,122 @@
import Grid from '@material-ui/core/Grid';
import GroupIcon from '@material-ui/icons/Group';
import SettingsIcon from '@material-ui/icons/Settings';
import AssignmentIndIcon from '@material-ui/icons/AssignmentInd';
import Box from '@material-ui/core/Box';
import Typography from '@material-ui/core/Typography';
import Button from '@material-ui/core/Button';
import ExitToAppIcon from '@material-ui/icons/ExitToApp';
import { connect } from 'react-redux';
import { withStyles } from '@material-ui/styles';
import { logout } from './reducers/login';
const styles = (theme) => {
return {
container: {
width: '100%'
},
flexbox: {
display: 'flex',
flexGrow: 1,
width: 'auto'
},
buttonWrapper: {
alignItems: 'flex-end'
},
button: {
alignItems: 'flex-end'
},
typography: {
margin: '5px 0px'
}
};
};
const Navbar = (props) => {
const { classes } = props;
return (
<div>
<Grid container direction='row' alignItems='flex-end' justify='flex-end' className={classes.container}>
<Grid item className={classes.flexbox}>
<Box className={classes.flexbox} />
</Grid>
<Grid item alignItems='flex-end' justify='flex-end'>
<LoginLogoutButton className={classes.buttonWrapper} {...props} />
</Grid>
</Grid>
</div>
);
};
const LoginLogoutButton = connect(
(state) => {
return {
token: state.login.token
};
},
(dispatch, props) => {
return {};
}
)(({ children, ...props }) => {
const { classes } = props;
return props.token !== undefined ? (
<>
<Button
variant='contained'
color='primary'
onClick={() => {
window.location.pathname = '/account';
}}
className={classes.button}>
<SettingsIcon />
<Typography>Settings</Typography>
</Button>
<Button
variant='contained'
color='primary'
onClick={() => {
props.logout();
}}
className={classes.button}>
<ExitToAppIcon />
<Typography className={classes.typography}>Log Out</Typography>
</Button>
</>
) : (
<>
<Button
variant='contained'
color='primary'
onClick={() => {
window.location.pathname = '/login';
}}
className={classes.button}>
<GroupIcon />
<Typography className={classes.typography}>Log In</Typography>
</Button>
<Button
variant='contained'
color='primary'
onClick={() => {
window.location.pathname = '/signup';
}}
className={classes.button}>
<AssignmentIndIcon />
<Typography className={classes.typography}>Sign Up</Typography>
</Button>
</>
);
});
export default connect(
(state, props) => {
return {};
},
(dispatch, props) => {
return {
logout: () => {
dispatch(logout());
}
};
}
)(withStyles(styles)(Navbar));

View file

@ -0,0 +1,37 @@
import CssBaseline from '@material-ui/core/CssBaseline';
import { withStyles, withTheme } from '@material-ui/styles';
import { createTheme, ThemeProvider } from '@material-ui/core/styles';
import theme from './theme';
const styles = () => {
return {
root: {
height: '100vh',
zIndex: 1,
overflow: 'hidden',
position: 'relative',
display: 'flex'
},
content: {
zIndex: 3,
flexGrow: 1
},
spacing: (n) => {
return `${n * 2}px`;
}
};
};
const ThemeWrapper = (props) => {
const { children, classes } = props;
return (
<ThemeProvider theme={createTheme(theme)}>
<CssBaseline />
<div id='main' className={classes.content}>
{children}
</div>
</ThemeProvider>
);
};
export default withTheme(withStyles(styles)(ThemeWrapper));

View file

@ -1,13 +0,0 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View file

@ -1,17 +1,79 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import { Provider } from 'react-redux';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { configureStore } from '@reduxjs/toolkit';
import RootReducer from './reducers';
import axios from 'axios';
import logger from 'redux-logger';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
const defaultConfig = {
apiUrl: 'http://localhost:8080/api'
};
const renderApp = ({ config, user }) => {
const isDev = process.env.NODE_ENV !== 'production';
const store = configureStore({
devTools: isDev,
preloadedState: {
config: config,
login: user
},
reducer: RootReducer,
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger)
});
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);
};
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
const findConfig = (fullConfig) => {
return Object.assign(fullConfig.defaultConfig, fullConfig.configs[fullConfig.hosts[window.location.host]]);
};
axios
.get('/config.json')
.then(
(success) => {
return Object.assign(defaultConfig, findConfig(success.data));
},
() => {
return defaultConfig;
}
)
.then((config) => {
const details = JSON.parse(localStorage.getItem('userDetails') || '{}');
return axios
.get(`${config.apiUrl}/user/authorized`, {
headers: {
id: details.id,
Authorization: details.token
}
})
.then(
(success) => {
return {
config,
user: details || {}
};
},
() => {
return {
config,
user: {}
};
}
);
})
.then(({ config, user }) => {
renderApp({ config, user });
});

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -0,0 +1,5 @@
const AboutPage = (props) => {
return <div></div>;
};
export default AboutPage;

View file

@ -0,0 +1,5 @@
const AccountPage = (props) => {
return <div></div>;
};
export default AccountPage;

View file

@ -0,0 +1,163 @@
import { login, forgotPassword } from '../../reducers/login';
import { useState } from 'react';
import Button from '@material-ui/core/Button';
import Grid from '@material-ui/core/Grid';
import InputAdornment from '@material-ui/core/InputAdornment';
import IconButton from '@material-ui/core/IconButton';
import TextField from '@material-ui/core/TextField';
import Typography from '@material-ui/core/Typography';
import Visibility from '@material-ui/icons/Visibility';
import VisibilityOff from '@material-ui/icons/VisibilityOff';
import { connect } from 'react-redux';
import { withStyles } from '@material-ui/styles';
const styles = (theme) => {};
const LoginPage = (props) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [visible, setVisible] = useState(false);
const [error, setError] = useState(false);
const [forgotPassword, setForgotPassword] = useState(false);
const handleForgotPassword = () => {
if (!!email) {
props.forgotPassword(email);
setForgotPassword(false);
setError(false);
setEmail('');
} else {
setError(true);
}
};
return (
<Grid container alignItems='center' justify='center'>
<Grid item>
<form>
<div>
<Typography align='center' variant='h6' gutterBottom>
{forgotPassword ? 'Reset Password' : 'Sign in'}
</Typography>
<TextField
autoComplete='current-email'
error={error}
label='Email'
name='email'
type='email'
value={email}
variant='outlined'
onChange={(event) => {
return setEmail(event.target.value ?? '');
}}
onKeyPress={(event) => {
if (event.key === 'Enter') {
forgotPassword ? props.forgotPassword(email) : props.login(email, password);
}
}}
/>
{forgotPassword === false ? (
<>
<div>
<TextField
autoComplete='current-password'
label='Password'
name='password'
type={visible ? 'text' : 'password'}
value={password}
variant='outlined'
onChange={(event) => {
setPassword(event.target.value ?? '');
}}
onKeyPress={(event) => {
if (event.key === 'Enter') {
props.login(email, password);
}
}}
InputProps={{
endAdornment: (
<InputAdornment position='end'>
<IconButton
aria-label='toggle password visibility'
onClick={() => {
return setVisible(!visible);
}}
edge='end'>
{visible ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}}
/>
<div>
<Button
id='forgot-password-toggle-button'
color='secondary'
onClick={() => {
return setForgotPassword(true);
}}
size='small'>
Forgot Password?
</Button>
</div>
</div>
<Button
id='login-button'
variant='contained'
color='primary'
onClick={() => {
props.login(email, password);
}}>
Login
</Button>
</>
) : (
<Grid container>
<Grid item>
<Button
id='forgot-password-cancel-button'
variant='contained'
fullWidth
color='primary'
onClick={() => {
setForgotPassword(false);
setError(false);
}}>
Cancel
</Button>
</Grid>
<Grid item>
<Button
id='forgot-password-submit-button'
variant='contained'
color='secondary'
fullWidth
onClick={() => {
handleForgotPassword();
}}>
Continue
</Button>
</Grid>
</Grid>
)}
</div>
</form>
</Grid>
</Grid>
);
};
export default connect(
(state) => {
return {};
},
(dispatch, props) => {
return {
login: (email, password) => {
dispatch(login(email, password));
},
forgotPassword: (email) => {
dispatch(forgotPassword(email));
}
};
}
)(withStyles(styles)(LoginPage));

View file

@ -0,0 +1,5 @@
const RootPage = (props) => {
return <div></div>;
};
export default RootPage;

View file

@ -0,0 +1,162 @@
import { signup } from '../../reducers/login';
import { useState } from 'react';
import Button from '@material-ui/core/Button';
import Grid from '@material-ui/core/Grid';
import InputAdornment from '@material-ui/core/InputAdornment';
import IconButton from '@material-ui/core/IconButton';
import TextField from '@material-ui/core/TextField';
import Typography from '@material-ui/core/Typography';
import Visibility from '@material-ui/icons/Visibility';
import VisibilityOff from '@material-ui/icons/VisibilityOff';
import { connect } from 'react-redux';
import { withStyles } from '@material-ui/styles';
const styles = (theme) => {};
const SignupPage = (props) => {
const { classes } = props;
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [visible, setVisible] = useState(false);
const [error, setError] = useState(false);
const checkSignup = () => {
if (password !== confirmPassword) {
setError(true);
} else {
setError(false);
}
};
return (
<Grid container alignItems='center' justify='center'>
<Grid item alignItems='center' justify='center'>
<form>
<div>
<Typography align='center' variant='h6' gutterBottom>
Sign Up
</Typography>
<TextField
autoComplete='new-email'
label='Email'
name='email'
type='email'
value={email}
variant='outlined'
fullWidth
onChange={(event) => {
return setEmail(event.target.value ?? '');
}}
onKeyPress={(event) => {
if (event.key === 'Enter') {
checkSignup();
if (!error) {
props.signup(email, password);
}
}
}}
/>
<div>
<TextField
autoComplete='new-password'
label='Password'
name='password'
type={visible ? 'text' : 'password'}
value={password}
variant='outlined'
fullWidth
onChange={(event) => {
setPassword(event.target.value ?? '');
}}
onKeyPress={(event) => {
if (event.key === 'Enter') {
checkSignup();
if (!error) {
props.signup(email, password);
}
}
}}
InputProps={{
endAdornment: (
<InputAdornment position='end'>
<IconButton
aria-label='toggle password visibility'
onClick={() => {
return setVisible(!visible);
}}
edge='end'>
{visible ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}}
/>
</div>
<div>
<TextField
autoComplete='confirm-password'
label='Confirm Password'
name='confirm-password'
type={visible ? 'text' : 'password'}
error={error}
value={confirmPassword}
variant='outlined'
fullWidth
onChange={(event) => {
setConfirmPassword(event.target.value ?? '');
}}
onKeyPress={(event) => {
checkSignup();
if (event.key === 'Enter') {
if (!error) {
props.signup(email, password);
}
}
}}
InputProps={{
endAdornment: (
<InputAdornment position='end'>
<IconButton
aria-label='toggle confirm password visibility'
onClick={() => {
return setVisible(!visible);
}}
edge='end'>
{visible ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}}
/>
</div>
<Button
id='login-button'
variant='contained'
color='primary'
onClick={() => {
checkSignup();
if (!error) {
props.signup(email, password);
}
}}>
Sign Up
</Button>
</div>
</form>
</Grid>
</Grid>
);
};
export default connect(
(state) => {
return {};
},
(dispatch, props) => {
return {
signup: (email, password) => {
dispatch(signup(email, password));
}
};
}
)(withStyles(styles)(SignupPage));

View file

@ -0,0 +1,5 @@
const TodoPage = (props) => {
return <div></div>;
};
export default TodoPage;

View file

@ -0,0 +1,28 @@
import { createAction, createAsyncAction } from './utils';
import { createReducer } from '@reduxjs/toolkit';
const actions = {
refresh: 'LOCAL_STORAGE_REFRESH'
};
export const getConfigValue = createAsyncAction((dispatch, getState, config, key) => {
const payload = {
key: key,
value: JSON.parse(localStorage.getItem(key)) || undefined
};
return dispatch(refreshConfigValue(payload));
});
export const setConfigValue = createAsyncAction((dispatch, getState, config, key, value) => {
localStorage.setItem(key, JSON.stringify(value));
return dispatch(refreshConfigValue({ key: key, value: value }));
});
export const refreshConfigValue = createAction(actions.refresh, (payload) => {
return payload;
});
export default createReducer({}, (builder) => {
builder.addDefaultCase((state, action) => {
state[action.payload?.key] = action.payload?.value;
});
});

View file

@ -0,0 +1,10 @@
import { combineReducers } from '@reduxjs/toolkit';
import LocalStorageReducer from './localStorage';
import LoginReducer from './login';
import ConfigReducer from './config';
export default combineReducers({
localStorage: LocalStorageReducer,
login: LoginReducer,
config: ConfigReducer
});

View file

@ -0,0 +1,28 @@
import { createAction, createAsyncAction } from './utils';
import { createReducer } from '@reduxjs/toolkit';
const actions = {
refresh: 'LOCAL_STORAGE_REFRESH'
};
export const readLocalStorage = createAsyncAction((dispatch, getState, config, key) => {
const payload = {
key: key,
value: JSON.parse(localStorage.getItem(key)) || undefined
};
return dispatch(refreshLocalStorage(payload));
});
export const updateLocalStorage = createAsyncAction((dispatch, getState, config, key, value) => {
localStorage.setItem(key, JSON.stringify(value));
return dispatch(refreshLocalStorage({ key: key, value: value }));
});
export const refreshLocalStorage = createAction(actions.refresh, (payload) => {
return payload;
});
export default createReducer({}, (builder) => {
builder.addDefaultCase((state, action) => {
state[action.payload?.key] = action.payload?.value;
});
});

View file

@ -0,0 +1,120 @@
import axios from 'axios';
import { createAction, createAsyncAction } from './utils';
import { createReducer } from '@reduxjs/toolkit';
import { updateLocalStorage } from './localStorage';
const actions = {
update: 'UPDATE_LOGIN_DETAILS'
};
const updateLoginDetails = createAction(actions.update, (payload) => {
return payload;
});
export const login = createAsyncAction((dispatch, getState, config, email, password) => {
axios
.post(`${config.apiUrl}/user/login`, {
email: email,
password: password
})
.then(
(success) => {
console.error('success', success);
dispatch(
updateLoginDetails({
id: success.data['userid'],
token: success.data['session_token'],
error: false
})
);
dispatch(
updateLocalStorage('userDetails', {
id: success.data['userid'],
token: success.data['session_token']
})
);
window.location.pathname = '/';
},
(reject) => {
console.error(reject);
dispatch(
updateLoginDetails({
id: undefined,
token: undefined,
error: true
})
);
dispatch(
updateLocalStorage('userDetails', {
id: undefined,
token: undefined
})
);
}
);
});
export const signup = createAsyncAction((dispatch, getState, config, email, password) => {
axios
.post(`${config.apiUrl}/user/signup`, {
email: email,
password: password
})
.then(
(success) => {
console.error('success', success);
window.location.pathname = '/login';
},
(reject) => {
console.error(reject);
}
);
});
export const forgotPassword = createAsyncAction((dispatch, getState, config, email) => {});
export const logout = createAsyncAction((dispatch, getState, config) => {
const details = getState().login;
axios
.post(`${config.apiUrl}/user/logout`, {
userid: details.id,
session_token: details.token
})
.then(
(success) => {
dispatch(
updateLoginDetails({
id: undefined,
token: undefined,
error: false
})
);
dispatch(
updateLocalStorage('userDetails', {
id: undefined,
token: undefined
})
);
window.location.pathname = '/login';
},
(reject) => {
console.warn(reject);
console.warn('could not log out.');
}
);
});
export default createReducer(
{
id: undefined,
token: undefined,
error: false
},
(builder) => {
builder.addCase(actions.update, (state, action) => {
console.error(state, action);
state = { ...state, ...action?.payload };
});
}
);

View file

@ -0,0 +1,16 @@
export const createAction = (type, payload) => {
return (...args) => {
return {
type: type,
payload: payload(...args)
};
};
};
export const createAsyncAction = (payload) => {
return (...args) => {
return function (dispatch, getState, config) {
payload(dispatch, getState, getState()?.config, ...args);
};
};
};

13
frontend/src/theme.js Normal file
View file

@ -0,0 +1,13 @@
import grey from '@material-ui/core/colors/grey';
const theme = {
palette: {
primary: {
main: grey[200]
},
secondary: {
main: grey[200]
}
}
};
export default theme;

3
readme.md Normal file
View file

@ -0,0 +1,3 @@
# Todo app
literally i have adhd so fucking bad this is my attempt to make something that i can actually use to keep track of my shit