##// END OF EJS Templates
account: convert change password form to colander schema and fix bug...
dan -
r665:a92da8f2 default
parent child Browse files
Show More
@@ -0,0 +1,33 b''
1 # -*- coding: utf-8 -*-
2
3 # Copyright (C) 2010-2016 RhodeCode GmbH
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
21 """
22 Base module for form rendering / validation - currently just a wrapper for
23 deform - later can be replaced with something custom.
24 """
25
26 from rhodecode.translation import _
27 from deform import Button, Form, widget, ValidationFailure
28
29
30 class buttons:
31 save = Button(name='Save', type='submit')
32 reset = Button(name=_('Reset'), type='reset')
33 delete = Button(name=_('Delete'), type='submit')
@@ -0,0 +1,61 b''
1 # -*- coding: utf-8 -*-
2
3 # Copyright (C) 2016-2016 RhodeCode GmbH
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
21 import colander
22
23 from rhodecode import forms
24 from rhodecode.model.db import User
25 from rhodecode.translation import _
26 from rhodecode.lib.auth import check_password
27
28
29 @colander.deferred
30 def deferred_user_password_validator(node, kw):
31 username = kw.get('username')
32 user = User.get_by_username(username)
33
34 def _user_password_validator(node, value):
35 if not check_password(value, user.password):
36 msg = _('Password is incorrect')
37 raise colander.Invalid(node, msg)
38 return _user_password_validator
39
40
41 class ChangePasswordSchema(colander.Schema):
42
43 current_password = colander.SchemaNode(
44 colander.String(),
45 missing=colander.required,
46 widget=forms.widget.PasswordWidget(redisplay=True),
47 validator=deferred_user_password_validator)
48
49 new_password = colander.SchemaNode(
50 colander.String(),
51 missing=colander.required,
52 widget=forms.widget.CheckedPasswordWidget(redisplay=True),
53 validator=colander.Length(min=6))
54
55
56 def validator(self, form, values):
57 if values['current_password'] == values['new_password']:
58 exc = colander.Invalid(form)
59 exc['new_password'] = _('New password must be different '
60 'to old password')
61 raise exc
@@ -0,0 +1,10 b''
1 <%def name="panel(title, class_='default')">
2 <div class="panel panel-${class_}">
3 <div class="panel-heading">
4 <h3 class="panel-title">${title}</h3>
5 </div>
6 <div class="panel-body">
7 ${caller.body()}
8 </div>
9 </div>
10 </%def>
@@ -0,0 +1,72 b''
1 # -*- coding: utf-8 -*-
2
3 # Copyright (C) 2016-2016 RhodeCode GmbH
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
21 import colander
22 import pytest
23
24 from rhodecode.model import validation_schema
25 from rhodecode.model.validation_schema.schemas import user_schema
26
27
28 class TestChangePasswordSchema(object):
29 original_password = 'm092d903fnio0m'
30
31 def test_deserialize_bad_data(self, user_regular):
32 schema = user_schema.ChangePasswordSchema().bind(
33 username=user_regular.username)
34
35 with pytest.raises(validation_schema.Invalid) as exc_info:
36 schema.deserialize('err')
37 err = exc_info.value.asdict()
38 assert err[''] == '"err" is not a mapping type: ' \
39 'Does not implement dict-like functionality.'
40
41 def test_validate_valid_change_password_data(self, user_util):
42 user = user_util.create_user(password=self.original_password)
43 schema = user_schema.ChangePasswordSchema().bind(
44 username=user.username)
45
46 schema.deserialize({
47 'current_password': self.original_password,
48 'new_password': '23jf04rm04imr'
49 })
50
51 @pytest.mark.parametrize(
52 'current_password,new_password,key,message', [
53 ('', 'abcdef123', 'current_password', 'required'),
54 ('wrong_pw', 'abcdef123', 'current_password', 'incorrect'),
55 (original_password, original_password, 'new_password', 'different'),
56 (original_password, '', 'new_password', 'Required'),
57 (original_password, 'short', 'new_password', 'minimum'),
58 ])
59 def test_validate_invalid_change_password_data(self, current_password,
60 new_password, key, message,
61 user_util):
62 user = user_util.create_user(password=self.original_password)
63 schema = user_schema.ChangePasswordSchema().bind(
64 username=user.username)
65
66 with pytest.raises(validation_schema.Invalid) as exc_info:
67 schema.deserialize({
68 'current_password': current_password,
69 'new_password': new_password
70 })
71 err = exc_info.value.asdict()
72 assert message.lower() in err[key].lower()
@@ -531,9 +531,7 b' def make_map(config):'
531 action='my_account_update', conditions={'method': ['POST']})
531 action='my_account_update', conditions={'method': ['POST']})
532
532
533 m.connect('my_account_password', '/my_account/password',
533 m.connect('my_account_password', '/my_account/password',
534 action='my_account_password', conditions={'method': ['GET']})
534 action='my_account_password', conditions={'method': ['GET', 'POST']})
535 m.connect('my_account_password', '/my_account/password',
536 action='my_account_password_update', conditions={'method': ['POST']})
537
535
538 m.connect('my_account_repos', '/my_account/repos',
536 m.connect('my_account_repos', '/my_account/repos',
539 action='my_account_repos', conditions={'method': ['GET']})
537 action='my_account_repos', conditions={'method': ['GET']})
@@ -32,6 +32,7 b' from pylons.controllers.util import redi'
32 from pylons.i18n.translation import _
32 from pylons.i18n.translation import _
33 from sqlalchemy.orm import joinedload
33 from sqlalchemy.orm import joinedload
34
34
35 from rhodecode import forms
35 from rhodecode.lib import helpers as h
36 from rhodecode.lib import helpers as h
36 from rhodecode.lib import auth
37 from rhodecode.lib import auth
37 from rhodecode.lib.auth import (
38 from rhodecode.lib.auth import (
@@ -39,10 +40,12 b' from rhodecode.lib.auth import ('
39 from rhodecode.lib.base import BaseController, render
40 from rhodecode.lib.base import BaseController, render
40 from rhodecode.lib.utils2 import safe_int, md5
41 from rhodecode.lib.utils2 import safe_int, md5
41 from rhodecode.lib.ext_json import json
42 from rhodecode.lib.ext_json import json
43
44 from rhodecode.model.validation_schema.schemas import user_schema
42 from rhodecode.model.db import (
45 from rhodecode.model.db import (
43 Repository, PullRequest, PullRequestReviewers, UserEmailMap, User,
46 Repository, PullRequest, PullRequestReviewers, UserEmailMap, User,
44 UserFollowing)
47 UserFollowing)
45 from rhodecode.model.forms import UserForm, PasswordChangeForm
48 from rhodecode.model.forms import UserForm
46 from rhodecode.model.scm import RepoList
49 from rhodecode.model.scm import RepoList
47 from rhodecode.model.user import UserModel
50 from rhodecode.model.user import UserModel
48 from rhodecode.model.repo import RepoModel
51 from rhodecode.model.repo import RepoModel
@@ -185,38 +188,44 b' class MyAccountController(BaseController'
185 force_defaults=False
188 force_defaults=False
186 )
189 )
187
190
188 @auth.CSRFRequired()
191 @auth.CSRFRequired(except_methods=['GET'])
189 def my_account_password_update(self):
192 def my_account_password(self):
190 c.active = 'password'
193 c.active = 'password'
191 self.__load_data()
194 self.__load_data()
192 _form = PasswordChangeForm(c.rhodecode_user.username)()
195
196 schema = user_schema.ChangePasswordSchema().bind(
197 username=c.rhodecode_user.username)
198
199 form = forms.Form(schema,
200 buttons=(forms.buttons.save, forms.buttons.reset))
201
202 if request.method == 'POST':
203 controls = request.POST.items()
193 try:
204 try:
194 form_result = _form.to_python(request.POST)
205 valid_data = form.validate(controls)
195 UserModel().update_user(c.rhodecode_user.user_id, **form_result)
206 UserModel().update_user(c.rhodecode_user.user_id, **valid_data)
196 instance = c.rhodecode_user.get_instance()
207 instance = c.rhodecode_user.get_instance()
197 instance.update_userdata(force_password_change=False)
208 instance.update_userdata(force_password_change=False)
198 Session().commit()
209 Session().commit()
210 except forms.ValidationFailure as e:
211 request.session.flash(
212 _('Error occurred during update of user password'),
213 queue='error')
214 form = e
215 except Exception:
216 log.exception("Exception updating password")
217 request.session.flash(
218 _('Error occurred during update of user password'),
219 queue='error')
220 else:
199 session.setdefault('rhodecode_user', {}).update(
221 session.setdefault('rhodecode_user', {}).update(
200 {'password': md5(instance.password)})
222 {'password': md5(instance.password)})
201 session.save()
223 session.save()
202 h.flash(_("Successfully updated password"), category='success')
224 request.session.flash(
203 except formencode.Invalid as errors:
225 _("Successfully updated password"), queue='success')
204 return htmlfill.render(
226 return redirect(url('my_account_password'))
205 render('admin/my_account/my_account.html'),
206 defaults=errors.value,
207 errors=errors.error_dict or {},
208 prefix_error=False,
209 encoding="UTF-8",
210 force_defaults=False)
211 except Exception:
212 log.exception("Exception updating password")
213 h.flash(_('Error occurred during update of user password'),
214 category='error')
215 return render('admin/my_account/my_account.html')
216
227
217 def my_account_password(self):
228 c.form = form
218 c.active = 'password'
219 self.__load_data()
220 return render('admin/my_account/my_account.html')
229 return render('admin/my_account/my_account.html')
221
230
222 def my_account_repos(self):
231 def my_account_repos(self):
@@ -1116,9 +1116,11 b' class CSRFRequired(object):'
1116 For use with the ``webhelpers.secure_form`` helper functions.
1116 For use with the ``webhelpers.secure_form`` helper functions.
1117
1117
1118 """
1118 """
1119 def __init__(self, token=csrf_token_key, header='X-CSRF-Token'):
1119 def __init__(self, token=csrf_token_key, header='X-CSRF-Token',
1120 except_methods=None):
1120 self.token = token
1121 self.token = token
1121 self.header = header
1122 self.header = header
1123 self.except_methods = except_methods or []
1122
1124
1123 def __call__(self, func):
1125 def __call__(self, func):
1124 return get_cython_compat_decorator(self.__wrapper, func)
1126 return get_cython_compat_decorator(self.__wrapper, func)
@@ -1131,6 +1133,9 b' class CSRFRequired(object):'
1131 return supplied_token and supplied_token == cur_token
1133 return supplied_token and supplied_token == cur_token
1132
1134
1133 def __wrapper(self, func, *fargs, **fkwargs):
1135 def __wrapper(self, func, *fargs, **fkwargs):
1136 if request.method in self.except_methods:
1137 return func(*fargs, **fkwargs)
1138
1134 cur_token = get_csrf_token(save_if_missing=False)
1139 cur_token = get_csrf_token(save_if_missing=False)
1135 if self.check_csrf(request, cur_token):
1140 if self.check_csrf(request, cur_token):
1136 if request.POST.get(self.token):
1141 if request.POST.get(self.token):
@@ -102,20 +102,6 b' def LoginForm():'
102 return _LoginForm
102 return _LoginForm
103
103
104
104
105 def PasswordChangeForm(username):
106 class _PasswordChangeForm(formencode.Schema):
107 allow_extra_fields = True
108 filter_extra_fields = True
109
110 current_password = v.ValidOldPassword(username)(not_empty=True)
111 new_password = All(v.ValidPassword(), v.UnicodeString(strip=False, min=6))
112 new_password_confirmation = All(v.ValidPassword(), v.UnicodeString(strip=False, min=6))
113
114 chained_validators = [v.ValidPasswordsMatch('new_password',
115 'new_password_confirmation')]
116 return _PasswordChangeForm
117
118
119 def UserForm(edit=False, available_languages=[], old_data={}):
105 def UserForm(edit=False, available_languages=[], old_data={}):
120 class _UserForm(formencode.Schema):
106 class _UserForm(formencode.Schema):
121 allow_extra_fields = True
107 allow_extra_fields = True
@@ -147,7 +147,6 b' class UserModel(BaseModel):'
147 # cleanups, my_account password change form
147 # cleanups, my_account password change form
148 kwargs.pop('current_password', None)
148 kwargs.pop('current_password', None)
149 kwargs.pop('new_password', None)
149 kwargs.pop('new_password', None)
150 kwargs.pop('new_password_confirmation', None)
151
150
152 # cleanups, user edit password change form
151 # cleanups, user edit password change form
153 kwargs.pop('password_confirmation', None)
152 kwargs.pop('password_confirmation', None)
@@ -13,7 +13,3 b' def ip_addr_validator(node, value):'
13 except ValueError:
13 except ValueError:
14 msg = _(u'Please enter a valid IPv4 or IpV6 address')
14 msg = _(u'Please enter a valid IPv4 or IpV6 address')
15 raise colander.Invalid(node, msg)
15 raise colander.Invalid(node, msg)
16
17
18
19
@@ -12,6 +12,7 b''
12
12
13 .control-label {
13 .control-label {
14 width: 200px;
14 width: 200px;
15 padding: 10px;
15 float: left;
16 float: left;
16 }
17 }
17 .control-inputs {
18 .control-inputs {
@@ -26,6 +27,13 b''
26
27
27 .form-group {
28 .form-group {
28 clear: left;
29 clear: left;
30 margin-bottom: 20px;
31
32 &:after { /* clear fix */
33 content: " ";
34 display: block;
35 clear: left;
36 }
29 }
37 }
30
38
31 .form-control {
39 .form-control {
@@ -34,6 +42,11 b''
34
42
35 .error-block {
43 .error-block {
36 color: red;
44 color: red;
45 margin: 0;
46 }
47
48 .help-block {
49 margin: 0;
37 }
50 }
38
51
39 .deform-seq-container .control-inputs {
52 .deform-seq-container .control-inputs {
@@ -62,7 +75,9 b''
62 }
75 }
63 }
76 }
64
77
65 .form-control.select2-container { height: 40px; }
78 .form-control.select2-container {
79 height: 40px;
80 }
66
81
67 .deform-two-field-sequence .deform-seq-container .deform-seq-item label {
82 .deform-two-field-sequence .deform-seq-container .deform-seq-item label {
68 display: none;
83 display: none;
@@ -74,7 +89,7 b''
74 display: none;
89 display: none;
75 }
90 }
76 .deform-two-field-sequence .deform-seq-container .deform-seq-item.form-group {
91 .deform-two-field-sequence .deform-seq-container .deform-seq-item.form-group {
77 background: red;
92 margin: 0;
78 }
93 }
79 .deform-two-field-sequence .deform-seq-container .deform-seq-item .deform-seq-item-group .form-group {
94 .deform-two-field-sequence .deform-seq-container .deform-seq-item .deform-seq-item-group .form-group {
80 width: 45%; padding: 0 2px; float: left; clear: none;
95 width: 45%; padding: 0 2px; float: left; clear: none;
@@ -1,42 +1,5 b''
1 <div class="panel panel-default">
1 <%namespace name="widgets" file="/widgets.html"/>
2 <div class="panel-heading">
3 <h3 class="panel-title">${_('Change Your Account Password')}</h3>
4 </div>
5 ${h.secure_form(url('my_account_password'), method='post')}
6 <div class="panel-body">
7 <div class="fields">
8 <div class="field">
9 <div class="label">
10 <label for="current_password">${_('Current Password')}:</label>
11 </div>
12 <div class="input">
13 ${h.password('current_password',class_='medium',autocomplete="off")}
14 </div>
15 </div>
16
2
17 <div class="field">
3 <%widgets:panel title="${_('Change Your Account Password')}">
18 <div class="label">
4 ${c.form.render() | n}
19 <label for="new_password">${_('New Password')}:</label>
5 </%widgets:panel>
20 </div>
21 <div class="input">
22 ${h.password('new_password',class_='medium', autocomplete="off")}
23 </div>
24 </div>
25
26 <div class="field">
27 <div class="label">
28 <label for="password_confirmation">${_('Confirm New Password')}:</label>
29 </div>
30 <div class="input">
31 ${h.password('new_password_confirmation',class_='medium', autocomplete="off")}
32 </div>
33 </div>
34
35 <div class="buttons">
36 ${h.submit('save',_('Save'),class_="btn")}
37 ${h.reset('reset',_('Reset'),class_="btn")}
38 </div>
39 </div>
40 </div>
41 ${h.end_form()}
42 </div> No newline at end of file
@@ -15,7 +15,6 b" if getattr(c, 'rhodecode_user', None) an"
15
15
16 c.template_context['visual']['default_renderer'] = h.get_visual_attr(c, 'default_renderer')
16 c.template_context['visual']['default_renderer'] = h.get_visual_attr(c, 'default_renderer')
17 %>
17 %>
18
19 <html xmlns="http://www.w3.org/1999/xhtml">
18 <html xmlns="http://www.w3.org/1999/xhtml">
20 <head>
19 <head>
21 <title>${self.title()}</title>
20 <title>${self.title()}</title>
@@ -21,6 +21,7 b''
21 import pytest
21 import pytest
22
22
23 from rhodecode.lib import helpers as h
23 from rhodecode.lib import helpers as h
24 from rhodecode.lib.auth import check_password
24 from rhodecode.model.db import User, UserFollowing, Repository, UserApiKeys
25 from rhodecode.model.db import User, UserFollowing, Repository, UserApiKeys
25 from rhodecode.model.meta import Session
26 from rhodecode.model.meta import Session
26 from rhodecode.tests import (
27 from rhodecode.tests import (
@@ -34,6 +35,7 b' fixture = Fixture()'
34
35
35 class TestMyAccountController(TestController):
36 class TestMyAccountController(TestController):
36 test_user_1 = 'testme'
37 test_user_1 = 'testme'
38 test_user_1_password = '0jd83nHNS/d23n'
37 destroy_users = set()
39 destroy_users = set()
38
40
39 @classmethod
41 @classmethod
@@ -158,7 +160,8 b' class TestMyAccountController(TestContro'
158 ('email', {'email': 'some@email.com'}),
160 ('email', {'email': 'some@email.com'}),
159 ])
161 ])
160 def test_my_account_update(self, name, attrs):
162 def test_my_account_update(self, name, attrs):
161 usr = fixture.create_user(self.test_user_1, password='qweqwe',
163 usr = fixture.create_user(self.test_user_1,
164 password=self.test_user_1_password,
162 email='testme@rhodecode.org',
165 email='testme@rhodecode.org',
163 extern_type='rhodecode',
166 extern_type='rhodecode',
164 extern_name=self.test_user_1,
167 extern_name=self.test_user_1,
@@ -167,7 +170,8 b' class TestMyAccountController(TestContro'
167
170
168 params = usr.get_api_data() # current user data
171 params = usr.get_api_data() # current user data
169 user_id = usr.user_id
172 user_id = usr.user_id
170 self.log_user(username=self.test_user_1, password='qweqwe')
173 self.log_user(
174 username=self.test_user_1, password=self.test_user_1_password)
171
175
172 params.update({'password_confirmation': ''})
176 params.update({'password_confirmation': ''})
173 params.update({'new_password': ''})
177 params.update({'new_password': ''})
@@ -321,8 +325,55 b' class TestMyAccountController(TestContro'
321 response = response.follow()
325 response = response.follow()
322 response.mustcontain(no=[api_key])
326 response.mustcontain(no=[api_key])
323
327
324 def test_password_is_updated_in_session_on_password_change(
328 def test_valid_change_password(self, user_util):
325 self, user_util):
329 new_password = 'my_new_valid_password'
330 user = user_util.create_user(password=self.test_user_1_password)
331 session = self.log_user(user.username, self.test_user_1_password)
332 form_data = [
333 ('current_password', self.test_user_1_password),
334 ('__start__', 'new_password:mapping'),
335 ('new_password', new_password),
336 ('new_password-confirm', new_password),
337 ('__end__', 'new_password:mapping'),
338 ('csrf_token', self.csrf_token),
339 ]
340 response = self.app.post(url('my_account_password'), form_data).follow()
341 assert 'Successfully updated password' in response
342
343 # check_password depends on user being in session
344 Session().add(user)
345 try:
346 assert check_password(new_password, user.password)
347 finally:
348 Session().expunge(user)
349
350 @pytest.mark.parametrize('current_pw,new_pw,confirm_pw', [
351 ('', 'abcdef123', 'abcdef123'),
352 ('wrong_pw', 'abcdef123', 'abcdef123'),
353 (test_user_1_password, test_user_1_password, test_user_1_password),
354 (test_user_1_password, '', ''),
355 (test_user_1_password, 'abcdef123', ''),
356 (test_user_1_password, '', 'abcdef123'),
357 (test_user_1_password, 'not_the', 'same_pw'),
358 (test_user_1_password, 'short', 'short'),
359 ])
360 def test_invalid_change_password(self, current_pw, new_pw, confirm_pw,
361 user_util):
362 user = user_util.create_user(password=self.test_user_1_password)
363 session = self.log_user(user.username, self.test_user_1_password)
364 old_password_hash = session['password']
365 form_data = [
366 ('current_password', current_pw),
367 ('__start__', 'new_password:mapping'),
368 ('new_password', new_pw),
369 ('new_password-confirm', confirm_pw),
370 ('__end__', 'new_password:mapping'),
371 ('csrf_token', self.csrf_token),
372 ]
373 response = self.app.post(url('my_account_password'), form_data)
374 assert 'Error occurred' in response
375
376 def test_password_is_updated_in_session_on_password_change(self, user_util):
326 old_password = 'abcdef123'
377 old_password = 'abcdef123'
327 new_password = 'abcdef124'
378 new_password = 'abcdef124'
328
379
@@ -330,12 +381,14 b' class TestMyAccountController(TestContro'
330 session = self.log_user(user.username, old_password)
381 session = self.log_user(user.username, old_password)
331 old_password_hash = session['password']
382 old_password_hash = session['password']
332
383
333 form_data = {
384 form_data = [
334 'current_password': old_password,
385 ('current_password', old_password),
335 'new_password': new_password,
386 ('__start__', 'new_password:mapping'),
336 'new_password_confirmation': new_password,
387 ('new_password', new_password),
337 'csrf_token': self.csrf_token
388 ('new_password-confirm', new_password),
338 }
389 ('__end__', 'new_password:mapping'),
390 ('csrf_token', self.csrf_token),
391 ]
339 self.app.post(url('my_account_password'), form_data)
392 self.app.post(url('my_account_password'), form_data)
340
393
341 response = self.app.get(url('home'))
394 response = self.app.get(url('home'))
General Comments 0
You need to be logged in to leave comments. Login now