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:
EgleH
2022-11-23 19:12:19 +01:00
committed by GitHub
parent a9edbdbd26
commit 5af7471886
21 changed files with 655 additions and 22 deletions

View File

@@ -1,7 +1,5 @@
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX3NpZCI6IjFjNTc4MWQyLTY5MGItNDIwYy1iZDUzLTVkN2Y1NjMwMDVjOCIsInNjb3BlIjoiYWRtaW4iLCJmb3JjZV9jaGFuZ2UiOnRydWUsInBlcm1pc3Npb25zIjpbIlBST1ZJU0lPTl9VU0VSUyIsIlBST1ZJU0lPTl9TRVJWSUNFUyIsIlZJRVdfT05MWSJdLCJpYXQiOjE2NjY3OTgzMTEsImV4cCI6MTY2NjgwMTkxMX0.ZV3KnRit8WGpipfiiMAZ2AVLQ25csWje1-K6hdqxktE",
"scope": "admin",
"user_sid": "78131ad5-f041-4d5d-821c-47b2d8c6d015",
"force_change": false,
"permissions": ["VIEW_ONLY", "PROVISION_SERVICES", "PROVISION_USERS"]
"force_change": false
}

View File

@@ -1,5 +1,6 @@
import type {
LimitField,
PasswordSettings,
SipGateway,
SmppGateway,
WebHook,
@@ -102,6 +103,13 @@ export const PER_PAGE_SELECTION = [
{ 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 */
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 */
export const CRED_OK = "ok";
export const CRED_FAIL = "fail";

View File

@@ -332,8 +332,8 @@ export const postPasswordSettings = (payload: Partial<PasswordSettings>) => {
};
/** Named wrappers for `putFetch` */
export const putUser = (sid: string, payload: UserUpdatePayload) => {
return putFetch<EmptyResponse, UserUpdatePayload>(
export const putUser = (sid: string, payload: Partial<UserUpdatePayload>) => {
return putFetch<EmptyResponse, Partial<UserUpdatePayload>>(
`${API_USERS}/${sid}`,
payload
);
@@ -418,6 +418,10 @@ export const putSmppGateway = (sid: string, payload: Partial<SmppGateway>) => {
/** Named wrappers for `deleteFetch` */
export const deleteUser = (sid: string) => {
return deleteFetch<EmptyResponse>(`${API_USERS}/${sid}`);
};
export const deleteServiceProvider = (sid: string) => {
return deleteFetch<EmptyResponse>(`${API_SERVICE_PROVIDERS}/${sid}`);
};
@@ -477,7 +481,6 @@ export const deleteAccountLimit = (sid: string, cat: LimitCategories) => {
/** Named wrappers for `getFetch` */
/** This is not a functioning API at the moment... */
export const getUser = (sid: string) => {
return getFetch<User>(`${API_USERS}/${sid}`);
};

View File

@@ -106,9 +106,16 @@ export interface PasswordSettings {
/** API responses/payloads */
export interface User {
scope: UserScopes;
scope?: UserScopes;
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 {
@@ -122,8 +129,15 @@ export interface UserLoginPayload {
}
export interface UserUpdatePayload {
old_password: string;
new_password: string;
old_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 {
@@ -345,6 +359,10 @@ export interface SidResponse {
sid: string;
}
export interface UserSidResponse {
user_sid: string;
}
export interface TokenResponse extends SidResponse {
token: string;
}

View File

@@ -22,6 +22,7 @@ import {
Settings,
FilePlus,
Activity,
UserCheck,
ChevronUp,
Clipboard,
RefreshCw,
@@ -69,6 +70,7 @@ export const Icons: IconMap = {
Settings,
FilePlus,
Activity,
UserCheck,
ChevronUp,
Clipboard,
RefreshCw,

View File

@@ -8,8 +8,6 @@ import { getHumanDateTime, hasLength } from "src/utils";
import type { ApiKey, TokenResponse } from "src/api/types";
import "./styles.scss";
type ApiKeyProps = {
path: string;
post: {

View File

@@ -1,6 +1,7 @@
import {
ROUTE_INTERNAL_ACCOUNTS,
ROUTE_INTERNAL_SETTINGS,
ROUTE_INTERNAL_USERS,
ROUTE_INTERNAL_ALERTS,
ROUTE_INTERNAL_RECENT_CALLS,
ROUTE_INTERNAL_APPLICATIONS,
@@ -22,6 +23,11 @@ export interface NaviItem {
}
export const naviTop: NaviItem[] = [
{
label: "Users",
icon: Icons.UserCheck,
route: ROUTE_INTERNAL_USERS,
},
{
label: "Settings",
icon: Icons.Settings,

View 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;

View 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;

View 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;

View 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}
/>
)}
</>
);
};

View 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>&nbsp;</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;

View File

@@ -3,8 +3,8 @@ import { Button, H1, M } from "jambonz-ui";
import { useNavigate } from "react-router-dom";
import { isValidPasswd } from "src/utils";
import { putUser } from "src/api";
import { StatusCodes } from "src/api/types";
import { putUser, useApiData } from "src/api";
import { PasswordSettings, StatusCodes } from "src/api/types";
import { Passwd, Message } from "src/components/forms";
import { ROUTE_LOGIN, ROUTE_INTERNAL_ACCOUNTS } from "src/router/routes";
import {
@@ -19,6 +19,7 @@ import {
import type { IMessage } from "src/store/types";
export const CreatePassword = () => {
const [passwdSettings] = useApiData<PasswordSettings>("PasswordSettings");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [message, setMessage] = useState<IMessage>("");
@@ -42,7 +43,7 @@ export const CreatePassword = () => {
return;
}
if (!isValidPasswd(password)) {
if (passwdSettings && !isValidPasswd(password, passwdSettings)) {
setMessage(MSG_PASSWD_CRITERIA);
return;
}

View File

@@ -11,6 +11,9 @@ import { NotFound } from "src/containers/notfound";
import CreatePassword from "src/containers/login/create-password";
/** 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 Accounts from "src/containers/internal/views/accounts";
import AccountAdd from "src/containers/internal/views/accounts/add";
@@ -66,6 +69,9 @@ export const Router = () => {
<Routes>
<Route path="*" element={<InternalLayout />}>
{/* 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="accounts" element={<Accounts />} />
<Route path="accounts/add" element={<AccountAdd />} />

View File

@@ -1,5 +1,6 @@
export const ROUTE_LOGIN = "/";
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_ACCOUNTS = "/internal/accounts";
export const ROUTE_INTERNAL_APPLICATIONS = "/internal/applications";

View File

@@ -63,8 +63,7 @@ export const currentServiceProviderAction = (
export const userAsyncAction = async (): Promise<User> => {
const token = getToken();
const { user_sid, permissions, scope } = parseJwt(token);
return { user_sid, permissions, scope };
return parseJwt(token);
};
export const serviceProvidersAsyncAction = async (): Promise<

View File

@@ -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;
}

View File

@@ -4,6 +4,7 @@
@use "jambonz-ui/src/styles/mixins" as ui-mixins;
input[type="text"],
input[type="email"],
input[type="number"],
input[type="password"] {
@include ui-mixins.m();
@@ -154,6 +155,7 @@ fieldset {
}
input[type="text"],
input[type="email"],
input[type="number"],
input[type="password"] {
width: 100%;

View File

@@ -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;
}
}
}

View File

@@ -4,6 +4,7 @@
@use "./smsel";
@use "./inpbtn";
@use "./filters";
@use "./grid";
@use "jambonz-ui/src/styles/index";
@use "./vars";
@@ -113,6 +114,12 @@ details {
font-size: 0;
}
.btns--spaced {
> :last-child {
margin-left: auto;
}
}
/** 404 page (login & internal) */
.notfound {
.btn {

View File

@@ -10,9 +10,12 @@ import {
INVALID,
IP,
TCP_MAX_PORT,
USER_ACCOUNT,
USER_ADMIN,
USER_SP,
} 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>(
variable: Type | null | undefined
@@ -32,10 +35,20 @@ export const isObject = (obj: unknown) => {
return typeof obj === "object" && hasValue(obj) && !Array.isArray(obj);
};
export const isValidPasswd = (password: string) => {
return (
password.length >= 6 && /\d/.test(password) && /[a-zA-Z]/.test(password)
);
export const isValidPasswd = (
password: string,
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) => {
@@ -125,6 +138,16 @@ export const sortLocaleName = (
b: Required<{ name: string }>
) => 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 {
withSuspense,
useMobileMedia,