diff --git a/backend/backend_django/galvanalyser/serializers.py b/backend/backend_django/galvanalyser/serializers.py index 1a9d58dd..52c8c801 100644 --- a/backend/backend_django/galvanalyser/serializers.py +++ b/backend/backend_django/galvanalyser/serializers.py @@ -323,7 +323,11 @@ class GroupSerializer(serializers.HyperlinkedModelSerializer): users = serializers.SerializerMethodField() def get_users(self, instance) -> list: - return UserSerializer(instance.user_set.all(), many=True, context={'request': self.context['request']}).data + return UserSerializer( + instance.user_set.filter(is_active=True), + many=True, + context={'request': self.context['request']} + ).data class Meta: model = Group @@ -343,7 +347,11 @@ def get_description(self, instance): return self.context.get('description') def get_users(self, instance) -> list: - return UserSerializer(instance.user_set.all(), many=True, context={'request': self.context['request']}).data + return UserSerializer( + instance.user_set.filter(is_active=True), + many=True, + context={'request': self.context['request']} + ).data def get_name(self, instance): return self.context.get('name', instance.name) @@ -375,6 +383,7 @@ class Meta: 'url', 'id', 'username', + 'email', 'first_name', 'last_name', 'is_active', diff --git a/backend/backend_django/galvanalyser/views.py b/backend/backend_django/galvanalyser/views.py index a5907a75..250871d1 100644 --- a/backend/backend_django/galvanalyser/views.py +++ b/backend/backend_django/galvanalyser/views.py @@ -102,6 +102,11 @@ def post(self, request, fmt=None): return super(LoginView, self).post(request=request, format=fmt) return Response({'detail': "Anonymous login not allowed"}, status=401) + def get_post_response_data(self, request, token, instance): + return { + **UserSerializer(request.user, context={'request': request}).data, + 'token': token + } @extend_schema( description="Log out current Knox Token.", @@ -779,6 +784,33 @@ class UserViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = UserSerializer queryset = User.objects.filter(is_active=True) + @action(detail=True, methods=['PATCH']) + def update_profile(self, request, pk: int = None): + try: + user = User.objects.get(id=pk) + except User.DoesNotExist: + return error_response("User not found", 404) + if user != request.user: + return error_response("You may only edit your own details", 401) + email = request.data.get('email') + if email: + try: + validators.validate_email(email) + except validators.ValidationError: + return error_response("Invalid email") + password = request.data.get('password') + if password and not len(password) > 7: + return error_response("Password must be at least 7 characters") + current_password = request.data.get('currentPassword') + if not user.check_password(current_password): + return error_response("You must include the correct current password", 401) + if email: + user.email = email + if password: + user.set_password(password) + user.save() + return Response(UserSerializer(user, context={'request': request}).data) + class GroupViewSet(viewsets.ReadOnlyModelViewSet): """ diff --git a/frontend/src/APIConnection.ts b/frontend/src/APIConnection.ts index 438be9fe..a7f42def 100644 --- a/frontend/src/APIConnection.ts +++ b/frontend/src/APIConnection.ts @@ -1,12 +1,15 @@ +import {UpdateResult} from "./UserProfile"; + export type User = { - url: string, - id: number, - username: string, - first_name: string, - last_name: string, - is_staff: boolean, - is_superuser: boolean, - token: string, + url: string; + id: number; + username: string; + email: string; + first_name: string; + last_name: string; + is_staff: boolean; + is_superuser: boolean; + token: string; } export interface APIObject { @@ -125,17 +128,41 @@ export class APIConnection { }) } + update_user(email: string, password: string, currentPassword: string): Promise { + if (!this.user) + return new Promise(() => {}) + return fetch( + `${this.user.url}update_profile/`, + { + method: 'PATCH', + headers: { + 'content-type': 'application/json', + accept: 'application/json', + authorization: `Bearer ${this.user.token}` + }, + body: JSON.stringify({email, password, currentPassword}) + } + ) + .then(r => { + if (r.status === 200) + return r.json() + .then(user => { + this.user = {...user, token: this.user?.token} + }) + .then(() => ({success: true, message: 'Updated successfully'})) + return r.json() + .then(r => ({success: false, message: r.error})) + }) + } + login(username: string, password: string) { let headers = new Headers(); headers.set('Authorization', 'Basic ' + btoa(username + ":" + password)); headers.set('Accept', 'application/json'); return fetch(this.url + 'login/', {method: 'POST', headers: headers}) .then(r => r.json()) - .then(r => { - this.user = { - ...r.user, - token: r.token - } + .then(user => { + this.user = user window.localStorage.setItem('user', JSON.stringify(this.user)) console.info(`Logged in as ${this.user?.username}`) return true diff --git a/frontend/src/Core.tsx b/frontend/src/Core.tsx index 0aa1557b..89c3f5ba 100644 --- a/frontend/src/Core.tsx +++ b/frontend/src/Core.tsx @@ -18,11 +18,6 @@ import GroupAddIcon from '@mui/icons-material/GroupAdd'; import Equipment from "./Equipment" import Datasets from "./Datasets" import TableChartIcon from '@mui/icons-material/TableChart'; -import Dialog from '@mui/material/Dialog'; -import DialogActions from '@mui/material/DialogActions'; -import DialogContent from '@mui/material/DialogContent'; -import DialogContentText from '@mui/material/DialogContentText'; -import DialogTitle from '@mui/material/DialogTitle'; import BatteryUnknownIcon from '@mui/icons-material/BatteryUnknown'; import BackupIcon from '@mui/icons-material/Backup'; import { makeStyles} from "@mui/styles"; @@ -47,6 +42,7 @@ import { ReactComponent as GalvanalyserLogo } from './Galvanalyser-logo.svg'; import Connection from "./APIConnection"; import Stack from "@mui/material/Stack"; import Tokens from "./Tokens"; +import UserProfile from "./UserProfile"; const PrivateRoute = (component: JSX.Element) => { const logged = Connection.is_logged_in; @@ -153,8 +149,10 @@ export default function Core() { const isCellsPath = matchPath({path: cellsPath, end: true}, pathname) !== null const equipmentPath = "/equipment" const isEquipmentPath = matchPath({path: equipmentPath, end: true}, pathname) !== null - const userPath = "/users" - const isUsersPath = matchPath({path: userPath, end: true}, pathname) !== null + const usersPath = "/users" + const isUsersPath = matchPath({path: usersPath, end: true}, pathname) !== null + const profilePath = "/profile" + const isProfilePath = matchPath({path: profilePath, end: true}, pathname) !== null const tokenPath = "/tokens" const isTokenPath = matchPath({path: tokenPath, end: true}, pathname) !== null @@ -203,7 +201,7 @@ export default function Core() { + component={Link} to={usersPath}> @@ -236,7 +234,12 @@ export default function Core() { User: {userDisplayName} + @@ -277,7 +280,8 @@ export default function Core() { - + + diff --git a/frontend/src/Login.tsx b/frontend/src/Login.tsx index 85e0d8e7..82b4795e 100644 --- a/frontend/src/Login.tsx +++ b/frontend/src/Login.tsx @@ -12,6 +12,7 @@ import Connection, {User} from "./APIConnection"; import Grid from "@mui/material/Grid"; import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; +import Tooltip from "@mui/material/Tooltip"; const useStyles = makeStyles((theme) => ({ paper: { @@ -184,20 +185,22 @@ export default function Login() { id="password" autoComplete="current-password" /> - {registerMode && } + {registerMode && + + } {error && {error} diff --git a/frontend/src/UserProfile.tsx b/frontend/src/UserProfile.tsx new file mode 100644 index 00000000..41c99102 --- /dev/null +++ b/frontend/src/UserProfile.tsx @@ -0,0 +1,101 @@ +import React, {SyntheticEvent, useState} from 'react'; +import TextField from '@mui/material/TextField'; +import Paper from '@mui/material/Paper'; +import Container from '@mui/material/Container'; +import Connection from "./APIConnection"; +import useStyles from "./UseStyles"; +import Typography from "@mui/material/Typography"; +import Stack from "@mui/material/Stack"; +import Button from "@mui/material/Button"; +import Alert from "@mui/material/Alert"; +import Snackbar from "@mui/material/Snackbar"; + +export type UpdateResult = { + success: boolean; + message: string; +} + +export default function UserProfile() { + const classes = useStyles(); + const [email, setEmail] = useState(Connection.user?.email || '') + const [password, setPassword] = useState('') + const [currentPassword, setCurrentPassword] = useState('') + const [updateResult, setUpdateResult] = useState() + const [open, setOpen] = useState(false) + + const updateUser = () => Connection.update_user(email, password, currentPassword) + .then(setUpdateResult) + .then(() => { + setOpen(true) + setEmail(Connection.user?.email || '') + setPassword('') + setCurrentPassword('') + }) + + const handleClose = (e: any, reason?: string) => { + if (reason !== 'clickaway') + setOpen(false) + } + + return ( + + + + {Connection.user?.username} profile + setEmail(e.target.value)} + /> + setPassword(e.target.value)} + error={password !== undefined && password.length > 0 && password.length < 8} + /> + setCurrentPassword(e.target.value)} + /> + + + + + {updateResult?.message} + + + + + ); +}