From 184b792263d21178cec730d4490a951566ee6fb2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Fugmann?= <andre.fugmann@rwth-aachen.de>
Date: Tue, 11 Jun 2024 16:49:33 +0200
Subject: [PATCH] Add user management Users can request an anonymous token from
 the data backend. With this token they can save and delete projects
 associated with this token from the data backend. There are also some other
 small changes such as the currently selected tab snapping back to the first
 when selecting a different project.

---
 src/app/Layout/Navigation/NavigationBar.tsx   |   2 +
 .../Layout/Navigation/NavigationDrawer.tsx    |  12 +-
 src/app/Layout/Navigation/NewTokenDialog.tsx  | 103 ++++++++++++++
 src/app/Layout/Navigation/UserMenu.tsx        |  46 ++++++-
 src/app/Layout/Navigation/UserTokenDialog.tsx |  86 ++++++++++++
 src/app/Layout/Navigation/usertokenSlice.tsx  |  97 +++++++++++++
 .../soil-editor/SoilToolbar/SaveButton.tsx    |  39 ++++++
 .../soil-editor/SoilToolbar/SoilToolbar.tsx   |   6 +-
 .../TopNavigationBar/AddFileModalContent.tsx  |  11 +-
 .../TopNavigationBar/TopNavigationBar.tsx     |   5 +-
 src/features/soil-editor/soileditorSlice.ts   | 129 +++++++++++++++++-
 src/store/hooks.ts                            |  24 ++++
 src/store/store.ts                            |  10 +-
 13 files changed, 551 insertions(+), 19 deletions(-)
 create mode 100644 src/app/Layout/Navigation/NewTokenDialog.tsx
 create mode 100644 src/app/Layout/Navigation/UserTokenDialog.tsx
 create mode 100644 src/app/Layout/Navigation/usertokenSlice.tsx
 create mode 100644 src/features/soil-editor/SoilToolbar/SaveButton.tsx

diff --git a/src/app/Layout/Navigation/NavigationBar.tsx b/src/app/Layout/Navigation/NavigationBar.tsx
index 17b069a..fd2d33d 100644
--- a/src/app/Layout/Navigation/NavigationBar.tsx
+++ b/src/app/Layout/Navigation/NavigationBar.tsx
@@ -12,6 +12,7 @@ import PublicIcon from '@mui/icons-material/Public';
 import { useAppDispatch, useAppSelector } from '../../../store/hooks';
 import { drawerWidth } from './NavigationDrawer';
 import { selectDarkmodeEnabled, toggleDarkmode } from './darkmodeSlice';
