v0.0.4 User Management UI

This commit is contained in:
Yann Stepienik 2023-03-13 21:06:19 +00:00
parent df0dd49261
commit 863a9a061c
15 changed files with 409 additions and 50 deletions

View file

@ -1,13 +1,34 @@
// project import
import Routes from './routes';
import * as React from 'react';
import ThemeCustomization from './themes';
import ScrollTop from './components/ScrollTop';
import Snackbar from '@mui/material/Snackbar';
import {Alert} from '@mui/material';
import { setSnackit } from './api/wrap';
// ==============================|| APP - THEME, ROUTER, LOCAL ||============================== //
const App = () => {
const [open, setOpen] = React.useState(false);
const [message, setMessage] = React.useState('');
setSnackit((message) => {
setMessage(message);
setOpen(true);
})
return (
<ThemeCustomization>
<Snackbar
open={open}
autoHideDuration={5000}
onClose={() => {setOpen(false)}}
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
>
<Alert className={open ? 'shake' : ''} severity="error" sx={{ width: '100%' }}>
{message}
</Alert>
</Snackbar>
<ScrollTop>
<Routes />
</ScrollTop>

View file

@ -1,3 +1,4 @@
import wrap from './wrap';
function list() {
return fetch('/cosmos/api/users', {
@ -9,6 +10,77 @@ function list() {
.then((res) => res.json())
}
function create(values) {
alert(JSON.stringify(values))
return wrap(fetch('/cosmos/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(values),
}));
}
function register(values) {
return fetch('/cosmos/api/register', {
method: 'POST',
headers: {
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(values),
},
})
.then((res) => res.json())
}
function invite(values) {
return wrap(fetch('/cosmos/api/invite', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(values),
}))
}
function edit(nickname, values) {
return fetch('/cosmos/api/users/'+nickname, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(values),
})
.then((res) => res.json())
}
function get(nickname) {
return fetch('/cosmos/api/users/'+nickname, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
})
.then((res) => res.json())
}
function deleteUser(nickname) {
return fetch('/cosmos/api/users/'+nickname, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
})
.then((res) => res.json())
}
export {
list,
create,
register,
invite,
edit,
get,
deleteUser,
};

16
client/src/api/wrap.js Normal file
View file

@ -0,0 +1,16 @@
let snackit;
export default function wrap(apicall) {
return apicall.then(async (response) => {
const rep = await response.json();
if (response.status == 200) {
return rep;
}
snackit(rep.message);
throw new Error(rep);
});
}
export function setSnackit(snack) {
snackit = snack;
}

43
client/src/index.css Normal file
View file

@ -0,0 +1,43 @@
@keyframes shiny-btn1 {
0% { -webkit-transform: scale(0) rotate(45deg); opacity: 0; }
80% { -webkit-transform: scale(0) rotate(45deg); opacity: 0.5; }
81% { -webkit-transform: scale(4) rotate(45deg); opacity: 1; }
100% { -webkit-transform: scale(50) rotate(45deg); opacity: 0; }
}
@keyframes shake {
0% { -webkit-transform: translateX(0); }
10% { -webkit-transform: translateX(-10px); }
20% { -webkit-transform: translateX(10px); }
30% { -webkit-transform: translateX(-10px); }
40% { -webkit-transform: translateX(10px); }
50% { -webkit-transform: translateX(-10px); }
60% { -webkit-transform: translateX(10px); }
70% { -webkit-transform: translateX(-10px); }
80% { -webkit-transform: translateX(10px); }
90% { -webkit-transform: translateX(-10px); }
100% { -webkit-transform: translateX(0); }
}
.shinyButton {
overflow: hidden;
}
.shinyButton:before {
position: absolute;
content: '';
display: inline-block;
top: -180px;
left: 0;
width: 30px;
height: 100%;
background-color: #fff;
animation: shiny-btn1 3s ease-in-out infinite;
}
.shake {
animation: shake 1s;
animation-iteration-count: 1;
}

View file

