Browse Source

Merge pull request #103 from tracim/feature/612_user_account_endpoint

inkhey 5 years ago
parent
commit
29456158cf
No account linked to committer's email

+ 2 - 0
tracim/__init__.py View File

@@ -32,6 +32,7 @@ from tracim.views.contents_api.comment_controller import CommentController
32 32
 from tracim.views.contents_api.file_controller import FileController
33 33
 from tracim.views.errors import ErrorSchema
34 34
 from tracim.exceptions import NotAuthenticated
35
+from tracim.exceptions import UserNotActive
35 36
 from tracim.exceptions import InvalidId
36 37
 from tracim.exceptions import InsufficientUserProfile
37 38
 from tracim.exceptions import InsufficientUserRoleInWorkspace
@@ -97,6 +98,7 @@ def web(global_config, **local_settings):
97 98
     context.handle_exception(InvalidId, HTTPStatus.BAD_REQUEST)
98 99
     # Auth exception
99 100
     context.handle_exception(NotAuthenticated, HTTPStatus.UNAUTHORIZED)
101
+    context.handle_exception(UserNotActive, HTTPStatus.FORBIDDEN)
100 102
     context.handle_exception(AuthenticationFailed, HTTPStatus.FORBIDDEN)
101 103
     context.handle_exception(InsufficientUserRoleInWorkspace, HTTPStatus.FORBIDDEN)  # nopep8
102 104
     context.handle_exception(InsufficientUserProfile, HTTPStatus.FORBIDDEN)

+ 13 - 0
tracim/exceptions.py View File

@@ -140,6 +140,7 @@ class InvalidWorkspaceId(InvalidId):
140 140
 class InvalidUserId(InvalidId):
141 141
     pass
142 142
 
143
+
143 144
 class ContentNotFound(TracimException):
144 145
     pass
145 146
 
@@ -152,6 +153,10 @@ class WorkspacesDoNotMatch(TracimException):
152 153
     pass
153 154
 
154 155
 
156
+class PasswordDoNotMatch(TracimException):
157
+    pass
158
+
159
+
155 160
 class EmptyValueNotAllowed(TracimException):
156 161
     pass
157 162
 
@@ -164,6 +169,14 @@ class EmptyCommentContentNotAllowed(EmptyValueNotAllowed):
164 169
     pass
165 170
 
166 171
 
172
+class UserNotActive(TracimException):
173
+    pass
174
+
175
+
176
+class NoUserSetted(TracimException):
177
+    pass
178
+
179
+
167 180
 class RoleDoesNotExist(TracimException):
168 181
     pass
169 182
 

+ 102 - 9
tracim/lib/core/user.py View File

@@ -1,23 +1,24 @@
1 1
 # -*- coding: utf-8 -*-
2
-import threading
3 2
 from smtplib import SMTPException
4 3
 
5 4
 import transaction
6 5
 import typing as typing
7
-
8
-from tracim.exceptions import NotificationNotSend
9
-from tracim.exceptions import EmailValidationFailed
10
-from tracim.lib.mail_notifier.notifier import get_email_manager
11 6
 from sqlalchemy.orm import Session
7
+from sqlalchemy.orm.exc import NoResultFound
12 8
 
13 9
 from tracim import CFG
14 10
 from tracim.models.auth import User
15 11
 from tracim.models.auth import Group
16
-from sqlalchemy.orm.exc import NoResultFound
12
+from tracim.exceptions import NoUserSetted
13
+from tracim.exceptions import PasswordDoNotMatch
14
+from tracim.exceptions import EmailValidationFailed
17 15
 from tracim.exceptions import UserDoesNotExist
18 16
 from tracim.exceptions import WrongUserPassword
19 17
 from tracim.exceptions import AuthenticationFailed
18
+from tracim.exceptions import NotificationNotSend
19
+from tracim.exceptions import UserNotActive
20 20
 from tracim.models.context_models import UserInContext
21
+from tracim.lib.mail_notifier.notifier import get_email_manager
21 22
 from tracim.models.context_models import TypeUser
22 23
 
23 24
 
