mirror of
https://github.com/xodio/xod.git
synced 2026-03-15 05:06:59 +01:00
feat(xod-client, xod-client-electron, cod-client-browser): add account pane
Closes #889
This commit is contained in:
@@ -35,6 +35,7 @@ module.exports = {
|
||||
beforeEach: true,
|
||||
after: true,
|
||||
afterEach: true,
|
||||
URLSearchParams: true,
|
||||
},
|
||||
|
||||
rules: {
|
||||
|
||||
@@ -42,6 +42,7 @@ class App extends client.App {
|
||||
this.hideInstallAppPopup = this.hideInstallAppPopup.bind(this);
|
||||
|
||||
props.actions.openProject(props.initialProject);
|
||||
props.actions.fetchGrant();
|
||||
}
|
||||
|
||||
onResize() {
|
||||
@@ -206,6 +207,7 @@ class App extends client.App {
|
||||
[
|
||||
onClick(items.toggleHelpbar, this.props.actions.toggleHelpbar),
|
||||
onClick(items.toggleDebugger, this.props.actions.toggleDebugger),
|
||||
onClick(items.toggleAccountPane, this.props.actions.toggleAccountPane),
|
||||
]
|
||||
),
|
||||
submenu(
|
||||
|
||||
@@ -59,6 +59,9 @@ function createWindow() {
|
||||
show: false,
|
||||
// this is required for subpixel antialiasing to work
|
||||
backgroundColor: '#FFF',
|
||||
webPreferences: {
|
||||
partition: 'persist:main',
|
||||
},
|
||||
});
|
||||
win.maximize();
|
||||
// and load the index.html of the app.
|
||||
|
||||
@@ -181,6 +181,7 @@ class App extends client.App {
|
||||
|
||||
// request for data from main process
|
||||
ipcRenderer.send(EVENTS.GET_SIDEBAR_PANE_HEIGHT);
|
||||
props.actions.fetchGrant();
|
||||
}
|
||||
|
||||
onClickMessageButton(buttonId, /* messageInfo */) {
|
||||
@@ -522,8 +523,18 @@ class App extends client.App {
|
||||
const viewMenu = {
|
||||
label: 'View',
|
||||
submenu: [
|
||||
client.menu.onClick(client.menu.items.toggleHelpbar, this.props.actions.toggleHelpbar),
|
||||
client.menu.onClick(client.menu.items.toggleDebugger, this.props.actions.toggleDebugger),
|
||||
client.menu.onClick(
|
||||
client.menu.items.toggleHelpbar,
|
||||
this.props.actions.toggleHelpbar
|
||||
),
|
||||
client.menu.onClick(
|
||||
client.menu.items.toggleDebugger,
|
||||
this.props.actions.toggleDebugger
|
||||
),
|
||||
client.menu.onClick(
|
||||
client.menu.items.toggleAccountPane,
|
||||
this.props.actions.toggleAccountPane
|
||||
),
|
||||
{ type: 'separator' },
|
||||
{ role: 'reload' },
|
||||
{ role: 'toggledevtools' },
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"sanctuary-def": "^0.9.0",
|
||||
"shortid": "^2.2.8",
|
||||
"url-parse": "^1.1.9",
|
||||
"url-search-params-polyfill": "^2.0.1",
|
||||
"xod-arduino": "^0.15.0",
|
||||
"xod-func-tools": "^0.15.0",
|
||||
"xod-patch-search": "^0.15.0",
|
||||
|
||||
37
packages/xod-client/src/core/components/Button.jsx
Normal file
37
packages/xod-client/src/core/components/Button.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const Button = ({
|
||||
as = 'button',
|
||||
className,
|
||||
disabled,
|
||||
light,
|
||||
small,
|
||||
children,
|
||||
...restProps
|
||||
}) => React.createElement(
|
||||
as,
|
||||
{
|
||||
...restProps,
|
||||
className: cn('Button', className, {
|
||||
'Button--light': light,
|
||||
'Button--small': small,
|
||||
disabled,
|
||||
}),
|
||||
disabled,
|
||||
role: as !== 'button' ? 'button' : restProps.role,
|
||||
},
|
||||
children
|
||||
);
|
||||
|
||||
Button.propTypes = {
|
||||
as: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
light: PropTypes.bool,
|
||||
small: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export default Button;
|
||||
@@ -151,6 +151,8 @@ App.propTypes = {
|
||||
logDebugger: PropTypes.func.isRequired,
|
||||
clearDebugger: PropTypes.func.isRequired,
|
||||
showLibSuggester: PropTypes.func.isRequired,
|
||||
toggleAccountPane: PropTypes.func.isRequired,
|
||||
fetchGrant: PropTypes.func.isRequired,
|
||||
/* eslint-enable react/no-unused-prop-types */
|
||||
}),
|
||||
};
|
||||
@@ -187,4 +189,6 @@ App.actions = {
|
||||
copyEntities: actions.copyEntities,
|
||||
pasteEntities: actions.pasteEntities,
|
||||
showLibSuggester: actions.showLibSuggester,
|
||||
toggleAccountPane: actions.toggleAccountPane,
|
||||
fetchGrant: actions.fetchGrant,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
.AccountPane {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
width: 240px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 20px;
|
||||
background: $sidebar-color-bg;
|
||||
border-left: 1px solid $sidebar-color-border;
|
||||
color: $sidebar-color-text;
|
||||
|
||||
.close-button {
|
||||
@include close-button();
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: $font-size-l;
|
||||
padding-bottom: 14px;
|
||||
cursor: default;
|
||||
color: $color-canvas-selected;
|
||||
}
|
||||
|
||||
.dailyQuotas {
|
||||
font-size: $font-size-m;
|
||||
padding-bottom: 14px;
|
||||
cursor: default;
|
||||
|
||||
.title {
|
||||
color: $color-canvas-selected;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.inspectorInput {
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.whyLogin {
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.authError {
|
||||
color: $color-error;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.forgotPassword {
|
||||
color: $color-canvas-selected;
|
||||
}
|
||||
|
||||
.ButtonsRow {
|
||||
margin-top: 9px;
|
||||
display: flex;
|
||||
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
'base/base';
|
||||
|
||||
@import
|
||||
'components/AccountPane',
|
||||
'components/App',
|
||||
'components/BackgroundLayer',
|
||||
'components/Breadcrumbs',
|
||||
|
||||
@@ -37,6 +37,7 @@ import Workarea from '../../utils/components/Workarea';
|
||||
import { RenderableSelection } from '../../types';
|
||||
import sanctuaryPropType from '../../utils/sanctuaryPropType';
|
||||
|
||||
import AccountPane from '../../user/containers/AccountPane';
|
||||
import ProjectBrowser from '../../projectBrowser/containers/ProjectBrowser';
|
||||
import Tabs from './Tabs';
|
||||
import Helpbar from './Helpbar';
|
||||
@@ -236,6 +237,7 @@ class Editor extends React.Component {
|
||||
</Workarea>
|
||||
</FocusTrap>
|
||||
<Helpbar />
|
||||
<AccountPane />
|
||||
<DragLayer />
|
||||
</HotKeys>
|
||||
);
|
||||
|
||||
@@ -1,2 +1,11 @@
|
||||
export const UPDATE_COMPILE_LIMIT = 'UPDATE_COMPILE_LIMIT';
|
||||
|
||||
export const TOGGLE_ACCOUNT_PANE = 'TOGGLE_ACCOUNT_PANE';
|
||||
|
||||
export const SET_AUTH_GRANT = 'SET_AUTH_GRANT';
|
||||
|
||||
export const LOGIN_STARTED = 'LOGIN_STARTED';
|
||||
export const LOGIN_FAILED = 'LOGIN_FAILED';
|
||||
// no 'SUCCEEDED', because we will fetch grant on success
|
||||
|
||||
export default {};
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { getCompileLimitUrl } from '../utils/urls';
|
||||
import { UPDATE_COMPILE_LIMIT } from './actionTypes';
|
||||
import 'url-search-params-polyfill';
|
||||
|
||||
import {
|
||||
getCompileLimitUrl,
|
||||
getWhoamiUrl,
|
||||
getLoginUrl,
|
||||
getLogoutUrl,
|
||||
} from '../utils/urls';
|
||||
import * as ActionTypes from './actionTypes';
|
||||
import * as Messages from './messages';
|
||||
|
||||
|
||||
export const updateCompileLimit = (startup = false) => dispatch =>
|
||||
fetch(getCompileLimitUrl(), {
|
||||
@@ -7,7 +16,68 @@ export const updateCompileLimit = (startup = false) => dispatch =>
|
||||
}).then(res => (res.ok ? res.json() : null))
|
||||
.catch(() => null)
|
||||
.then(limit => dispatch({
|
||||
type: UPDATE_COMPILE_LIMIT,
|
||||
type: ActionTypes.UPDATE_COMPILE_LIMIT,
|
||||
payload: limit,
|
||||
}));
|
||||
|
||||
export const toggleAccountPane = () => ({
|
||||
type: ActionTypes.TOGGLE_ACCOUNT_PANE,
|
||||
});
|
||||
|
||||
const setGrant = grant => ({
|
||||
type: ActionTypes.SET_AUTH_GRANT,
|
||||
payload: grant,
|
||||
});
|
||||
|
||||
/**
|
||||
* User has a cookie with only a session id.
|
||||
* To know his username and other data we need to fetch
|
||||
* a keycloak grant from server
|
||||
*/
|
||||
export const fetchGrant = () => dispatch =>
|
||||
fetch(getWhoamiUrl(), { credentials: 'include' })
|
||||
.then(res => (res.ok ? res.json() : null))
|
||||
.catch(() => null)
|
||||
.then((grant) => {
|
||||
dispatch(setGrant(grant));
|
||||
dispatch(updateCompileLimit(false));
|
||||
});
|
||||
|
||||
export const login = (username, password) => (dispatch) => {
|
||||
const form = new URLSearchParams();
|
||||
form.set('username', username);
|
||||
form.set('password', password);
|
||||
|
||||
dispatch({ type: ActionTypes.LOGIN_STARTED });
|
||||
|
||||
fetch(getLoginUrl(), { method: 'POST', credentials: 'include', body: form })
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
const err = new Error(res.statusText);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return dispatch(fetchGrant());
|
||||
})
|
||||
.catch((err) => {
|
||||
const errMessage = err.status === 403
|
||||
? Messages.INCORRECT_CREDENTIALS
|
||||
: Messages.UNAVAILABLE;
|
||||
|
||||
return dispatch({
|
||||
type: ActionTypes.LOGIN_FAILED,
|
||||
payload: errMessage,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const logout = () => (dispatch) => {
|
||||
fetch(getLogoutUrl(), { credentials: 'include' })
|
||||
.then(() => {
|
||||
dispatch(setGrant(null));
|
||||
dispatch(updateCompileLimit(false));
|
||||
});
|
||||
};
|
||||
|
||||
export default {};
|
||||
|
||||
100
packages/xod-client/src/user/components/AuthForm.jsx
Normal file
100
packages/xod-client/src/user/components/AuthForm.jsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { getAuthFormUrl, getPasswordResetUrl } from '../../utils/urls';
|
||||
|
||||
import Button from '../../core/components/Button';
|
||||
|
||||
class AuthForm extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
username: '',
|
||||
password: '',
|
||||
};
|
||||
|
||||
this.onUsernameChange = this.onUsernameChange.bind(this);
|
||||
this.onPasswordChange = this.onPasswordChange.bind(this);
|
||||
this.onLogin = this.onLogin.bind(this);
|
||||
}
|
||||
|
||||
onUsernameChange(e) {
|
||||
this.setState({ username: e.target.value });
|
||||
}
|
||||
|
||||
onPasswordChange(e) {
|
||||
this.setState({ password: e.target.value });
|
||||
}
|
||||
|
||||
onLogin() {
|
||||
this.props.onLogin(
|
||||
this.state.username,
|
||||
this.state.password
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isAuthorising, authError } = this.props;
|
||||
const { username, password } = this.state;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="whyLogin">
|
||||
Log in to increase quotas and access more features
|
||||
</div>
|
||||
|
||||
<input
|
||||
className="inspectorInput inspectorInput--full-width"
|
||||
type="text"
|
||||
placeholder="username or email"
|
||||
value={username}
|
||||
onChange={this.onUsernameChange}
|
||||
/>
|
||||
|
||||
<input
|
||||
className="inspectorInput inspectorInput--full-width"
|
||||
placeholder="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={this.onPasswordChange}
|
||||
/>
|
||||
|
||||
{authError ? <div className="authError">{authError}</div> : null}
|
||||
|
||||
<a
|
||||
className="forgotPassword"
|
||||
href={getPasswordResetUrl()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>Forgot password</a>
|
||||
|
||||
<div className="ButtonsRow">
|
||||
<Button
|
||||
light
|
||||
onClick={this.onLogin}
|
||||
disabled={isAuthorising}
|
||||
>
|
||||
Log In
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={getAuthFormUrl()}
|
||||
>
|
||||
Sign Up
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AuthForm.propTypes = {
|
||||
onLogin: PropTypes.func.isRequired,
|
||||
isAuthorising: PropTypes.bool.isRequired,
|
||||
authError: PropTypes.string,
|
||||
};
|
||||
|
||||
export default AuthForm;
|
||||
91
packages/xod-client/src/user/containers/AccountPane.jsx
Normal file
91
packages/xod-client/src/user/containers/AccountPane.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import R from 'ramda';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
|
||||
import { foldMaybe } from 'xod-func-tools';
|
||||
|
||||
import Button from '../../core/components/Button';
|
||||
|
||||
import * as Actions from '../actions';
|
||||
import * as Selectors from '../selectors';
|
||||
|
||||
import AuthForm from '../components/AuthForm';
|
||||
|
||||
const AccountPane = ({
|
||||
user,
|
||||
isVisible,
|
||||
compilationsLeft,
|
||||
actions,
|
||||
isAuthorising,
|
||||
authError,
|
||||
}) => {
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="AccountPane">
|
||||
<a
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
className="close-button"
|
||||
onClick={actions.toggleAccountPane}
|
||||
>
|
||||
×
|
||||
</a>
|
||||
|
||||
{foldMaybe(
|
||||
<div className="username">Guest Xoder</div>,
|
||||
({ username }) => (
|
||||
<div>
|
||||
<div>Logged in as</div>
|
||||
<div className="username">{username}</div>
|
||||
</div>
|
||||
),
|
||||
user
|
||||
)}
|
||||
|
||||
<div className="dailyQuotas">
|
||||
<div className="title">Daily quotas</div>
|
||||
<div>
|
||||
Compilations: {compilationsLeft == null ? 'currently offline' : compilationsLeft}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{foldMaybe(
|
||||
<AuthForm onLogin={actions.login} {...{ isAuthorising, authError }} />,
|
||||
() => (<Button onClick={actions.logout}>Logout</Button>),
|
||||
user
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
AccountPane.propTypes = {
|
||||
compilationsLeft: PropTypes.number,
|
||||
isVisible: PropTypes.bool.isRequired,
|
||||
user: PropTypes.object,
|
||||
isAuthorising: PropTypes.bool.isRequired,
|
||||
authError: PropTypes.string,
|
||||
actions: PropTypes.shape({
|
||||
toggleAccountPane: PropTypes.func.isRequired, // eslint-disable-line react/no-unused-prop-types
|
||||
login: PropTypes.func.isRequired, // eslint-disable-line react/no-unused-prop-types
|
||||
}),
|
||||
};
|
||||
|
||||
const mapStateToProps = R.applySpec({
|
||||
isVisible: Selectors.isAccountPaneVisible,
|
||||
user: Selectors.getUser,
|
||||
compilationsLeft: Selectors.getCompileLimitLeft,
|
||||
isAuthorising: Selectors.isAuthorising,
|
||||
authError: Selectors.getAuthError,
|
||||
});
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
actions: bindActionCreators({
|
||||
toggleAccountPane: Actions.toggleAccountPane,
|
||||
login: Actions.login,
|
||||
logout: Actions.logout,
|
||||
}, dispatch),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(AccountPane);
|
||||
2
packages/xod-client/src/user/messages.js
Normal file
2
packages/xod-client/src/user/messages.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export const INCORRECT_CREDENTIALS = 'Incorrect username or password';
|
||||
export const SERVICE_UNAVAILABLE = 'Service unavailable';
|
||||
@@ -1,10 +1,35 @@
|
||||
import { UPDATE_COMPILE_LIMIT } from './actionTypes';
|
||||
import R from 'ramda';
|
||||
|
||||
import * as ActionTypes from './actionTypes';
|
||||
|
||||
const userReducer = (state = {}, action) => {
|
||||
switch (action.type) {
|
||||
case UPDATE_COMPILE_LIMIT: {
|
||||
const limit = action.payload;
|
||||
return { ...state, limit };
|
||||
case ActionTypes.TOGGLE_ACCOUNT_PANE:
|
||||
return R.compose(
|
||||
R.over(R.lensProp('isAccountPaneVisible'), R.not),
|
||||
R.assoc('authError', null)
|
||||
)(state);
|
||||
case ActionTypes.UPDATE_COMPILE_LIMIT:
|
||||
return R.assoc('limit', action.payload, state);
|
||||
case ActionTypes.LOGIN_STARTED:
|
||||
return R.merge(state, {
|
||||
isAuthorising: true,
|
||||
});
|
||||
case ActionTypes.LOGIN_FAILED:
|
||||
return R.merge(state, {
|
||||
isAuthorising: false,
|
||||
authError: action.payload,
|
||||
});
|
||||
case ActionTypes.SET_AUTH_GRANT: {
|
||||
const grant = action.payload;
|
||||
|
||||
if (grant == null) return R.assoc('grant', grant, state);
|
||||
|
||||
return R.merge(state, {
|
||||
grant,
|
||||
isAuthorising: false,
|
||||
authError: null,
|
||||
});
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import R from 'ramda';
|
||||
import { Maybe } from 'ramda-fantasy';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
export const getUserState = R.prop('user');
|
||||
@@ -12,3 +13,23 @@ export const getCompileLimitLeft = createSelector(
|
||||
getCompileLimit,
|
||||
limit => (limit ? limit.limit - limit.pending - limit.used : null)
|
||||
);
|
||||
|
||||
export const isAccountPaneVisible = createSelector(
|
||||
getUserState,
|
||||
R.prop('isAccountPaneVisible')
|
||||
);
|
||||
|
||||
export const isAuthorising = createSelector(
|
||||
getUserState,
|
||||
R.prop('isAuthorising')
|
||||
);
|
||||
|
||||
export const getAuthError = createSelector(
|
||||
getUserState,
|
||||
R.prop('authError')
|
||||
);
|
||||
|
||||
export const getUser = createSelector(
|
||||
getUserState,
|
||||
R.pipe(R.path(['grant', 'user']), Maybe)
|
||||
);
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
export default {
|
||||
limit: null,
|
||||
isAccountPaneVisible: false,
|
||||
grant: null,
|
||||
isAuthorising: false,
|
||||
authError: null,
|
||||
};
|
||||
|
||||
@@ -131,6 +131,10 @@ const rawItems = {
|
||||
label: 'Toggle Debugger',
|
||||
command: COMMAND.TOGGLE_DEBUGGER,
|
||||
},
|
||||
toggleAccountPane: {
|
||||
key: 'toggleAccountPane',
|
||||
label: 'Toggle Account Pane',
|
||||
},
|
||||
|
||||
help: {
|
||||
key: 'help',
|
||||
|
||||
@@ -32,9 +32,24 @@ export const getUtmForumUrl = getUtmUrl(process.env.XOD_FORUM_DOMAIN, '', 'forum
|
||||
|
||||
const HOSTNAME = process.env.XOD_HOSTNAME || 'xod.io';
|
||||
|
||||
// :: String -> String
|
||||
// :: () -> String
|
||||
export const getCompileLimitUrl = () =>
|
||||
`https://compile.${HOSTNAME}/limits`;
|
||||
`https://compile.${HOSTNAME}/limits/`;
|
||||
|
||||
export const getPmSwaggerUrl = () =>
|
||||
`https://pm.${HOSTNAME}/swagger`;
|
||||
`https://pm.${HOSTNAME}/swagger/`;
|
||||
|
||||
export const getLoginUrl = () =>
|
||||
`https://${HOSTNAME}/auth/login/`;
|
||||
|
||||
export const getLogoutUrl = () =>
|
||||
`https://${HOSTNAME}/auth/logout/`;
|
||||
|
||||
export const getWhoamiUrl = () =>
|
||||
`https://${HOSTNAME}/auth/whoami/`;
|
||||
|
||||
export const getAuthFormUrl = () =>
|
||||
`https://${HOSTNAME}/auth/#signup`;
|
||||
|
||||
export const getPasswordResetUrl = () =>
|
||||
`https://auth.${HOSTNAME}/auth/realms/xod/login-actions/reset-credentials`;
|
||||
|
||||
@@ -10481,6 +10481,10 @@ url-parse@^1.1.8, url-parse@^1.1.9:
|
||||
querystringify "~1.0.0"
|
||||
requires-port "1.0.x"
|
||||
|
||||
url-search-params-polyfill@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/url-search-params-polyfill/-/url-search-params-polyfill-2.0.1.tgz#4db2bc1f81e253470b1d13db01b9bc9c18d4116e"
|
||||
|
||||
url@^0.11.0, url@~0.11.0:
|
||||
version "0.11.0"
|
||||
resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"
|
||||
|
||||
Reference in New Issue
Block a user