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 // project import
import Routes from './routes'; import Routes from './routes';
import * as React from 'react';
import ThemeCustomization from './themes'; import ThemeCustomization from './themes';
import ScrollTop from './components/ScrollTop'; 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 ||============================== // // ==============================|| APP - THEME, ROUTER, LOCAL ||============================== //
const App = () => { const App = () => {
const [open, setOpen] = React.useState(false);
const [message, setMessage] = React.useState('');
setSnackit((message) => {
setMessage(message);
setOpen(true);
})
return ( return (
<ThemeCustomization> <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> <ScrollTop>
<Routes /> <Routes />
</ScrollTop> </ScrollTop>

View file

@ -1,3 +1,4 @@
import wrap from './wrap';
function list() { function list() {
return fetch('/cosmos/api/users', { return fetch('/cosmos/api/users', {
@ -9,6 +10,77 @@ function list() {
.then((res) => res.json()) .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 { export {
list, 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 // apex-chart
import './assets/third-party/apex-chart.css'; import './assets/third-party/apex-chart.css';
import './index.css';
// project import // project import
import App from './App'; import App from './App';
import { store } from './store'; import { store } from './store';

View file

@ -1,6 +1,7 @@
// material-ui // material-ui
import * as React from 'react';
import { Button, Typography } from '@mui/material'; 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 Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody'; import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell'; import TableCell from '@mui/material/TableCell';
@ -8,6 +9,15 @@ import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead'; import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow'; import TableRow from '@mui/material/TableRow';
import Paper from '@mui/material/Paper'; 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'; import * as API from '../../../api';
@ -16,54 +26,219 @@ import MainCard from '../../../components/MainCard';
import isLoggedIn from '../../../isLoggedIn'; import isLoggedIn from '../../../isLoggedIn';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
// ==============================|| SAMPLE PAGE ||============================== //
const UserManagement = () => { 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([ const roles = ['Guest', 'User', 'Admin']
{ id: 0, nickname: '12313', reset:'123132' },
{ id: 1, nickname: '354345', reset:'345345' }, const [rows, setRows] = useState([]);
]);
isLoggedIn(); isLoggedIn();
useEffect(() => { function refresh() {
setIsLoading(true);
API.users.list() API.users.list()
.then(data => { .then(data => {
console.log(data); console.log(data);
setRows(data.data); setRows(data.data);
}) setIsLoading(false);
})
}
useEffect(() => {
refresh();
}, []) }, [])
return <MainCard title="Users"> function sendlink(nickname) {
<TableContainer component={Paper}> API.users.invite({
<Table aria-label="simple table"> nickname
<TableHead> })
<TableRow> .then((values) => {
<TableCell>Nickname</TableCell> let sendLink = window.location.origin + '/register?nickname='+nickname+'&key=' + values.data.registerKey;
<TableCell>Role</TableCell> setToAction({...values.data, nickname, sendLink});
<TableCell>Password</TableCell> setOpenInviteForm(true);
<TableCell>Actions</TableCell> });
</TableRow> }
</TableHead>
<TableBody> return <>
{rows.map((row) => ( {openInviteForm ? <Dialog open={openInviteForm} onClose={() => setOpenInviteForm(false)}>
<TableRow <DialogTitle>Invite User</DialogTitle>
key={row.nickname} <DialogContent>
sx={{ '&:last-child td, &:last-child th': { border: 0 } }} <DialogContentText>
> Send this link to {toAction.nickname} to invite them to the system:
<TableCell component="th" scope="row"> <div>
{row.nickname} <IconButton size="large" style={{float: 'left'}} aria-label="copy" onClick={
</TableCell> () => {
<TableCell>User</TableCell> navigator.clipboard.writeText(toAction.sendLink);
<TableCell><Button variant="contained" color="primary">Send Password Link</Button></TableCell> }
<TableCell><Button variant="contained" color="error">Delete</Button></TableCell> }>
</TableRow> <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>
</TableBody> </div>
</Table> </DialogContentText>
</TableContainer> </DialogContent>
</MainCard> <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; export default UserManagement;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -69,6 +69,18 @@ func UserLogin(w http.ResponseWriter, req *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]interface{}{
"status": "OK", "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 { } else {
utils.Error("UserLogin: Method not allowed" + req.Method, nil) 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") utils.HTTPError(w, "User Register Error", http.StatusInternalServerError, "UR001")
return return
} else { } else {
RegisteredAt := user.RegisteredAt
if RegisteredAt.IsZero() {
RegisteredAt = time.Now()
}
_, err4 := c.UpdateOne(nil, map[string]interface{}{ _, err4 := c.UpdateOne(nil, map[string]interface{}{
"Nickname": nickname, "Nickname": nickname,
"RegisterKey": registerKey, "RegisterKey": registerKey,
@ -81,7 +85,8 @@ func UserRegister(w http.ResponseWriter, req *http.Request) {
"Password": hashedPassword, "Password": hashedPassword,
"RegisterKey": "", "RegisterKey": "",
"RegisterKeyExp": time.Time{}, "RegisterKeyExp": time.Time{},
"RegisteredAt": time.Now(), "RegisteredAt": RegisteredAt,
"LastPasswordChangedAt": time.Now(),
"PassowrdCycle": user.PasswordCycle + 1, "PassowrdCycle": user.PasswordCycle + 1,
}, },
}) })

View file

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

View file

@ -47,6 +47,11 @@ type User struct {
Role Role `validate:"required" json:"role"` Role Role `validate:"required" json:"role"`
PasswordCycle int `json:"-"` PasswordCycle int `json:"-"`
Link string `json:"link"` 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 { type Config struct {