@@ -150,6 +151,8 @@ class UserApi(object):
150 151
         """
151 152
         try:
152 153
             user = self.get_one_by_email(email)
154
+            if not user.is_active:
155
+                raise UserNotActive('User "{}" is not active'.format(email))
153 156
             if user.validate_password(password):
154 157
                 return user
155 158
             else:
@@ -158,6 +161,72 @@ class UserApi(object):
158 161
             raise AuthenticationFailed('User "{}" authentication failed'.format(email)) from exc  # nopep8
159 162
 
160 163
     # Actions
164
+    def set_password(
165
+            self,
166
+            user: User,
167
+            loggedin_user_password: str,
168
+            new_password: str,
169
+            new_password2: str,
170
+            do_save: bool=True
171
+    ):
172
+        """
173
+        Set User password if loggedin user password is correct
174
+        and both new_password are the same.
175
+        :param user: User who need password changed
176
+        :param loggedin_user_password: cleartext password of logged user (not
177
+        same as user)
178
+        :param new_password: new password for user
179
+        :param new_password2: should be same as new_password
180
+        :param do_save: should we save new user password ?
181
+        :return:
182
+        """
183
+        if not self._user:
184
+            raise NoUserSetted('Current User should be set in UserApi to use this method')  # nopep8
185
+        if not self._user.validate_password(loggedin_user_password):  # nopep8
186
+            raise WrongUserPassword(
187
+                'Wrong password for authenticated user {}'. format(self._user.user_id)  # nopep8
188
+            )
189
+        if new_password != new_password2:
190
+            raise PasswordDoNotMatch('Passwords given are different')
191
+
192
+        self.update(
193
+            user=user,
194
+            password=new_password,
195
+            do_save=do_save,
196
+        )
197
+        if do_save:
198
+            # TODO - G.M - 2018-07-24 - Check why commit is needed here
199
+            transaction.commit()
200
+        return user
201
+
202
+    def set_email(
203
+            self,
204
+            user: User,
205
+            loggedin_user_password: str,
206
+            email: str,
207
+            do_save: bool = True
208
+    ):
209
+        """
210
+        Set email address of user if loggedin user password is correct
211
+        :param user: User who need email changed
212
+        :param loggedin_user_password: cleartext password of logged user (not
213
+        same as user)
214
+        :param email:
215
+        :param do_save:
216
+        :return:
217
+        """
218
+        if not self._user:
219
+            raise NoUserSetted('Current User should be set in UserApi to use this method')  # nopep8
220
+        if not self._user.validate_password(loggedin_user_password):  # nopep8
221
+            raise WrongUserPassword(
222
+                'Wrong password for authenticated user {}'. format(self._user.user_id)  # nopep8
223
+            )
224
+        self.update(
225
+            user=user,
226
+            email=email,
227
+            do_save=do_save,
228
+        )
229
+        return user
161 230
 
162 231
     def _check_email(self, email: str) -> bool:
163 232
         # TODO - G.M - 2018-07-05 - find a better way to check email
@@ -174,9 +243,10 @@ class UserApi(object):
174 243
             name: str=None,
175 244
             email: str=None,
176 245
             password: str=None,
177
-            timezone: str='',
246
+            timezone: str=None,
247
+            groups: typing.Optional[typing.List[Group]]=None,
178 248
             do_save=True,
179
-    ) -> None:
249
+    ) -> User:
180 250
         if name is not None:
181 251
             user.display_name = name
182 252
 
@@ -189,11 +259,24 @@ class UserApi(object):
189 259
         if password is not None:
190 260
             user.password = password
191 261
 
192
-        user.timezone = timezone
262
+        if timezone is not None:
263
+            user.timezone = timezone
264
+
265
+        if groups is not None:
266
+            # INFO - G.M - 2018-07-18 - Delete old groups
267
+            for group in user.groups:
268
+                if group not in groups:
269
+                    user.groups.remove(group)
270
+            # INFO - G.M - 2018-07-18 - add new groups
271
+            for group in groups:
272
+                if group not in user.groups:
273
+                    user.groups.append(group)
193 274
 
194 275
         if do_save:
195 276
             self.save(user)
196 277
 
278
+        return user
279
+
197 280
     def create_user(
198 281
         self,
199 282
         email,
@@ -251,6 +334,16 @@ class UserApi(object):
251 334
 
252 335
         return user
253 336
 
337
+    def enable(self, user: User, do_save=False):
338
+        user.is_active = True
339
+        if do_save:
340
+            self.save(user)
341
+
342
+    def disable(self, user:User, do_save=False):
343
+        user.is_active = False
344
+        if do_save:
345
+            self.save(user)
346
+
254 347
     def save(self, user: User):
255 348
         self._session.flush()
256 349
 

+ 1 - 0
tracim/lib/utils/authentification.py View File

@@ -32,6 +32,7 @@ def basic_auth_check_credentials(
32 32
     user = _get_basic_auth_unsafe_user(request)
33 33
     if not user \
34 34
             or user.email != login \
35
+            or not user.is_active \
35 36
             or not user.validate_password(cleartext_password):
36 37
         return None
37 38
     return []

+ 3 - 0
tracim/lib/utils/request.py View File

@@ -3,6 +3,7 @@ from pyramid.request import Request
3 3
 from sqlalchemy.orm.exc import NoResultFound
4 4
 
5 5
 from tracim.exceptions import NotAuthenticated
6
+from tracim.exceptions import UserNotActive
6 7
 from tracim.exceptions import ContentNotFound
7 8
 from tracim.exceptions import InvalidUserId
8 9
 from tracim.exceptions import InvalidWorkspaceId
@@ -321,6 +322,8 @@ class TracimRequest(Request):
321 322
             if not login:
322 323
                 raise UserNotFoundInTracimRequest('You request a current user but the context not permit to found one')  # nopep8
323 324
             user = uapi.get_one_by_email(login)
325
+            if not user.is_active:
326
+                raise UserNotActive('User {} is not active'.format(login))
324 327
         except (UserDoesNotExist, UserNotFoundInTracimRequest) as exc:
325 328
             raise NotAuthenticated('User {} not found'.format(login)) from exc
326 329
         return user

+ 61 - 0
tracim/models/context_models.py View File

@@ -49,6 +49,67 @@ class LoginCredentials(object):
49 49
         self.password = password
50 50
 
51 51
 
52
+class SetEmail(object):
53
+    """
54
+    Just an email
55
+    """
56
+    def __init__(self, loggedin_user_password: str, email: str) -> None:
57
+        self.loggedin_user_password = loggedin_user_password
58
+        self.email = email
59
+
60
+
61
+class SetPassword(object):
62
+    """
63
+    Just an password
64
+    """
65
+    def __init__(self,
66
+        loggedin_user_password: str,
67
+        new_password: str,
68
+        new_password2: str
69
+    ) -> None:
70
+        self.loggedin_user_password = loggedin_user_password
71
+        self.new_password = new_password
72
+        self.new_password2 = new_password2
73
+
74
+
75
+class UserInfos(object):
76
+    """
77
+    Just some user infos
78
+    """
79
+    def __init__(self, timezone: str, public_name: str) -> None:
80
+        self.timezone = timezone
81
+        self.public_name = public_name
82
+
83
+
84
+class UserProfile(object):
85
+    """
86
+    Just some user infos
87
+    """
88
+    def __init__(self, profile: str) -> None:
89
+        self.profile = profile
90
+
91
+
92
+class UserCreation(object):
93
+    """
94
+    Just some user infos
95
+    """
96
+    def __init__(
97
+            self,
98
+            email: str,
99
+            password: str,
100
+            public_name: str,
101
+            timezone: str,
102
+            profile: str,
103
+            email_notification: str,
104
+    ) -> None:
105
+        self.email = email
106
+        self.password = password
107
+        self.public_name = public_name
108
+        self.timezone = timezone
109
+        self.profile = profile
110
+        self.email_notification = email_notification
111
+
112
+
52 113
 class WorkspaceAndContentPath(object):
53 114
     """