@ -11,6 +11,8 @@ import { Provider as ReduxProvider } from 'react-redux';
// apex-chart
import './assets/third-party/apex-chart.css';
import './index.css';
// project import
import App from './App';
import { store } from './store';

View file

@ -1,6 +1,7 @@
// material-ui
import * as React from 'react';
import { Button, Typography } from '@mui/material';
import { WarningOutlined, PlusCircleOutlined, CopyOutlined, ExclamationCircleOutlined , SyncOutlined, UserOutlined, KeyOutlined } from '@ant-design/icons';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
@ -8,6 +9,15 @@ import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Paper from '@mui/material/Paper';
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 TextField from '@mui/material/TextField';
import CircularProgress from '@mui/material/CircularProgress';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import * as API from '../../../api';
@ -16,54 +26,219 @@ import MainCard from '../../../components/MainCard';
import isLoggedIn from '../../../isLoggedIn';
import { useEffect, useState } from 'react';
// ==============================|| SAMPLE PAGE ||============================== //
const UserManagement = () => {
const [isLoading, setIsLoading] = useState(false);
const [openCreateForm, setOpenCreateForm] = React.useState(false);
const [openDeleteForm, setOpenDeleteForm] = React.useState(false);
const [openInviteForm, setOpenInviteForm] = React.useState(false);
const [toAction, setToAction] = React.useState(null);
const [rows, setRows] = useState([
{ id: 0, nickname: '12313', reset:'123132' },
{ id: 1, nickname: '354345', reset:'345345' },
]);
const roles = ['Guest', 'User', 'Admin']
const [rows, setRows] = useState([]);
isLoggedIn();
useEffect(() => {
function refresh() {
setIsLoading(true);
API.users.list()
.then(data => {
console.log(data);
setRows(data.data);
})
.then(data => {
console.log(data);
setRows(data.data);
setIsLoading(false);
})
}
useEffect(() => {
refresh();
}, [])
return <MainCard title="Users">
<TableContainer component={Paper}>
<Table aria-label="simple table">
<TableHead>
<TableRow>
<TableCell>Nickname</TableCell>
<TableCell>Role</TableCell>
<TableCell>Password</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row) => (
<TableRow
key={row.nickname}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
<TableCell component="th" scope="row">
{row.nickname}
</TableCell>
<TableCell>User</TableCell>
<TableCell><Button variant="contained" color="primary">Send Password Link</Button></TableCell>
<TableCell><Button variant="contained" color="error">Delete</Button></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</MainCard>
function sendlink(nickname) {
API.users.invite({
nickname
})
.then((values) => {
let sendLink = window.location.origin + '/register?nickname='+nickname+'&key=' + values.data.registerKey;
setToAction({...values.data, nickname, sendLink});
setOpenInviteForm(true);
});
}
return <>
{openInviteForm ? <Dialog open={openInviteForm} onClose={() => setOpenInviteForm(false)}>
<DialogTitle>Invite User</DialogTitle>
<DialogContent>
<DialogContentText>
Send this link to {toAction.nickname} to invite them to the system:
<div>
<IconButton size="large" style={{float: 'left'}} aria-label="copy" onClick={
() => {
navigator.clipboard.writeText(toAction.sendLink);
}
}>
<CopyOutlined />
</IconButton><div style={{float: 'left', width: '300px', padding: '5px', background:'rgba(0,0,0,0.15)', whiteSpace: 'nowrap', wordBreak: 'keep-all', overflow: 'auto', fontStyle: 'italic'}}>{toAction.sendLink}</div>
</div>
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => {
setOpenInviteForm(false);
refresh();
}}>Close</Button>
</DialogActions>
</Dialog>: ''}
<Dialog open={openDeleteForm} onClose={() => setOpenDeleteForm(false)}>
<DialogTitle>Delete User</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete user {toAction} ?
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenDeleteForm(false)}>Cancel</Button>
<Button onClick={() => {
API.users.deleteUser(toAction)
.then(() => {
refresh();
setOpenDeleteForm(false);
})
}}>Delete</Button>
</DialogActions>
</Dialog>
<Dialog open={openCreateForm} onClose={() => setOpenCreateForm(false)}>
<DialogTitle>Create User</DialogTitle>
<DialogContent>
<DialogContentText>
Use this form to invite a new user to the system.
</DialogContentText>
<TextField
autoFocus
margin="dense"
id="c-nickname"
label="Nickname"
type="text"
fullWidth
variant="standard"
/>
<TextField
autoFocus
margin="dense"
id="c-email"
label="Email Address (Optional)"
type="email"
fullWidth
variant="standard"
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenCreateForm(false)}>Cancel</Button>
<Button onClick={() => {
API.users.create({
nickname: document.getElementById('c-nickname').value,
email: document.getElementById('c-email').value,
}).then(() => {
setOpenCreateForm(false);
refresh();
});
}}>Create</Button>
</DialogActions>
</Dialog>
<MainCard title="Users">
<Button variant="contained" color="primary" startIcon={<SyncOutlined />} onClick={() => {
refresh();
}}>Refresh</Button>&nbsp;&nbsp;
<Button variant="contained" color="primary" startIcon={<PlusCircleOutlined />} onClick={() => {
setOpenCreateForm(true)
}}>Create</Button><br /><br />
{isLoading ? <center><br /><CircularProgress color="inherit" /></center>
: <TableContainer component={Paper}>
<Table aria-label="simple table">
<TableHead>
<TableRow>
<TableCell>Nickname</TableCell>
<TableCell>Status</TableCell>
<TableCell>Created At</TableCell>
<TableCell>Last Login</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row) => {
const isRegistered = new Date(row.registeredAt).getTime() > 0;
const inviteExpired = new Date(row.registerKeyExp).getTime() < new Date().getTime();
const hasLastLogin = new Date(row.lastLogin).getTime() > 0;
return (
<TableRow
key={row.nickname}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
<TableCell component="th" scope="row">
&nbsp;&nbsp;<strong>{row.nickname}</strong>
</TableCell>
<TableCell>
{isRegistered ? (row.role > 1 ? <Chip
icon={<KeyOutlined />}
label="Admin"
variant="outlined"
/> : <Chip
icon={<UserOutlined />}
label="User"
variant="outlined"
/>) : (
inviteExpired ? <Chip
icon={<ExclamationCircleOutlined />}
label="Invite Expired"
color="error"
/> : <Chip
icon={<WarningOutlined />}
label="Invite Pending"
color="warning"
/>
)}
</TableCell>
<TableCell>
{new Date(row.createdAt).toLocaleDateString()} -&nbsp;
{new Date(row.createdAt).toLocaleTimeString()}
</TableCell>
<TableCell>
{hasLastLogin ? <span>
{new Date(row.lastLogin).toLocaleDateString()} -&nbsp;
{new Date(row.lastLogin).toLocaleTimeString()}
</span> : '-'}
</TableCell>
<TableCell>
{isRegistered ?
(<Button variant="contained" color="primary" onClick={
() => {
sendlink(row.nickname);
}
}>Send password reset</Button>) :
(<Button variant="contained" className={inviteExpired ? 'shinyButton' : ''} onClick={
() => {
sendlink(row.nickname);
}
} color="primary">Re-Send Invite</Button>)
}
&nbsp;&nbsp;<Button variant="contained" color="error" onClick={
() => {
setToAction(row.nickname);
setOpenDeleteForm(true);
}
}>Delete</Button></TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</TableContainer>}
</MainCard>
</>;
};
export default UserManagement;

