diff --git a/config/settings/common_settings.py b/config/settings/common_settings.py index 91e7039539..2a9bc0a5ee 100644 --- a/config/settings/common_settings.py +++ b/config/settings/common_settings.py @@ -81,6 +81,7 @@ def pipe_delim(pipe_string): 'mathesar.rpc.tables', 'mathesar.rpc.tables.metadata', 'mathesar.rpc.tables.privileges', + 'mathesar.rpc.users' ] TEMPLATES = [ diff --git a/mathesar/api/permission_conditions.py b/mathesar/api/permission_conditions.py deleted file mode 100644 index 3521fa4c96..0000000000 --- a/mathesar/api/permission_conditions.py +++ /dev/null @@ -1,11 +0,0 @@ -# These are available to all AccessPolicy instances -# https://rsinger86.github.io/drf-access-policy/reusable_conditions/ - - -def is_superuser(request, view, action): - return request.user.is_superuser - - -def is_self(request, view, action): - user = view.get_object() - return request.user == user diff --git a/mathesar/api/permissions/__init__.py b/mathesar/api/permissions/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/mathesar/api/permissions/users.py b/mathesar/api/permissions/users.py deleted file mode 100644 index 8da687f94c..0000000000 --- a/mathesar/api/permissions/users.py +++ /dev/null @@ -1,34 +0,0 @@ -from rest_access_policy import AccessPolicy - - -class UserAccessPolicy(AccessPolicy): - statements = [ - # Anyone can read all users - { - 'action': ['list', 'retrieve', 'password_change'], - 'principal': '*', - 'effect': 'allow' - }, - # Only superusers can create users - { - 'action': ['create', 'password_reset'], - 'principal': '*', - 'effect': 'allow', - 'condition': 'is_superuser' - }, - # Users can edit and delete themselves - # Superusers can also edit and delete users - { - 'action': ['destroy', 'partial_update', 'update'], - 'principal': ['*'], - 'effect': 'allow', - 'condition_expression': ['(is_superuser or is_self)'] - }, - ] - - @classmethod - def scope_fields(cls, request, fields, instance=None): - # Don't show emails except to admins or self - if not (request.user.is_superuser or request.user == instance): - fields.pop('email', None) - return fields diff --git a/mathesar/api/serializers/users.py b/mathesar/api/serializers/users.py deleted file mode 100644 index ff33e32b69..0000000000 --- a/mathesar/api/serializers/users.py +++ /dev/null @@ -1,77 +0,0 @@ -from django.contrib.auth.password_validation import validate_password -from django.core.exceptions import ValidationError as DjangoValidationError -from rest_access_policy import FieldAccessMixin -from rest_framework import serializers - -from mathesar.api.exceptions.mixins import MathesarErrorMessageMixin -from mathesar.api.exceptions.validation_exceptions.exceptions import IncorrectOldPassword -from mathesar.api.permissions.users import UserAccessPolicy -from mathesar.models.users import User - - -class UserSerializer(MathesarErrorMessageMixin, FieldAccessMixin, serializers.ModelSerializer): - access_policy = UserAccessPolicy - - class Meta: - model = User - fields = [ - 'id', - 'full_name', - 'short_name', - 'username', - 'password', - 'email', - 'is_superuser', - 'display_language' - ] - extra_kwargs = { - 'password': {'write_only': True}, - } - - def get_fields(self): - fields = super().get_fields() - request = self.context.get("request", None) - if not hasattr(request, 'parser_context'): - return fields - kwargs = request.parser_context.get('kwargs') - if kwargs: - user_pk = kwargs.get('pk') - if user_pk: - if request.user.id == int(user_pk) or not request.user.is_superuser: - fields["is_superuser"].read_only = True - return fields - - def create(self, validated_data): - password = validated_data.pop('password') - user = User(**validated_data) - user.password_change_needed = True - user.set_password(password) - user.save() - return user - - -class ChangePasswordSerializer(MathesarErrorMessageMixin, serializers.Serializer): - password = serializers.CharField(write_only=True, required=True) - old_password = serializers.CharField(write_only=True, required=True) - - def validate_old_password(self, value): - user = self.context['request'].user - if user.check_password(value) is True: - return value - raise IncorrectOldPassword(field='old_password') - - def validate_password(self, value): - try: - validate_password(value) - except DjangoValidationError as e: - raise e - return value - - def update(self, instance, validated_data): - instance.set_password(validated_data['password']) - instance.save() - return instance - - -class PasswordResetSerializer(MathesarErrorMessageMixin, serializers.Serializer): - password = serializers.CharField(write_only=True, required=True) diff --git a/mathesar/api/viewsets/data_files.py b/mathesar/api/viewsets/data_files.py index fc20278de3..e5cf087a21 100644 --- a/mathesar/api/viewsets/data_files.py +++ b/mathesar/api/viewsets/data_files.py @@ -24,7 +24,7 @@ class DataFileViewSet(viewsets.GenericViewSet, ListModelMixin, RetrieveModelMixi filterset_class = DataFileFilter parser_classes = [MultiPartParser, JSONParser] - def partial_update(self, request, pk=None): + def partial_update(self, request, **kwargs): serializer = DataFileSerializer( data=request.data, context={'request': request}, partial=True ) diff --git a/mathesar/api/viewsets/users.py b/mathesar/api/viewsets/users.py deleted file mode 100644 index 1bcb2c30e4..0000000000 --- a/mathesar/api/viewsets/users.py +++ /dev/null @@ -1,42 +0,0 @@ -from django.contrib.auth import get_user_model -from rest_access_policy import AccessViewSetMixin -from rest_framework import status, viewsets -from rest_framework.decorators import action -from rest_framework.generics import get_object_or_404 -from rest_framework.response import Response - -from mathesar.api.serializers.users import ( - ChangePasswordSerializer, PasswordResetSerializer, UserSerializer, -) -from mathesar.api.pagination import DefaultLimitOffsetPagination -from mathesar.api.permissions.users import UserAccessPolicy - - -class UserViewSet(AccessViewSetMixin, viewsets.ModelViewSet): - queryset = get_user_model().objects.all().order_by('id') - serializer_class = UserSerializer - pagination_class = DefaultLimitOffsetPagination - access_policy = UserAccessPolicy - - @action(methods=['post'], detail=True) - def password_reset(self, request, pk=None): - serializer = PasswordResetSerializer(data=request.data, context={'request': request}) - serializer.is_valid(raise_exception=True) - user = get_object_or_404(get_user_model(), pk=pk) - password = serializer.validated_data["password"] - user.set_password(password) - # Make sure we redirect user to change password set by the admin on login - user.password_change_needed = True - user.save() - return Response(status=status.HTTP_200_OK) - - @action(methods=['post'], detail=False) - def password_change(self, request): - serializer = ChangePasswordSerializer( - instance=request.user, - data=request.data, - context={'request': request} - ) - serializer.is_valid(raise_exception=True) - serializer.save() - return Response(status=status.HTTP_200_OK) diff --git a/mathesar/rpc/users.py b/mathesar/rpc/users.py new file mode 100644 index 0000000000..947702009c --- /dev/null +++ b/mathesar/rpc/users.py @@ -0,0 +1,152 @@ +""" +Classes and functions exposed to the RPC endpoint for managing mathesar users. +""" +from typing import Optional, TypedDict +from modernrpc.core import rpc_method, REQUEST_KEY +from modernrpc.auth.basic import ( + http_basic_auth_login_required, + http_basic_auth_superuser_required +) + +from mathesar.rpc.exceptions.handlers import handle_rpc_exceptions +from mathesar.utils.users import ( + get_user, + list_users, + add_user, + update_self_user_info, + update_other_user_info, + delete_user, + change_password, + revoke_password +) + + +class UserInfo(TypedDict): + id: int + username: str + is_superuser: bool + email: str + full_name: str + display_language: str + + @classmethod + def from_model(cls, model): + return cls( + id=model.id, + username=model.username, + is_superuser=model.is_superuser, + email=model.email, + full_name=model.full_name, + display_language=model.display_language + ) + + +class UserDef(TypedDict): + username: str + password: str + is_superuser: bool + email: Optional[str] + full_name: Optional[str] + display_language: Optional[str] + + +@rpc_method(name='users.add') +@http_basic_auth_superuser_required +@handle_rpc_exceptions +def add(*, user_def: UserDef) -> UserInfo: + user = add_user(user_def) + return UserInfo.from_model(user) + + +@rpc_method(name='users.delete') +@http_basic_auth_superuser_required +@handle_rpc_exceptions +def delete(*, user_id: int) -> None: + delete_user(user_id) + + +@rpc_method(name="users.get") +@http_basic_auth_login_required +@handle_rpc_exceptions +def get(*, user_id: int) -> UserInfo: + user = get_user(user_id) + return UserInfo.from_model(user) + + +@rpc_method(name='users.list') +@http_basic_auth_login_required +@handle_rpc_exceptions +def list_() -> list[UserInfo]: + users = list_users() + return [UserInfo.from_model(user) for user in users] + + +@rpc_method(name='users.patch_self') +@http_basic_auth_login_required +@handle_rpc_exceptions +def patch_self( + *, + username: str, + email: str, + full_name: str, + display_language: str, + **kwargs +) -> UserInfo: + user = kwargs.get(REQUEST_KEY).user + updated_user_info = update_self_user_info( + user_id=user.id, + username=username, + email=email, + full_name=full_name, + display_language=display_language + ) + return UserInfo.from_model(updated_user_info) + + +@rpc_method(name='users.patch_other') +@http_basic_auth_superuser_required +@handle_rpc_exceptions +def patch_other( + *, + user_id: int, + username: str, + is_superuser: bool, + email: str, + full_name: str, + display_language: str +) -> UserInfo: + updated_user_info = update_other_user_info( + user_id=user_id, + username=username, + is_superuser=is_superuser, + email=email, + full_name=full_name, + display_language=display_language + ) + return UserInfo.from_model(updated_user_info) + + +@rpc_method(name='users.password.replace_own') +@http_basic_auth_login_required +@handle_rpc_exceptions +def replace_own( + *, + old_password: str, + new_password: str, + **kwargs +) -> None: + user = kwargs.get(REQUEST_KEY).user + if not user.check_password(old_password): + raise Exception('Old password is not correct') + change_password(user.id, new_password) + + +@rpc_method(name='users.password.revoke') +@http_basic_auth_superuser_required +@handle_rpc_exceptions +def revoke( + *, + user_id: int, + new_password: str, +) -> None: + revoke_password(user_id, new_password) diff --git a/mathesar/tests/api/test_custom_exceptions.py b/mathesar/tests/api/test_custom_exceptions.py deleted file mode 100644 index 9c1e5bc044..0000000000 --- a/mathesar/tests/api/test_custom_exceptions.py +++ /dev/null @@ -1,13 +0,0 @@ -import re - -from django.test import override_settings - - -@override_settings(MATHESAR_MODE='DEVELOPMENT') -def test_exception_stacktrace(client): - response = client.post('/api/ui/v0/users/') - response_data = response.json() - assert response.status_code == 400 - assert response_data[0]['code'] == 2002 - assert len(response_data[0]['stacktrace']) >= 1 - assert bool(re.match('(.*)",(.*)line(.*),(.*)in(.*)', response_data[0]['stacktrace'][0])) is True diff --git a/mathesar/tests/api/test_user_api.py b/mathesar/tests/api/test_user_api.py deleted file mode 100644 index 29953135e7..0000000000 --- a/mathesar/tests/api/test_user_api.py +++ /dev/null @@ -1,252 +0,0 @@ -from mathesar.models.users import User - - -def test_user_list(client): - response = client.get('/api/ui/v0/users/') - response_data = response.json() - - assert response.status_code == 200 - assert response_data['count'] >= 1 - assert len(response_data['results']) == response_data['count'] - - -def test_user_detail(client, admin_user): - response = client.get(f'/api/ui/v0/users/{admin_user.id}/') - response_data = response.json() - - assert response.status_code == 200 - assert response_data['username'] == 'admin' - assert 'password' not in response_data - assert response_data['email'] == 'admin@example.com' - assert response_data['is_superuser'] is True - - -def test_same_user_detail_as_non_superuser(client_bob, user_bob): - response = client_bob.get(f'/api/ui/v0/users/{user_bob.id}/') - response_data = response.json() - - assert response.status_code == 200 - assert response_data['username'] == 'bob' - assert 'password' not in response_data - assert response_data['email'] == 'bob@example.com' - assert response_data['is_superuser'] is False - - -def test_user_password_reset(client, user_bob): - new_password = 'new_password' - data = { - 'password': new_password - } - response = client.post(f'/api/ui/v0/users/{user_bob.id}/password_reset/', data=data) - assert response.status_code == 200 - user_bob.refresh_from_db() - assert user_bob.check_password(new_password) is True - - -def test_user_password_reset_non_superuser(client_bob, user_bob): - new_password = 'new_password' - data = { - 'password': new_password - } - response = client_bob.post(f'/api/ui/v0/users/{user_bob.id}/password_reset/', data=data) - assert response.status_code == 403 - - -def test_user_password_change(client_bob, user_bob): - new_password = 'NewPass0!' - old_password = 'password' - data = { - 'password': new_password, - 'old_password': old_password - } - response = client_bob.post('/api/ui/v0/users/password_change/', data=data) - assert response.status_code == 200 - user_bob.refresh_from_db() - assert user_bob.check_password(new_password) is True - - -def test_user_password_change_invalid(client_bob, user_bob): - new_password = 'new_pwd' - old_password = 'password' - data = { - 'password': new_password, - 'old_password': old_password - } - response = client_bob.post('/api/ui/v0/users/password_change/', data=data) - assert response.status_code == 400 - user_bob.refresh_from_db() - assert user_bob.check_password(new_password) is False - - -def test_diff_user_detail_as_non_superuser(client_bob, admin_user): - response = client_bob.get(f'/api/ui/v0/users/{admin_user.id}/') - response_data = response.json() - - assert response.status_code == 200 - assert response_data['username'] == 'admin' - assert 'password' not in response_data - # email should not be visible - assert 'email' not in response_data - assert response_data['is_superuser'] is True - - -def test_user_patch(client, admin_user): - desired_full_name = 'Administrator' - desired_short_name = 'Admin' - - # Check that the names are not present - initial_response = client.get(f'/api/ui/v0/users/{admin_user.id}/') - initial_response_data = initial_response.json() - assert initial_response.status_code == 200 - assert initial_response_data['full_name'] is None - assert initial_response_data['full_name'] is None - - # Change the names - data = {'full_name': desired_full_name, 'short_name': desired_short_name} - response = client.patch(f'/api/ui/v0/users/{admin_user.id}/', data) - response_data = response.json() - - # Ensure the names are changed - assert response.status_code == 200 - assert response_data['full_name'] == desired_full_name - assert response_data['short_name'] == desired_short_name - - -def test_user_patch_different_user(client_bob, user_alice): - # Change name - data = {'short_name': 'Bob'} - response = client_bob.patch(f'/api/ui/v0/users/{user_alice.id}/', data) - - assert response.status_code == 403 - assert response.json()[0]['code'] == 4004 - - -def test_user_create(client): - data = { - 'username': 'alice', - 'email': 'alice@example.com', - 'password': 'password', - 'short_name': 'Alice', - 'full_name': 'Alice Jones' - } - response = client.post('/api/ui/v0/users/', data) - response_data = response.json() - - # Ensure the names are changed - assert response.status_code == 201 - assert 'id' in response_data - assert response_data['username'] == data['username'] - assert response_data['email'] == data['email'] - assert 'password' not in response_data - assert response_data['short_name'] == data['short_name'] - assert response_data['full_name'] == data['full_name'] - created_user = User.objects.get(id=response_data['id']) - assert created_user.password_change_needed is True - # clean up - created_user.delete() - - -def test_user_create_no_superuser(client_bob): - data = { - 'username': 'alice', - 'email': 'alice@example.com', - 'password': 'password', - 'short_name': 'Alice', - 'full_name': 'Alice Jones' - } - response = client_bob.post('/api/ui/v0/users/', data) - assert response.status_code == 403 - assert response.json()[0]['code'] == 4004 - - -def test_superuser_create_superuser(client): - data = { - 'username': 'alice_admin', - 'email': 'alice_admin@example.com', - 'password': 'password', - 'short_name': 'Alice', - 'full_name': 'Alice Jones', - 'is_superuser': True - } - response = client.post('/api/ui/v0/users/', data) - response_data = response.json() - - assert response.status_code == 201 - assert 'id' in response_data - assert response_data['username'] == data['username'] - assert response_data['email'] == data['email'] - assert 'password' not in response_data - assert response_data['short_name'] == data['short_name'] - assert response_data['full_name'] == data['full_name'] - assert response_data['is_superuser'] is True - created_user = User.objects.get(id=response_data['id']) - assert created_user.password_change_needed is True - # clean up - created_user.delete() - - -def test_superuser_patch_different_user_admin_privileges(client, user_alice): - data = {'is_superuser': True} - response = client.patch(f'/api/ui/v0/users/{user_alice.id}/', data) - response_data = response.json() - - assert response.status_code == 200 - assert response_data['is_superuser'] is True - - -def test_superuser_patch_self_admin_privileges(client, admin_user): - data = {'is_superuser': False} - response = client.patch(f'/api/ui/v0/users/{admin_user.id}/', data) - response_data = response.json() - - assert response.status_code == 200 - assert response_data['is_superuser'] is True - - -def test_user_patch_self_admin_privileges(client_bob, user_bob): - data = {'is_superuser': True} - response = client_bob.patch(f'/api/ui/v0/users/{user_bob.id}/', data) - response_data = response.json() - - assert response.status_code == 200 - assert response_data['is_superuser'] is False - - -def test_user_delete(client, user_bob): - # Ensure we can access the user via API - initial_response = client.get(f'/api/ui/v0/users/{user_bob.id}/') - initial_response_data = initial_response.json() - assert initial_response_data['username'] == user_bob.username - - # Delete the user - response = client.delete(f'/api/ui/v0/users/{user_bob.id}/') - # Ensure that the deletion happened - assert response.status_code == 204 - assert User.objects.filter(id=user_bob.id).exists() is False - - -def test_user_delete_self(client_bob, user_bob): - # Delete the user - response = client_bob.delete(f'/api/ui/v0/users/{user_bob.id}/') - # Ensure that the deletion happened - assert response.status_code == 204 - assert User.objects.filter(id=user_bob.id).exists() is False - - -def test_user_delete_different_user(client_bob, user_alice): - # Delete the user - response = client_bob.delete(f'/api/ui/v0/users/{user_alice.id}/') - assert response.status_code == 403 - assert response.json()[0]['code'] == 4004 - - -def test_superuser_create_redirect_if_superuser_exists(client, admin_user): - response = client.get('/auth/create_superuser/') - assert response.status_code == 302 - assert response.url == '/' - - -def test_login_redirect_if_superuser_not_exists(anonymous_client): - response = anonymous_client.get('/auth/login/') - assert response.status_code == 302 - assert response.url == '/auth/create_superuser/' diff --git a/mathesar/tests/conftest.py b/mathesar/tests/conftest.py index 1a9ed6923d..7497f030dc 100644 --- a/mathesar/tests/conftest.py +++ b/mathesar/tests/conftest.py @@ -151,13 +151,6 @@ def anonymous_client(): return client -@pytest.fixture -def client_bob(user_bob): - client = APIClient() - client.login(username='bob', password='password') - return client - - @pytest.fixture def client_alice(user_alice): client = APIClient() diff --git a/mathesar/tests/rpc/test_endpoints.py b/mathesar/tests/rpc/test_endpoints.py index c788da56b5..34c652d54e 100644 --- a/mathesar/tests/rpc/test_endpoints.py +++ b/mathesar/tests/rpc/test_endpoints.py @@ -19,6 +19,7 @@ from mathesar.rpc import schemas from mathesar.rpc import servers from mathesar.rpc import tables +from mathesar.rpc import users METHODS = [ ( @@ -413,6 +414,47 @@ tables.metadata.set_, "tables.metadata.set", [user_is_authenticated] + ), + + ( + users.add, + "users.add", + [user_is_superuser] + ), + ( + users.delete, + "users.delete", + [user_is_superuser] + ), + ( + users.get, + "users.get", + [user_is_authenticated] + ), + ( + users.list_, + "users.list", + [user_is_authenticated] + ), + ( + users.patch_self, + "users.patch_self", + [user_is_authenticated] + ), + ( + users.patch_other, + "users.patch_other", + [user_is_superuser] + ), + ( + users.replace_own, + "users.password.replace_own", + [user_is_authenticated] + ), + ( + users.revoke, + "users.password.revoke", + [user_is_superuser] ) ] diff --git a/mathesar/urls.py b/mathesar/urls.py index a3f60b4c8a..57beb50da3 100644 --- a/mathesar/urls.py +++ b/mathesar/urls.py @@ -4,7 +4,6 @@ from mathesar import views from mathesar.api.viewsets.data_files import DataFileViewSet -from mathesar.api.viewsets.users import UserViewSet from mathesar.users.decorators import superuser_exist, superuser_must_not_exist from mathesar.users.password_reset import MathesarPasswordResetConfirmView from mathesar.users.superuser_create import SuperuserFormView @@ -12,13 +11,9 @@ db_router = routers.DefaultRouter() db_router.register(r'data_files', DataFileViewSet, basename='data-file') -ui_router = routers.DefaultRouter() -ui_router.register(r'users', UserViewSet, basename='user') - urlpatterns = [ path('api/rpc/v0/', views.MathesarRPCEntryPoint.as_view()), path('api/db/v0/', include(db_router.urls)), - path('api/ui/v0/', include(ui_router.urls)), path('auth/password_reset_confirm', MathesarPasswordResetConfirmView.as_view(), name='password_reset_confirm'), path('auth/login/', superuser_exist(LoginView.as_view(redirect_authenticated_user=True)), name='login'), path('auth/create_superuser/', superuser_must_not_exist(SuperuserFormView.as_view()), name='superuser_create'), diff --git a/mathesar/utils/users.py b/mathesar/utils/users.py new file mode 100644 index 0000000000..1fe5475335 --- /dev/null +++ b/mathesar/utils/users.py @@ -0,0 +1,65 @@ +from django.db import transaction + +from mathesar.models import User + + +def get_user(user_id): + return User.objects.get(id=user_id) + + +def list_users(): + return User.objects.all() + + +@transaction.atomic +def add_user(user_def): + user = User.objects.create( + username=user_def["username"], + is_superuser=user_def["is_superuser"], + email=user_def.get("email", ""), + full_name=user_def.get("full_name", ""), + display_language=user_def.get("display_language", "en"), + password_change_needed=True + ) + user.set_password(user_def["password"]) + user.save() + return user + + +def update_self_user_info(user_id, username, email, full_name, display_language): + User.objects.filter(id=user_id).update( + username=username, + email=email, + full_name=full_name, + display_language=display_language + ) + return get_user(user_id) + + +def update_other_user_info(user_id, username, is_superuser, email, full_name, display_language): + User.objects.filter(id=user_id).update( + username=username, + is_superuser=is_superuser, + email=email, + full_name=full_name, + display_language=display_language + ) + return get_user(user_id) + + +def delete_user(user_id): + User.objects.get(id=user_id).delete() + + +def change_password(user_id, new_password): + user = get_user(user_id) + user.set_password(new_password) + user.password_change_needed = False + user.save() + + +def revoke_password(user_id, new_password): + user = get_user(user_id) + user.set_password(new_password) + user.password_change_needed = True + user.save() diff --git a/mathesar/views.py b/mathesar/views.py index 7035d9e048..986b51330a 100644 --- a/mathesar/views.py +++ b/mathesar/views.py @@ -12,7 +12,7 @@ from mathesar.rpc.schemas import list_ as schemas_list from mathesar.rpc.servers.configured import list_ as get_servers_list from mathesar.rpc.tables import list_with_metadata as tables_list -from mathesar.api.serializers.users import UserSerializer +from mathesar.rpc.users import get as get_user_info from mathesar import __version__ @@ -71,12 +71,7 @@ def get_queries_list(request, database_id, schema_oid): def get_user_data(request): - user_serializer = UserSerializer( - request.user, - many=False, - context={'request': request} - ) - return user_serializer.data + return get_user_info(user_id=request.user.id) def _get_internal_db_meta(): diff --git a/mathesar_ui/src/api/rest/users.ts b/mathesar_ui/src/api/rest/users.ts deleted file mode 100644 index 6649efe8e6..0000000000 --- a/mathesar_ui/src/api/rest/users.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { Language } from '@mathesar/i18n/languages/utils'; - -import { - type PaginatedResponse, - deleteAPI, - getAPI, - patchAPI, - postAPI, -} from './utils/requestUtils'; - -export interface UnsavedUser { - full_name: string | null; - email: string | null; - username: string; - password: string; - display_language: Language; -} - -export interface User extends Omit { - readonly id: number; - readonly is_superuser: boolean; -} - -function list() { - return getAPI>('/api/ui/v0/users/'); -} - -function get(userId: User['id']) { - return getAPI(`/api/ui/v0/users/${userId}/`); -} - -function add(user: UnsavedUser) { - return postAPI('/api/ui/v0/users/', user); -} - -function deleteUser(userId: User['id']) { - return deleteAPI(`/api/ui/v0/users/${userId}/`); -} - -function update( - userId: User['id'], - properties: Partial>, -) { - return patchAPI(`/api/ui/v0/users/${userId}/`, properties); -} - -function changePassword(old_password: string, password: string) { - return postAPI('/api/ui/v0/users/password_change/', { - password, - old_password, - }); -} - -function resetPassword(userId: User['id'], password: string) { - return postAPI(`/api/ui/v0/users/${userId}/password_reset/`, { - password, - }); -} - -export default { - list, - get, - add, - delete: deleteUser, - update, - changePassword, - resetPassword, -}; diff --git a/mathesar_ui/src/api/rpc/index.ts b/mathesar_ui/src/api/rpc/index.ts index 16510638df..933e8b2d4c 100644 --- a/mathesar_ui/src/api/rpc/index.ts +++ b/mathesar_ui/src/api/rpc/index.ts @@ -13,6 +13,7 @@ import { roles } from './roles'; import { schemas } from './schemas'; import { servers } from './servers'; import { tables } from './tables'; +import { users } from './users'; /** Mathesar's JSON-RPC API */ export const api = buildRpcApi({ @@ -30,5 +31,6 @@ export const api = buildRpcApi({ schemas, servers, tables, + users, }, }); diff --git a/mathesar_ui/src/api/rpc/users.ts b/mathesar_ui/src/api/rpc/users.ts new file mode 100644 index 0000000000..d1c9548eb5 --- /dev/null +++ b/mathesar_ui/src/api/rpc/users.ts @@ -0,0 +1,54 @@ +import type { Language } from '@mathesar/i18n/languages/utils'; +import { rpcMethodTypeContainer } from '@mathesar/packages/json-rpc-client-builder'; + +export interface BaseUser { + readonly full_name: string | null; + readonly email: string | null; + readonly username: string; + readonly display_language: Language; +} + +interface UserDef extends BaseUser { + readonly password: string; + readonly is_superuser: boolean; +} + +export interface User extends BaseUser { + readonly id: number; + readonly is_superuser: boolean; +} + +export const users = { + list: rpcMethodTypeContainer(), + + get: rpcMethodTypeContainer<{ user_id: User['id'] }, User>(), + + add: rpcMethodTypeContainer<{ user_def: UserDef }, User>(), + + delete: rpcMethodTypeContainer<{ user_id: User['id'] }, void>(), + + patch_self: rpcMethodTypeContainer(), + + patch_other: rpcMethodTypeContainer< + Partial> & { user_id: User['id'] }, + User + >(), + + password: { + replace_own: rpcMethodTypeContainer< + { + old_password: string; + new_password: string; + }, + void + >(), + + revoke: rpcMethodTypeContainer< + { + user_id: User['id']; + new_password: string; + }, + void + >(), + }, +}; diff --git a/mathesar_ui/src/contexts/DatabaseSettingsRouteContext.ts b/mathesar_ui/src/contexts/DatabaseSettingsRouteContext.ts index 810e4ed0d0..1bc861f843 100644 --- a/mathesar_ui/src/contexts/DatabaseSettingsRouteContext.ts +++ b/mathesar_ui/src/contexts/DatabaseSettingsRouteContext.ts @@ -1,6 +1,7 @@ import { type Readable, derived } from 'svelte/store'; -import userApi, { type User } from '@mathesar/api/rest/users'; +import { api } from '@mathesar/api/rpc'; +import type { User } from '@mathesar/api/rpc/users'; import type { Collaborator } from '@mathesar/models/Collaborator'; import type { ConfiguredRole } from '@mathesar/models/ConfiguredRole'; import type { Database } from '@mathesar/models/Database'; @@ -21,15 +22,13 @@ export type CombinedLoginRole = { // TODO: Make CancellablePromise chainable const getUsersPromise = () => { - const promise = userApi.list(); + const promise = api.users.list().run(); return new CancellablePromise>( (resolve, reject) => { promise .then( (response) => - resolve( - new ImmutableMap(response.results.map((user) => [user.id, user])), - ), + resolve(new ImmutableMap(response.map((user) => [user.id, user]))), (err) => reject(err), ) .catch((err) => reject(err)); diff --git a/mathesar_ui/src/pages/admin-users/NewUserPage.svelte b/mathesar_ui/src/pages/admin-users/NewUserPage.svelte index 9d17a8b589..778e02508c 100644 --- a/mathesar_ui/src/pages/admin-users/NewUserPage.svelte +++ b/mathesar_ui/src/pages/admin-users/NewUserPage.svelte @@ -2,7 +2,7 @@ import { _ } from 'svelte-i18n'; import { router } from 'tinro'; - import type { User } from '@mathesar/api/rest/users'; + import type { User } from '@mathesar/api/rpc/users'; import AppendBreadcrumb from '@mathesar/components/breadcrumb/AppendBreadcrumb.svelte'; import FormBox from '@mathesar/components/form/FormBox.svelte'; import { iconAddUser } from '@mathesar/icons'; diff --git a/mathesar_ui/src/pages/database/settings/collaborators/AddCollaboratorModal.svelte b/mathesar_ui/src/pages/database/settings/collaborators/AddCollaboratorModal.svelte index 0be74c4447..df0f53275d 100644 --- a/mathesar_ui/src/pages/database/settings/collaborators/AddCollaboratorModal.svelte +++ b/mathesar_ui/src/pages/database/settings/collaborators/AddCollaboratorModal.svelte @@ -1,7 +1,7 @@