54 115
     Paths params with workspace id and content_id model

+ 82 - 0
tracim/tests/functional/test_session.py View File

@@ -1,8 +1,13 @@
1 1
 # coding=utf-8
2 2
 import datetime
3 3
 import pytest
4
+import transaction
4 5
 from sqlalchemy.exc import OperationalError
5 6
 
7
+from tracim import models
8
+from tracim.lib.core.group import GroupApi
9
+from tracim.lib.core.user import UserApi
10
+from tracim.models import get_tm_session
6 11
 from tracim.tests import FunctionalTest
7 12
 from tracim.tests import FunctionalTestNoDB
8 13
 
@@ -59,6 +64,45 @@ class TestLoginEndpoint(FunctionalTest):
59 64
         assert res.json_body['caldav_url'] is None
60 65
         assert res.json_body['avatar_url'] is None
61 66
 
67
+    def test_api__try_login_enpoint__err_401__user_not_activated(self):
68
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
69
+        admin = dbsession.query(models.User) \
70
+            .filter(models.User.email == 'admin@admin.admin') \
71
+            .one()
72
+        uapi = UserApi(
73
+            current_user=admin,
74
+            session=dbsession,
75
+            config=self.app_config,
76
+        )
77
+        gapi = GroupApi(
78
+            current_user=admin,
79
+            session=dbsession,
80
+            config=self.app_config,
81
+        )
82
+        groups = [gapi.get_one_with_name('users')]
83
+        test_user = uapi.create_user(
84
+            email='test@test.test',
85
+            password='pass',
86
+            name='bob',
87
+            groups=groups,
88
+            timezone='Europe/Paris',
89
+            do_save=True,
90
+            do_notify=False,
91
+        )
92
+        uapi.save(test_user)
93
+        uapi.disable(test_user)
94
+        transaction.commit()
95
+
96
+        params = {
97
+            'email': 'test@test.test',
98
+            'password': 'test@test.test',
99
+        }
100
+        res = self.testapp.post_json(
101
+            '/api/v2/sessions/login',
102
+            params=params,
103
+            status=403,
104
+        )
105
+
62 106
     def test_api__try_login_enpoint__err_403__bad_password(self):