View file

@ -14,7 +14,7 @@ export default function Button(theme) {
},
styleOverrides: {
root: {
fontWeight: 400
fontWeight: 500
},
contained: {
...disabledStyle

View file

@ -5,7 +5,9 @@ export default function InputLabel(theme) {
MuiInputLabel: {
styleOverrides: {
root: {
color: theme.palette.grey[600]
color: theme.palette.mode === 'dark' ?
theme.palette.grey[500] :
theme.palette.grey[600]
},
outlined: {
lineHeight: '0.8em',

View file

@ -33,7 +33,7 @@ const Typography = (fontFamily) => ({
lineHeight: 1.5
},
h6: {
fontWeight: 400,
fontWeight: 600,
fontSize: '0.875rem',
lineHeight: 1.57
},

View file

@ -86,6 +86,6 @@
},
"description": "Cosmos Server",
"name": "cosmos-server",
"version": "0.0.3",
"version": "0.0.4",
"wrapInstallFolder": "src"
}

View file

@ -14,6 +14,7 @@ import (
type CreateRequestJSON struct {
Nickname string `validate:"required,min=3,max=32,alphanum"`
Email string `validate:"email"`
}
func UserCreate(w http.ResponseWriter, req *http.Request) {
@ -40,6 +41,7 @@ func UserCreate(w http.ResponseWriter, req *http.Request) {
}
nickname := utils.Sanitize(request.Nickname)
email := utils.Sanitize(request.Email)
c := utils.GetCollection(utils.GetRootAppId(), "users")
@ -57,6 +59,7 @@ func UserCreate(w http.ResponseWriter, req *http.Request) {
_, err3 := c.InsertOne(nil, map[string]interface{}{
"Nickname": nickname,
"Email": email,
"Password": "",
"RegisterKey": RegisterKey,
"RegisterKeyExp": RegisterKeyExp,

View file

@ -69,6 +69,18 @@ func UserLogin(w http.ResponseWriter, req *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "OK",
})
_, errE := c.UpdateOne(nil, map[string]interface{}{
"Nickname": nickname,
}, map[string]interface{}{
"$set": map[string]interface{}{
"LastLogin": time.Now(),
},
})
if errE != nil {
utils.Error("UserLogin: Error while updating user last login", errE)
}
}
} else {
utils.Error("UserLogin: Method not allowed" + req.Method, nil)

View file

@ -72,6 +72,10 @@ func UserRegister(w http.ResponseWriter, req *http.Request) {
utils.HTTPError(w, "User Register Error", http.StatusInternalServerError, "UR001")
return
} else {
RegisteredAt := user.RegisteredAt
if RegisteredAt.IsZero() {
RegisteredAt = time.Now()
}
_, err4 := c.UpdateOne(nil, map[string]interface{}{
"Nickname": nickname,
"RegisterKey": registerKey,
@ -81,7 +85,8 @@ func UserRegister(w http.ResponseWriter, req *http.Request) {
"Password": hashedPassword,
"RegisterKey": "",
"RegisterKeyExp": time.Time{},
"RegisteredAt": time.Now(),
"RegisteredAt": RegisteredAt,
"LastPasswordChangedAt": time.Now(),
"PassowrdCycle": user.PasswordCycle + 1,
},
})

View file

@ -51,10 +51,13 @@ func UserResendInviteLink(w http.ResponseWriter, req *http.Request) {
return
} else {
RegisterKeyExp := time.Now().Add(time.Hour * 24 * 7)
RegisterKey := utils.GenerateRandomString(24)
RegisterKey := utils.GenerateRandomString(48)
utils.Debug(RegisterKey)
utils.Debug(RegisterKeyExp.String())
_, err := c.UpdateOne(nil, map[string]interface{}{
"nickname": nickname,
"Nickname": nickname,
}, map[string]interface{}{
"$set": map[string]interface{}{
"RegisterKeyExp": RegisterKeyExp,
@ -73,7 +76,7 @@ func UserResendInviteLink(w http.ResponseWriter, req *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "OK",
"data": map[string]interface{}{
"registerKey": user.RegisterKey,
"registerKey": RegisterKey,
"registerKeyExp": RegisterKeyExp,
},
})

View file

@ -47,6 +47,11 @@ type User struct {
Role Role `validate:"required" json:"role"`
PasswordCycle int `json:"-"`
Link string `json:"link"`
Email string `validate:"email" json:"email"`
RegisteredAt time.Time `json:"registeredAt"`
LastPasswordChangedAt time.Time `json:"lastPasswordChangedAt"`
CreatedAt time.Time `json:"createdAt"`
LastLogin time.Time `json:"lastLogin"`
}
type Config struct {