+import UserMenu from './UserMenu';
 
 // NavigationBar component
 const NavigationBar = (): JSX.Element => {
@@ -44,6 +45,7 @@ const NavigationBar = (): JSX.Element => {
         <div style={{ marginRight: "2rem" }}>
           <DarkModeSwitch checked={darkmodeEnabled} onClick={() => dispatch(toggleDarkmode())} />
         </div>
+        <UserMenu/>
       </Toolbar>
     </AppBar>
   );
diff --git a/src/app/Layout/Navigation/NavigationDrawer.tsx b/src/app/Layout/Navigation/NavigationDrawer.tsx
index 0d72d21..6c9e360 100644
--- a/src/app/Layout/Navigation/NavigationDrawer.tsx
+++ b/src/app/Layout/Navigation/NavigationDrawer.tsx
@@ -10,9 +10,11 @@ import LogoLight from '../../../assets/SOIL-logo-tight-light.png';
 import Logo from '../../../assets/SOIL-logo-tight.png';
 import AddProjectModal from '../../../features/soil-editor/AddProject/AddProject';
 import DeleteFileDialog from '../../../features/soil-editor/TopNavigationBar/DeleteFileDialog';
-import { removeProject, selectProjects } from '../../../features/soil-editor/soileditorSlice';
+import { deleteProjectById, removeProject, selectProjects } from '../../../features/soil-editor/soileditorSlice';
 import { useAppDispatch, useAppSelector } from "../../../store/hooks";
 import { selectDarkmodeEnabled } from './darkmodeSlice';
+import { setCurrentTab } from '../../../features/soil-editor/TopNavigationBar/topnavigationSlice';
+import { usertokenState } from './usertokenSlice';
 
 export const drawerWidth = 240;
 
@@ -22,6 +24,7 @@ const NavigationDrawer = (): JSX.Element => {
   const [open, setOpen] = React.useState(true);
   const [dialogOpen, setDialogOpen] = React.useState([false]);
   const darkmodeEnabled = useAppSelector(selectDarkmodeEnabled)
+  const usertoken = useAppSelector(usertokenState);
   const dispatch = useAppDispatch();
 
   // Toggle the list of projects
@@ -68,14 +71,17 @@ const NavigationDrawer = (): JSX.Element => {
                 Object.keys(soilProjects).map((value, index) => {
                   return <div key={"ListProject" + value}>
                     {/* Delete file dialog */}
-                    <DeleteFileDialog open={!!dialogOpen[index]} setOpen={(value: boolean) => { handleDialog(index) }} filename={soilProjects[value].name} deleteFile={(filename: string) => { dispatch(removeProject(value)) }} />
+                    <DeleteFileDialog open={!!dialogOpen[index]} setOpen={(value: boolean) => { handleDialog(index) }} filename={soilProjects[value].name} deleteFile={(filename: string) => 
+                      { dispatch(removeProject(value)); 
+                        if (usertoken.logged_in && usertoken.token) dispatch(deleteProjectById(value));
+                      }} />
                     {/* Project list item */}
                     <ListItem secondaryAction={
                       <IconButton onClick={(e) => { handleDialog(index) }} edge="end" aria-label="delete">
                         <DeleteIcon />
                       </IconButton>
                     }>
-                      <ListItemButton component={Link} to={"/project/" + value} key={value} sx={{paddingLeft: '8pt'}}>
+                      <ListItemButton component={Link} to={"/project/" + value} key={value} sx={{paddingLeft: '8pt'}} onClick={() => {dispatch(setCurrentTab(0))}}>
                         <ListItemIcon sx={{minWidth: 48}}>
                           <CircleIcon />
                         </ListItemIcon>
diff --git a/src/app/Layout/Navigation/NewTokenDialog.tsx b/src/app/Layout/Navigation/NewTokenDialog.tsx
new file mode 100644
index 0000000..3ffa7d8
--- /dev/null
+++ b/src/app/Layout/Navigation/NewTokenDialog.tsx
@@ -0,0 +1,103 @@
+import { Button, Checkbox, ClickAwayListener, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, FormControlLabel, IconButton, TextField, Tooltip } from '@mui/material'
+import React, { useCallback, useEffect, useState } from 'react';
+import ContentCopyIcon from '@mui/icons-material/ContentCopy';
+import { useAppDispatch, useAppSelector } from '../../../store/hooks';
+import { getNewToken, usertokenState } from './usertokenSlice';
+
+type Props = {
+    open: boolean;
+    onClose: ()=>void;
+}
+
+const NewTokenDialog = ({open, onClose}: Props) => {
+    const dispatch = useAppDispatch();
+    const usertoken = useAppSelector(usertokenState);
+
+    const [checked, setChecked] = useState(false);
+    const [tooltipOpen, setTooltipOpen] = useState(false);
+    const [newToken, setNewToken] = useState("");
+
+    useEffect(() => {
+        if(usertoken.token) setNewToken(usertoken.token);
+    }, [usertoken])
+
+    const onGenerate = () => {
+        dispatch(getNewToken());
+    }
+
+    const handleChange = () => {
+        const currentChecked = checked;
+        setChecked(!currentChecked);
+    }
+
+    const handleTooltipClose = () => {
+        setTooltipOpen(false);
+      };
+
+  return (
+    <Dialog open={open} onClose={onClose} fullWidth={true} maxWidth={'lg'}>
+        <DialogTitle>
+            Generate new token
+        </DialogTitle>
+        <DialogContent>
+            <DialogContentText marginBottom={2}>
+            To use this site correctly you need a generated usertoken.
+            </DialogContentText>
+            <DialogContentText marginBottom={2}>    
+            You will need to enter it on the previous dialog if you are logged out. (E.g. you are using this website from a different device or using a different browser)
+            </DialogContentText>
+            <DialogContentText marginBottom={2}>  
+            If you do not have a token yet you can create a new one here.
+            </DialogContentText>
+            <DialogContentText>  
+            IMPORTANT: The token is NOT recoverable, if you lose it you will lose any progress associated with it. Make sure to save it locally or write it down.
+            </DialogContentText>
+            <FormControlLabel
+                control={<Checkbox checked={checked} onChange={handleChange}/>} label={<div>I am aware that this token cannot be recovered and have made a copy of it.</div>}
+            />
+            <TextField
+                margin='normal'
+                id="tokentf"
+                label="Usertoken"
+                value={newToken}
+                type="token"
+                fullWidth
+                variant="outlined"
+                disabled={true}
+                InputProps={{endAdornment:
+                    <ClickAwayListener onClickAway={handleTooltipClose}>
+                        <div>
+                            <Tooltip
+                              PopperProps={{
+                                disablePortal: true,
+                              }}
+                              onClose={handleTooltipClose}
+                              open={tooltipOpen}
+                              disableFocusListener
+                              disableHoverListener
+                              disableTouchListener
+                              title="Copied to clipboard"
+                            >
+                                <IconButton onClick={() => {
+                                        navigator.clipboard.writeText(newToken);
+                                        setTooltipOpen(true);
+                                    }}>
+                                    <ContentCopyIcon/>
+                                </IconButton>
+                            </Tooltip>
+                        </div>
+                    </ClickAwayListener>
+                }}
+            />
+            <Button disabled={!checked} variant={'contained'} color={'success'} onClick={onGenerate}>
+            Generate
+            </Button>
+        </DialogContent>
+        <DialogActions>
+            <Button onClick={onClose}>Close</Button>
+        </DialogActions>
+    </Dialog>
+  )
+}
+
+export default NewTokenDialog
\ No newline at end of file
diff --git a/src/app/Layout/Navigation/UserMenu.tsx b/src/app/Layout/Navigation/UserMenu.tsx
index e643040..7830a3c 100644
--- a/src/app/Layout/Navigation/UserMenu.tsx
+++ b/src/app/Layout/Navigation/UserMenu.tsx
@@ -1,19 +1,46 @@
 import { AccountCircle } from '@mui/icons-material';
-import { IconButton, Menu } from '@mui/material';
+import { Badge, IconButton, Menu, MenuItem } from '@mui/material';
 import { useState } from 'react';
+import { useAppDispatch, useAppSelector } from '../../../store/hooks';
+import { logout, usertokenState } from './usertokenSlice';
+import UserTokenDialog from './UserTokenDialog';
+import JSZip from 'jszip';
+import { selectProjects } from '../../../features/soil-editor/soileditorSlice';
+import FileSaver from 'file-saver';
 
 const UserMenu = (): JSX.Element => {
   const [anchorEl, setAnchorEl] = useState(null);
+  const usertoken = useAppSelector(usertokenState);
+  const projects = useAppSelector(selectProjects);
+  const [open, setOpen] = useState(false);
+  const dispatch = useAppDispatch();
 
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   const handleMenuClick = (event: any) => {
     setAnchorEl(event.currentTarget);
   };
 
-
+  const downloadAllProjects = () => {
+    const zip = new JSZip();
+    Object.entries(projects).forEach(([projectName, project]) => {
+      Object.entries(project.files).forEach(([fileName, file]) => {
+        zip.file(project.name + "/" + fileName + ".soil", file.textModel);
+      });
+    });
+    zip.generateAsync({ type: 'blob' }).then(function (content) {
+      const timestamp = new Date().toISOString();
+      if(usertoken.token) FileSaver.saveAs(content, timestamp + "+" + usertoken.token + '.zip');
+    });
+  };
 
   return (
     <div>
+      <Badge 
+        invisible={usertoken.logged_in} 
+        badgeContent={'!'} 
+        color={'error'}
+        overlap={"circular"}
+        sx={{mr: 3}}>
       <IconButton
         size="large"
         aria-controls="menu-appbar"
@@ -27,8 +54,8 @@ const UserMenu = (): JSX.Element => {
         id="menu-appbar"
         anchorEl={anchorEl}
         anchorOrigin={{
-          vertical: 'top',
-          horizontal: 'right',
+          vertical: 'center',
+          horizontal: 'center',
         }}
         keepMounted
         transformOrigin={{
@@ -38,7 +65,18 @@ const UserMenu = (): JSX.Element => {
         open={Boolean(anchorEl)}
         onClose={() => setAnchorEl(null)}
       >
+        <MenuItem onClick={() => {setOpen(true)}}>
+          {usertoken.logged_in ? (
+            <>Logged in as: {usertoken.token}</>
+          ) : (
+            <>Login</>
+          )}
+        </MenuItem>
+        {usertoken.logged_in && <MenuItem onClick={() => {dispatch(logout())}}>Logout</MenuItem>}
+        {usertoken.logged_in && <MenuItem onClick={() => {downloadAllProjects()}}>Download all projects</MenuItem>}
+        <UserTokenDialog open={open} onClose={()=>{setOpen(false)}}></UserTokenDialog>
       </Menu>
+      </Badge>
     </div>
   );
 };
diff --git a/src/app/Layout/Navigation/UserTokenDialog.tsx b/src/app/Layout/Navigation/UserTokenDialog.tsx
new file mode 100644
index 0000000..1bb54b6
--- /dev/null
+++ b/src/app/Layout/Navigation/UserTokenDialog.tsx
@@ -0,0 +1,86 @@
+import React, { useEffect, useRef, useState } from 'react'
+import { Button, Dialog, Link, DialogActions, DialogContent, DialogContentText, DialogTitle, IconButton, TextField, Typography } from '@mui/material'
+import { useAppDispatch, useAppSelector } from '../../../store/hooks';
+import NewTokenDialog from './NewTokenDialog';
+import { clearError, login, usertokenState } from './usertokenSlice';
+
+type Props = {
+    open: boolean;
+    onClose: () => void;
+};
+
+const UserTokenDialog = ({open, onClose}: Props): JSX.Element => {
+    const [tokenGenDialogOpen, setTokenGenDialogOpen] = useState(false);
+    const [tfValue, setTfValue] = useState("");
+    const dispatch = useAppDispatch();
+    const usertoken = useAppSelector(usertokenState)
+
+    useEffect(()=>{
+        if (usertoken.logged_in) handleClose();
+    }, [usertoken]);
+
+    const handleClose = () => {
+        setTfValue("");
+        dispatch(clearError());
+        onClose();
+    }
+
+    const onLogin = async () => {
+        try {
+            dispatch(login(tfValue));
+        }
+        catch (error) {
+
+        }
+    };
+
+    return (
+        <Dialog open={open} onClose={handleClose}>
+            <DialogTitle>User Login</DialogTitle>
+            <DialogContent>
+                <DialogContentText>
+                    {usertoken.logged_in ? (
+                        <DialogContentText>
+                            <DialogContentText marginBottom={3}>Logged in as: <i>{usertoken.token}</i></DialogContentText>
+                            <DialogContentText>If you want to change the login token, please enter a different one and click "Login".</DialogContentText>
+                        </DialogContentText>
+                    ) : (
+                        <DialogContentText>
+                            <DialogContentText marginBottom={3}>Not logged in</DialogContentText>
+                            <DialogContentText>You are currently not logged in. To login, please enter your usertoken and click on "Login".</DialogContentText>
+                        </DialogContentText>
+                    )}
+                </DialogContentText>
+                <TextField
+                    onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
+                        setTfValue(event.target.value);
+                    }}
+                    margin='normal'
+                    id="name"
+                    label="Usertoken"
+                    type="token"
+                    value={tfValue}
+                    fullWidth
+                    variant="outlined"
+                />
+                {usertoken.error && <Typography color={"common.red"}>This token does not exist!</Typography>}
+                <Link href="#" onClick={()=>{setTokenGenDialogOpen(true)}}>
+                    I don't have a usertoken.
+                </Link>
+            </DialogContent>
+                {!usertoken.logged_in ? (
+                    <DialogActions>
+                        <Button onClick={handleClose}>Cancel</Button>
+                        <Button onClick={onLogin}>Login</Button>
+                    </DialogActions>
+                ) : (                    
+                    <DialogActions>
+                        <Button onClick={handleClose}>Close</Button>
+                        <Button onClick={onLogin}>Login</Button>
+                    </DialogActions>)}
+            <NewTokenDialog open={tokenGenDialogOpen} onClose={()=>{setTokenGenDialogOpen(false)}}/>
+        </Dialog>
+    )
+}
+
+export default UserTokenDialog
\ No newline at end of file
diff --git a/src/app/Layout/Navigation/usertokenSlice.tsx b/src/app/Layout/Navigation/usertokenSlice.tsx
new file mode 100644
index 0000000..e24d232
--- /dev/null
+++ b/src/app/Layout/Navigation/usertokenSlice.tsx
@@ -0,0 +1,97 @@
+import { PayloadAction, createAsyncThunk, createSlice } from "@reduxjs/toolkit";
+import { RootState } from "../../../store/store";
+import { DATA_BACKEND } from "../../../const";
+
+type userToken = {
+    logged_in: boolean;
+    token: string | null;
+    error: string | null;
+}
+
+const initializer = (): userToken => {
+    if(localStorage.getItem("token") !== null) {
+        return {
+            logged_in: true,
+            token: localStorage.getItem("token"),
+            error: null
+        };
+    }
+    else {
+        return {
+            logged_in: false,
+            token: null,
+            error: null
+        };
+    }
+}
+
+export const getNewToken = createAsyncThunk<string, void, { state: RootState }>(
+    'user/getNewToken',
+    async () => {
+        var myHeaders = new Headers();
+        myHeaders.append("Content-Type", "application/json");
+        let response = await fetch(DATA_BACKEND + "/user/new/", {
+            method: 'GET',
+            headers: myHeaders,
+        }).then(rsp => rsp.text().then(text => {return JSON.parse(text).token})).catch(error => error.text());
+        return response;
+    }  
+);
+
+// Queries the data-backend whether the specified token exists
+export const login = createAsyncThunk<boolean, string, { state: RootState }>(
+    'user/validateToken',
+    async (token, thunkAPI) => {
+        var myHeaders = new Headers();
+        myHeaders.append("Content-Type", "application/json");
+        let response = await fetch(DATA_BACKEND + `/user/${token}/`, {
+            method: 'GET',
+            headers: myHeaders,
+        }).then(rsp => rsp.text().then(text => {return JSON.parse(text).value})).catch(error => error.text());
+        console.log(response);
+        console.log("I'm executed!")
+        if ( response === true ) return response;
+        else return thunkAPI.rejectWithValue("User does not exist!");
+    }
+)
+
+const usertokenSlice = createSlice({
+    name: 'usertoken',
+    initialState: initializer(),
+    reducers: {
+        logout(state, action: PayloadAction<void>) {
+            console.log("test123");
+            localStorage.removeItem("token");
+            state.logged_in = false;
+            state.token = null;
+            state.error = null;
+        },
+        clearError(state, action: PayloadAction<void>) {
+            state.error = null;
+        }
+    },
+    extraReducers: (builder) => {
+        builder.addCase(getNewToken.fulfilled, (state, action) => {
+            state.token = action.payload;
+        });
+        builder.addCase(getNewToken.rejected, (state, action) => {
+
+        });
+        builder.addCase(login.fulfilled, (state, action) => {
+            state.token = action.meta.arg
+            state.logged_in = action.payload
+            state.error = null;
+            localStorage.setItem("token", action.meta.arg);
+        });
+        builder.addCase(login.rejected, (state, action) => {
+            if(action.error) {
+                state.error = action.payload as string;
+            }
+        })
+    }
+})
+
+export const { logout, clearError } = usertokenSlice.actions;
+export const usertokenState = (state: RootState) => state.usertoken;
+
+export default usertokenSlice.reducer;
\ No newline at end of file
diff --git a/src/features/soil-editor/SoilToolbar/SaveButton.tsx b/src/features/soil-editor/SoilToolbar/SaveButton.tsx
new file mode 100644
index 0000000..754d8ad
--- /dev/null
+++ b/src/features/soil-editor/SoilToolbar/SaveButton.tsx
@@ -0,0 +1,39 @@
+import { Button, Stack } from '@mui/material'
+import React, { useEffect } from 'react'
+import SaveIcon from '@mui/icons-material/Save';
+import CheckCircleIcon from '@mui/icons-material/CheckCircle';
+import { useAppDispatch, useAppSelector } from '../../../store/hooks';
+import { saveProject, saveProjectById, selectProjects } from '../soileditorSlice';
+import { usertokenState } from '../../../app/Layout/Navigation/usertokenSlice';
+
+const SaveButton = (props: { projectId: string }) => {
+  const dispatch = useAppDispatch();
+  const projects = useAppSelector(selectProjects);
+  const token = useAppSelector(usertokenState);
+
+  useEffect(() => {
+    const handleKeyPress = (event: any) => {
+      if (event.key === 's' && event.ctrlKey) {
+        event.preventDefault();
+        if(token.logged_in) handleSave();
+      }
+    };
+
+    window.addEventListener('keydown', handleKeyPress);
+    return () => window.removeEventListener('keydown', handleKeyPress);
+  }, [])
+
+  const handleSave = () => {
+    // Project is saved locally
+    dispatch(saveProject(props.projectId));
+
+    // Project is saved in the database
+    if (token.logged_in && token.token) dispatch(saveProjectById([props.projectId, projects[props.projectId].name, projects[props.projectId].version, token.token]));
+  }
+
+  return (
+    <Button startIcon={<SaveIcon/>} onClick={handleSave} disabled={token.logged_in ? false : true}>Save</Button>
+  )
+}
+
+export default SaveButton
\ No newline at end of file
diff --git a/src/features/soil-editor/SoilToolbar/SoilToolbar.tsx b/src/features/soil-editor/SoilToolbar/SoilToolbar.tsx
index 24e1ef8..eb3ebfe 100644
--- a/src/features/soil-editor/SoilToolbar/SoilToolbar.tsx
+++ b/src/features/soil-editor/SoilToolbar/SoilToolbar.tsx
@@ -16,6 +16,7 @@ import GenerateButton from './GenerateButton';
 import PublishModal from './PublishModal';
 import VerifyButton from './VerifyButton';
 import { toggleErrorLog } from './toolbarSlice';
+import SaveButton from './SaveButton';
 
 const SoilToolbar = (): JSX.Element => {
 
@@ -98,13 +99,14 @@ const SoilToolbar = (): JSX.Element => {
         <Tooltip title="Generate this Project">
           <GenerateButton />
         </Tooltip>
+        <SaveButton projectId={projectId} />
       </Stack>
 
       <Stack style={{ marginLeft: "auto" }} direction="row" spacing={1}>
         <Button onClick={() => dispatch(toggleErrorLog())} variant='outlined' startIcon={<ErrorIcon />}>Error Log</Button>
-        {/* {<Tooltip title={"Switch visualization."}>
+        <Tooltip title={"Switch visualization."}>
           {graphSwitch}
-        </Tooltip>} */}
+        </Tooltip>
       </Stack>
     </Toolbar>
   );
diff --git a/src/features/soil-editor/TopNavigationBar/AddFileModalContent.tsx b/src/features/soil-editor/TopNavigationBar/AddFileModalContent.tsx
index eb14777..dafe67a 100644
--- a/src/features/soil-editor/TopNavigationBar/AddFileModalContent.tsx
+++ b/src/features/soil-editor/TopNavigationBar/AddFileModalContent.tsx
@@ -60,7 +60,6 @@ export default function AddFileModalContent({ handleClose, projectId }: AddFileM
     setValue(newValue);
   };
 
-
   function handleCreateProject() {
     if (checkIfFileExists(uploadedFile)) {
       return
@@ -89,6 +88,11 @@ export default function AddFileModalContent({ handleClose, projectId }: AddFileM
     const target = event.target as HTMLInputElement;
     const files = target.files;
     if (files !== null) {
+      if(/\s/g.test(files[0].name)) {
+        setSnackbarMessage("The Filename can not include space characters.")
+        setShowSnackbar(true);
+        return
+      }
       setUploadedFile(files[0].name)
       let reader = new FileReader();
       reader.onload = handleFileLoad;
@@ -103,6 +107,11 @@ export default function AddFileModalContent({ handleClose, projectId }: AddFileM
       setShowSnackbar(true);
       return
     }
+    if (/\s/g.test(newFileName)) {
+      setSnackbarMessage("The Filename can not include space characters.")
+      setShowSnackbar(true);
+      return
+    }
     if (checkIfFileExists(newFileName)) {
       return
     }
diff --git a/src/features/soil-editor/TopNavigationBar/TopNavigationBar.tsx b/src/features/soil-editor/TopNavigationBar/TopNavigationBar.tsx
index 268c009..631b8b8 100644
--- a/src/features/soil-editor/TopNavigationBar/TopNavigationBar.tsx
+++ b/src/features/soil-editor/TopNavigationBar/TopNavigationBar.tsx
@@ -17,7 +17,8 @@ function asignProps(index: number) {
 type File = {
   fileName: string,
   textModel: string,
-  graphModel: Object
+  graphModel: Object,
+  changed: boolean
 }
 
 type TopNavigationBarProps = {
@@ -64,7 +65,7 @@ export default function TopNavigationBar({ files }: TopNavigationBarProps) {
         <Tabs value={currentTab} onChange={handleChange} aria-label="basic tabs example">
           {
             Object.values(files).map((file, index) => {
-              return <Tab key={index + "TNB"} label={<div>{file.fileName}  <CloseIcon fontSize='small' color='action' onClick={(e) => { handleDeleteClick(e, file.fileName) }} /></div>} {...asignProps(index)}></Tab>
+              return <Tab style={{textTransform: 'none'}} key={index + "TNB"} label={<div>{file.changed ? ("*" + file.fileName) : (file.fileName)}  <CloseIcon fontSize='small' color='action' onClick={(e) => { handleDeleteClick(e, file.fileName) }} /></div>} {...asignProps(index)}></Tab>
 
             })
           }
diff --git a/src/features/soil-editor/soileditorSlice.ts b/src/features/soil-editor/soileditorSlice.ts
index 76919c9..e9e1a00 100644
--- a/src/features/soil-editor/soileditorSlice.ts
+++ b/src/features/soil-editor/soileditorSlice.ts
@@ -5,6 +5,7 @@ import FileSaver from 'file-saver';
 import { v4 as uuidv4 } from 'uuid';
 import { DATA_BACKEND, SOIL_BACKEND } from '../../const';
 import { RootState } from '../../store/store';
+import { access, stat } from 'fs';
 
 export type GenerationSetting = {
   projectId: string;
@@ -121,6 +122,7 @@ export type File = {
     errors: Error[];
     warnings: Object[];
   };
+  changed: boolean;
 };
 
 // Define the Project type
@@ -133,6 +135,7 @@ export type Project = {
   };
   showTextModel: boolean;
   loading: boolean;
+  timestamp: string;
 };
 
 // Define the SoileditorState interface
@@ -168,6 +171,13 @@ type GenerationReportResponse = {
   generationReport: GenerationReport;
 };
 
+type DataBackendGetProjectsResponse = {
+  id: string;
+  project: Project;
+  projectName: string;
+  version: string;
+}
+
 // Initialize the state for the soileditor slice
 const initialState: SoileditorState = {
   snackbarInfo: {
@@ -273,6 +283,69 @@ export const validateModelById = createAsyncThunk<GenerationReportResponse, stri
 
   }
 );
+
+export const saveProjectById = createAsyncThunk<string, string[], { state: RootState }>(
+  'projects/saveProjectById',
+  async ([projectId, name, version, usertoken], thunkAPI) => {
+    const state = thunkAPI.getState().soileditor;
+    var myHeaders = new Headers();
+    myHeaders.append("Content-Type", "application/json");
+    myHeaders.append("Authorization", usertoken);
+    let response = await fetch(DATA_BACKEND + "/projects", {
+      method: 'POST',
+      headers: myHeaders,
+      body: JSON.stringify({ id: projectId, project: state.projects[projectId], version: version, projectName: name }),
+      redirect: 'follow',
+    })
+      .then(rsp => rsp.text())
+      .catch(error => error.text());
+    return response;
+  }
+);
+
+export const deleteProjectById = createAsyncThunk<string, string, { state: RootState }>(
+  'projects/deleteProjectById',
+  async (projectId, thunkAPI) => {
+    var myHeaders = new Headers();
+    const usertoken = thunkAPI.getState().usertoken.token;
+    if (usertoken === null) return;
+    myHeaders.append("Content-Type", "application/json");
+    myHeaders.append("Authorization", usertoken);
+    let response = await fetch(DATA_BACKEND + "/projects/" + projectId + "/", {
+      method: 'DELETE',
+      headers: myHeaders,
+      redirect: 'follow',
+    })
+      .then(rsp => rsp.text())
+      .catch(error => error.text());
+    return response;
+  }
+);
+
+// Fetches all existing projects from the database and synchronizes them with the local state.
+// This method should only be called after the store was rehydrated while the user is logged in
+// or after a login was performed.
+export const getProjectsFromDatabase = createAsyncThunk<DataBackendGetProjectsResponse[], void, { state: RootState }>(
+  'projects/getProjectsFromDatabase',
+  async (_, thunkAPI) => {
+    const user = thunkAPI.getState().usertoken;
+
+    if (user.token == null) return thunkAPI.rejectWithValue("Tried to fetch projects from database without a token!");
+
+    var myHeaders = new Headers();
+    myHeaders.append("Authorization", user.token);
+    let response = await fetch(DATA_BACKEND + "/projects", {
+      method: 'GET',
+      headers: myHeaders,
+      redirect: 'follow',
+    })
+      .then(rsp => {return rsp.text()}).then(text => {return JSON.parse(text)})
+      .catch(error => error.text());
+    return response;
+  }
+);
+
+
 // Define the soileditorSlice, which contains the reducers for manipulating the state.
 export const soileditorSlice = createSlice({
   // Set the name of the slice.
@@ -284,11 +357,11 @@ export const soileditorSlice = createSlice({
     // Add a new project to the state with a given text model and filename.
     addTextModel: (state, action: PayloadAction<string[]>) => {
       const newUUID = uuidv4()
-      state.projects[newUUID] = { name: action.payload[0], projectId: newUUID, files: {}, version: "1.0", showTextModel: true, loading: false }
+      state.projects[newUUID] = { name: action.payload[0], projectId: newUUID, files: {}, version: "1.0", showTextModel: true, loading: false, timestamp: new Date().toISOString() }
       const files = state.projects[newUUID].files;
       var counter = 1;
       while (counter < action.payload.length - 1) {
-        files[action.payload[counter]] = { cardsOpen: [false], imports: [], textModel: action.payload[counter + 1], graphModel: [], fileName: action.payload[counter], generationReport: { errors: [], warnings: [] } }
+        files[action.payload[counter]] = { cardsOpen: [false], imports: [], textModel: action.payload[counter + 1], graphModel: [], fileName: action.payload[counter], generationReport: { errors: [], warnings: [] }, changed: false }
         counter += 2;
       }
 
@@ -311,7 +384,8 @@ export const soileditorSlice = createSlice({
         fileName: action.payload[1],
         graphModel: [],
         textModel: action.payload[2],
-        generationReport: { errors: [], warnings: [] }
+        generationReport: { errors: [], warnings: [] },
+        changed: false
       }
     },
     // Remove a project from the state.
@@ -360,7 +434,9 @@ export const soileditorSlice = createSlice({
       if (state.projects[action.payload[0]]) {
         state.projects[action.payload[0]].loading = false;
         if (state.projects[action.payload[0]].files[action.payload[1]]) {
-          state.projects[action.payload[0]].files[action.payload[1]].textModel = action.payload[2]
+          if (state.projects[action.payload[0]].files[action.payload[1]].textModel !== action.payload[2])
+            state.projects[action.payload[0]].files[action.payload[1]].changed = true;
+          state.projects[action.payload[0]].files[action.payload[1]].textModel = action.payload[2];
         }
       }
     },
@@ -437,6 +513,13 @@ export const soileditorSlice = createSlice({
       };
       state.projects[action.payload[0]].files[action.payload[1]].graphModel.push(newComponent)
     },
+    setFileChanged: (state, action: PayloadAction<any[]>) => {
+      state.projects[action.payload[0]].files[action.payload[1]].changed = true;
+    },
+    saveProject: (state, action: PayloadAction<string>) => {
+      Object.entries(state.projects[action.payload].files).forEach(([filename, file]) => file.changed = false);
+      state.projects[action.payload].timestamp = new Date().toISOString();
+    }
   },
   extraReducers: (builder) => {
     // Add a case for when the `generateModelById` action is fulfilled
@@ -548,10 +631,46 @@ export const soileditorSlice = createSlice({
       state.snackbarInfo.open = true
 
     });
+    builder.addCase(saveProjectById.fulfilled, (state, action) => {
+      state.snackbarInfo.severity = "success";
+      state.snackbarInfo.content = "Project saved in database.";
+      state.snackbarInfo.open = true;
+    });
+    builder.addCase(saveProjectById.rejected, (state, action) => {
+      state.snackbarInfo.severity = "error";
+      state.snackbarInfo.content = "There was an error saving your project";
+      state.snackbarInfo.open = true;
+    });
+    builder.addCase(deleteProjectById.fulfilled, (state, action) => {
+      state.snackbarInfo.severity = "success";
+      state.snackbarInfo.content = "Project deleted successfully.";
+      state.snackbarInfo.open = true;
+    });
+    builder.addCase(deleteProjectById.rejected, (state, action) => {
+      state.snackbarInfo.severity = "error";
+      state.snackbarInfo.content = "There was a problem deleting your project";
+      state.snackbarInfo.open = true;
+    });
+    builder.addCase(getProjectsFromDatabase.rejected, (state, action) => {
+      state.snackbarInfo.severity = "error";
+      state.snackbarInfo.content = "There was an error fetching your projects from the database";
+      state.snackbarInfo.open = true;
+    });
+    builder.addCase(getProjectsFromDatabase.fulfilled, (state, action) => {
+      action.payload.forEach(proj => {
+        if(state.projects[proj.id] !== undefined) {
+          // TODO: implement logic to ask if user wants newer project state instead of just overwriting the local state
+          if(state.projects[proj.id].timestamp < proj.project.timestamp) state.projects[proj.id] = proj.project;
+        }
+        else {
+          state.projects[proj.id] = proj.project;
+        }
+      });
+    });
   },
 });
 
-export const { addTextModel, addFileToProject, removeFileFromProject, setGraphModel, setImports, addEnumToFile, addFunctionToFile, addMeasurementToFile, addParameterToFile, addComponentToFile, deleteSoilElementFromGraph, addImportToFile, removeImportFromFile, setCardsOpen, toggleSnackbar, changeTextOfFile, importProject, removeProject, setGraphModelCard, toggleModelRepresentation } = soileditorSlice.actions;
+export const { addTextModel, addFileToProject, removeFileFromProject, setGraphModel, setImports, addEnumToFile, addFunctionToFile, addMeasurementToFile, addParameterToFile, addComponentToFile, deleteSoilElementFromGraph, addImportToFile, removeImportFromFile, setCardsOpen, toggleSnackbar, changeTextOfFile, importProject, removeProject, setGraphModelCard, toggleModelRepresentation, saveProject } = soileditorSlice.actions;
 
 export const selectProjects = (state: RootState) => state.soileditor.projects;
 export const selectSnackbarInfo = (state: RootState) => state.soileditor.snackbarInfo;
diff --git a/src/store/hooks.ts b/src/store/hooks.ts
index 520e84e..fc1a1cf 100644
--- a/src/store/hooks.ts
+++ b/src/store/hooks.ts
@@ -1,6 +1,30 @@
 import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
 import type { RootState, AppDispatch } from './store';
+import { useEffect, useRef } from 'react';
 
 // Use throughout your app instead of plain `useDispatch` and `useSelector`
 export const useAppDispatch = () => useDispatch<AppDispatch>();
 export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
+
+export function useKey(key: any, cb: any) {
+    const callback = useRef(cb);
+
+    useEffect(() => {
+        callback.current = cb;
+    })
+
+    useEffect(() => {
+        function handle(event: any) {
+            if (event.code === key) {
+                callback.current(event);
+            }
+            else if (key === "ctrls" && event.key === 's' && event.ctrlKey) {
+                callback.current(event);
+            }
+
+            document.addEventListener('keydown', handle);
+            return () => document.removeEventListener('keydown', handle);
+        }
+    }, [key]);
+    
+}
diff --git a/src/store/store.ts b/src/store/store.ts
index 0a93f27..3d7a40b 100644
--- a/src/store/store.ts
+++ b/src/store/store.ts
@@ -3,9 +3,10 @@ import { Action, ThunkAction, combineReducers, configureStore, getDefaultMiddlew
 import { persistReducer, persistStore } from 'redux-persist';
 import storage from 'redux-persist/lib/storage';
 import darkmodeReducer from '../app/Layout/Navigation/darkmodeSlice';
+import usertokenReducer from '../app/Layout/Navigation/usertokenSlice';
 import toolbarReducer from '../features/soil-editor/SoilToolbar/toolbarSlice';
 import topnavigationReducer from '../features/soil-editor/TopNavigationBar/topnavigationSlice';
-import soileditorReducer from '../features/soil-editor/soileditorSlice';
+import soileditorReducer, { getProjectsFromDatabase } from '../features/soil-editor/soileditorSlice';
 
 // Configuration for persisting the Redux store
 const persistConfig = {
@@ -13,10 +14,15 @@ const persistConfig = {
   storage,
 }
 
+const onRehydrate = () => {
+  store.dispatch(getProjectsFromDatabase())
+}
+
 // Combine reducers and create a persisted reducer
 const persistedReducer = persistReducer(persistConfig, combineReducers({
   toolbar: toolbarReducer,
   darkmode: darkmodeReducer,
+  usertoken: usertokenReducer,
   soileditor: soileditorReducer,
   topnavigation: topnavigationReducer,
 }))
@@ -35,7 +41,7 @@ export const store = configureStore({
 })
 
 // Create a persistor for the Redux store
-export const persistor = persistStore(store)
+export const persistor = persistStore(store, null, onRehydrate);
 
 // Define types for dispatch, state, and thunks
 export type AppDispatch = typeof store.dispatch;
-- 
GitLab