63 107
         params = {
64 108
             'email': 'admin@admin.admin',
@@ -117,6 +161,44 @@ class TestWhoamiEndpoint(FunctionalTest):
117 161
         assert res.json_body['caldav_url'] is None
118 162
         assert res.json_body['avatar_url'] is None
119 163
 
164
+    def test_api__try_whoami_enpoint__err_401__user_is_not_active(self):
165
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
166
+        admin = dbsession.query(models.User) \
167
+            .filter(models.User.email == 'admin@admin.admin') \
168
+            .one()
169
+        uapi = UserApi(
170
+            current_user=admin,
171
+            session=dbsession,
172
+            config=self.app_config,
173
+        )
174
+        gapi = GroupApi(
175
+            current_user=admin,
176
+            session=dbsession,
177
+            config=self.app_config,
178
+        )
179
+        groups = [gapi.get_one_with_name('users')]
180
+        test_user = uapi.create_user(
181
+            email='test@test.test',
182
+            password='pass',
183
+            name='bob',
184
+            groups=groups,
185
+            timezone='Europe/Paris',
186
+            do_save=True,
187
+            do_notify=False,
188
+        )
189
+        uapi.save(test_user)
190
+        uapi.disable(test_user)
191
+        transaction.commit()
192
+        self.testapp.authorization = (
193
+            'Basic',
194
+            (
195
+                'test@test.test',
196
+                'pass'
197
+            )
198
+        )
199
+
200
+        res = self.testapp.get('/api/v2/sessions/whoami', status=401)
201
+
120 202
     def test_api__try_whoami_enpoint__err_401__unauthenticated(self):
121 203
         self.testapp.authorization = (
122 204
             'Basic',

File diff suppressed because it is too large
+ 1439 - 1
tracim/tests/functional/test_user.py


+ 29 - 3
tracim/tests/library/test_user_api.py View File

@@ -1,10 +1,11 @@
1 1
 # -*- coding: utf-8 -*-
2 2
 import pytest
3
-from sqlalchemy.orm.exc import NoResultFound
4
-
5 3
 import transaction
6 4
 
7
-from tracim.exceptions import UserDoesNotExist, AuthenticationFailed
5
+from tracim.exceptions import AuthenticationFailed
6
+from tracim.exceptions import UserDoesNotExist
7
+from tracim.exceptions import UserNotActive
8
+from tracim.lib.core.group import GroupApi
8 9
 from tracim.lib.core.user import UserApi
9 10
 from tracim.models import User
10 11
 from tracim.models.context_models import UserInContext
@@ -171,6 +172,31 @@ class TestUserApi(DefaultTest):
171 172
         assert isinstance(user, User)
172 173
         assert user.email == 'admin@admin.admin'
173 174
 
175
+    def test_unit__authenticate_user___err__user_not_active(self):
176
+        api = UserApi(
177
+            current_user=None,
178
+            session=self.session,
179
+            config=self.config,
180
+        )
181
+        gapi = GroupApi(
182
+            current_user=None,
183
+            session=self.session,
184
+            config=self.config,
185
+        )
186
+        groups = [gapi.get_one_with_name('users')]
187
+        user = api.create_user(
188
+            email='test@test.test',
189
+            password='pass',
190
+            name='bob',
191
+            groups=groups,
192
+            timezone='Europe/Paris',
193
+            do_save=True,
194
+            do_notify=False,
195
+        )
196
+        api.disable(user)
197
+        with pytest.raises(UserNotActive):
198
+            api.authenticate_user('test@test.test', 'test@test.test')
199
+
174 200
     def test_unit__authenticate_user___err__wrong_password(self):
175 201
         api = UserApi(
176 202
             current_user=None,

+ 78 - 3
tracim/views/core_api/schemas.py View File

@@ -14,6 +14,11 @@ from tracim.models.context_models import ActiveContentFilter
14 14
 from tracim.models.context_models import ContentIdsQuery
15 15
 from tracim.models.context_models import UserWorkspaceAndContentPath
16 16
 from tracim.models.context_models import ContentCreation
17
+from tracim.models.context_models import UserCreation
18
+from tracim.models.context_models import SetEmail
19
+from tracim.models.context_models import SetPassword
20
+from tracim.models.context_models import UserInfos
21
+from tracim.models.context_models import UserProfile
17 22
 from tracim.models.context_models import ContentPreviewSizedPath
18 23
 from tracim.models.context_models import RevisionPreviewSizedPath
19 24
 from tracim.models.context_models import PageQuery
@@ -65,11 +70,11 @@ class UserSchema(UserDigestSchema):
65 70
     )
66 71
     is_active = marshmallow.fields.Bool(
67 72
         example=True,
68
-         # TODO - G.M - Explains this value.
73
+        description='Is user account activated ?'
69 74
     )
70 75
     # TODO - G.M - 17-04-2018 - Restrict timezone values
71 76
     timezone = marshmallow.fields.String(
72
-        example="Paris/Europe",
77
+        example="Europe/Paris",
73 78
     )
74 79
     # TODO - G.M - 17-04-2018 - check this, relative url allowed ?
75 80
     caldav_url = marshmallow.fields.Url(
@@ -88,9 +93,78 @@ class UserSchema(UserDigestSchema):
88 93
     class Meta:
89 94
         description = 'User account of Tracim'
90 95
 
91
-# Path Schemas
96
+
97
+class LoggedInUserPasswordSchema(marshmallow.Schema):
98
+    loggedin_user_password = marshmallow.fields.String(
99
+        required=True,
100
+    )
101
+
102
+
103
+class SetEmailSchema(LoggedInUserPasswordSchema):
104
+    email = marshmallow.fields.Email(
105
+        required=True,
106
+        example='suri.cate@algoo.fr'
107
+    )
108
+
109
+    @post_load
110
+    def create_set_email_object(self, data):
111
+        return SetEmail(**data)
112
+
113
+
114
+class SetPasswordSchema(LoggedInUserPasswordSchema):
115
+    new_password = marshmallow.fields.String(
116
+        example='8QLa$<w',
117
+        required=True
118
+    )
119
+    new_password2 = marshmallow.fields.String(
120
+        example='8QLa$<w',
121
+        required=True
122
+    )
123
+
124
+    @post_load
125
+    def create_set_password_object(self, data):
126
+        return SetPassword(**data)
127
+
128
+
129
+class UserInfosSchema(marshmallow.Schema):
130
+    timezone = marshmallow.fields.String(
131
+        example="Europe/Paris",
132
+        required=True,
133
+    )
134
+    public_name = marshmallow.fields.String(
135
+        example='Suri Cate',
136
+        required=True,
137
+    )
138
+
139
+    @post_load
140
+    def create_user_info_object(self, data):
141
+        return UserInfos(**data)
142
+
143
+
144
+class UserProfileSchema(marshmallow.Schema):
145
+    profile = marshmallow.fields.String(
146
+        attribute='profile',
147
+        validate=OneOf(Profile._NAME),
148
+        example='managers',
149
+    )
150
+    @post_load
151
+    def create_user_profile(self, data):
152
+        return UserProfile(**data)
153
+
154
+
155
+class UserCreationSchema(
156
+    SetEmailSchema,
157
+    SetPasswordSchema,
158
+    UserInfosSchema,
159
+    UserProfileSchema
160
+):
161
+    @post_load
162
+    def create_user(self, data):
163
+        return UserCreation(**data)
92 164
 
93 165
 
166
+# Path Schemas
167
+
94 168
 class UserIdPathSchema(marshmallow.Schema):
95 169
     user_id = marshmallow.fields.Int(
96 170
         example=3,
@@ -306,6 +380,7 @@ class ContentIdsQuerySchema(marshmallow.Schema):
306 380
     def make_contents_ids(self, data):
307 381
         return ContentIdsQuery(**data)
308 382
 
383
+
309 384
 ###
310 385
 
311 386
 

+ 236 - 12
tracim/views/core_api/user_controller.py View File

@@ -1,27 +1,36 @@
1 1
 from pyramid.config import Configurator
2
-
3
-from tracim.lib.core.content import ContentApi
4
-from tracim.lib.utils.authorization import require_same_user_or_profile
5
-from tracim.models import Group
6
-
7 2
 try:  # Python 3.5+
8 3
     from http import HTTPStatus
9 4
 except ImportError:
10 5
     from http import client as HTTPStatus
11 6
 
12
-from tracim import hapic, TracimRequest
13
-
7
+from tracim import hapic
8
+from tracim import TracimRequest
9
+from tracim.models import Group
10
+from tracim.lib.core.group import GroupApi
11
+from tracim.lib.core.user import UserApi
14 12
 from tracim.lib.core.workspace import WorkspaceApi
13
+from tracim.lib.core.content import ContentApi
15 14
 from tracim.views.controllers import Controller
16
-from tracim.views.core_api.schemas import UserIdPathSchema, ReadStatusSchema, \
17
-    ContentIdsQuerySchema
15
+from tracim.lib.utils.authorization import require_same_user_or_profile
16
+from tracim.lib.utils.authorization import require_profile
17
+from tracim.exceptions import WrongUserPassword
18
+from tracim.exceptions import PasswordDoNotMatch
19
+from tracim.views.core_api.schemas import UserSchema
20
+from tracim.views.core_api.schemas import SetEmailSchema
21
+from tracim.views.core_api.schemas import SetPasswordSchema
22
+from tracim.views.core_api.schemas import UserInfosSchema
23
+from tracim.views.core_api.schemas import UserCreationSchema
24
+from tracim.views.core_api.schemas import UserProfileSchema
25
+from tracim.views.core_api.schemas import UserIdPathSchema
26
+from tracim.views.core_api.schemas import ReadStatusSchema
27
+from tracim.views.core_api.schemas import ContentIdsQuerySchema
18 28
 from tracim.views.core_api.schemas import NoContentSchema
19 29
 from tracim.views.core_api.schemas import UserWorkspaceIdPathSchema
20 30
 from tracim.views.core_api.schemas import UserWorkspaceAndContentIdPathSchema
21 31
 from tracim.views.core_api.schemas import ContentDigestSchema
22 32
 from tracim.views.core_api.schemas import ActiveContentFilterQuerySchema
23 33
 from tracim.views.core_api.schemas import WorkspaceDigestSchema
24
-from tracim.models.contents import ContentTypeLegacy as ContentType
25 34
 
26 35
 USER_ENDPOINTS_TAG = 'Users'
27 36
 
@@ -51,6 +60,189 @@ class UserController(Controller):
51 60
 
52 61
     @hapic.with_api_doc(tags=[USER_ENDPOINTS_TAG])
53 62
     @require_same_user_or_profile(Group.TIM_ADMIN)
63
+    @hapic.input_path(UserIdPathSchema())
64
+    @hapic.output_body(UserSchema())
65
+    def user(self, context, request: TracimRequest, hapic_data=None):
66
+        """
67
+        Get user infos.
68
+        """
69
+        app_config = request.registry.settings['CFG']
70
+        uapi = UserApi(
71
+            current_user=request.current_user,  # User
72
+            session=request.dbsession,
73
+            config=app_config,
74
+        )
75
+        return uapi.get_user_with_context(request.candidate_user)
76
+
77
+    @hapic.with_api_doc(tags=[USER_ENDPOINTS_TAG])
78
+    @hapic.handle_exception(WrongUserPassword, HTTPStatus.FORBIDDEN)
79
+    @require_same_user_or_profile(Group.TIM_ADMIN)
80
+    @hapic.input_body(SetEmailSchema())
81
+    @hapic.input_path(UserIdPathSchema())
82
+    @hapic.output_body(UserSchema())
83
+    def set_user_email(self, context, request: TracimRequest, hapic_data=None):
84
+        """
85
+        Set user Email
86
+        """
87
+        app_config = request.registry.settings['CFG']
88
+        uapi = UserApi(
89
+            current_user=request.current_user,  # User
90
+            session=request.dbsession,
91
+            config=app_config,
92
+        )
93
+        user = uapi.set_email(
94
+            request.candidate_user,
95
+            hapic_data.body.loggedin_user_password,
96
+            hapic_data.body.email,
97
+            do_save=True
98
+        )
99
+        return uapi.get_user_with_context(user)
100
+
101
+    @hapic.with_api_doc(tags=[USER_ENDPOINTS_TAG])
102
+    @hapic.handle_exception(WrongUserPassword, HTTPStatus.FORBIDDEN)
103
+    @hapic.handle_exception(PasswordDoNotMatch, HTTPStatus.BAD_REQUEST)
104
+    @require_same_user_or_profile(Group.TIM_ADMIN)
105
+    @hapic.input_body(SetPasswordSchema())
106
+    @hapic.input_path(UserIdPathSchema())
107
+    @hapic.output_body(NoContentSchema(), default_http_code=HTTPStatus.NO_CONTENT)  # nopep8
108
+    def set_user_password(self, context, request: TracimRequest, hapic_data=None):  # nopep8
109
+        """
110
+        Set user password
111
+        """
112
+        app_config = request.registry.settings['CFG']
113
+        uapi = UserApi(
114
+            current_user=request.current_user,  # User
115
+            session=request.dbsession,
116
+            config=app_config,
117
+        )
118
+        uapi.set_password(
119
+            request.candidate_user,
120
+            hapic_data.body.loggedin_user_password,
121
+            hapic_data.body.new_password,
122
+            hapic_data.body.new_password2,
123
+            do_save=True
124
+        )
125
+        return
126
+
127
+    @hapic.with_api_doc(tags=[USER_ENDPOINTS_TAG])
128
+    @require_same_user_or_profile(Group.TIM_ADMIN)
129
+    @hapic.input_body(UserInfosSchema())
130
+    @hapic.input_path(UserIdPathSchema())
131
+    @hapic.output_body(UserSchema())
132
+    def set_user_infos(self, context, request: TracimRequest, hapic_data=None):
133
+        """
134
+        Set user info data
135
+        """
136
+        app_config = request.registry.settings['CFG']
137
+        uapi = UserApi(
138
+            current_user=request.current_user,  # User
139
+            session=request.dbsession,
140
+            config=app_config,
141
+        )
142
+        user = uapi.update(
143
+            request.candidate_user,
144
+            name=hapic_data.body.public_name,
145
+            timezone=hapic_data.body.timezone,
146
+            do_save=True
147
+        )
148
+        return uapi.get_user_with_context(user)
149
+
150
+    @hapic.with_api_doc(tags=[USER_ENDPOINTS_TAG])
151
+    @require_profile(Group.TIM_ADMIN)
152
+    @hapic.input_path(UserIdPathSchema())
153
+    @hapic.input_body(UserCreationSchema())
154
+    @hapic.output_body(UserSchema())
155
+    def create_user(self, context, request: TracimRequest, hapic_data=None):
156
+        """
157
+        Create new user
158
+        """
159
+        app_config = request.registry.settings['CFG']
160
+        uapi = UserApi(
161
+            current_user=request.current_user,  # User
162
+            session=request.dbsession,
163
+            config=app_config,
164
+        )
165
+        gapi = GroupApi(
166
+            current_user=request.current_user,  # User
167
+            session=request.dbsession,
168
+            config=app_config,
169
+        )
170
+        groups = [gapi.get_one_with_name(hapic_data.body.profile)]
171
+        user = uapi.create_user(
172
+            email=hapic_data.body.email,
173
+            password=hapic_data.body.password,
174
+            timezone=hapic_data.body.timezone,
175
+            name=hapic_data.body.public_name,
176
+            do_notify=hapic_data.body.email_notification,
177
+            groups=groups,
178
+            do_save=True
179
+        )
180
+        return uapi.get_user_with_context(user)
181
+
182
+    @hapic.with_api_doc(tags=[USER_ENDPOINTS_TAG])
183
+    @require_profile(Group.TIM_ADMIN)
184
+    @hapic.input_path(UserIdPathSchema())
185
+    @hapic.output_body(NoContentSchema(), default_http_code=HTTPStatus.NO_CONTENT)  # nopep8
186
+    def enable_user(self, context, request: TracimRequest, hapic_data=None):
187
+        """
188
+        enable user
189
+        """
190
+        app_config = request.registry.settings['CFG']
191
+        uapi = UserApi(
192
+            current_user=request.current_user,  # User
193
+            session=request.dbsession,
194
+            config=app_config,
195
+        )
196
+        uapi.enable(user=request.candidate_user, do_save=True)
197
+        return
198
+
199
+    @hapic.with_api_doc(tags=[USER_ENDPOINTS_TAG])
200
+    @require_profile(Group.TIM_ADMIN)
201
+    @hapic.input_path(UserIdPathSchema())
202
+    @hapic.output_body(NoContentSchema(), default_http_code=HTTPStatus.NO_CONTENT)  # nopep8
203
+    def disable_user(self, context, request: TracimRequest, hapic_data=None):
204
+        """
205
+        disable user
206
+        """
207
+        app_config = request.registry.settings['CFG']
208
+        uapi = UserApi(
209
+            current_user=request.current_user,  # User
210
+            session=request.dbsession,
211
+            config=app_config,
212
+        )
213
+        uapi.disable(user=request.candidate_user, do_save=True)
214
+        return
215
+
216
+    @hapic.with_api_doc(tags=[USER_ENDPOINTS_TAG])
217
+    @require_profile(Group.TIM_ADMIN)
218
+    @hapic.input_path(UserIdPathSchema())
219
+    @hapic.input_body(UserProfileSchema())
220
+    @hapic.output_body(NoContentSchema(), default_http_code=HTTPStatus.NO_CONTENT)  # nopep8
221
+    def set_profile(self, context, request: TracimRequest, hapic_data=None):
222
+        """
223
+        set user profile
224
+        """
225
+        app_config = request.registry.settings['CFG']
226
+        uapi = UserApi(
227
+            current_user=request.current_user,  # User
228
+            session=request.dbsession,
229
+            config=app_config,
230
+        )
231
+        gapi = GroupApi(
232
+            current_user=request.current_user,  # User
233
+            session=request.dbsession,
234
+            config=app_config,
235
+        )
236
+        groups = [gapi.get_one_with_name(hapic_data.body.profile)]
237
+        uapi.update(
238
+            user=request.candidate_user,
239
+            groups=groups,
240
+            do_save=True,
241
+        )
242
+        return
243
+
244
+    @hapic.with_api_doc(tags=[USER_ENDPOINTS_TAG])
245
+    @require_same_user_or_profile(Group.TIM_ADMIN)
54 246
     @hapic.input_path(UserWorkspaceIdPathSchema())
55 247
     @hapic.input_query(ActiveContentFilterQuerySchema())
56 248
     @hapic.output_body(ContentDigestSchema(many=True))
@@ -175,10 +367,42 @@ class UserController(Controller):
175 367
         for this controller
176 368
         """
177 369
 
178
-        # user worskpace
370
+        # user workspace
179 371
         configurator.add_route('user_workspace', '/users/{user_id}/workspaces', request_method='GET')  # nopep8
180 372
         configurator.add_view(self.user_workspace, route_name='user_workspace')
181 373
 
374
+        # user info
375
+        configurator.add_route('user', '/users/{user_id}', request_method='GET')  # nopep8
376
+        configurator.add_view(self.user, route_name='user')
377
+
378
+        # set user email
379
+        configurator.add_route('set_user_email', '/users/{user_id}/email', request_method='PUT')  # nopep8
380
+        configurator.add_view(self.set_user_email, route_name='set_user_email')
381
+
382
+        # set user password
383
+        configurator.add_route('set_user_password', '/users/{user_id}/password', request_method='PUT')  # nopep8
384
+        configurator.add_view(self.set_user_password, route_name='set_user_password')  # nopep8
385
+
386
+        # set user_info
387
+        configurator.add_route('set_user_info', '/users/{user_id}', request_method='PUT')  # nopep8
388
+        configurator.add_view(self.set_user_infos, route_name='set_user_info')
389
+
390
+        # create user
391
+        configurator.add_route('create_user', '/users', request_method='POST')
392
+        configurator.add_view(self.create_user, route_name='create_user')
393
+
394
+        # enable user
395
+        configurator.add_route('enable_user', '/users/{user_id}/enable', request_method='PUT')  # nopep8
396
+        configurator.add_view(self.enable_user, route_name='enable_user')
397
+
398
+        # disable user
399
+        configurator.add_route('disable_user', '/users/{user_id}/disable', request_method='PUT')  # nopep8
400
+        configurator.add_view(self.disable_user, route_name='disable_user')
401
+
402
+        # set user profile
403
+        configurator.add_route('set_user_profile', '/users/{user_id}/profile', request_method='PUT')  # nopep8
404
+        configurator.add_view(self.set_profile, route_name='set_user_profile')
405
+
182 406
         # user content
183 407
         configurator.add_route('contents_read_status', '/users/{user_id}/workspaces/{workspace_id}/contents/read_status', request_method='GET')  # nopep8
184 408
         configurator.add_view(self.contents_read_status, route_name='contents_read_status')  # nopep8
@@ -194,4 +418,4 @@ class UserController(Controller):
194 418
 
195 419
         # set workspace as read
196 420
         configurator.add_route('read_workspace', '/users/{user_id}/workspaces/{workspace_id}/read', request_method='PUT')  # nopep8
197
-        configurator.add_view(self.set_workspace_as_read, route_name='read_workspace')  # nopep8
421
+        configurator.add_view(self.set_workspace_as_read, route_name='read_workspace')  # nopep8