mirror of
https://github.com/jambonz/jambonz-webapp.git
synced 2025-12-19 05:37:43 +00:00
Feature/users dashboard (#150)
* Add user list into nav, add user form with basic functionality * add password check for temp password, add search filter * apply review comments * fix filters, apply review comments * fix button placement * handle SelfDelete * apply review comments * add conditional class * creating scoped users * apply review comments * apply review comments * apply review comments Co-authored-by: eglehelms <e.helms@cognigy.com>
This commit is contained in:
@@ -1,7 +1,5 @@
|
|||||||
{
|
{
|
||||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX3NpZCI6IjFjNTc4MWQyLTY5MGItNDIwYy1iZDUzLTVkN2Y1NjMwMDVjOCIsInNjb3BlIjoiYWRtaW4iLCJmb3JjZV9jaGFuZ2UiOnRydWUsInBlcm1pc3Npb25zIjpbIlBST1ZJU0lPTl9VU0VSUyIsIlBST1ZJU0lPTl9TRVJWSUNFUyIsIlZJRVdfT05MWSJdLCJpYXQiOjE2NjY3OTgzMTEsImV4cCI6MTY2NjgwMTkxMX0.ZV3KnRit8WGpipfiiMAZ2AVLQ25csWje1-K6hdqxktE",
|
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX3NpZCI6IjFjNTc4MWQyLTY5MGItNDIwYy1iZDUzLTVkN2Y1NjMwMDVjOCIsInNjb3BlIjoiYWRtaW4iLCJmb3JjZV9jaGFuZ2UiOnRydWUsInBlcm1pc3Npb25zIjpbIlBST1ZJU0lPTl9VU0VSUyIsIlBST1ZJU0lPTl9TRVJWSUNFUyIsIlZJRVdfT05MWSJdLCJpYXQiOjE2NjY3OTgzMTEsImV4cCI6MTY2NjgwMTkxMX0.ZV3KnRit8WGpipfiiMAZ2AVLQ25csWje1-K6hdqxktE",
|
||||||
"scope": "admin",
|
|
||||||
"user_sid": "78131ad5-f041-4d5d-821c-47b2d8c6d015",
|
"user_sid": "78131ad5-f041-4d5d-821c-47b2d8c6d015",
|
||||||
"force_change": false,
|
"force_change": false
|
||||||
"permissions": ["VIEW_ONLY", "PROVISION_SERVICES", "PROVISION_USERS"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
LimitField,
|
LimitField,
|
||||||
|
PasswordSettings,
|
||||||
SipGateway,
|
SipGateway,
|
||||||
SmppGateway,
|
SmppGateway,
|
||||||
WebHook,
|
WebHook,
|
||||||
@@ -102,6 +103,13 @@ export const PER_PAGE_SELECTION = [
|
|||||||
{ name: "100 / page", value: "100" },
|
{ name: "100 / page", value: "100" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const USER_SCOPE_SELECTION = [
|
||||||
|
{ name: "All Scopes", value: "all" },
|
||||||
|
{ name: "Admin", value: "admin" },
|
||||||
|
{ name: "Service Provider", value: "service_provider" },
|
||||||
|
{ name: "Account", value: "account" },
|
||||||
|
];
|
||||||
|
|
||||||
/** Available webhook methods */
|
/** Available webhook methods */
|
||||||
export const WEBHOOK_METHODS: WebhookOption[] = [
|
export const WEBHOOK_METHODS: WebhookOption[] = [
|
||||||
{
|
{
|
||||||
@@ -131,6 +139,17 @@ export const LIMITS: LimitField[] = [
|
|||||||
// },
|
// },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_PSWD_SETTINGS: PasswordSettings = {
|
||||||
|
min_password_length: 6,
|
||||||
|
require_digit: 0,
|
||||||
|
require_special_character: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** User scope values values */
|
||||||
|
export const USER_ADMIN = "admin";
|
||||||
|
export const USER_SP = "service_provider";
|
||||||
|
export const USER_ACCOUNT = "account";
|
||||||
|
|
||||||
/** Speech credential test result status values */
|
/** Speech credential test result status values */
|
||||||
export const CRED_OK = "ok";
|
export const CRED_OK = "ok";
|
||||||
export const CRED_FAIL = "fail";
|
export const CRED_FAIL = "fail";
|
||||||
|
|||||||
@@ -332,8 +332,8 @@ export const postPasswordSettings = (payload: Partial<PasswordSettings>) => {
|
|||||||
};
|
};
|
||||||
/** Named wrappers for `putFetch` */
|
/** Named wrappers for `putFetch` */
|
||||||
|
|
||||||
export const putUser = (sid: string, payload: UserUpdatePayload) => {
|
export const putUser = (sid: string, payload: Partial<UserUpdatePayload>) => {
|
||||||
return putFetch<EmptyResponse, UserUpdatePayload>(
|
return putFetch<EmptyResponse, Partial<UserUpdatePayload>>(
|
||||||
`${API_USERS}/${sid}`,
|
`${API_USERS}/${sid}`,
|
||||||
payload
|
payload
|
||||||
);
|
);
|
||||||
@@ -418,6 +418,10 @@ export const putSmppGateway = (sid: string, payload: Partial<SmppGateway>) => {
|
|||||||
|
|
||||||
/** Named wrappers for `deleteFetch` */
|
/** Named wrappers for `deleteFetch` */
|
||||||
|
|
||||||
|
export const deleteUser = (sid: string) => {
|
||||||
|
return deleteFetch<EmptyResponse>(`${API_USERS}/${sid}`);
|
||||||
|
};
|
||||||
|
|
||||||
export const deleteServiceProvider = (sid: string) => {
|
export const deleteServiceProvider = (sid: string) => {
|
||||||
return deleteFetch<EmptyResponse>(`${API_SERVICE_PROVIDERS}/${sid}`);
|
return deleteFetch<EmptyResponse>(`${API_SERVICE_PROVIDERS}/${sid}`);
|
||||||
};
|
};
|
||||||
@@ -477,7 +481,6 @@ export const deleteAccountLimit = (sid: string, cat: LimitCategories) => {
|
|||||||
|
|
||||||
/** Named wrappers for `getFetch` */
|
/** Named wrappers for `getFetch` */
|
||||||
|
|
||||||
/** This is not a functioning API at the moment... */
|
|
||||||
export const getUser = (sid: string) => {
|
export const getUser = (sid: string) => {
|
||||||
return getFetch<User>(`${API_USERS}/${sid}`);
|
return getFetch<User>(`${API_USERS}/${sid}`);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -106,9 +106,16 @@ export interface PasswordSettings {
|
|||||||
/** API responses/payloads */
|
/** API responses/payloads */
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
scope: UserScopes;
|
scope?: UserScopes;
|
||||||
user_sid: string;
|
user_sid: string;
|
||||||
permissions: UserPermissions[];
|
name: string;
|
||||||
|
email: string;
|
||||||
|
is_active: boolean;
|
||||||
|
force_change: boolean;
|
||||||
|
account_sid?: string | null;
|
||||||
|
service_provider_sid?: string | null;
|
||||||
|
initial_password?: string;
|
||||||
|
permissions?: UserPermissions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserLogin extends User {
|
export interface UserLogin extends User {
|
||||||
@@ -122,8 +129,15 @@ export interface UserLoginPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UserUpdatePayload {
|
export interface UserUpdatePayload {
|
||||||
old_password: string;
|
old_password?: string;
|
||||||
new_password: string;
|
new_password?: string;
|
||||||
|
initial_password: string | null;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
force_change: boolean;
|
||||||
|
is_active: boolean;
|
||||||
|
service_provider_sid: string | null;
|
||||||
|
account_sid: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServiceProvider {
|
export interface ServiceProvider {
|
||||||
@@ -345,6 +359,10 @@ export interface SidResponse {
|
|||||||
sid: string;
|
sid: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserSidResponse {
|
||||||
|
user_sid: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TokenResponse extends SidResponse {
|
export interface TokenResponse extends SidResponse {
|
||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
FilePlus,
|
FilePlus,
|
||||||
Activity,
|
Activity,
|
||||||
|
UserCheck,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Clipboard,
|
Clipboard,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -69,6 +70,7 @@ export const Icons: IconMap = {
|
|||||||
Settings,
|
Settings,
|
||||||
FilePlus,
|
FilePlus,
|
||||||
Activity,
|
Activity,
|
||||||
|
UserCheck,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Clipboard,
|
Clipboard,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import { getHumanDateTime, hasLength } from "src/utils";
|
|||||||
|
|
||||||
import type { ApiKey, TokenResponse } from "src/api/types";
|
import type { ApiKey, TokenResponse } from "src/api/types";
|
||||||
|
|
||||||
import "./styles.scss";
|
|
||||||
|
|
||||||
type ApiKeyProps = {
|
type ApiKeyProps = {
|
||||||
path: string;
|
path: string;
|
||||||
post: {
|
post: {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ROUTE_INTERNAL_ACCOUNTS,
|
ROUTE_INTERNAL_ACCOUNTS,
|
||||||
ROUTE_INTERNAL_SETTINGS,
|
ROUTE_INTERNAL_SETTINGS,
|
||||||
|
ROUTE_INTERNAL_USERS,
|
||||||
ROUTE_INTERNAL_ALERTS,
|
ROUTE_INTERNAL_ALERTS,
|
||||||
ROUTE_INTERNAL_RECENT_CALLS,
|
ROUTE_INTERNAL_RECENT_CALLS,
|
||||||
ROUTE_INTERNAL_APPLICATIONS,
|
ROUTE_INTERNAL_APPLICATIONS,
|
||||||
@@ -22,6 +23,11 @@ export interface NaviItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const naviTop: NaviItem[] = [
|
export const naviTop: NaviItem[] = [
|
||||||
|
{
|
||||||
|
label: "Users",
|
||||||
|
icon: Icons.UserCheck,
|
||||||
|
route: ROUTE_INTERNAL_USERS,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Settings",
|
label: "Settings",
|
||||||
icon: Icons.Settings,
|
icon: Icons.Settings,
|
||||||
|
|||||||
15
src/containers/internal/views/users/add.tsx
Normal file
15
src/containers/internal/views/users/add.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { H1 } from "jambonz-ui";
|
||||||
|
|
||||||
|
import { UserForm } from "./form";
|
||||||
|
|
||||||
|
export const AddUser = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<H1 className="h2">Add new user</H1>
|
||||||
|
<UserForm />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddUser;
|
||||||
26
src/containers/internal/views/users/delete.tsx
Normal file
26
src/containers/internal/views/users/delete.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { P } from "jambonz-ui";
|
||||||
|
import { Modal } from "src/components";
|
||||||
|
import type { User } from "src/api/types";
|
||||||
|
|
||||||
|
type DeleteProps = {
|
||||||
|
user: User;
|
||||||
|
handleCancel: () => void;
|
||||||
|
handleSubmit: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DeleteUser = ({
|
||||||
|
user,
|
||||||
|
handleCancel,
|
||||||
|
handleSubmit,
|
||||||
|
}: DeleteProps) => {
|
||||||
|
return (
|
||||||
|
<Modal handleCancel={handleCancel} handleSubmit={handleSubmit}>
|
||||||
|
<P>
|
||||||
|
Are you sure you want to delete the user <strong>{user.name}</strong>?
|
||||||
|
</P>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeleteUser;
|
||||||
29
src/containers/internal/views/users/edit.tsx
Normal file
29
src/containers/internal/views/users/edit.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { H1 } from "jambonz-ui";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { UserForm } from "./form";
|
||||||
|
import { useApiData } from "src/api";
|
||||||
|
import { User } from "src/api/types";
|
||||||
|
import { toastError } from "src/store";
|
||||||
|
|
||||||
|
export const EditUser = () => {
|
||||||
|
const params = useParams();
|
||||||
|
const [data, refetch, error] = useApiData<User>(`Users/${params.user_sid}`);
|
||||||
|
|
||||||
|
/** Handle error toast at top level... */
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
toastError(error.msg);
|
||||||
|
}
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<H1 className="h2">Edit user</H1>
|
||||||
|
<UserForm user={{ data, refetch, error }} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditUser;
|
||||||
326
src/containers/internal/views/users/form.tsx
Normal file
326
src/containers/internal/views/users/form.tsx
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Button, ButtonGroup, MS } from "jambonz-ui";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { toastError, toastSuccess, useSelectState } from "src/store";
|
||||||
|
import {
|
||||||
|
deleteUser,
|
||||||
|
postFetch,
|
||||||
|
putUser,
|
||||||
|
useApiData,
|
||||||
|
useServiceProviderData,
|
||||||
|
} from "src/api";
|
||||||
|
import { ROUTE_INTERNAL_USERS } from "src/router/routes";
|
||||||
|
import { useAuth } from "src/router/auth";
|
||||||
|
|
||||||
|
import { ClipBoard, Section } from "src/components";
|
||||||
|
import { DeleteUser } from "./delete";
|
||||||
|
import { MSG_REQUIRED_FIELDS } from "src/constants";
|
||||||
|
import {
|
||||||
|
API_USERS,
|
||||||
|
DEFAULT_PSWD_SETTINGS,
|
||||||
|
USER_SCOPE_SELECTION,
|
||||||
|
USER_ACCOUNT,
|
||||||
|
USER_ADMIN,
|
||||||
|
USER_SP,
|
||||||
|
} from "src/api/constants";
|
||||||
|
import { isValidPasswd, getUserScope, hasLength } from "src/utils";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
UserSidResponse,
|
||||||
|
User,
|
||||||
|
PasswordSettings,
|
||||||
|
UserScopes,
|
||||||
|
UseApiDataMap,
|
||||||
|
Account,
|
||||||
|
} from "src/api/types";
|
||||||
|
import type { IMessage } from "src/store/types";
|
||||||
|
import { AccountSelect, Passwd, Selector } from "src/components/forms";
|
||||||
|
|
||||||
|
type UserFormProps = {
|
||||||
|
user?: UseApiDataMap<User>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UserForm = ({ user }: UserFormProps) => {
|
||||||
|
const { signout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const currentUser = useSelectState("user");
|
||||||
|
const currentServiceProvider = useSelectState("currentServiceProvider");
|
||||||
|
const [pwdSettings] =
|
||||||
|
useApiData<PasswordSettings>("PasswordSettings") || DEFAULT_PSWD_SETTINGS;
|
||||||
|
const [accounts] = useServiceProviderData<Account[]>("Accounts");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [initialPassword, setInitialPassword] = useState("");
|
||||||
|
const [scope, setScope] = useState<UserScopes>();
|
||||||
|
const [isActive, setIsActive] = useState(true);
|
||||||
|
const [forceChange, setForceChange] = useState(true);
|
||||||
|
const [modal, setModal] = useState(false);
|
||||||
|
const [accountSid, setAccountSid] = useState("");
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelfDetete = () => {
|
||||||
|
if (user?.data?.user_sid === currentUser?.user_sid) {
|
||||||
|
signout();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (user && user.data) {
|
||||||
|
deleteUser(user.data.user_sid)
|
||||||
|
.then(() => {
|
||||||
|
navigate(ROUTE_INTERNAL_USERS);
|
||||||
|
toastSuccess(
|
||||||
|
<>
|
||||||
|
Deleted user <strong>{user?.data?.name}</strong>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
handleSelfDetete();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
toastError(error.msg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const passwdCheck = () => {
|
||||||
|
if (pwdSettings && !isValidPasswd(initialPassword, pwdSettings)) {
|
||||||
|
toastError("Invalid password.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (scope === USER_ACCOUNT && !accounts?.length) {
|
||||||
|
toastError("Cannot create an account. Service Provider has no accounts.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
if (!passwdCheck()) return;
|
||||||
|
|
||||||
|
postFetch<UserSidResponse, Partial<User>>(API_USERS, {
|
||||||
|
name: name,
|
||||||
|
email: email,
|
||||||
|
initial_password: initialPassword,
|
||||||
|
force_change: forceChange,
|
||||||
|
is_active: isActive,
|
||||||
|
service_provider_sid:
|
||||||
|
scope === USER_ADMIN && currentUser?.scope === USER_ADMIN
|
||||||
|
? null
|
||||||
|
: currentServiceProvider?.service_provider_sid,
|
||||||
|
account_sid:
|
||||||
|
scope !== USER_ACCOUNT && currentUser?.scope !== USER_ACCOUNT
|
||||||
|
? null
|
||||||
|
: accountSid,
|
||||||
|
})
|
||||||
|
.then(({ json }) => {
|
||||||
|
toastSuccess("User created successfully");
|
||||||
|
navigate(`${ROUTE_INTERNAL_USERS}/${json.user_sid}/edit`);
|
||||||
|
})
|
||||||
|
.catch((error: { msg: IMessage }) => {
|
||||||
|
toastError(error.msg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user && user.data) {
|
||||||
|
if (initialPassword && !passwdCheck()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
putUser(user.data.user_sid, {
|
||||||
|
name: name || user.data.name,
|
||||||
|
email: email || user.data.email,
|
||||||
|
initial_password: initialPassword || null,
|
||||||
|
force_change: forceChange || !!user.data.force_change,
|
||||||
|
is_active: isActive || !!user.data.is_active,
|
||||||
|
service_provider_sid:
|
||||||
|
scope === USER_ADMIN && currentUser?.scope === USER_ADMIN
|
||||||
|
? null
|
||||||
|
: currentServiceProvider?.service_provider_sid,
|
||||||
|
account_sid:
|
||||||
|
scope !== USER_ACCOUNT && currentUser?.scope !== USER_ACCOUNT
|
||||||
|
? null
|
||||||
|
: accountSid,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
user.refetch();
|
||||||
|
toastSuccess("User updated successfully");
|
||||||
|
navigate(ROUTE_INTERNAL_USERS);
|
||||||
|
})
|
||||||
|
.catch((error: { msg: IMessage }) => {
|
||||||
|
toastError(error.msg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Set current user data values if applicable -- e.g. "edit mode" */
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && user.data) {
|
||||||
|
setName(user.data.name);
|
||||||
|
setForceChange(!!user.data.force_change);
|
||||||
|
setIsActive(!!user.data.is_active);
|
||||||
|
setEmail(user.data.email);
|
||||||
|
setScope(getUserScope(user.data));
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Section slim>
|
||||||
|
<form className="form form--internal" onSubmit={handleSubmit}>
|
||||||
|
<fieldset>
|
||||||
|
<MS>{MSG_REQUIRED_FIELDS}</MS>
|
||||||
|
</fieldset>
|
||||||
|
{currentUser?.scope !== USER_ACCOUNT && (
|
||||||
|
<fieldset>
|
||||||
|
<label htmlFor="scope">Scope:</label>
|
||||||
|
<Selector
|
||||||
|
id="scope"
|
||||||
|
name="scope"
|
||||||
|
value={scope}
|
||||||
|
options={
|
||||||
|
currentUser?.scope === USER_SP
|
||||||
|
? USER_SCOPE_SELECTION.filter(
|
||||||
|
(e) => e.value !== USER_ADMIN && e.value !== "all"
|
||||||
|
)
|
||||||
|
: USER_SCOPE_SELECTION.filter((e) => e.value !== "all")
|
||||||
|
}
|
||||||
|
onChange={(e) => setScope(e.target.value as UserScopes)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasLength(accounts) && scope === USER_ACCOUNT && (
|
||||||
|
<>
|
||||||
|
<AccountSelect
|
||||||
|
accounts={accounts}
|
||||||
|
account={[accountSid, setAccountSid]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{scope !== USER_ACCOUNT && !hasLength(accounts) && (
|
||||||
|
<>
|
||||||
|
<label htmlFor="account">
|
||||||
|
Account:<span>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="account"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
name="account"
|
||||||
|
value="No accounts."
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
|
{user && user.data && (
|
||||||
|
<fieldset>
|
||||||
|
<label htmlFor="user_sid">User SID</label>
|
||||||
|
<ClipBoard
|
||||||
|
id="user_sid"
|
||||||
|
name="user_sid"
|
||||||
|
text={user.data.user_sid}
|
||||||
|
/>
|
||||||
|
<label htmlFor="is_active" className="chk">
|
||||||
|
<input
|
||||||
|
id="is_active"
|
||||||
|
name="is_active"
|
||||||
|
type="checkbox"
|
||||||
|
checked={isActive}
|
||||||
|
onChange={(e) => setIsActive(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<div>User is active</div>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
|
<fieldset>
|
||||||
|
<label htmlFor="name">
|
||||||
|
User name<span>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
placeholder="User Name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<label htmlFor="email">
|
||||||
|
User email<span>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
required
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
placeholder="User Email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<label htmlFor="initial_password">
|
||||||
|
Temporary password
|
||||||
|
{!user && <span>*</span>}
|
||||||
|
</label>
|
||||||
|
<Passwd
|
||||||
|
id="initial_password"
|
||||||
|
required={!user}
|
||||||
|
name="initial_password"
|
||||||
|
value={initialPassword}
|
||||||
|
placeholder="Temporary password"
|
||||||
|
setValue={setInitialPassword}
|
||||||
|
/>
|
||||||
|
<label htmlFor="force_change" className="chk">
|
||||||
|
<input
|
||||||
|
id="force_change"
|
||||||
|
name="force_change"
|
||||||
|
type="checkbox"
|
||||||
|
checked={forceChange}
|
||||||
|
onChange={(e) => setForceChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<div>Force change of password</div>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<ButtonGroup left className={user && "btns--spaced"}>
|
||||||
|
<Button small subStyle="grey" as={Link} to={ROUTE_INTERNAL_USERS}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" small>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
{user && user.data && (
|
||||||
|
<Button
|
||||||
|
small
|
||||||
|
type="button"
|
||||||
|
subStyle="grey"
|
||||||
|
onClick={() => setModal(true)}
|
||||||
|
>
|
||||||
|
Delete User
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ButtonGroup>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Section>
|
||||||
|
{user && user.data && modal && (
|
||||||
|
<DeleteUser
|
||||||
|
user={user.data}
|
||||||
|
handleCancel={handleCancel}
|
||||||
|
handleSubmit={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
134
src/containers/internal/views/users/index.tsx
Normal file
134
src/containers/internal/views/users/index.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import { H1, Button, Icon } from "jambonz-ui";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useApiData, useServiceProviderData } from "src/api";
|
||||||
|
import { ROUTE_INTERNAL_USERS } from "src/router/routes";
|
||||||
|
import { USER_SCOPE_SELECTION, USER_ADMIN } from "src/api/constants";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Section,
|
||||||
|
Icons,
|
||||||
|
Spinner,
|
||||||
|
SearchFilter,
|
||||||
|
AccountFilter,
|
||||||
|
SelectFilter,
|
||||||
|
} from "src/components";
|
||||||
|
import { hasLength, hasValue, useFilteredResults } from "src/utils";
|
||||||
|
|
||||||
|
import type { Account, User } from "src/api/types";
|
||||||
|
import { useSelectState } from "src/store";
|
||||||
|
|
||||||
|
export const Users = () => {
|
||||||
|
const currentServiceProvider = useSelectState("currentServiceProvider");
|
||||||
|
const [users] = useApiData<User[]>("Users");
|
||||||
|
const [filter, setFilter] = useState("");
|
||||||
|
const [scopeFilter, setScopeFilter] = useState("all");
|
||||||
|
const [accountSid, setAccountSid] = useState("");
|
||||||
|
const [accounts] = useServiceProviderData<Account[]>("Accounts");
|
||||||
|
|
||||||
|
const usersFiltered = useMemo(() => {
|
||||||
|
const serviceProviderUsers = users?.filter((e) => {
|
||||||
|
return (
|
||||||
|
e.scope === USER_ADMIN ||
|
||||||
|
e.service_provider_sid === currentServiceProvider?.service_provider_sid
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (scopeFilter === "all" && !accountSid) {
|
||||||
|
return serviceProviderUsers;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scopeFilter !== "all" && !accountSid) {
|
||||||
|
return serviceProviderUsers?.filter((e) => e.scope === scopeFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scopeFilter !== "all" && accountSid) {
|
||||||
|
return serviceProviderUsers?.filter(
|
||||||
|
(e) => e.scope === scopeFilter && accountSid === e.account_sid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scopeFilter === "all" && accountSid) {
|
||||||
|
return serviceProviderUsers?.filter((e) => e.account_sid === accountSid);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, [accountSid, scopeFilter, users, accounts, currentServiceProvider]);
|
||||||
|
|
||||||
|
const filteredUsers = useFilteredResults<User>(filter, usersFiltered);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="mast">
|
||||||
|
<H1 className="h2">Users</H1>
|
||||||
|
<Link to={`${ROUTE_INTERNAL_USERS}/add`} title="Add user">
|
||||||
|
<Icon>
|
||||||
|
<Icons.Plus />
|
||||||
|
</Icon>
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
<section className="filters filters--mix">
|
||||||
|
<section>
|
||||||
|
<SearchFilter
|
||||||
|
placeholder="Filter users"
|
||||||
|
filter={[filter, setFilter]}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<SelectFilter
|
||||||
|
id="scope"
|
||||||
|
label="Scope"
|
||||||
|
filter={[scopeFilter, setScopeFilter]}
|
||||||
|
options={USER_SCOPE_SELECTION}
|
||||||
|
/>
|
||||||
|
<AccountFilter
|
||||||
|
account={[accountSid, setAccountSid]}
|
||||||
|
accounts={accounts}
|
||||||
|
defaultOption={true}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Section slim>
|
||||||
|
<div className="grid grid--col4">
|
||||||
|
<div className="grid__row grid__th">
|
||||||
|
<div>User Name</div>
|
||||||
|
<div>Email</div>
|
||||||
|
<div>Scope</div>
|
||||||
|
<div> </div>
|
||||||
|
</div>
|
||||||
|
{!hasValue(users) ? (
|
||||||
|
<Spinner />
|
||||||
|
) : hasLength(filteredUsers) ? (
|
||||||
|
filteredUsers.map((user) => {
|
||||||
|
return (
|
||||||
|
<div className="grid__row" key={user.user_sid}>
|
||||||
|
<div>{user.name}</div>
|
||||||
|
<div>{user.email}</div>
|
||||||
|
<div>{user.scope}</div>
|
||||||
|
<div className="item__actions">
|
||||||
|
<Link
|
||||||
|
to={`${ROUTE_INTERNAL_USERS}/${user.user_sid}/edit`}
|
||||||
|
title="Edit user"
|
||||||
|
>
|
||||||
|
<Icons.Edit3 />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="grid__row grid__empty">
|
||||||
|
<div>No users.</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
<Section clean>
|
||||||
|
<Button small as={Link} to={`${ROUTE_INTERNAL_USERS}/add`}>
|
||||||
|
Add user
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Users;
|
||||||
@@ -3,8 +3,8 @@ import { Button, H1, M } from "jambonz-ui";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { isValidPasswd } from "src/utils";
|
import { isValidPasswd } from "src/utils";
|
||||||
import { putUser } from "src/api";
|
import { putUser, useApiData } from "src/api";
|
||||||
import { StatusCodes } from "src/api/types";
|
import { PasswordSettings, StatusCodes } from "src/api/types";
|
||||||
import { Passwd, Message } from "src/components/forms";
|
import { Passwd, Message } from "src/components/forms";
|
||||||
import { ROUTE_LOGIN, ROUTE_INTERNAL_ACCOUNTS } from "src/router/routes";
|
import { ROUTE_LOGIN, ROUTE_INTERNAL_ACCOUNTS } from "src/router/routes";
|
||||||
import {
|
import {
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import type { IMessage } from "src/store/types";
|
import type { IMessage } from "src/store/types";
|
||||||
|
|
||||||
export const CreatePassword = () => {
|
export const CreatePassword = () => {
|
||||||
|
const [passwdSettings] = useApiData<PasswordSettings>("PasswordSettings");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [message, setMessage] = useState<IMessage>("");
|
const [message, setMessage] = useState<IMessage>("");
|
||||||
@@ -42,7 +43,7 @@ export const CreatePassword = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValidPasswd(password)) {
|
if (passwdSettings && !isValidPasswd(password, passwdSettings)) {
|
||||||
setMessage(MSG_PASSWD_CRITERIA);
|
setMessage(MSG_PASSWD_CRITERIA);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import { NotFound } from "src/containers/notfound";
|
|||||||
import CreatePassword from "src/containers/login/create-password";
|
import CreatePassword from "src/containers/login/create-password";
|
||||||
|
|
||||||
/** Top navi */
|
/** Top navi */
|
||||||
|
import Users from "src/containers/internal/views/users";
|
||||||
|
import UserAdd from "src/containers/internal/views/users/add";
|
||||||
|
import UserEdit from "src/containers/internal/views/users/edit";
|
||||||
import Settings from "src/containers/internal/views/settings";
|
import Settings from "src/containers/internal/views/settings";
|
||||||
import Accounts from "src/containers/internal/views/accounts";
|
import Accounts from "src/containers/internal/views/accounts";
|
||||||
import AccountAdd from "src/containers/internal/views/accounts/add";
|
import AccountAdd from "src/containers/internal/views/accounts/add";
|
||||||
@@ -66,6 +69,9 @@ export const Router = () => {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="*" element={<InternalLayout />}>
|
<Route path="*" element={<InternalLayout />}>
|
||||||
{/* Top navi */}
|
{/* Top navi */}
|
||||||
|
<Route path="users" element={<Users />} />
|
||||||
|
<Route path="users/add" element={<UserAdd />} />
|
||||||
|
<Route path="users/:user_sid/edit" element={<UserEdit />} />
|
||||||
<Route path="settings" element={<Settings />} />
|
<Route path="settings" element={<Settings />} />
|
||||||
<Route path="accounts" element={<Accounts />} />
|
<Route path="accounts" element={<Accounts />} />
|
||||||
<Route path="accounts/add" element={<AccountAdd />} />
|
<Route path="accounts/add" element={<AccountAdd />} />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export const ROUTE_LOGIN = "/";
|
export const ROUTE_LOGIN = "/";
|
||||||
export const ROUTE_CREATE_PASSWORD = "/create-password";
|
export const ROUTE_CREATE_PASSWORD = "/create-password";
|
||||||
|
export const ROUTE_INTERNAL_USERS = "/internal/users";
|
||||||
export const ROUTE_INTERNAL_SETTINGS = "/internal/settings";
|
export const ROUTE_INTERNAL_SETTINGS = "/internal/settings";
|
||||||
export const ROUTE_INTERNAL_ACCOUNTS = "/internal/accounts";
|
export const ROUTE_INTERNAL_ACCOUNTS = "/internal/accounts";
|
||||||
export const ROUTE_INTERNAL_APPLICATIONS = "/internal/applications";
|
export const ROUTE_INTERNAL_APPLICATIONS = "/internal/applications";
|
||||||
|
|||||||
@@ -63,8 +63,7 @@ export const currentServiceProviderAction = (
|
|||||||
|
|
||||||
export const userAsyncAction = async (): Promise<User> => {
|
export const userAsyncAction = async (): Promise<User> => {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
const { user_sid, permissions, scope } = parseJwt(token);
|
return parseJwt(token);
|
||||||
return { user_sid, permissions, scope };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const serviceProvidersAsyncAction = async (): Promise<
|
export const serviceProvidersAsyncAction = async (): Promise<
|
||||||
|
|||||||
@@ -30,6 +30,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--mix {
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
grid-gap: ui-vars.$px02;
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
> :nth-child(2) {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+ * {
|
+ * {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
@use "jambonz-ui/src/styles/mixins" as ui-mixins;
|
@use "jambonz-ui/src/styles/mixins" as ui-mixins;
|
||||||
|
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
|
input[type="email"],
|
||||||
input[type="number"],
|
input[type="number"],
|
||||||
input[type="password"] {
|
input[type="password"] {
|
||||||
@include ui-mixins.m();
|
@include ui-mixins.m();
|
||||||
@@ -154,6 +155,7 @@ fieldset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
|
input[type="email"],
|
||||||
input[type="number"],
|
input[type="number"],
|
||||||
input[type="password"] {
|
input[type="password"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -98,4 +98,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--col4 {
|
||||||
|
.grid__row {
|
||||||
|
grid-template-columns: [col] 37% [col] 35% [col] 20% [col] 3%;
|
||||||
|
grid-template-rows: [row] auto [row] auto [row] [row] auto;
|
||||||
|
display: grid;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
@use "./smsel";
|
@use "./smsel";
|
||||||
@use "./inpbtn";
|
@use "./inpbtn";
|
||||||
@use "./filters";
|
@use "./filters";
|
||||||
|
@use "./grid";
|
||||||
@use "jambonz-ui/src/styles/index";
|
@use "jambonz-ui/src/styles/index";
|
||||||
|
|
||||||
@use "./vars";
|
@use "./vars";
|
||||||
@@ -113,6 +114,12 @@ details {
|
|||||||
font-size: 0;
|
font-size: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btns--spaced {
|
||||||
|
> :last-child {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 404 page (login & internal) */
|
/** 404 page (login & internal) */
|
||||||
.notfound {
|
.notfound {
|
||||||
.btn {
|
.btn {
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ import {
|
|||||||
INVALID,
|
INVALID,
|
||||||
IP,
|
IP,
|
||||||
TCP_MAX_PORT,
|
TCP_MAX_PORT,
|
||||||
|
USER_ACCOUNT,
|
||||||
|
USER_ADMIN,
|
||||||
|
USER_SP,
|
||||||
} from "src/api/constants";
|
} from "src/api/constants";
|
||||||
|
|
||||||
import type { IpType } from "src/api/types";
|
import type { IpType, PasswordSettings, User, UserScopes } from "src/api/types";
|
||||||
|
|
||||||
export const hasValue = <Type>(
|
export const hasValue = <Type>(
|
||||||
variable: Type | null | undefined
|
variable: Type | null | undefined
|
||||||
@@ -32,10 +35,20 @@ export const isObject = (obj: unknown) => {
|
|||||||
return typeof obj === "object" && hasValue(obj) && !Array.isArray(obj);
|
return typeof obj === "object" && hasValue(obj) && !Array.isArray(obj);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isValidPasswd = (password: string) => {
|
export const isValidPasswd = (
|
||||||
return (
|
password: string,
|
||||||
password.length >= 6 && /\d/.test(password) && /[a-zA-Z]/.test(password)
|
passwordSettings: PasswordSettings
|
||||||
);
|
) => {
|
||||||
|
if (passwordSettings) {
|
||||||
|
return (
|
||||||
|
password.length >= passwordSettings?.min_password_length &&
|
||||||
|
(passwordSettings?.require_digit ? /\d/.test(password) : true) &&
|
||||||
|
(passwordSettings?.require_special_character
|
||||||
|
? /[!@#$%^&*(),.?"';:{}|<>+~]/.test(password)
|
||||||
|
: true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isValidPort = (port: number) => {
|
export const isValidPort = (port: number) => {
|
||||||
@@ -125,6 +138,16 @@ export const sortLocaleName = (
|
|||||||
b: Required<{ name: string }>
|
b: Required<{ name: string }>
|
||||||
) => a.name.localeCompare(b.name);
|
) => a.name.localeCompare(b.name);
|
||||||
|
|
||||||
|
export const getUserScope = (user: User): UserScopes => {
|
||||||
|
if (user.account_sid) {
|
||||||
|
return USER_ACCOUNT;
|
||||||
|
} else if (user.service_provider_sid) {
|
||||||
|
return USER_SP;
|
||||||
|
} else {
|
||||||
|
return USER_ADMIN;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
withSuspense,
|
withSuspense,
|
||||||
useMobileMedia,
|
useMobileMedia,
|
||||||
|
|||||||
Reference in New Issue
Block a user