Oauth code flow in API and frontend
This commit is contained in:
parent
254b262a72
commit
1e0544802f
0
api/src/config/oauth2orize.js
Normal file
0
api/src/config/oauth2orize.js
Normal file
|
@ -8,6 +8,31 @@ const { User, AccessToken, RefreshToken } = require('../models');
|
|||
|
||||
const secret = require('../config').secret;
|
||||
|
||||
// used to serialize the user for the session
|
||||
passport.serializeUser(function (user, done) {
|
||||
done(null, user._id);
|
||||
});
|
||||
|
||||
// used to deserialize the user
|
||||
passport.deserializeUser(function (id, done) {
|
||||
User.findById(id, function (err, user) {
|
||||
done(err, user);
|
||||
});
|
||||
});
|
||||
|
||||
async function loginWithPassword(email, password, done) {
|
||||
try {
|
||||
const user = await User.findOne({ email: email });
|
||||
if (!user || !user.validPassword(password)) {
|
||||
return done(null, false, { errors: { 'email or password': 'is invalid' } });
|
||||
}
|
||||
|
||||
return done(null, user);
|
||||
} catch (err) {
|
||||
done(err);
|
||||
}
|
||||
}
|
||||
|
||||
passport.use(
|
||||
'usernameAndPassword',
|
||||
new LocalStrategy(
|
||||
|
@ -16,18 +41,19 @@ passport.use(
|
|||
passwordField: 'user[password]',
|
||||
session: false,
|
||||
},
|
||||
async function (email, password, done) {
|
||||
try {
|
||||
const user = await User.findOne({ email: email });
|
||||
if (!user || !user.validPassword(password)) {
|
||||
return done(null, false, { errors: { 'email or password': 'is invalid' } });
|
||||
}
|
||||
loginWithPassword,
|
||||
),
|
||||
);
|
||||
|
||||
return done(null, user);
|
||||
} catch (err) {
|
||||
done(err);
|
||||
}
|
||||
passport.use(
|
||||
'usernameAndPasswordSession',
|
||||
new LocalStrategy(
|
||||
{
|
||||
usernameField: 'email',
|
||||
passwordField: 'password',
|
||||
session: true,
|
||||
},
|
||||
loginWithPassword,
|
||||
),
|
||||
);
|
||||
|
||||
|
@ -57,7 +83,7 @@ passport.use(
|
|||
async function (token, done) {
|
||||
try {
|
||||
// we used to put the user ID into the token directly :(
|
||||
const {id} = token
|
||||
const { id } = token;
|
||||
const user = await User.findById(id);
|
||||
return done(null, user || false);
|
||||
} catch (err) {
|
||||
|
@ -74,7 +100,7 @@ passport.use(
|
|||
const accessToken = await AccessToken.findOne({ token }).populate('user');
|
||||
if (accessToken && accessToken.user) {
|
||||
// TODO: scope
|
||||
return done(null, user, { scope: accessToken.scope });
|
||||
return done(null, accessToken.user, { scope: accessToken.scope });
|
||||
} else {
|
||||
return done(null, false);
|
||||
}
|
||||
|
@ -134,12 +160,12 @@ passport.use(
|
|||
/**
|
||||
* This function creates a middleware that does a passport authentication.
|
||||
*/
|
||||
function createMiddleware(strategies, required) {
|
||||
function createMiddleware(strategies, required = true, session = false) {
|
||||
return (req, res, next) => {
|
||||
passport.authenticate(strategies, { session: false }, (err, user, info) => {
|
||||
passport.authenticate(strategies, { session }, (err, user, info) => {
|
||||
// If this authentication produced an error, throw it. In a chain of
|
||||
// multiple strategies, errors are ignored, unless every strategy errors.
|
||||
if (required && err) {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
|
@ -154,9 +180,9 @@ function createMiddleware(strategies, required) {
|
|||
return res.status(403).json({ errors: { 'E-Mail-Bestätigung': 'noch nicht erfolgt' } });
|
||||
}
|
||||
|
||||
req.user = user
|
||||
req.authInfo = info
|
||||
req.scope = (info && info.scope) || '*'
|
||||
req.user = user;
|
||||
req.authInfo = info;
|
||||
req.scope = (info && info.scope) || '*';
|
||||
|
||||
return next();
|
||||
})(req, res, next);
|
||||
|
@ -173,6 +199,8 @@ module.exports = {
|
|||
// on the /users/login route, and later on oauth routes
|
||||
usernameAndPassword: createMiddleware('usernameAndPassword', true),
|
||||
|
||||
usernameAndPasswordSession: createMiddleware('usernameAndPasswordSession', false, true),
|
||||
|
||||
// will be used to verify a refresh token on the route that will exchange the
|
||||
// refresh token for a new access token (not in use yet)
|
||||
refreshToken: createMiddleware('refreshToken', true),
|
||||
|
|
|
@ -6,16 +6,14 @@ const cors = require('cors');
|
|||
const errorhandler = require('errorhandler');
|
||||
const passport = require('passport');
|
||||
|
||||
require('./config/passport')
|
||||
require('./config/passport');
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
// Create global app object
|
||||
const app = express();
|
||||
|
||||
|
||||
app.use(cors());
|
||||
app.use(passport.initialize());
|
||||
|
||||
// Normal express config defaults
|
||||
app.use(require('morgan')('dev'));
|
||||
|
@ -26,6 +24,8 @@ app.use(require('method-override')());
|
|||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
app.use(session({ secret: 'obsobs', cookie: { maxAge: 60000 }, resave: false, saveUninitialized: false }));
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
|
||||
if (!isProduction) {
|
||||
app.use(errorhandler());
|
||||
|
|
|
@ -6,6 +6,7 @@ const schema = new mongoose.Schema(
|
|||
{
|
||||
token: { index: true, type: String, required: true, unique: true },
|
||||
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
|
||||
client: { type: mongoose.Schema.Types.ObjectId, ref: 'Client', required: true },
|
||||
expiresAt: { type: Date, required: true },
|
||||
scope: { type: String, required: true, defaultValue: '*' },
|
||||
},
|
||||
|
@ -30,12 +31,13 @@ class AccessToken extends mongoose.Model {
|
|||
return 'Bearer ' + this.token;
|
||||
}
|
||||
|
||||
static generate(user, scope = '*', expiresInSeconds = 24 * 60 * 60) {
|
||||
static generate(client, user, scope = '*', expiresInSeconds = 24 * 60 * 60) {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
return new AccessToken({
|
||||
token,
|
||||
user,
|
||||
client,
|
||||
expiresAt: new Date(new Date().getTime() + 1000 * expiresInSeconds),
|
||||
scope,
|
||||
});
|
||||
|
|
33
api/src/models/AuthorizationCode.js
Normal file
33
api/src/models/AuthorizationCode.js
Normal file
|
@ -0,0 +1,33 @@
|
|||
const mongoose = require('mongoose');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const schema = new mongoose.Schema(
|
||||
{
|
||||
code: { type: String, unique: true, required: true },
|
||||
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
|
||||
client: { type: mongoose.Schema.Types.ObjectId, ref: 'Client', required: true },
|
||||
scope: { type: String, required: true, defaultValue: '*' },
|
||||
redirectUri: {type: String, required: true},
|
||||
expiresAt: {type: Date, required: true},
|
||||
},
|
||||
{ timestamps: true },
|
||||
);
|
||||
|
||||
class AuthorizationCode extends mongoose.Model {
|
||||
static generate(client, user, redirectUri, scope = '*', expiresInSeconds = 60) {
|
||||
const code = crypto.randomBytes(8).toString('hex');
|
||||
|
||||
return new AuthorizationCode({
|
||||
code,
|
||||
user,
|
||||
client,
|
||||
redirectUri,
|
||||
expiresAt: new Date(new Date().getTime() + 1000 * expiresInSeconds),
|
||||
scope,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
mongoose.model(AuthorizationCode, schema);
|
||||
|
||||
module.exports = AuthorizationCode;
|
20
api/src/models/Client.js
Normal file
20
api/src/models/Client.js
Normal file
|
@ -0,0 +1,20 @@
|
|||
const mongoose = require('mongoose');
|
||||
|
||||
const schema = new mongoose.Schema(
|
||||
{
|
||||
clientId: { type: String, required: true }, // this is external, so we do not use the ObjectID
|
||||
validRedirectUris: [{ type: String }],
|
||||
|
||||
// this implementation deals with public clients only, so the following fields are not required:
|
||||
// scope: {type: String, required: true, default: '*'}, // max possible scope
|
||||
// confidential: {type: Boolean}, // whether this is a non-public, aka confidential client
|
||||
// clientSecret: { type: String },
|
||||
},
|
||||
{ timestamps: true },
|
||||
);
|
||||
|
||||
class Client extends mongoose.Model {}
|
||||
|
||||
mongoose.model(Client, schema);
|
||||
|
||||
module.exports = Client;
|
|
@ -8,6 +8,7 @@ const schema = new mongoose.Schema(
|
|||
{
|
||||
token: { index: true, type: String, required: true, unique: true },
|
||||
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
|
||||
client: { type: mongoose.Schema.Types.ObjectId, ref: 'Client', required: true },
|
||||
expiresAt: { type: Date, required: false },
|
||||
scope: { type: String, required: true, defaultValue: '*' },
|
||||
},
|
||||
|
@ -28,11 +29,12 @@ class RefreshToken extends mongoose.Model {
|
|||
return this.expiresAt == null || this.expiresAt < new Date()
|
||||
}
|
||||
|
||||
static generate(user, scope = '*', expiresInSeconds = 24 * 60 * 60) {
|
||||
static generate(client, user, scope = '*', expiresInSeconds = 24 * 60 * 60) {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
return new RefreshToken({
|
||||
token,
|
||||
client,
|
||||
user,
|
||||
expiresAt: new Date(new Date().getTime() + 1000 * expiresInSeconds),
|
||||
scope,
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
module.exports.AccessToken = require('./AccessToken')
|
||||
module.exports.AuthorizationCode = require('./AuthorizationCode')
|
||||
module.exports.Client = require('./Client')
|
||||
module.exports.Comment = require('./Comment')
|
||||
module.exports.RefreshToken = require('./RefreshToken')
|
||||
module.exports.Track = require('./Track')
|
||||
|
|
|
@ -42,6 +42,7 @@ router.put(
|
|||
}),
|
||||
);
|
||||
|
||||
// Remove this at some point
|
||||
router.post('/users/login',
|
||||
auth.usernameAndPassword,
|
||||
wrapRoute((req, res) => {
|
||||
|
|
|
@ -1,45 +1,287 @@
|
|||
const passport = require('passport')
|
||||
const {LocalStrategy} = require('passport-local')
|
||||
const secret = require('../config').secret;
|
||||
const User = require('../models/User');
|
||||
const router = require('express').Router();
|
||||
const passport = require('passport');
|
||||
const { URL } = require('url');
|
||||
const querystring = require('querystring');
|
||||
|
||||
function getTokenFromHeader(req) {
|
||||
const authorization = req.headers.authorization;
|
||||
const [tokenType, token] = (authorization && authorization.split(' ')) || [];
|
||||
const { AuthorizationCode, AccessToken, RefreshToken, Client } = require('../models');
|
||||
const wrapRoute = require('../_helpers/wrapRoute');
|
||||
|
||||
if (tokenType === 'Token' || tokenType === 'Bearer') {
|
||||
return token;
|
||||
}
|
||||
|
||||
return null;
|
||||
// Check whether the "bigScope" fully includes the "smallScope".
|
||||
function scopeIncludes(smallScope, bigScope) {
|
||||
const smallScopeParts = smallScope.split(/\s/);
|
||||
const bigScopeParts = bigScope.split(/\s/);
|
||||
return bigScopeParts.includes('*') || smallScopeParts.every((part) => bigScopeParts.includes(part));
|
||||
}
|
||||
|
||||
const jwtOptional = jwt({
|
||||
secret: secret,
|
||||
userProperty: 'authInfo',
|
||||
credentialsRequired: false,
|
||||
getToken: getTokenFromHeader,
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
function returnError(res, error, errorDescription = undefined, status = 400) {
|
||||
return res
|
||||
.status(status)
|
||||
.json({ error, ...(errorDescription != null ? { error_description: errorDescription } : {}) });
|
||||
}
|
||||
|
||||
async function getUserIdMiddleware(req, res, next) {
|
||||
try {
|
||||
const authorization = req.headers.authorization;
|
||||
const [tokenType, token] = (authorization && authorization.split(' ')) || [];
|
||||
function redirectWithParams(res, redirectUri, params) {
|
||||
const targetUrl = new URL(redirectUri);
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
targetUrl.searchParams.append(key, value);
|
||||
}
|
||||
return res.redirect(targetUrl.toString());
|
||||
}
|
||||
|
||||
if (tokenType === 'Token' || tokenType === 'Bearer') {
|
||||
const ALL_SCOPE_NAMES = `
|
||||
tracks.create
|
||||
tracks.update
|
||||
tracks.list
|
||||
tracks.show
|
||||
tracks.delete
|
||||
users.update
|
||||
users.show
|
||||
tracks.comments.create
|
||||
tracks.comments.update
|
||||
tracks.comments.list
|
||||
tracks.comments.show
|
||||
`.split(/\s/);
|
||||
|
||||
// only parse the token as jwt if it looks like one, otherwise we get an error
|
||||
return jwtOptional(req, res, next);
|
||||
function isValidScope(scope) {
|
||||
return scope === '*' || scopeIncludes(scope, ALL_SCOPE_NAMES.join(' '));
|
||||
}
|
||||
|
||||
} else if (tokenType === 'OBSUserId') {
|
||||
req.authInfo = { id: token.trim() };
|
||||
next();
|
||||
req.authInfo = null;
|
||||
next();
|
||||
router.post(
|
||||
'/login',
|
||||
passport.authenticate('usernameAndPasswordSession'),
|
||||
wrapRoute((req, res, next) => {
|
||||
if (!req.user) {
|
||||
return res.redirect('/login');
|
||||
}
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
if (req.session.next) {
|
||||
res.redirect(req.session.next);
|
||||
req.session.next = null;
|
||||
return;
|
||||
}
|
||||
return res.type('html').end('You are logged in.');
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/login',
|
||||
wrapRoute(async (req, res) => {
|
||||
if (req.user) {
|
||||
return res.type('html').end('Already logged in, nothing to do.');
|
||||
}
|
||||
|
||||
res
|
||||
.type('html')
|
||||
.end(
|
||||
'<form method="post"><input name="email" value="test@example.com" /><input type="password" name="password" value="hunter2" /><button type="submit">Login</button></form>',
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/authorize',
|
||||
passport.authenticate('session'),
|
||||
wrapRoute(async (req, res) => {
|
||||
if (!req.user) {
|
||||
console.log(req);
|
||||
req.session.next = req.url;
|
||||
return res.redirect('/login');
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: responseType,
|
||||
scope = '*', // fallback to "all" scope
|
||||
} = req.query;
|
||||
|
||||
// 1. Find our client and check if it exists
|
||||
if (!clientId) {
|
||||
return returnError(res, 'invalid_request', 'client_id parameter required');
|
||||
}
|
||||
|
||||
const client = await Client.findOne({ clientId });
|
||||
if (!client) {
|
||||
return returnError(res, 'invalid_client', 'unknown client');
|
||||
}
|
||||
|
||||
// 2. Check that we have a redirect_uri. In addition to [RFC6749] we
|
||||
// *always* require a redirect_uri.
|
||||
if (!redirectUri) {
|
||||
return returnError(res, 'invalid_request', 'redirect_uri parameter required');
|
||||
}
|
||||
|
||||
// We enforce that the redirectUri exactly matches one of the provided URIs
|
||||
if (!client.validRedirectUris.includes(redirectUri)) {
|
||||
return returnError(res, 'invalid_request', 'invalid redirect_uri');
|
||||
}
|
||||
|
||||
// 3. Find out which type of response to use. [RFC6749] requires one of
|
||||
// "code" or "token", but "token" is implicit grant and we do not support
|
||||
// that.
|
||||
|
||||
if (responseType !== 'code') {
|
||||
return redirectWithParams(res, redirectUri, {
|
||||
error: 'unsupported_grant_type',
|
||||
error_description: 'only authorization code flow with PKCE is supported by this server',
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Get the scope.
|
||||
if (!isValidScope(scope)) {
|
||||
return redirectWithParams(res, redirectUri, {
|
||||
error: 'invalid_scope',
|
||||
error_description: 'the requested scope is not known',
|
||||
});
|
||||
}
|
||||
|
||||
// Ok, let's save all this in the session, and show a dialog for the
|
||||
// decision to the user.
|
||||
|
||||
req.session.authorizationTransaction = {
|
||||
responseType,
|
||||
clientId,
|
||||
redirectUri,
|
||||
scope,
|
||||
expiresAt: new Date().getTime() + 1000 * 60 * 2, // 2 minute decision time
|
||||
};
|
||||
|
||||
res.type('html').end(`
|
||||
<p>
|
||||
You are about to confirm a login to client <code>${clientId}</code>
|
||||
with redirectUri <code>${redirectUri}</code> and scope <code>${scope}</code>.
|
||||
You have 2 minutes time for your decision.
|
||||
</p>
|
||||
|
||||
<form method="post" action="/authorize/approve">
|
||||
<input type="submit" value="Authorize" />
|
||||
</form>
|
||||
<form method="post" action="/authorize/decline">
|
||||
<input type="submit" value="Decline" />
|
||||
</form>
|
||||
`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(400).json({ error: 'invalid_request', error_description: 'unknown error' });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
['/authorize/approve', '/authorize/decline'],
|
||||
passport.authenticate('session'),
|
||||
wrapRoute(async (req, res) => {
|
||||
if (!req.session.authorizationTransaction) {
|
||||
return res.sendStatus(400);
|
||||
}
|
||||
|
||||
if (!req.user) {
|
||||
return res.sendStatus(400);
|
||||
}
|
||||
|
||||
const { clientId, redirectUri, scope, expiresAt } = req.session.authorizationTransaction;
|
||||
|
||||
if (expiresAt < new Date().getTime()) {
|
||||
return res.status(400).type('html').end(`Your authorization has expired. Please go back and retry the process.`);
|
||||
}
|
||||
|
||||
const client = await Client.findOne({ clientId });
|
||||
|
||||
// invalidate the transaction
|
||||
req.session.authorizationTransaction = null;
|
||||
|
||||
if (req.path === '/authorize/approve') {
|
||||
const code = AuthorizationCode.generate(client, req.user, redirectUri, scope);
|
||||
await code.save();
|
||||
|
||||
return redirectWithParams(res, redirectUri, { code: code.code, scope });
|
||||
} else {
|
||||
return redirectWithParams(res, redirectUri, { error: 'access_denied' });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* This function is called when the client presents an authorization code
|
||||
* (generated above) and wants it turned into an access (and possibly refresh)
|
||||
* token.
|
||||
*/
|
||||
router.get(
|
||||
'/token',
|
||||
wrapRoute(async (req, res) => {
|
||||
const {
|
||||
grant_type: grantType,
|
||||
code,
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
//
|
||||
} = req.query;
|
||||
|
||||
if (!grantType || grantType !== 'authorization_code') {
|
||||
return returnError(
|
||||
res,
|
||||
'unsupported_grant_type',
|
||||
'only authorization code flow with PKCE is supported by this server',
|
||||
);
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return returnError(res, 'invalid_request', 'code parameter required');
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return returnError(res, 'invalid_client', 'client_id parameter required');
|
||||
}
|
||||
|
||||
if (!redirectUri) {
|
||||
return returnError(res, 'invalid_request', 'redirect_uri parameter required');
|
||||
}
|
||||
|
||||
const client = await Client.findOne({ clientId });
|
||||
|
||||
if (!client) {
|
||||
return returnError(res, 'invalid_client', 'invalid client_id');
|
||||
}
|
||||
|
||||
const authorizationCode = await AuthorizationCode.findOne({ code });
|
||||
if ( !authorizationCode ) {
|
||||
console.log('no code found')
|
||||
return returnError(res, 'invalid_grant', 'invalid authorization code');
|
||||
}
|
||||
if (authorizationCode.redirectUri !== redirectUri) {
|
||||
console.log('redirect_uri mismatch')
|
||||
return returnError(res, 'invalid_grant', 'invalid authorization code');
|
||||
}
|
||||
if (authorizationCode.expiresAt <= new Date().getTime()) {
|
||||
console.log('expired')
|
||||
return returnError(res, 'invalid_grant', 'invalid authorization code');
|
||||
}
|
||||
if (!client._id.equals(authorizationCode.client)) {
|
||||
console.log('client mismatch', authorizationCode.client, client._id)
|
||||
return returnError(res, 'invalid_grant', 'invalid authorization code');
|
||||
}
|
||||
|
||||
// invalidate auth code now, before generating tokens
|
||||
await AuthorizationCode.deleteOne({ _id: authorizationCode._id });
|
||||
|
||||
const accessToken = AccessToken.generate(authorizationCode.client, authorizationCode.user, authorizationCode.scope);
|
||||
|
||||
const refreshToken = RefreshToken.generate(
|
||||
authorizationCode.client,
|
||||
authorizationCode.user,
|
||||
authorizationCode.scope,
|
||||
);
|
||||
|
||||
await Promise.all([accessToken.save(), refreshToken.save()]);
|
||||
|
||||
return res.json({
|
||||
access_token: accessToken.token,
|
||||
token_type: 'Bearer',
|
||||
expires_in: Math.round((accessToken.expiresAt - new Date().getTime()) / 1000),
|
||||
refresh_token: refreshToken.token,
|
||||
scope: accessToken.scope,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
@ -2,4 +2,7 @@ const router = require('express').Router();
|
|||
|
||||
router.use('/api', require('./api'));
|
||||
|
||||
// no prefix
|
||||
router.use(require('./auth'));
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
@ -4,27 +4,19 @@ import {Icon, Button} from 'semantic-ui-react'
|
|||
import {BrowserRouter as Router, Switch, Route, Link} from 'react-router-dom'
|
||||
|
||||
import styles from './App.module.scss'
|
||||
import api from './api'
|
||||
|
||||
import {
|
||||
LoginPage,
|
||||
LogoutPage,
|
||||
NotFoundPage,
|
||||
TracksPage,
|
||||
TrackPage,
|
||||
HomePage,
|
||||
UploadPage,
|
||||
RegistrationPage,
|
||||
} from './pages'
|
||||
LoginRedirectPage,
|
||||
} from 'pages'
|
||||
import {LoginButton} from 'components'
|
||||
|
||||
const App = connect((state) => ({login: state.login}))(function App({login}) {
|
||||
// update the API header on each render, the App is rerendered when the login changes
|
||||
if (login) {
|
||||
api.setAuthorizationHeader('Token ' + login.token)
|
||||
} else {
|
||||
api.setAuthorizationHeader(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<div className={styles.App}>
|
||||
|
@ -68,9 +60,7 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
|
|||
) : (
|
||||
<>
|
||||
<li>
|
||||
<Button as={Link} to="/login">
|
||||
Login
|
||||
</Button>
|
||||
<LoginButton as='a' compact />
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
|
@ -92,11 +82,8 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
|
|||
<Route path={`/tracks/:slug`} exact>
|
||||
<TrackPage />
|
||||
</Route>
|
||||
<Route path="/register" exact>
|
||||
<RegistrationPage />
|
||||
</Route>
|
||||
<Route path="/login" exact>
|
||||
<LoginPage />
|
||||
<Route path="/redirect" exact>
|
||||
<LoginRedirectPage />
|
||||
</Route>
|
||||
<Route path="/logout" exact>
|
||||
<LogoutPage />
|
||||
|
|
|
@ -1,19 +1,124 @@
|
|||
import {stringifyParams} from 'query'
|
||||
import globalStore from 'store'
|
||||
import {setAuth, invalidateAccessToken, resetAuth} from 'reducers/auth'
|
||||
import {setLogin} from 'reducers/login'
|
||||
import config from 'config.json'
|
||||
|
||||
class API {
|
||||
setAuthorizationHeader(authorization) {
|
||||
this.authorization = authorization
|
||||
constructor(store) {
|
||||
this.store = store
|
||||
this._getValidAccessTokenPromise = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an access token, if it is (still) valid. If not, and a refresh
|
||||
* token exists, use the refresh token to issue a new access token. If that
|
||||
* fails, or neither is available, return `null`. This should usually result
|
||||
* in a redirect to login.
|
||||
*/
|
||||
async getValidAccessToken() {
|
||||
// prevent multiple parallel refresh processes
|
||||
if (this._getValidAccessTokenPromise) {
|
||||
return await this._getValidAccessTokenPromise
|
||||
} else {
|
||||
this._getValidAccessTokenPromise = this._getValidAccessToken()
|
||||
const result = await this._getValidAccessTokenPromise
|
||||
this._getValidAccessTokenPromise = null
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
async _getValidAccessToken() {
|
||||
let {auth} = this.store.getState()
|
||||
|
||||
if (!auth) {
|
||||
return null
|
||||
}
|
||||
|
||||
const {tokenType, accessToken, refreshToken, expiresAt} = auth
|
||||
|
||||
// access token is valid
|
||||
if (accessToken && expiresAt > new Date().getTime()) {
|
||||
return `${tokenType} ${accessToken}`
|
||||
}
|
||||
|
||||
if (!refreshToken) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Try to use the refresh token
|
||||
const url = new URL(config.auth.tokenEndpoint)
|
||||
url.searchParams.append('refresh_token', refreshToken)
|
||||
url.searchParams.append('grant_type', 'refresh_token')
|
||||
url.searchParams.append('scope', config.auth.scope)
|
||||
const response = await window.fetch(url.toString())
|
||||
const json = await response.json()
|
||||
|
||||
if (response.status === 200 && json != null && json.error == null) {
|
||||
auth = this.getAuthFromTokenResponse(json)
|
||||
this.store.dispatch(setAuth(auth))
|
||||
return `${auth.tokenType} ${auth.accessToken}`
|
||||
} else {
|
||||
console.warn('Could not use refresh token, error response:', json)
|
||||
this.store.dispatch(resetAuth())
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async exchangeAuthorizationCode(code) {
|
||||
const url = new URL(config.auth.tokenEndpoint)
|
||||
url.searchParams.append('code', code)
|
||||
url.searchParams.append('grant_type', 'authorization_code')
|
||||
url.searchParams.append('client_id', config.auth.clientId)
|
||||
url.searchParams.append('redirect_uri', config.auth.redirectUri)
|
||||
const response = await window.fetch(url.toString())
|
||||
const json = await response.json()
|
||||
|
||||
if (json.error) {
|
||||
return json
|
||||
}
|
||||
|
||||
const auth = api.getAuthFromTokenResponse(json)
|
||||
this.store.dispatch(setAuth(auth))
|
||||
|
||||
const {user} = await this.get('/user')
|
||||
this.store.dispatch(setLogin(user))
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
getLoginUrl() {
|
||||
const loginUrl = new URL(config.auth.authorizationEndpoint)
|
||||
loginUrl.searchParams.append('client_id', config.auth.clientId)
|
||||
loginUrl.searchParams.append('scope', config.auth.scope)
|
||||
loginUrl.searchParams.append('redirect_uri', config.auth.redirectUri)
|
||||
loginUrl.searchParams.append('response_type', 'code')
|
||||
|
||||
// TODO: Implement PKCE
|
||||
|
||||
return loginUrl.toString()
|
||||
}
|
||||
|
||||
async fetch(url, options = {}) {
|
||||
const accessToken = await this.getValidAccessToken()
|
||||
|
||||
const response = await window.fetch('/api' + url, {
|
||||
...options,
|
||||
headers: {
|
||||
...(options.headers || {}),
|
||||
Authorization: this.authorization,
|
||||
Authorization: accessToken,
|
||||
},
|
||||
})
|
||||
|
||||
if (response.status === 401) {
|
||||
// Unset login, since 401 means that we're not logged in. On the next
|
||||
// request with `getValidAccessToken()`, this will be detected and the
|
||||
// refresh token is used (if still valid).
|
||||
this.store.dispatch(invalidateAccessToken())
|
||||
|
||||
throw new Error('401 Unauthorized')
|
||||
}
|
||||
|
||||
if (response.status === 200) {
|
||||
return await response.json()
|
||||
} else {
|
||||
|
@ -26,15 +131,15 @@ class API {
|
|||
let headers = {...(options.headers || {})}
|
||||
|
||||
if (!(typeof body === 'string' || body instanceof FormData)) {
|
||||
body = JSON.stringify(body)
|
||||
headers['Content-Type'] = 'application/json'
|
||||
body = JSON.stringify(body)
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
return await this.fetch(url, {
|
||||
...options,
|
||||
body,
|
||||
method: 'post',
|
||||
headers
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -46,8 +151,18 @@ class API {
|
|||
async delete(url, options = {}) {
|
||||
return await this.get(url, {...options, method: 'delete'})
|
||||
}
|
||||
|
||||
getAuthFromTokenResponse(tokenResponse) {
|
||||
return {
|
||||
tokenType: tokenResponse.token_type,
|
||||
accessToken: tokenResponse.access_token,
|
||||
refreshToken: tokenResponse.refresh_token,
|
||||
expiresAt: new Date().getTime() + tokenResponse.expires_in * 1000,
|
||||
scope: tokenResponse.scope,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const api = new API()
|
||||
const api = new API(globalStore)
|
||||
|
||||
export default api
|
||||
|
|
10
frontend/src/components/LoginButton.js
Normal file
10
frontend/src/components/LoginButton.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
import {Button} from 'semantic-ui-react'
|
||||
|
||||
import api from 'api'
|
||||
|
||||
export default function LoginButton(props) {
|
||||
// TODO: Implement PKCE, generate login URL when clicked (with challenge),
|
||||
// and then redirect there.
|
||||
const href = api.getLoginUrl()
|
||||
return <Button as='a' href={href} {...props}>Login</Button>
|
||||
}
|
|
@ -1,58 +0,0 @@
|
|||
import React from 'react'
|
||||
import {connect} from 'react-redux'
|
||||
import {Form, Button} from 'semantic-ui-react'
|
||||
|
||||
import {login as loginAction} from '../reducers/login'
|
||||
|
||||
async function fetchLogin(email, password) {
|
||||
const response = await window.fetch('/api/users/login', {
|
||||
body: JSON.stringify({user: {email, password}}),
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.user) {
|
||||
return result.user
|
||||
} else {
|
||||
throw new Error('invalid credentials')
|
||||
}
|
||||
}
|
||||
|
||||
const LoginForm = connect(
|
||||
(state) => ({loggedIn: Boolean(state.login)}),
|
||||
(dispatch) => ({
|
||||
dispatchLogin: (user) => dispatch(loginAction(user)),
|
||||
})
|
||||
)(function LoginForm({loggedIn, dispatchLogin, className}) {
|
||||
const [email, setEmail] = React.useState('')
|
||||
const [password, setPassword] = React.useState('')
|
||||
const onChangeEmail = React.useCallback((e) => setEmail(e.target.value), [])
|
||||
const onChangePassword = React.useCallback((e) => setPassword(e.target.value), [])
|
||||
|
||||
const onSubmit = React.useCallback(() => fetchLogin(email, password).then(dispatchLogin), [
|
||||
email,
|
||||
password,
|
||||
dispatchLogin,
|
||||
])
|
||||
|
||||
return loggedIn ? null : (
|
||||
<Form className={className} onSubmit={onSubmit}>
|
||||
<Form.Field>
|
||||
<label>e-Mail</label>
|
||||
<input value={email} onChange={onChangeEmail} name='email' />
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label>Password</label>
|
||||
<input type="password" value={password} onChange={onChangePassword} name='password' />
|
||||
</Form.Field>
|
||||
<Button type="submit">Submit</Button>
|
||||
</Form>
|
||||
)
|
||||
})
|
||||
|
||||
export default LoginForm
|
|
@ -1,37 +0,0 @@
|
|||
import React from 'react'
|
||||
import {Form, Button} from 'semantic-ui-react'
|
||||
|
||||
export default function RegistrationForm({onSubmit: onSubmitOuter}) {
|
||||
const [username, setUsername] = React.useState(null)
|
||||
const [email, setEmail] = React.useState(null)
|
||||
const [password, setPassword] = React.useState(null)
|
||||
const [password2, setPassword2] = React.useState(null)
|
||||
|
||||
const onChangeUsername = React.useCallback((e) => setUsername(e.target.value), [])
|
||||
const onChangeEmail = React.useCallback((e) => setEmail(e.target.value), [])
|
||||
const onChangePassword = React.useCallback((e) => setPassword(e.target.value), [])
|
||||
const onChangePassword2 = React.useCallback((e) => setPassword2(e.target.value), [])
|
||||
|
||||
const onSubmit = React.useCallback(() => {
|
||||
if (username && email && password && password2 === password) {
|
||||
onSubmitOuter({username, email, password})
|
||||
}
|
||||
}, [username, email, password, password2, onSubmitOuter])
|
||||
|
||||
return (
|
||||
<Form onSubmit={onSubmit}>
|
||||
<Form.Input label="Username" value={username} onChange={onChangeUsername} name="username" />
|
||||
<Form.Input label="e-Mail" value={email} onChange={onChangeEmail} name="email" />
|
||||
<Form.Input label="Password" type="password" value={password} onChange={onChangePassword} name="password" />
|
||||
<Form.Input
|
||||
label="Password (repeat)"
|
||||
type="password"
|
||||
value={password2}
|
||||
onChange={onChangePassword2}
|
||||
name="password2"
|
||||
error={password2 != null && password !== password2 ? 'Your passwords do not match.' : null}
|
||||
/>
|
||||
<Button type="submit">Submit</Button>
|
||||
</Form>
|
||||
)
|
||||
}
|
|
@ -1,7 +1,6 @@
|
|||
export {default as FileDrop} from './FileDrop'
|
||||
export {default as FormattedDate} from './FormattedDate'
|
||||
export {default as LoginForm} from './LoginForm'
|
||||
export {default as LoginButton} from './LoginButton'
|
||||
export {default as Map} from './Map'
|
||||
export {default as Page} from './Page'
|
||||
export {default as RegistrationForm} from './RegistrationForm'
|
||||
export {default as StripMarkdown} from './StripMarkdown'
|
||||
|
|
9
frontend/src/config.json
Normal file
9
frontend/src/config.json
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"auth": {
|
||||
"authorizationEndpoint": "http://localhost:3000/authorize",
|
||||
"tokenEndpoint": "http://localhost:3000/token",
|
||||
"clientId": "123",
|
||||
"scope": "*",
|
||||
"redirectUri": "http://localhost:3001/redirect"
|
||||
}
|
||||
}
|
|
@ -6,14 +6,8 @@ import './index.css'
|
|||
import App from './App'
|
||||
|
||||
import {Provider} from 'react-redux'
|
||||
import {compose, createStore} from 'redux'
|
||||
import persistState from 'redux-localstorage'
|
||||
|
||||
import rootReducer from './reducers'
|
||||
|
||||
const enhancer = compose(persistState(['login']))
|
||||
|
||||
const store = createStore(rootReducer, undefined, enhancer)
|
||||
import store from './store'
|
||||
|
||||
// TODO: remove
|
||||
Settings.defaultLocale = 'de-DE'
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import _ from 'lodash'
|
||||
import React from 'react'
|
||||
import {connect} from 'react-redux'
|
||||
import {Message, Grid, Loader, Statistic, Segment, Header, Item} from 'semantic-ui-react'
|
||||
import {Tab, Message, Grid, Loader, Statistic, Segment, Header, Item} from 'semantic-ui-react'
|
||||
import {useObservable} from 'rxjs-hooks'
|
||||
import {of, pipe, from} from 'rxjs'
|
||||
import {map, switchMap, distinctUntilChanged} from 'rxjs/operators'
|
||||
import {fromLonLat} from 'ol/proj'
|
||||
import {Duration} from 'luxon'
|
||||
|
||||
import api from '../api'
|
||||
import {Map, Page, LoginForm} from '../components'
|
||||
import {Map, Page} from '../components'
|
||||
|
||||
import {TrackListItem} from './TracksPage'
|
||||
|
||||
|
@ -20,8 +20,9 @@ function formatDuration(seconds) {
|
|||
|
||||
function WelcomeMap() {
|
||||
return (
|
||||
<Map style={{height: '24rem'}}>
|
||||
<Map style={{height: '60rem', maxHeight: '80vh'}}>
|
||||
<Map.TileLayer />
|
||||
<Map.View maxZoom={22} zoom={6} center={fromLonLat([10, 51])} />
|
||||
</Map>
|
||||
)
|
||||
}
|
||||
|
@ -41,7 +42,7 @@ function Stats() {
|
|||
<Segment>
|
||||
<Loader active={stats == null} />
|
||||
|
||||
<Statistic.Group widths={4} size="tiny">
|
||||
<Statistic.Group widths={2} size="mini" >
|
||||
<Statistic>
|
||||
<Statistic.Value>{Number(stats?.publicTrackLength / 1000).toFixed(1)}</Statistic.Value>
|
||||
<Statistic.Label>km track length</Statistic.Label>
|
||||
|
@ -64,19 +65,6 @@ function Stats() {
|
|||
)
|
||||
}
|
||||
|
||||
const LoginState = connect((state) => ({login: state.login}))(function LoginState({login}) {
|
||||
return login ? (
|
||||
<>
|
||||
<Header as="h2">Logged in as {login.username} </Header>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Header as="h2">Login</Header>
|
||||
<LoginForm />
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
function MostRecentTrack() {
|
||||
const track: Track | null = useObservable(
|
||||
() =>
|
||||
|
@ -88,8 +76,6 @@ function MostRecentTrack() {
|
|||
[]
|
||||
)
|
||||
|
||||
console.log(track)
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Most recent track</h2>
|
||||
|
@ -110,19 +96,15 @@ export default function HomePage() {
|
|||
<Page>
|
||||
<Grid>
|
||||
<Grid.Row>
|
||||
<Grid.Column width={16}>
|
||||
<Grid.Column width={10}>
|
||||
<WelcomeMap />
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
<Grid.Row>
|
||||
<Grid.Column width={10}>
|
||||
<Grid.Column width={6}>
|
||||
<Stats />
|
||||
<MostRecentTrack />
|
||||
</Grid.Column>
|
||||
<Grid.Column width={6}>
|
||||
<LoginState />
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
|
||||
</Grid>
|
||||
</Page>
|
||||
)
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
import React from 'react'
|
||||
import {connect} from 'react-redux'
|
||||
import {Redirect} from 'react-router-dom'
|
||||
|
||||
import {Page, LoginForm} from '../components'
|
||||
|
||||
const LoginPage = connect((state) => ({loggedIn: Boolean(state.login)}))(function LoginPage({loggedIn}) {
|
||||
return loggedIn ? (
|
||||
<Redirect to="/" />
|
||||
) : (
|
||||
<Page small>
|
||||
<h2>Login</h2>
|
||||
<LoginForm />
|
||||
</Page>
|
||||
)
|
||||
})
|
||||
|
||||
export default LoginPage
|
97
frontend/src/pages/LoginRedirectPage.tsx
Normal file
97
frontend/src/pages/LoginRedirectPage.tsx
Normal file
|
@ -0,0 +1,97 @@
|
|||
import React from 'react'
|
||||
import {connect} from 'react-redux'
|
||||
import {Redirect, useLocation, useHistory} from 'react-router-dom'
|
||||
import {Icon, Message} from 'semantic-ui-react'
|
||||
import {useObservable} from 'rxjs-hooks'
|
||||
import {switchMap, pluck, distinctUntilChanged} from 'rxjs/operators'
|
||||
|
||||
import {Page} from 'components'
|
||||
import api from 'api'
|
||||
|
||||
const LoginRedirectPage = connect((state) => ({loggedIn: Boolean(state.login)}))(function LoginRedirectPage({
|
||||
loggedIn,
|
||||
}) {
|
||||
const location = useLocation()
|
||||
const history = useHistory()
|
||||
const {search} = location
|
||||
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
|
||||
// Hook dependency arrays in this block are intentionally left blank, we want
|
||||
// to keep the initial state, but reset the url once, ASAP, to not leak the
|
||||
// query parameters. This is considered good practice by OAuth.
|
||||
const searchParams = React.useMemo(() => Object.fromEntries(new URLSearchParams(search).entries()), [])
|
||||
|
||||
React.useEffect(() => {
|
||||
history.replace({...location, search: ''})
|
||||
}, [])
|
||||
/* eslint-enable react-hooks/exhaustive-deps */
|
||||
|
||||
if (loggedIn) {
|
||||
return <Redirect to="/" />
|
||||
}
|
||||
|
||||
const {error, error_description: errorDescription, code} = searchParams
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Page small>
|
||||
<Message icon error>
|
||||
<Icon name="warning sign" />
|
||||
<Message.Content>
|
||||
<Message.Header>Login error</Message.Header>
|
||||
The login server reported: {errorDescription || error}.
|
||||
</Message.Content>
|
||||
</Message>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
return <ExchangeAuthCode code={code} />
|
||||
})
|
||||
|
||||
function ExchangeAuthCode({code}) {
|
||||
const result = useObservable(
|
||||
(_$, args$) =>
|
||||
args$.pipe(
|
||||
pluck(0),
|
||||
distinctUntilChanged(),
|
||||
switchMap((code) => api.exchangeAuthorizationCode(code))
|
||||
),
|
||||
null,
|
||||
[code]
|
||||
)
|
||||
|
||||
let content
|
||||
if (result === null) {
|
||||
content = (
|
||||
<Message icon info>
|
||||
<Icon name="circle notched" loading />
|
||||
<Message.Content>
|
||||
<Message.Header>Logging you in</Message.Header>
|
||||
Hang tight...
|
||||
</Message.Content>
|
||||
</Message>
|
||||
)
|
||||
} else if (result === true) {
|
||||
content = <Redirect to="/" />
|
||||
} else {
|
||||
const {error, error_description: errorDescription} = result
|
||||
content = (
|
||||
<>
|
||||
<Message icon error>
|
||||
<Icon name="warning sign" />
|
||||
<Message.Content>
|
||||
<Message.Header>Login error</Message.Header>
|
||||
The login server reported: {errorDescription || error}.
|
||||
</Message.Content>
|
||||
</Message>
|
||||
<pre>{JSON.stringify(result, null, 2)}</pre>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return <Page small>{content}</Page>
|
||||
}
|
||||
|
||||
export default LoginRedirectPage
|
|
@ -2,16 +2,14 @@ import React from 'react'
|
|||
import {connect} from 'react-redux'
|
||||
import {Redirect} from 'react-router-dom'
|
||||
|
||||
import {logout as logoutAction} from '../reducers/login'
|
||||
import {resetAuth} from 'reducers/auth'
|
||||
|
||||
const LogoutPage = connect(
|
||||
(state) => ({loggedIn: Boolean(state.login)}),
|
||||
(dispatch) => ({
|
||||
dispatchLogout: () => dispatch(logoutAction()),
|
||||
})
|
||||
)(function LogoutPage({loggedIn, dispatchLogout}) {
|
||||
{resetAuth}
|
||||
)(function LogoutPage({loggedIn, resetAuth}) {
|
||||
React.useEffect(() => {
|
||||
dispatchLogout()
|
||||
resetAuth()
|
||||
})
|
||||
|
||||
return loggedIn ? null : <Redirect to="/" />
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
import React from 'react'
|
||||
import {connect} from 'react-redux'
|
||||
import {Redirect} from 'react-router-dom'
|
||||
|
||||
import {Page, RegistrationForm} from '../components'
|
||||
|
||||
const RegistrationPage = connect((state) => ({loggedIn: Boolean(state.login)}))(function RegistrationPage({loggedIn}) {
|
||||
return loggedIn ? (
|
||||
<Redirect to="/" />
|
||||
) : (
|
||||
<Page small>
|
||||
<h2>Register</h2>
|
||||
<RegistrationForm />
|
||||
</Page>
|
||||
)
|
||||
})
|
||||
|
||||
export default RegistrationPage
|
|
@ -37,7 +37,6 @@ function TracksPageTabs() {
|
|||
|
||||
function TrackList({privateFeed}: {privateFeed: boolean}) {
|
||||
const [page, setPage] = useQueryParam<number>('page', 1, Number)
|
||||
console.log('page', page)
|
||||
|
||||
const pageSize = 10
|
||||
|
||||
|
@ -86,7 +85,7 @@ function TrackList({privateFeed}: {privateFeed: boolean}) {
|
|||
}
|
||||
|
||||
function maxLength(t, max) {
|
||||
if (t.length > max) {
|
||||
if (t && t.length > max) {
|
||||
return t.substring(0, max) + ' ...'
|
||||
} else {
|
||||
return t
|
||||
|
@ -99,7 +98,7 @@ export function TrackListItem({track, privateFeed = false}) {
|
|||
<Item.Image size="tiny" src={track.author.image} />
|
||||
<Item.Content>
|
||||
<Item.Header as={Link} to={`/tracks/${track.slug}`}>
|
||||
{track.title}
|
||||
{track.title || 'Unnamed track'}
|
||||
</Item.Header>
|
||||
<Item.Meta>
|
||||
Created by {track.author.username} on {track.createdAt}
|
||||
|
|
|
@ -58,13 +58,11 @@ function FileUploadStatus({
|
|||
const xhr = new XMLHttpRequest()
|
||||
|
||||
const onProgress = (e) => {
|
||||
console.log('progress', e)
|
||||
const progress = (e.loaded || 0) / (e.total || 1)
|
||||
setProgress(progress)
|
||||
}
|
||||
|
||||
const onLoad = (e) => {
|
||||
console.log('loaded', e)
|
||||
onComplete(id, xhr.response)
|
||||
}
|
||||
|
||||
|
@ -72,17 +70,18 @@ function FileUploadStatus({
|
|||
xhr.onload = onLoad
|
||||
xhr.upload.onprogress = onProgress
|
||||
xhr.open('POST', '/api/tracks')
|
||||
xhr.setRequestHeader('Authorization', api.authorization)
|
||||
xhr.send(formData)
|
||||
|
||||
api.getValidAccessToken().then((accessToken) => {
|
||||
xhr.setRequestHeader('Authorization', accessToken)
|
||||
xhr.send(formData)
|
||||
})
|
||||
|
||||
return () => xhr.abort()
|
||||
}, [file])
|
||||
|
||||
return (
|
||||
<span>
|
||||
<Loader inline size="mini" active />
|
||||
{' '}
|
||||
{progress < 1 ? (progress * 100).toFixed(0) + ' %' : 'Processing...'}
|
||||
<Loader inline size="mini" active /> {progress < 1 ? (progress * 100).toFixed(0) + ' %' : 'Processing...'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
@ -113,8 +112,6 @@ export default function UploadPage() {
|
|||
}, [labelRef.current])
|
||||
|
||||
function onSelectFiles(fileList) {
|
||||
console.log('UPLOAD', fileList)
|
||||
|
||||
const newFiles = Array.from(fileList).map((file) => ({
|
||||
id: 'file-' + String(Math.floor(Math.random() * 1000000)),
|
||||
file,
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
export {default as HomePage} from './HomePage'
|
||||
export {default as LoginPage} from './LoginPage'
|
||||
export {default as LogoutPage} from './LogoutPage'
|
||||
export {default as NotFoundPage} from './NotFoundPage'
|
||||
export {default as RegistrationPage} from './RegistrationPage'
|
||||
export {default as LoginRedirectPage} from './LoginRedirectPage'
|
||||
export {default as TrackPage} from './TrackPage'
|
||||
export {default as TracksPage} from './TracksPage'
|
||||
export {default as UploadPage} from './UploadPage'
|
||||
|
|
30
frontend/src/reducers/auth.js
Normal file
30
frontend/src/reducers/auth.js
Normal file
|
@ -0,0 +1,30 @@
|
|||
const initialState = null
|
||||
|
||||
export function setAuth(auth) {
|
||||
return {type: 'AUTH.SET', payload: {auth}}
|
||||
}
|
||||
|
||||
export function resetAuth() {
|
||||
return {type: 'AUTH.RESET'}
|
||||
}
|
||||
|
||||
export function invalidateAccessToken() {
|
||||
return {type: 'AUTH.INVALIDATE_ACCESS_TOKEN'}
|
||||
}
|
||||
|
||||
export default function loginReducer(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case 'AUTH.SET':
|
||||
return action.payload.auth
|
||||
case 'AUTH.INVALIDATE_ACCESS_TOKEN':
|
||||
return state && {
|
||||
...state,
|
||||
accessToken: null,
|
||||
expiresAt: 0,
|
||||
}
|
||||
case 'AUTH.RESET':
|
||||
return null
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import {combineReducers} from 'redux'
|
||||
|
||||
import login from './login'
|
||||
import auth from './auth'
|
||||
|
||||
export default combineReducers({login})
|
||||
export default combineReducers({login, auth})
|
||||
|
|
|
@ -1,19 +1,17 @@
|
|||
const initialState = null
|
||||
|
||||
export function login(user) {
|
||||
return {type: 'LOGIN.LOGIN', payload: {user}}
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return {type: 'LOGIN.LOGOUT'}
|
||||
export function setLogin(user) {
|
||||
return {type: 'LOGIN.SET', payload: {user}}
|
||||
}
|
||||
|
||||
export default function loginReducer(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case 'LOGIN.LOGIN':
|
||||
case 'LOGIN.SET':
|
||||
return action.payload.user
|
||||
case 'LOGIN.LOGOUT':
|
||||
|
||||
case 'AUTH.RESET': // cross reducer action :)
|
||||
return null
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
|
|
10
frontend/src/store.js
Normal file
10
frontend/src/store.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
import {compose, createStore} from 'redux'
|
||||
import persistState from 'redux-localstorage'
|
||||
|
||||
import rootReducer from './reducers'
|
||||
|
||||
const enhancer = compose(persistState(['login', 'auth']))
|
||||
|
||||
const store = createStore(rootReducer, undefined, enhancer)
|
||||
|
||||
export default store
|
Loading…
Reference in a new issue