mirror of
https://github.com/GSA/notifications-admin.git
synced 2026-09-09 16:19:49 -04:00
Merge pull request #219 from alphagov/accept-invite-part-2
[WIP] New user can now accept invite and will be made to
This commit is contained in:
@@ -8,7 +8,8 @@ from wtforms import (
|
|||||||
TextAreaField,
|
TextAreaField,
|
||||||
FileField,
|
FileField,
|
||||||
RadioField,
|
RadioField,
|
||||||
BooleanField
|
BooleanField,
|
||||||
|
HiddenField
|
||||||
)
|
)
|
||||||
from wtforms.fields.html5 import EmailField, TelField
|
from wtforms.fields.html5 import EmailField, TelField
|
||||||
from wtforms.validators import DataRequired, Email, Length, Regexp
|
from wtforms.validators import DataRequired, Email, Length, Regexp
|
||||||
@@ -97,6 +98,15 @@ class RegisterUserForm(Form):
|
|||||||
password = password()
|
password = password()
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterUserFromInviteForm(Form):
|
||||||
|
name = StringField('Full name',
|
||||||
|
validators=[DataRequired(message='Name can not be empty')])
|
||||||
|
mobile_number = mobile_number()
|
||||||
|
password = password()
|
||||||
|
service = HiddenField('service')
|
||||||
|
email_address = HiddenField('email_address')
|
||||||
|
|
||||||
|
|
||||||
class InviteUserForm(Form):
|
class InviteUserForm(Form):
|
||||||
email_address = email_address('Their email address')
|
email_address = email_address('Their email address')
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,31 @@
|
|||||||
import re
|
from flask import (
|
||||||
|
render_template,
|
||||||
|
redirect,
|
||||||
|
session,
|
||||||
|
url_for
|
||||||
|
)
|
||||||
|
|
||||||
|
from flask_login import login_required
|
||||||
|
|
||||||
from flask import render_template, request, redirect, session, url_for
|
|
||||||
from flask_login import login_required, current_user
|
|
||||||
from app.main import main
|
from app.main import main
|
||||||
from app.main.dao import services_dao, users_dao
|
from app.main.dao import services_dao, users_dao
|
||||||
from app.main.forms import AddServiceForm
|
from app.main.forms import AddServiceForm
|
||||||
|
from app import user_api_client
|
||||||
|
|
||||||
|
|
||||||
@main.route("/add-service", methods=['GET', 'POST'])
|
@main.route("/add-service", methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def add_service():
|
def add_service():
|
||||||
|
|
||||||
|
invited_user = session.get('invited_user')
|
||||||
|
if invited_user:
|
||||||
|
# if invited user add to service and redirect to dashboard
|
||||||
|
user = users_dao.get_user_by_id(session['user_id'])
|
||||||
|
service_id = invited_user['service']
|
||||||
|
user_api_client.add_user_to_service(service_id, user.id)
|
||||||
|
session.pop('invited_user', None)
|
||||||
|
return redirect(url_for('main.service_dashboard', service_id=service_id))
|
||||||
|
|
||||||
form = AddServiceForm(services_dao.find_all_service_names)
|
form = AddServiceForm(services_dao.find_all_service_names)
|
||||||
heading = 'Which service do you want to set up notifications for?'
|
heading = 'Which service do you want to set up notifications for?'
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
|
|||||||
@@ -12,12 +12,6 @@ def index():
|
|||||||
return render_template('views/signedout.html')
|
return render_template('views/signedout.html')
|
||||||
|
|
||||||
|
|
||||||
@main.route("/register-from-invite")
|
|
||||||
@login_required
|
|
||||||
def register_from_invite():
|
|
||||||
return render_template('views/register-from-invite.html')
|
|
||||||
|
|
||||||
|
|
||||||
@main.route("/verify-mobile")
|
@main.route("/verify-mobile")
|
||||||
@login_required
|
@login_required
|
||||||
def verify_mobile():
|
def verify_mobile():
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from flask import (
|
from flask import (
|
||||||
redirect,
|
redirect,
|
||||||
url_for,
|
url_for,
|
||||||
abort
|
abort,
|
||||||
|
session
|
||||||
)
|
)
|
||||||
|
|
||||||
from notifications_python_client.errors import HTTPError
|
from notifications_python_client.errors import HTTPError
|
||||||
@@ -15,6 +16,7 @@ from app import (
|
|||||||
|
|
||||||
@main.route("/invitation/<token>")
|
@main.route("/invitation/<token>")
|
||||||
def accept_invite(token):
|
def accept_invite(token):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
invited_user = invite_api_client.accept_invite(token)
|
invited_user = invite_api_client.accept_invite(token)
|
||||||
existing_user = user_api_client.get_user_by_email(invited_user.email_address)
|
existing_user = user_api_client.get_user_by_email(invited_user.email_address)
|
||||||
@@ -22,11 +24,11 @@ def accept_invite(token):
|
|||||||
if existing_user:
|
if existing_user:
|
||||||
user_api_client.add_user_to_service(invited_user.service,
|
user_api_client.add_user_to_service(invited_user.service,
|
||||||
existing_user.id)
|
existing_user.id)
|
||||||
|
|
||||||
return redirect(url_for('main.service_dashboard', service_id=invited_user.service))
|
return redirect(url_for('main.service_dashboard', service_id=invited_user.service))
|
||||||
else:
|
else:
|
||||||
# TODO implement registration flow for new users
|
session['invited_user'] = invited_user.serialize()
|
||||||
abort(404)
|
return redirect(url_for('main.register_from_invite'))
|
||||||
|
|
||||||
except HTTPError as e:
|
except HTTPError as e:
|
||||||
if e.status_code == 404:
|
if e.status_code == 404:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ from notifications_python_client.errors import HTTPError
|
|||||||
|
|
||||||
from app.main import main
|
from app.main import main
|
||||||
from app.main.dao import users_dao
|
from app.main.dao import users_dao
|
||||||
from app.main.forms import RegisterUserForm
|
from app.main.forms import (
|
||||||
|
RegisterUserForm,
|
||||||
|
RegisterUserFromInviteForm
|
||||||
|
)
|
||||||
|
|
||||||
from app import user_api_client
|
from app import user_api_client
|
||||||
|
|
||||||
@@ -27,29 +30,53 @@ def register():
|
|||||||
|
|
||||||
form = RegisterUserForm()
|
form = RegisterUserForm()
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
if users_dao.is_email_unique(form.email_address.data):
|
return _do_registration(form)
|
||||||
try:
|
else:
|
||||||
user = user_api_client.register_user(form.name.data,
|
return render_template('views/register.html', form=form)
|
||||||
form.email_address.data,
|
|
||||||
form.mobile_number.data,
|
|
||||||
form.password.data)
|
|
||||||
except HTTPError as e:
|
|
||||||
if e.status_code == 404:
|
|
||||||
abort(404)
|
|
||||||
else:
|
|
||||||
raise e
|
|
||||||
|
|
||||||
# TODO possibly there should be some exception handling
|
|
||||||
# for sending sms and email codes.
|
|
||||||
# How do we report to the user there is a problem with
|
|
||||||
# sending codes apart from service unavailable?
|
|
||||||
# at the moment i believe http 500 is fine.
|
|
||||||
users_dao.send_verify_code(user.id, 'sms', user.mobile_number)
|
|
||||||
users_dao.send_verify_code(user.id, 'email', user.email_address)
|
|
||||||
session['expiry_date'] = str(datetime.now() + timedelta(hours=1))
|
|
||||||
session['user_details'] = {"email": user.email_address, "id": user.id}
|
|
||||||
return redirect(url_for('main.verify'))
|
|
||||||
else:
|
|
||||||
flash('There was an error registering your account')
|
|
||||||
|
|
||||||
return render_template('views/register.html', form=form)
|
@main.route('/register-from-invite', methods=['GET', 'POST'])
|
||||||
|
def register_from_invite():
|
||||||
|
|
||||||
|
form = RegisterUserFromInviteForm()
|
||||||
|
invited_user = session.get('invited_user')
|
||||||
|
if not invited_user:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if form.validate_on_submit():
|
||||||
|
if form.service.data != invited_user['service'] or form.email_address.data != invited_user['email_address']:
|
||||||
|
abort(400)
|
||||||
|
return _do_registration(form)
|
||||||
|
|
||||||
|
form.service.data = invited_user['service']
|
||||||
|
form.email_address.data = invited_user['email_address']
|
||||||
|
|
||||||
|
return render_template('views/register-from-invite.html', form=form)
|
||||||
|
|
||||||
|
|
||||||
|
def _do_registration(form, service=None):
|
||||||
|
if users_dao.is_email_unique(form.email_address.data):
|
||||||
|
try:
|
||||||
|
user = user_api_client.register_user(form.name.data,
|
||||||
|
form.email_address.data,
|
||||||
|
form.mobile_number.data,
|
||||||
|
form.password.data)
|
||||||
|
|
||||||
|
except HTTPError as e:
|
||||||
|
if e.status_code == 404:
|
||||||
|
abort(404)
|
||||||
|
else:
|
||||||
|
raise e
|
||||||
|
|
||||||
|
# TODO possibly there should be some exception handling
|
||||||
|
# for sending sms and email codes.
|
||||||
|
# How do we report to the user there is a problem with
|
||||||
|
# sending codes apart from service unavailable?
|
||||||
|
# at the moment i believe http 500 is fine.
|
||||||
|
users_dao.send_verify_code(user.id, 'sms', user.mobile_number)
|
||||||
|
users_dao.send_verify_code(user.id, 'email', user.email_address)
|
||||||
|
session['expiry_date'] = str(datetime.now() + timedelta(hours=1))
|
||||||
|
session['user_details'] = {"email": user.email_address, "id": user.id}
|
||||||
|
return redirect(url_for('main.verify'))
|
||||||
|
else:
|
||||||
|
flash('There was an error registering your account')
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from app.main.forms import VerifyForm
|
|||||||
|
|
||||||
@main.route('/verify', methods=['GET', 'POST'])
|
@main.route('/verify', methods=['GET', 'POST'])
|
||||||
def verify():
|
def verify():
|
||||||
|
|
||||||
# TODO there needs to be a way to regenerate a session id
|
# TODO there needs to be a way to regenerate a session id
|
||||||
# or handle gracefully.
|
# or handle gracefully.
|
||||||
user_id = session['user_details']['id']
|
user_id = session['user_details']['id']
|
||||||
|
|||||||
@@ -125,3 +125,12 @@ class InvitedUser(object):
|
|||||||
|
|
||||||
def has_permissions(self, permission):
|
def has_permissions(self, permission):
|
||||||
return permission in self.permissions
|
return permission in self.permissions
|
||||||
|
|
||||||
|
def serialize(self):
|
||||||
|
return {'id': self.id,
|
||||||
|
'service': self.service,
|
||||||
|
'from_user': self.from_user,
|
||||||
|
'email_address': self.email_address,
|
||||||
|
'permissions': self.permissions,
|
||||||
|
'status': self.status
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,8 +34,14 @@ class UserApiClient(BaseAPIClient):
|
|||||||
return User(user_data['data'], max_failed_login_count=self.max_failed_login_count)
|
return User(user_data['data'], max_failed_login_count=self.max_failed_login_count)
|
||||||
|
|
||||||
def get_user_by_email(self, email_address):
|
def get_user_by_email(self, email_address):
|
||||||
params = {'email': email_address}
|
try:
|
||||||
user_data = self.get('/user/email', params=params)
|
params = {'email': email_address}
|
||||||
|
user_data = self.get('/user/email', params=params)
|
||||||
|
except HTTPError as e:
|
||||||
|
if e.status_code == 404:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
raise e
|
||||||
return User(user_data['data'], max_failed_login_count=self.max_failed_login_count)
|
return User(user_data['data'], max_failed_login_count=self.max_failed_login_count)
|
||||||
|
|
||||||
def get_users(self):
|
def get_users(self):
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
{% extends "withoutnav_template.html" %}
|
{% extends "withoutnav_template.html" %}
|
||||||
|
{% from "components/textbox.html" import textbox %}
|
||||||
|
{% from "components/page-footer.html" import page_footer %}
|
||||||
|
|
||||||
{% block page_title %}
|
{% block page_title %}
|
||||||
Create an account – GOV.UK Notify
|
Create an account – GOV.UK Notify
|
||||||
@@ -9,26 +11,14 @@ Create an account – GOV.UK Notify
|
|||||||
<div class="grid-row">
|
<div class="grid-row">
|
||||||
<div class="column-two-thirds">
|
<div class="column-two-thirds">
|
||||||
<h1 class="heading-large">Create an account</h1>
|
<h1 class="heading-large">Create an account</h1>
|
||||||
|
<form method="post" autocomplete="nope">
|
||||||
<p>If you've used GOV.UK Notify before, <a href="">sign in to your account</a>.</p>
|
{{ textbox(form.name, width='3-4') }}
|
||||||
|
{{ textbox(form.mobile_number, width='3-4') }}
|
||||||
<p>
|
{{ textbox(form.password, hint="Your password must have at least 10 characters", width='3-4') }}
|
||||||
<label class="form-label" for="name-f1">Full name</label>
|
{{ page_footer("Continue") }}
|
||||||
<input class="form-control-2-3" id="name-f1" type="text">
|
{{form.service}}
|
||||||
</p>
|
{{form.email_address}}
|
||||||
<p>
|
</form>
|
||||||
<label class="form-label" for="mobile">Mobile phone number</label>
|
|
||||||
<input class="form-control-1-4" id="mobile" type="text">
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<label class="form-label" for="password">Create a password</label>
|
|
||||||
<input class="form-control-1-4" id="password" type="password"><br>
|
|
||||||
<span class="font-xsmall">Your password must have at least 10 characters</span></label>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<a class="button" href="verify-mobile" role="button">Continue</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
from flask import url_for
|
from flask import url_for
|
||||||
|
|
||||||
from app.notify_client.models import InvitedUser
|
from bs4 import BeautifulSoup
|
||||||
from notifications_python_client.errors import HTTPError
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
def test_existing_user_accept_invite_calls_api_and_redirects_to_dashboard(app_,
|
def test_existing_user_accept_invite_calls_api_and_redirects_to_dashboard(app_,
|
||||||
@@ -28,3 +25,142 @@ def test_existing_user_accept_invite_calls_api_and_redirects_to_dashboard(app_,
|
|||||||
|
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
assert response.location == expected_redirect_location
|
assert response.location == expected_redirect_location
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_signed_out_user_accept_invite_redirects_to_sign_in(app_,
|
||||||
|
service_one,
|
||||||
|
api_user_active,
|
||||||
|
sample_invite,
|
||||||
|
mock_accept_invite,
|
||||||
|
mock_get_user_by_email,
|
||||||
|
mock_add_user_to_service):
|
||||||
|
|
||||||
|
expected_service = service_one['id']
|
||||||
|
expected_redirect_location = 'http://localhost/services/{}/dashboard'.format(expected_service)
|
||||||
|
|
||||||
|
with app_.test_request_context():
|
||||||
|
with app_.test_client() as client:
|
||||||
|
|
||||||
|
response = client.get(url_for('main.accept_invite', token='thisisnotarealtoken'), follow_redirects=True)
|
||||||
|
|
||||||
|
mock_accept_invite.assert_called_with('thisisnotarealtoken')
|
||||||
|
mock_get_user_by_email.assert_called_with('invited_user@test.gov.uk')
|
||||||
|
mock_add_user_to_service.assert_called_with(expected_service, api_user_active.id)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
page = BeautifulSoup(response.data.decode('utf-8'), 'html.parser')
|
||||||
|
assert page.h1.string.strip() == 'Sign in'
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_user_accept_invite_calls_api_and_redirects_to_registration(app_,
|
||||||
|
service_one,
|
||||||
|
sample_invite,
|
||||||
|
mock_accept_invite,
|
||||||
|
mock_dont_get_user_by_email,
|
||||||
|
mock_add_user_to_service):
|
||||||
|
|
||||||
|
expected_service = service_one['id']
|
||||||
|
expected_redirect_location = 'http://localhost/register-from-invite'
|
||||||
|
|
||||||
|
with app_.test_request_context():
|
||||||
|
with app_.test_client() as client:
|
||||||
|
|
||||||
|
response = client.get(url_for('main.accept_invite', token='thisisnotarealtoken'))
|
||||||
|
|
||||||
|
mock_accept_invite.assert_called_with('thisisnotarealtoken')
|
||||||
|
mock_dont_get_user_by_email.assert_called_with('invited_user@test.gov.uk')
|
||||||
|
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert response.location == expected_redirect_location
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_user_accept_invite_completes_new_registration_redirects_to_verify(app_,
|
||||||
|
service_one,
|
||||||
|
sample_invite,
|
||||||
|
mock_accept_invite,
|
||||||
|
mock_dont_get_user_by_email,
|
||||||
|
mock_register_user,
|
||||||
|
mock_send_verify_code,
|
||||||
|
mock_add_user_to_service):
|
||||||
|
|
||||||
|
expected_service = service_one['id']
|
||||||
|
expected_email = sample_invite['email_address']
|
||||||
|
expected_from_user = service_one['users'][0]
|
||||||
|
expected_redirect_location = 'http://localhost/register-from-invite'
|
||||||
|
|
||||||
|
with app_.test_request_context():
|
||||||
|
with app_.test_client() as client:
|
||||||
|
response = client.get(url_for('main.accept_invite', token='thisisnotarealtoken'))
|
||||||
|
with client.session_transaction() as session:
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert response.location == expected_redirect_location
|
||||||
|
invited_user = session.get('invited_user')
|
||||||
|
assert invited_user
|
||||||
|
assert expected_service == invited_user['service']
|
||||||
|
assert expected_email == invited_user['email_address']
|
||||||
|
assert expected_from_user == invited_user['from_user']
|
||||||
|
|
||||||
|
data = {'service': invited_user['service'],
|
||||||
|
'email_address': invited_user['email_address'],
|
||||||
|
'from_user': invited_user['from_user'],
|
||||||
|
'password': 'longpassword',
|
||||||
|
'mobile_number': '+447890123456',
|
||||||
|
'name': 'Invited User'
|
||||||
|
}
|
||||||
|
|
||||||
|
expected_redirect_location = 'http://localhost/verify'
|
||||||
|
response = client.post(url_for('main.register_from_invite'), data=data)
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert response.location == expected_redirect_location
|
||||||
|
|
||||||
|
mock_register_user.assert_called_with(data['name'],
|
||||||
|
data['email_address'],
|
||||||
|
data['mobile_number'],
|
||||||
|
data['password'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_invited_user_verifies_and_added_to_service(app_,
|
||||||
|
service_one,
|
||||||
|
sample_invite,
|
||||||
|
mock_accept_invite,
|
||||||
|
mock_dont_get_user_by_email,
|
||||||
|
mock_register_user,
|
||||||
|
mock_send_verify_code,
|
||||||
|
mock_check_verify_code,
|
||||||
|
mock_get_user,
|
||||||
|
mock_update_user,
|
||||||
|
mock_add_user_to_service,
|
||||||
|
mock_get_service,
|
||||||
|
mock_get_service_templates,
|
||||||
|
mock_get_jobs):
|
||||||
|
|
||||||
|
with app_.test_request_context():
|
||||||
|
with app_.test_client() as client:
|
||||||
|
# visit accept token page
|
||||||
|
response = client.get(url_for('main.accept_invite', token='thisisnotarealtoken'))
|
||||||
|
data = {'service': sample_invite['service'],
|
||||||
|
'email_address': sample_invite['email_address'],
|
||||||
|
'from_user': sample_invite['from_user'],
|
||||||
|
'password': 'longpassword',
|
||||||
|
'mobile_number': '+447890123456',
|
||||||
|
'name': 'Invited User'
|
||||||
|
}
|
||||||
|
|
||||||
|
# get redirected to register from invite
|
||||||
|
response = client.post(url_for('main.register_from_invite'), data=data)
|
||||||
|
|
||||||
|
# that sends user on to verify
|
||||||
|
response = client.post(url_for('main.verify'), data={'sms_code': '12345', 'email_code': '23456'},
|
||||||
|
follow_redirects=True)
|
||||||
|
|
||||||
|
# when they post codes back to admin user should be added to
|
||||||
|
# service and sent on to dash board
|
||||||
|
with client.session_transaction() as session:
|
||||||
|
new_user_id = session['user_id']
|
||||||
|
mock_add_user_to_service.assert_called_with(data['service'], new_user_id)
|
||||||
|
|
||||||
|
page = BeautifulSoup(response.data.decode('utf-8'), 'html.parser')
|
||||||
|
element = page.find('h2', class_='navigation-service-name').find('a')
|
||||||
|
assert element.text == 'Test Service'
|
||||||
|
service_link = element.attrs['href']
|
||||||
|
assert service_link == '/services/{}/dashboard'.format(service_one['id'])
|
||||||
|
|||||||
@@ -252,7 +252,6 @@ def test_should_redirect_after_mobile_number_confirm(app_,
|
|||||||
response = client.post(
|
response = client.post(
|
||||||
url_for('main.user_profile_mobile_number_confirm'),
|
url_for('main.user_profile_mobile_number_confirm'),
|
||||||
data=data)
|
data=data)
|
||||||
print(response.get_data(as_text=True))
|
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
assert response.location == url_for(
|
assert response.location == url_for(
|
||||||
'main.user_profile', _external=True)
|
'main.user_profile', _external=True)
|
||||||
@@ -287,7 +286,6 @@ def test_should_redirect_after_password_change(app_,
|
|||||||
url_for('main.user_profile_password'),
|
url_for('main.user_profile_password'),
|
||||||
data=data)
|
data=data)
|
||||||
|
|
||||||
print(response.get_data(as_text=True))
|
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
assert response.location == url_for(
|
assert response.location == url_for(
|
||||||
'main.user_profile', _external=True)
|
'main.user_profile', _external=True)
|
||||||
|
|||||||
@@ -572,7 +572,7 @@ def sample_invite(mocker, service_one):
|
|||||||
email_address = 'invited_user@test.gov.uk'
|
email_address = 'invited_user@test.gov.uk'
|
||||||
service_id = service_one['id']
|
service_id = service_one['id']
|
||||||
permissions = 'send_messages,manage_service,manage_api_keys'
|
permissions = 'send_messages,manage_service,manage_api_keys'
|
||||||
created_at = datetime.datetime.now()
|
created_at = str(datetime.datetime.now())
|
||||||
return invite_json(id, from_user, service_id, email_address, permissions, created_at)
|
return invite_json(id, from_user, service_id, email_address, permissions, created_at)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user