mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-15 12:20:31 +00:00
WIP: register setup wizard pages.
This commit is contained in:
@@ -2,15 +2,15 @@
|
||||
|
||||
export const registerWizardSteps = [
|
||||
{
|
||||
label: 'organization_register',
|
||||
label: 'payment_or_trial',
|
||||
},
|
||||
{
|
||||
label: 'payment_or_trial',
|
||||
label: 'initializing',
|
||||
},
|
||||
{
|
||||
label: 'getting_started',
|
||||
},
|
||||
{
|
||||
label: 'initializing',
|
||||
label: 'Congratulations',
|
||||
},
|
||||
];
|
||||
@@ -2,8 +2,10 @@ import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { compose } from 'utils';
|
||||
|
||||
import withAuthentication from 'containers/Authentication/withAuthentication';
|
||||
import withOrganizationByOrgId from 'containers/Organization/withOrganizationByOrgId';
|
||||
import withOrganization from 'containers/Organization/withOrganization';
|
||||
|
||||
|
||||
function EnsureOrganizationIsReady({
|
||||
// #ownProps
|
||||
@@ -11,9 +13,9 @@ function EnsureOrganizationIsReady({
|
||||
redirectTo = '/setup',
|
||||
|
||||
// #withOrganizationByOrgId
|
||||
organization,
|
||||
isOrganizationBuilt,
|
||||
}) {
|
||||
return (organization.is_ready) ? children : (
|
||||
return (isOrganizationBuilt) ? children : (
|
||||
<Redirect
|
||||
to={{ pathname: redirectTo }}
|
||||
/>
|
||||
@@ -25,5 +27,5 @@ export default compose(
|
||||
connect((state, props) => ({
|
||||
organizationId: props.currentOrganizationId,
|
||||
})),
|
||||
withOrganizationByOrgId(),
|
||||
withOrganization(({ isOrganizationBuilt }) => ({ isOrganizationBuilt })),
|
||||
)(EnsureOrganizationIsReady);
|
||||
@@ -14,11 +14,12 @@ import { compose } from 'utils';
|
||||
* Dashboard inner private pages.
|
||||
*/
|
||||
function DashboardPrivatePages({
|
||||
requestOrganizationsList,
|
||||
|
||||
// #withOrganizationActions
|
||||
requestAllOrganizations,
|
||||
}) {
|
||||
const fetchOrganizations = useQuery(
|
||||
['organizations'],
|
||||
() => requestOrganizationsList(),
|
||||
['organizations'], () => requestAllOrganizations(),
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,7 +6,7 @@ export default (mapState) => {
|
||||
const mapped = {
|
||||
isAuthorized: isAuthenticated(state),
|
||||
user: state.authentication.user,
|
||||
currentOrganizationId: state.authentication?.tenant?.organization_id,
|
||||
currentOrganizationId: state.authentication?.organizationId,
|
||||
};
|
||||
return mapState ? mapState(mapped, state, props) : mapped;
|
||||
};
|
||||
|
||||
37
client/src/containers/Organization/withOrganization.js
Normal file
37
client/src/containers/Organization/withOrganization.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { connect } from 'react-redux';
|
||||
import {
|
||||
getOrganizationByIdFactory,
|
||||
isOrganizationReadyFactory,
|
||||
isOrganizationSeededFactory,
|
||||
isOrganizationBuiltFactory,
|
||||
isOrganizationSeedingFactory,
|
||||
isOrganizationInitializingFactory,
|
||||
isOrganizationSubscribedFactory,
|
||||
} from 'store/organizations/organizations.selectors';
|
||||
|
||||
export default (mapState) => {
|
||||
const getOrganizationById = getOrganizationByIdFactory();
|
||||
const isOrganizationReady = isOrganizationReadyFactory();
|
||||
|
||||
const isOrganizationSeeded = isOrganizationSeededFactory();
|
||||
const isOrganizationBuilt = isOrganizationBuiltFactory();
|
||||
|
||||
const isOrganizationInitializing = isOrganizationInitializingFactory();
|
||||
const isOrganizationSeeding = isOrganizationSeedingFactory();
|
||||
|
||||
const isOrganizationSubscribed = isOrganizationSubscribedFactory();
|
||||
|
||||
const mapStateToProps = (state, props) => {
|
||||
const mapped = {
|
||||
organization: getOrganizationById(state, props),
|
||||
isOrganizationReady: isOrganizationReady(state, props),
|
||||
isOrganizationSeeded: isOrganizationSeeded(state, props),
|
||||
isOrganizationInitialized: isOrganizationBuilt(state, props),
|
||||
isOrganizationSeeding: isOrganizationInitializing(state, props),
|
||||
isOrganizationInitializing: isOrganizationSeeding(state, props),
|
||||
isOrganizationSubscribed: isOrganizationSubscribed(state, props),
|
||||
};
|
||||
return (mapState) ? mapState(mapped, state, props) : mapped;
|
||||
};
|
||||
return connect(mapStateToProps);
|
||||
};
|
||||
@@ -5,10 +5,10 @@ import {
|
||||
seedTenant,
|
||||
} from 'store/organizations/organizations.actions';
|
||||
|
||||
export const mapDispatchToProps = (dispatch) => ({
|
||||
requestOrganizationsList: () => dispatch(fetchOrganizations()),
|
||||
requestBuildTenant: () => dispatch(buildTenant()),
|
||||
requestSeedTenant: () => dispatch(seedTenant()),
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
requestOrganizationBuild: () => dispatch(buildTenant()),
|
||||
requestOrganizationSeed: () => dispatch(seedTenant()),
|
||||
requestAllOrganizations: () => dispatch(fetchOrganizations()),
|
||||
});
|
||||
|
||||
export default connect(null, mapDispatchToProps);
|
||||
@@ -1,16 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
import {
|
||||
getOrganizationByOrgIdFactory,
|
||||
} from 'store/organizations/organizations.selectors';
|
||||
|
||||
export default (mapState) => {
|
||||
const getOrganizationByOrgId = getOrganizationByOrgIdFactory();
|
||||
|
||||
const mapStateToProps = (state, props) => {
|
||||
const mapped = {
|
||||
organization: getOrganizationByOrgId(state, props),
|
||||
};
|
||||
return mapState ? mapState(mapped, state, props) : mapped;
|
||||
};
|
||||
return connect(mapStateToProps);
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
import {
|
||||
getOrganizationByTenantIdFactory,
|
||||
} from 'store/organizations/organizations.selectors';
|
||||
|
||||
export default (mapState) => {
|
||||
const getOrgByTenId = getOrganizationByTenantIdFactory();
|
||||
|
||||
const mapStateToProps = (state, props) => {
|
||||
const mapped = {
|
||||
organization: getOrgByTenId(state, props),
|
||||
};
|
||||
return mapState ? mapState(mapped, state, props) : mapped;
|
||||
};
|
||||
return connect(mapStateToProps);
|
||||
};
|
||||
@@ -3,23 +3,25 @@ import { connect } from 'react-redux';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { compose } from 'utils';
|
||||
import withAuthentication from 'containers/Authentication/withAuthentication';
|
||||
import withOrganizationByOrgId from 'containers/Organization/withOrganizationByOrgId';
|
||||
import withOrganization from 'containers/Organization/withOrganization';
|
||||
|
||||
function EnsureOrganizationIsNotReady({
|
||||
children,
|
||||
|
||||
// #withOrganizationByOrgId
|
||||
organization,
|
||||
// #withOrganization
|
||||
isOrganizationReady,
|
||||
}) {
|
||||
return (organization.is_ready) ? (
|
||||
return (isOrganizationReady) ? (
|
||||
<Redirect to={{ pathname: '/' }} />
|
||||
) : children;
|
||||
}
|
||||
|
||||
export default compose(
|
||||
withAuthentication(),
|
||||
withAuthentication(({ currentOrganizationId }) => ({
|
||||
currentOrganizationId,
|
||||
})),
|
||||
connect((state, props) => ({
|
||||
organizationId: props.currentOrganizationId,
|
||||
})),
|
||||
withOrganizationByOrgId(),
|
||||
withOrganization(({ isOrganizationReady }) => ({ isOrganizationReady })),
|
||||
)(EnsureOrganizationIsNotReady);
|
||||
30
client/src/containers/Setup/SetupInitializingForm.js
Normal file
30
client/src/containers/Setup/SetupInitializingForm.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
import withOrganizationActions from 'containers/Organization/withOrganizationActions';
|
||||
import withOrganization from 'containers/Organization/withOrganization'
|
||||
|
||||
import { compose } from 'utils';
|
||||
|
||||
/**
|
||||
* Setup initializing step form.
|
||||
*/
|
||||
function SetupInitializingForm({
|
||||
|
||||
// #withOrganizationActions
|
||||
requestOrganizationBuild,
|
||||
}) {
|
||||
const requestBuildOrgnization = useQuery(
|
||||
['build-tenant'], () => requestOrganizationBuild(),
|
||||
);
|
||||
|
||||
return (
|
||||
<div class="setup-initializing-form">
|
||||
<h1>You organization is initializin...</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default compose(
|
||||
withOrganizationActions
|
||||
)(SetupInitializingForm);
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Icon, If } from 'components';
|
||||
import { Icon } from 'components';
|
||||
import { FormattedMessage as T } from 'react-intl';
|
||||
|
||||
import withAuthentication from 'containers/Authentication/withAuthentication';
|
||||
import withAuthenticationActions from 'containers/Authentication/withAuthenticationActions';
|
||||
|
||||
import { compose } from 'utils';
|
||||
@@ -11,8 +10,8 @@ import { compose } from 'utils';
|
||||
* Wizard setup left section.
|
||||
*/
|
||||
function SetupLeftSection({
|
||||
// #withAuthenticationActions
|
||||
requestLogout,
|
||||
isAuthorized
|
||||
}) {
|
||||
const [org] = useState('LibyanSpider');
|
||||
|
||||
@@ -40,17 +39,15 @@ function SetupLeftSection({
|
||||
<T id={'you_have_a_bigcapital_account'} />
|
||||
</p>
|
||||
|
||||
<If condition={!!isAuthorized}>
|
||||
<div className={'content-org'}>
|
||||
<span>
|
||||
<T id={'welcome'} />
|
||||
{org},
|
||||
</span>
|
||||
<span>
|
||||
<a onClick={onClickLogout} href="#"><T id={'sign_out'} /></a>
|
||||
</span>
|
||||
</div>
|
||||
</If>
|
||||
<div className={'content-org'}>
|
||||
<span>
|
||||
<T id={'welcome'} />
|
||||
{org},
|
||||
</span>
|
||||
<span>
|
||||
<a onClick={onClickLogout} href="#"><T id={'sign_out'} /></a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={'content-contact'}>
|
||||
<a href={'#!'}>
|
||||
@@ -70,6 +67,5 @@ function SetupLeftSection({
|
||||
}
|
||||
|
||||
export default compose(
|
||||
withAuthentication(({ isAuthorized }) => ({ isAuthorized })),
|
||||
withAuthenticationActions,
|
||||
)(SetupLeftSection);
|
||||
|
||||
@@ -26,7 +26,10 @@ import withOrganizationActions from 'containers/Organization/withOrganizationAct
|
||||
|
||||
import { compose, optionsMapToArray } from 'utils';
|
||||
|
||||
function SetupOrganizationForm({ requestSubmitOptions, requestSeedTenant }) {
|
||||
function SetupOrganizationForm({
|
||||
requestSubmitOptions,
|
||||
requestSeedTenant
|
||||
}) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [selected, setSelected] = useState();
|
||||
const history = useHistory();
|
||||
|
||||
@@ -2,33 +2,48 @@ import React, { useCallback } from 'react';
|
||||
import { TransitionGroup, CSSTransition } from 'react-transition-group';
|
||||
import { Wizard, Steps, Step } from 'react-albus';
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import WizardSetupSteps from './WizardSetupSteps';
|
||||
|
||||
import SetupSubscriptionForm from './SetupSubscriptionForm';
|
||||
import SetupOrganizationForm from './SetupOrganizationForm';
|
||||
import SetupInitializingForm from './SetupInitializingForm';
|
||||
|
||||
import withAuthentication from 'containers/Authentication/withAuthentication';
|
||||
|
||||
import withOrganization from 'containers/Organization/withOrganization'
|
||||
import { compose } from 'utils';
|
||||
|
||||
/**
|
||||
* Wizard setup right section.
|
||||
*/
|
||||
function SetupRightSection ({
|
||||
isTenantHasSubscriptions: hasSubscriptions = false,
|
||||
// #withAuthentication
|
||||
currentOrganizationId,
|
||||
|
||||
// #withOrganization
|
||||
isOrganizationInitialized,
|
||||
isOrganizationSubscribed: hasSubscriptions,
|
||||
isOrganizationSeeded
|
||||
}) {
|
||||
const history = useHistory();
|
||||
|
||||
const handleSkip = useCallback(({ step, push }) => {
|
||||
const scenarios = [
|
||||
{ condition: hasSubscriptions, redirectTo: 'organization' },
|
||||
{ condition: !hasSubscriptions, redirectTo: 'subscription' },
|
||||
// { condition: , redirectTo: 'initializing' }
|
||||
{ condition: !hasSubscriptions, redirectTo: 'organization' },
|
||||
];
|
||||
const scenario = scenarios.find((scenario) => scenario.condition);
|
||||
|
||||
if (scenario) {
|
||||
push(scenario.redirectTo);
|
||||
}
|
||||
}, [hasSubscriptions]);
|
||||
}, [
|
||||
hasSubscriptions,
|
||||
isOrganizationInitialized,
|
||||
isOrganizationSeeded,
|
||||
]);
|
||||
|
||||
return (
|
||||
<section className={'setup-page__right-section'}>
|
||||
@@ -48,6 +63,10 @@ function SetupRightSection ({
|
||||
<SetupSubscriptionForm />
|
||||
</Step>
|
||||
|
||||
<Step id={'initializing'}>
|
||||
<SetupInitializingForm />
|
||||
</Step>
|
||||
|
||||
<Step id="organization">
|
||||
<SetupOrganizationForm />
|
||||
</Step>
|
||||
@@ -66,5 +85,19 @@ function SetupRightSection ({
|
||||
}
|
||||
|
||||
export default compose(
|
||||
withAuthentication(({ isAuthorized }) => ({ isAuthorized })),
|
||||
withAuthentication(({ currentOrganizationId }) => ({ currentOrganizationId })),
|
||||
connect((state, props) => ({
|
||||
organizationId: props.currentOrganizationId,
|
||||
})),
|
||||
withOrganization(({
|
||||
organization,
|
||||
isOrganizationInitialized,
|
||||
isOrganizationSubscribed,
|
||||
isOrganizationSeeded,
|
||||
}) => ({
|
||||
organization,
|
||||
isOrganizationInitialized,
|
||||
isOrganizationSubscribed,
|
||||
isOrganizationSeeded,
|
||||
})),
|
||||
)(SetupRightSection);
|
||||
@@ -5,6 +5,9 @@ import { FormattedMessage as T } from 'react-intl';
|
||||
import { Button, Intent } from '@blueprintjs/core';
|
||||
import BillingTab from 'containers/Subscriptions/BillingTab';
|
||||
|
||||
/**
|
||||
* Subscription step of wizard setup.
|
||||
*/
|
||||
export default function SetupSubscriptionForm({
|
||||
|
||||
}) {
|
||||
|
||||
@@ -5,9 +5,7 @@ import SetupRightSection from './SetupRightSection';
|
||||
import SetupLeftSection from './SetupLeftSection';
|
||||
|
||||
|
||||
export default function WizardSetupPage({
|
||||
organizationId,
|
||||
}) {
|
||||
export default function WizardSetupPage() {
|
||||
return (
|
||||
<EnsureOrganizationIsNotReady>
|
||||
<div class="setup-page">
|
||||
|
||||
@@ -4,6 +4,7 @@ import t from 'store/types';
|
||||
const initialState = {
|
||||
token: '',
|
||||
organization: '',
|
||||
organizationId: null,
|
||||
user: '',
|
||||
tenant: {},
|
||||
locale: '',
|
||||
@@ -16,6 +17,7 @@ export default createReducer(initialState, {
|
||||
state.token = token;
|
||||
state.user = user;
|
||||
state.organization = tenant.organization_id;
|
||||
state.organizationId = tenant.id;
|
||||
state.tenant = tenant;
|
||||
},
|
||||
|
||||
|
||||
@@ -13,18 +13,38 @@ export const fetchOrganizations = () => (dispatch) => new Promise((resolve, reje
|
||||
}).catch(error => { reject(error); });
|
||||
});
|
||||
|
||||
export const buildTenant = () => (dispatch) => new Promise((resolve, reject) => {
|
||||
export const buildTenant = () => (dispatch, getState) => new Promise((resolve, reject) => {
|
||||
const organizationId = getState().authentication.organizationId;
|
||||
|
||||
dispatch({
|
||||
type: t.SET_ORGANIZATION_INITIALIZING,
|
||||
payload: { organizationId }
|
||||
});
|
||||
ApiService.post(`organization/build`).then((response) => {
|
||||
resolve(response);
|
||||
dispatch({
|
||||
type: t.SET_ORGANIZATION_INITIALIZED,
|
||||
payload: { organizationId }
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error.response.data.errors || []);
|
||||
});
|
||||
});
|
||||
|
||||
export const seedTenant = () => (dispatch) => new Promise((resolve, reject) => {
|
||||
export const seedTenant = () => (dispatch, getState) => new Promise((resolve, reject) => {
|
||||
const organizationId = getState().authentication.organizationId;
|
||||
|
||||
dispatch({
|
||||
type: t.SET_ORGANIZATION_INITIALIZING,
|
||||
payload: { organizationId }
|
||||
});
|
||||
ApiService.post(`organization/seed/`).then((response) => {
|
||||
resolve(response);
|
||||
dispatch({
|
||||
type: t.SET_ORGANIZATION_INITIALIZED,
|
||||
payload: { organizationId }
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error.response.data.errors || []);
|
||||
|
||||
@@ -20,6 +20,44 @@ const reducer = createReducer(initialState, {
|
||||
state.data = _data;
|
||||
state.byOrganizationId = _dataByOrganizationId;
|
||||
},
|
||||
|
||||
[t.SET_ORGANIZATION_SEEDING]: (state, action) => {
|
||||
const { organizationId } = action.payload;
|
||||
|
||||
state.data[organizationId] = {
|
||||
...(state.data[organizationId] || {}),
|
||||
is_seeding: true,
|
||||
};
|
||||
},
|
||||
|
||||
[t.SET_ORGANIZATION_SEEDED]: (state, action) => {
|
||||
const { organizationId } = action.payload;
|
||||
|
||||
state.data[organizationId] = {
|
||||
...(state.data[organizationId] || {}),
|
||||
is_seeding: false,
|
||||
seeded_at: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
|
||||
[t.SET_ORGANIZATION_INITIALIZING]: (state, action) => {
|
||||
const { organizationId } = action.payload;
|
||||
|
||||
state.data[organizationId] = {
|
||||
...(state.data[organizationId] || {}),
|
||||
is_initializing: true,
|
||||
};
|
||||
},
|
||||
|
||||
[t.SET_ORGANIZATION_INITIALIZED]: (state, action) => {
|
||||
const { organizationId } = action.payload;
|
||||
|
||||
state.data[organizationId] = {
|
||||
...(state.data[organizationId] || {}),
|
||||
is_initializing: false,
|
||||
initialized_at: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
})
|
||||
|
||||
export default reducer;
|
||||
@@ -1,18 +1,50 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
|
||||
const oragnizationByTenantIdSelector = (state, props) => state.organizations[props.tenantId];
|
||||
const organizationByIdSelector = (state, props) => state.organizations.byOrganizationId[props.organizationId];
|
||||
const organizationsDataSelector = (state, props) => state.organizations.data;
|
||||
const organizationSelector = (state, props) => state.organizations.data[props.organizationId];
|
||||
|
||||
export const getOrganizationByOrgIdFactory = () => createSelector(
|
||||
organizationByIdSelector,
|
||||
organizationsDataSelector,
|
||||
(organizationId, organizationsData) => {
|
||||
return organizationsData[organizationId];
|
||||
}
|
||||
export const getOrganizationByIdFactory = () => createSelector(
|
||||
organizationSelector,
|
||||
(organization) => organization
|
||||
);
|
||||
|
||||
export const getOrganizationByTenantIdFactory = () => createSelector(
|
||||
oragnizationByTenantIdSelector,
|
||||
(organization) => organization,
|
||||
);
|
||||
export const isOrganizationSeededFactory = () => createSelector(
|
||||
organizationSelector,
|
||||
(organization) => {
|
||||
return !!organization?.seeded_at;
|
||||
},
|
||||
);
|
||||
|
||||
export const isOrganizationBuiltFactory = () => createSelector(
|
||||
organizationSelector,
|
||||
(organization) => {
|
||||
return !!organization?.initialized_at;
|
||||
},
|
||||
);
|
||||
|
||||
export const isOrganizationInitializingFactory = () => createSelector(
|
||||
organizationSelector,
|
||||
(organization) => {
|
||||
return organization?.is_initializing;
|
||||
},
|
||||
);
|
||||
|
||||
export const isOrganizationSeedingFactory = () => createSelector(
|
||||
organizationSelector,
|
||||
(organization) => {
|
||||
return organization?.is_seeding;
|
||||
},
|
||||
);
|
||||
|
||||
export const isOrganizationReadyFactory = () => createSelector(
|
||||
organizationSelector,
|
||||
(organization) => {
|
||||
return organization?.is_ready;
|
||||
},
|
||||
);
|
||||
|
||||
export const isOrganizationSubscribedFactory = () => createSelector(
|
||||
organizationSelector,
|
||||
(organization) => {
|
||||
return organization?.subscriptions?.length > 0;
|
||||
}
|
||||
)
|
||||
@@ -1,5 +1,10 @@
|
||||
|
||||
|
||||
export default {
|
||||
ORGANIZATIONS_LIST_SET: 'ORGANIZATIONS_LIST_SET',
|
||||
|
||||
SET_ORGANIZATION_SEEDING: 'SET_ORGANIZATION_SEEDING',
|
||||
SET_ORGANIZATION_SEEDED: 'SET_ORGANIZATION_SEEDED',
|
||||
|
||||
SET_ORGANIZATION_INITIALIZED: 'SET_ORGANIZATION_INITIALIZED',
|
||||
SET_ORGANIZATION_INITIALIZING: 'SET_ORGANIZATION_INITIALIZING',
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
import Container from 'typedi';
|
||||
import TenancyService from 'services/Tenancy/TenancyService'
|
||||
|
||||
exports.up = (knex) => {
|
||||
const tenancyService = Container.get(TenancyService);
|
||||
|
||||
@@ -87,9 +87,6 @@ export default class AuthenticationService implements IAuthenticationService {
|
||||
// Remove password property from user object.
|
||||
Reflect.deleteProperty(user, 'password');
|
||||
|
||||
// Remove id property from tenant object.
|
||||
Reflect.deleteProperty(tenant, 'id');
|
||||
|
||||
return { user, token, tenant };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user