diff --git a/client/src/App.jsx b/client/src/App.jsx index 5f2615d..3d528b3 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -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 ( + {setOpen(false)}} + anchorOrigin={{ vertical: 'top', horizontal: 'center' }} + > + + {message} + + diff --git a/client/src/api/users.jsx b/client/src/api/users.jsx index ef775dc..701f0ff 100644 --- a/client/src/api/users.jsx +++ b/client/src/api/users.jsx @@ -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, }; \ No newline at end of file diff --git a/client/src/api/wrap.js b/client/src/api/wrap.js new file mode 100644 index 0000000..d0e0c2b --- /dev/null +++ b/client/src/api/wrap.js @@ -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; +} \ No newline at end of file diff --git a/client/src/index.css b/client/src/index.css new file mode 100644 index 0000000..daac495 --- /dev/null +++ b/client/src/index.css @@ -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; +} \ No newline at end of file diff --git a/client/src/index.jsx b/client/src/index.jsx index 7168d81..8195674 100644 --- a/client/src/index.jsx +++ b/client/src/index.jsx @@ -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'; diff --git a/client/src/pages/config/users/usermanagement.jsx b/client/src/pages/config/users/usermanagement.jsx index 6159640..62e1555 100644 --- a/client/src/pages/config/users/usermanagement.jsx +++ b/client/src/pages/config/users/usermanagement.jsx @@ -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 - - - - - Nickname - Role - Password - Actions - - - - {rows.map((row) => ( - - - {row.nickname} - - User - - - - ))} - -
-
-
+ 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 ? setOpenInviteForm(false)}> + Invite User + + + Send this link to {toAction.nickname} to invite them to the system: +
+ { + navigator.clipboard.writeText(toAction.sendLink); + } + }> + +
{toAction.sendLink}
+
+
+
+ + + +
: ''} + + setOpenDeleteForm(false)}> + Delete User + + + Are you sure you want to delete user {toAction} ? + + + + + + + + + setOpenCreateForm(false)}> + Create User + + + Use this form to invite a new user to the system. + + + + + + + + + + + +    +

+ {isLoading ?

+ : + + + + Nickname + Status + Created At + Last Login + Actions + + + + {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 ( + + +   {row.nickname} + + + {isRegistered ? (row.role > 1 ? } + label="Admin" + variant="outlined" + /> : } + label="User" + variant="outlined" + />) : ( + inviteExpired ? } + label="Invite Expired" + color="error" + /> : } + label="Invite Pending" + color="warning" + /> + )} + + + {new Date(row.createdAt).toLocaleDateString()} -  + {new Date(row.createdAt).toLocaleTimeString()} + + + {hasLastLogin ? + {new Date(row.lastLogin).toLocaleDateString()} -  + {new Date(row.lastLogin).toLocaleTimeString()} + : '-'} + + + {isRegistered ? + () : + () + } +    + + ) + })} + +
+
} +
+ ; }; export default UserManagement; diff --git a/client/src/themes/overrides/Button.jsx b/client/src/themes/overrides/Button.jsx index 3cf97db..85bc778 100644 --- a/client/src/themes/overrides/Button.jsx +++ b/client/src/themes/overrides/Button.jsx @@ -14,7 +14,7 @@ export default function Button(theme) { }, styleOverrides: { root: { - fontWeight: 400 + fontWeight: 500 }, contained: { ...disabledStyle diff --git a/client/src/themes/overrides/InputLabel.jsx b/client/src/themes/overrides/InputLabel.jsx index d2300a4..ed43fee 100644 --- a/client/src/themes/overrides/InputLabel.jsx +++ b/client/src/themes/overrides/InputLabel.jsx @@ -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', diff --git a/client/src/themes/typography.jsx b/client/src/themes/typography.jsx index 448825f..ed0a248 100644 --- a/client/src/themes/typography.jsx +++ b/client/src/themes/typography.jsx @@ -33,7 +33,7 @@ const Typography = (fontFamily) => ({ lineHeight: 1.5 }, h6: { - fontWeight: 400, + fontWeight: 600, fontSize: '0.875rem', lineHeight: 1.57 }, diff --git a/gupm.json b/gupm.json index fa7fe07..89e7ac9 100644 --- a/gupm.json +++ b/gupm.json @@ -86,6 +86,6 @@ }, "description": "Cosmos Server", "name": "cosmos-server", - "version": "0.0.3", + "version": "0.0.4", "wrapInstallFolder": "src" } \ No newline at end of file diff --git a/src/user/create.go b/src/user/create.go index f625737..79b5a3f 100644 --- a/src/user/create.go +++ b/src/user/create.go @@ -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, diff --git a/src/user/login.go b/src/user/login.go index cb2b4f9..b136beb 100644 --- a/src/user/login.go +++ b/src/user/login.go @@ -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) diff --git a/src/user/register.go b/src/user/register.go index 4d7ab26..e48e2b8 100644 --- a/src/user/register.go +++ b/src/user/register.go @@ -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, }, }) diff --git a/src/user/resend.go b/src/user/resend.go index 2b9dede..b5d317d 100644 --- a/src/user/resend.go +++ b/src/user/resend.go @@ -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, }, }) diff --git a/src/utils/types.go b/src/utils/types.go index 1496355..4fcdc38 100644 --- a/src/utils/types.go +++ b/src/utils/types.go @@ -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 {