Object Browser only mode (#2157)
- Added flag CONSOLE_OBJECT_BROWSER_ONLY=on to trigger between console mode & Object Browser only - Hidden not necessary buttons for object browse - STS Login Signed-off-by: Benjamin Perez <benjamin@bexsoft.net> Signed-off-by: Daniel Valdivia <18384552+dvaldivia@users.noreply.github.com>
This commit is contained in:
2
.github/workflows/jobs.yaml
vendored
2
.github/workflows/jobs.yaml
vendored
@@ -276,7 +276,7 @@ jobs:
|
||||
semgrep --config semgrep.yaml $(pwd)/portal-ui --error
|
||||
|
||||
no-warnings-and-make-assets:
|
||||
name: "React Code Has No Warning & Prettified and then Make Assets"
|
||||
name: "React Code Has No Warnings & is Prettified, then Make Assets"
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
|
||||
@@ -28,7 +28,6 @@ import (
|
||||
"github.com/go-openapi/errors"
|
||||
"github.com/go-openapi/strfmt"
|
||||
"github.com/go-openapi/swag"
|
||||
"github.com/go-openapi/validate"
|
||||
)
|
||||
|
||||
// LoginRequest login request
|
||||
@@ -37,48 +36,32 @@ import (
|
||||
type LoginRequest struct {
|
||||
|
||||
// access key
|
||||
// Required: true
|
||||
AccessKey *string `json:"accessKey"`
|
||||
AccessKey string `json:"accessKey,omitempty"`
|
||||
|
||||
// features
|
||||
Features *LoginRequestFeatures `json:"features,omitempty"`
|
||||
|
||||
// secret key
|
||||
// Required: true
|
||||
SecretKey *string `json:"secretKey"`
|
||||
SecretKey string `json:"secretKey,omitempty"`
|
||||
|
||||
// sts
|
||||
Sts string `json:"sts,omitempty"`
|
||||
}
|
||||
|
||||
// Validate validates this login request
|
||||
func (m *LoginRequest) Validate(formats strfmt.Registry) error {
|
||||
var res []error
|
||||
|
||||
if err := m.validateAccessKey(formats); err != nil {
|
||||
res = append(res, err)
|
||||
}
|
||||
|
||||
if err := m.validateFeatures(formats); err != nil {
|
||||
res = append(res, err)
|
||||
}
|
||||
|
||||
if err := m.validateSecretKey(formats); err != nil {
|
||||
res = append(res, err)
|
||||
}
|
||||
|
||||
if len(res) > 0 {
|
||||
return errors.CompositeValidationError(res...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *LoginRequest) validateAccessKey(formats strfmt.Registry) error {
|
||||
|
||||
if err := validate.Required("accessKey", "body", m.AccessKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *LoginRequest) validateFeatures(formats strfmt.Registry) error {
|
||||
if swag.IsZero(m.Features) { // not required
|
||||
return nil
|
||||
@@ -98,15 +81,6 @@ func (m *LoginRequest) validateFeatures(formats strfmt.Registry) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *LoginRequest) validateSecretKey(formats strfmt.Registry) error {
|
||||
|
||||
if err := validate.Required("secretKey", "body", m.SecretKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ContextValidate validate this login request based on the context it is used
|
||||
func (m *LoginRequest) ContextValidate(ctx context.Context, formats strfmt.Registry) error {
|
||||
var res []error
|
||||
|
||||
@@ -48,6 +48,9 @@ type Principal struct {
|
||||
|
||||
// hm
|
||||
Hm bool `json:"hm,omitempty"`
|
||||
|
||||
// ob
|
||||
Ob bool `json:"ob,omitempty"`
|
||||
}
|
||||
|
||||
// Validate validates this principal
|
||||
|
||||
@@ -3288,10 +3288,6 @@ func init() {
|
||||
},
|
||||
"loginRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"accessKey",
|
||||
"secretKey"
|
||||
],
|
||||
"properties": {
|
||||
"accessKey": {
|
||||
"type": "string"
|
||||
@@ -3306,6 +3302,9 @@ func init() {
|
||||
},
|
||||
"secretKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"sts": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -8801,10 +8800,6 @@ func init() {
|
||||
},
|
||||
"loginRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"accessKey",
|
||||
"secretKey"
|
||||
],
|
||||
"properties": {
|
||||
"accessKey": {
|
||||
"type": "string"
|
||||
@@ -8819,6 +8814,9 @@ func init() {
|
||||
},
|
||||
"secretKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"sts": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -67,11 +67,18 @@ type TokenClaims struct {
|
||||
STSSessionToken string `json:"stsSessionToken,omitempty"`
|
||||
AccountAccessKey string `json:"accountAccessKey,omitempty"`
|
||||
HideMenu bool `json:"hm,omitempty"`
|
||||
ObjectBrowser bool `json:"ob,omitempty"`
|
||||
}
|
||||
|
||||
// STSClaims claims struct for STS Token
|
||||
type STSClaims struct {
|
||||
AccessKey string `json:"accessKey,omitempty"`
|
||||
}
|
||||
|
||||
// SessionFeatures represents features stored in the session
|
||||
type SessionFeatures struct {
|
||||
HideMenu bool
|
||||
HideMenu bool
|
||||
ObjectBrowser bool
|
||||
}
|
||||
|
||||
// SessionTokenAuthenticate takes a session token, decode it, extract claims and validate the signature
|
||||
@@ -115,6 +122,7 @@ func NewEncryptedTokenForClient(credentials *credentials.Value, accountAccessKey
|
||||
}
|
||||
if features != nil {
|
||||
tokenClaims.HideMenu = features.HideMenu
|
||||
tokenClaims.ObjectBrowser = features.ObjectBrowser
|
||||
}
|
||||
encryptedClaims, err := encryptClaims(tokenClaims)
|
||||
if err != nil {
|
||||
|
||||
@@ -36,13 +36,15 @@ import {
|
||||
IAM_ROLES,
|
||||
IAM_SCOPES,
|
||||
} from "../../../../common/SecureComponent/permissions";
|
||||
import SearchBox from "../../Common/SearchBox";
|
||||
import BackLink from "../../../../common/BackLink";
|
||||
import {
|
||||
setSearchObjects,
|
||||
setSearchVersions,
|
||||
setVersionsModeEnabled,
|
||||
} from "../../ObjectBrowser/objectBrowserSlice";
|
||||
import SearchBox from "../../Common/SearchBox";
|
||||
import { selFeatures } from "../../consoleSlice";
|
||||
import { LoginMinIOLogo } from "../../../../icons";
|
||||
|
||||
const styles = (theme: Theme) =>
|
||||
createStyles({
|
||||
@@ -67,9 +69,13 @@ const BrowserHandler = () => {
|
||||
(state: AppState) => state.objectBrowser.searchVersions
|
||||
);
|
||||
|
||||
const features = useSelector(selFeatures);
|
||||
|
||||
const bucketName = params.bucketName || "";
|
||||
const internalPaths = get(params, "subpaths", "");
|
||||
|
||||
const obOnly = !!features?.includes("object-browser-only");
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(setVersionsModeEnabled({ status: false }));
|
||||
}, [internalPaths, dispatch]);
|
||||
@@ -78,59 +84,79 @@ const BrowserHandler = () => {
|
||||
navigate(`/buckets/${bucketName}/admin`);
|
||||
};
|
||||
|
||||
const searchBar = (
|
||||
<Fragment>
|
||||
{!versionsMode ? (
|
||||
<SecureComponent
|
||||
scopes={[IAM_SCOPES.S3_LIST_BUCKET]}
|
||||
resource={bucketName}
|
||||
errorProps={{ disabled: true }}
|
||||
>
|
||||
<SearchBox
|
||||
placeholder={"Start typing to filter objects in the bucket"}
|
||||
onChange={(value) => {
|
||||
dispatch(setSearchObjects(value));
|
||||
}}
|
||||
value={searchObjects}
|
||||
/>
|
||||
</SecureComponent>
|
||||
) : (
|
||||
<Fragment>
|
||||
<SearchBox
|
||||
placeholder={`Start typing to filter versions of ${versionedFile}`}
|
||||
onChange={(value) => {
|
||||
dispatch(setSearchVersions(value));
|
||||
}}
|
||||
value={searchVersions}
|
||||
/>
|
||||
</Fragment>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<PageHeader
|
||||
label={<BackLink label={"Buckets"} to={IAM_PAGES.BUCKETS} />}
|
||||
actions={
|
||||
<SecureComponent
|
||||
scopes={IAM_PERMISSIONS[IAM_ROLES.BUCKET_ADMIN]}
|
||||
resource={bucketName}
|
||||
errorProps={{ disabled: true }}
|
||||
>
|
||||
<Tooltip title={"Configure Bucket"}>
|
||||
<IconButton
|
||||
color="primary"
|
||||
aria-label="Configure Bucket"
|
||||
component="span"
|
||||
onClick={openBucketConfiguration}
|
||||
size="large"
|
||||
>
|
||||
<SettingsIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</SecureComponent>
|
||||
}
|
||||
middleComponent={
|
||||
<Fragment>
|
||||
{!versionsMode ? (
|
||||
<SecureComponent
|
||||
scopes={[IAM_SCOPES.S3_LIST_BUCKET]}
|
||||
resource={bucketName}
|
||||
errorProps={{ disabled: true }}
|
||||
>
|
||||
<SearchBox
|
||||
placeholder={"Start typing to filter objects in the bucket"}
|
||||
onChange={(value) => {
|
||||
dispatch(setSearchObjects(value));
|
||||
}}
|
||||
value={searchObjects}
|
||||
/>
|
||||
</SecureComponent>
|
||||
) : (
|
||||
<Fragment>
|
||||
<SearchBox
|
||||
placeholder={`Start typing to filter versions of ${versionedFile}`}
|
||||
onChange={(value) => {
|
||||
dispatch(setSearchVersions(value));
|
||||
}}
|
||||
value={searchVersions}
|
||||
/>
|
||||
</Fragment>
|
||||
)}
|
||||
</Fragment>
|
||||
}
|
||||
/>
|
||||
{!obOnly ? (
|
||||
<PageHeader
|
||||
label={<BackLink label={"Buckets"} to={IAM_PAGES.BUCKETS} />}
|
||||
actions={
|
||||
<SecureComponent
|
||||
scopes={IAM_PERMISSIONS[IAM_ROLES.BUCKET_ADMIN]}
|
||||
resource={bucketName}
|
||||
errorProps={{ disabled: true }}
|
||||
>
|
||||
<Tooltip title={"Configure Bucket"}>
|
||||
<IconButton
|
||||
color="primary"
|
||||
aria-label="Configure Bucket"
|
||||
component="span"
|
||||
onClick={openBucketConfiguration}
|
||||
size="large"
|
||||
>
|
||||
<SettingsIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</SecureComponent>
|
||||
}
|
||||
middleComponent={searchBar}
|
||||
/>
|
||||
) : (
|
||||
<Grid
|
||||
container
|
||||
sx={{
|
||||
padding: "20px 32px 0",
|
||||
}}
|
||||
>
|
||||
<Grid>
|
||||
<LoginMinIOLogo
|
||||
style={{ width: 105, marginRight: 30, marginTop: 10 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs>
|
||||
{searchBar}
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid>
|
||||
<ListObjects />
|
||||
</Grid>
|
||||
|
||||
@@ -163,6 +163,7 @@ interface IBucketListItem {
|
||||
onSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
selected: boolean;
|
||||
bulkSelect: boolean;
|
||||
noManage?: boolean;
|
||||
}
|
||||
|
||||
const BucketListItem = ({
|
||||
@@ -171,6 +172,7 @@ const BucketListItem = ({
|
||||
onSelect,
|
||||
selected,
|
||||
bulkSelect,
|
||||
noManage = false,
|
||||
}: IBucketListItem) => {
|
||||
const usage = niceBytes(`${bucket.size}` || "0");
|
||||
const usageScalar = usage.split(" ")[0];
|
||||
@@ -236,24 +238,26 @@ const BucketListItem = ({
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={5} className={classes.bucketActionButtons}>
|
||||
<SecureComponent
|
||||
scopes={IAM_PERMISSIONS[IAM_ROLES.BUCKET_ADMIN]}
|
||||
resource={bucket.name}
|
||||
>
|
||||
<Link
|
||||
to={`/buckets/${bucket.name}/admin`}
|
||||
style={{ textDecoration: "none" }}
|
||||
{!noManage && (
|
||||
<SecureComponent
|
||||
scopes={IAM_PERMISSIONS[IAM_ROLES.BUCKET_ADMIN]}
|
||||
resource={bucket.name}
|
||||
>
|
||||
<RBIconButton
|
||||
tooltip={"Manage"}
|
||||
onClick={() => {}}
|
||||
text={"Manage"}
|
||||
icon={<SettingsIcon />}
|
||||
color={"primary"}
|
||||
variant={"outlined"}
|
||||
/>
|
||||
</Link>
|
||||
</SecureComponent>
|
||||
<Link
|
||||
to={`/buckets/${bucket.name}/admin`}
|
||||
style={{ textDecoration: "none" }}
|
||||
>
|
||||
<RBIconButton
|
||||
tooltip={"Manage"}
|
||||
onClick={() => {}}
|
||||
text={"Manage"}
|
||||
icon={<SettingsIcon />}
|
||||
color={"primary"}
|
||||
variant={"outlined"}
|
||||
/>
|
||||
</Link>
|
||||
</SecureComponent>
|
||||
)}
|
||||
<Link
|
||||
to={`/buckets/${bucket.name}/browse`}
|
||||
style={{ textDecoration: "none" }}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
AddIcon,
|
||||
BucketsIcon,
|
||||
LifecycleConfigIcon,
|
||||
LoginMinIOLogo,
|
||||
SelectAllIcon,
|
||||
} from "../../../../icons";
|
||||
import {
|
||||
@@ -57,6 +58,8 @@ import BulkLifecycleModal from "./BulkLifecycleModal";
|
||||
import hasPermission from "../../../../common/SecureComponent/accessControl";
|
||||
import { setErrorSnackMessage } from "../../../../systemSlice";
|
||||
import { useAppDispatch } from "../../../../store";
|
||||
import { useSelector } from "react-redux";
|
||||
import { selFeatures } from "../../consoleSlice";
|
||||
|
||||
const styles = (theme: Theme) =>
|
||||
createStyles({
|
||||
@@ -98,9 +101,11 @@ const ListBuckets = ({ classes }: IListBucketsProps) => {
|
||||
const [replicationModalOpen, setReplicationModalOpen] =
|
||||
useState<boolean>(false);
|
||||
const [lifecycleModalOpen, setLifecycleModalOpen] = useState<boolean>(false);
|
||||
|
||||
const [bulkSelect, setBulkSelect] = useState<boolean>(false);
|
||||
|
||||
const features = useSelector(selFeatures);
|
||||
const obOnly = !!features?.includes("object-browser-only");
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
const fetchRecords = () => {
|
||||
@@ -172,6 +177,7 @@ const ListBuckets = ({ classes }: IListBucketsProps) => {
|
||||
onSelect={selectListBuckets}
|
||||
selected={selectedBuckets.includes(bucket.name)}
|
||||
bulkSelect={bulkSelect}
|
||||
noManage={obOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -209,9 +215,16 @@ const ListBuckets = ({ classes }: IListBucketsProps) => {
|
||||
open={lifecycleModalOpen}
|
||||
/>
|
||||
)}
|
||||
<PageHeader label={"Buckets"} />
|
||||
{!obOnly && <PageHeader label={"Buckets"} />}
|
||||
<PageLayout>
|
||||
<Grid item xs={12} className={classes.actionsTray} display="flex">
|
||||
{obOnly && (
|
||||
<Grid item xs>
|
||||
<LoginMinIOLogo
|
||||
style={{ width: 105, marginRight: 15, marginTop: 10 }}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
<SearchBox
|
||||
onChange={setFilterBuckets}
|
||||
placeholder="Search Buckets"
|
||||
@@ -226,59 +239,63 @@ const ListBuckets = ({ classes }: IListBucketsProps) => {
|
||||
alignItems={"center"}
|
||||
justifyContent={"flex-end"}
|
||||
>
|
||||
<RBIconButton
|
||||
tooltip={
|
||||
bulkSelect ? "Unselect Buckets" : "Select Multiple Buckets"
|
||||
}
|
||||
onClick={() => {
|
||||
setBulkSelect(!bulkSelect);
|
||||
setSelectedBuckets([]);
|
||||
}}
|
||||
text={""}
|
||||
icon={<SelectMultipleIcon />}
|
||||
color={"primary"}
|
||||
variant={bulkSelect ? "contained" : "outlined"}
|
||||
/>
|
||||
{!obOnly && (
|
||||
<Fragment>
|
||||
<RBIconButton
|
||||
tooltip={
|
||||
bulkSelect ? "Unselect Buckets" : "Select Multiple Buckets"
|
||||
}
|
||||
onClick={() => {
|
||||
setBulkSelect(!bulkSelect);
|
||||
setSelectedBuckets([]);
|
||||
}}
|
||||
text={""}
|
||||
icon={<SelectMultipleIcon />}
|
||||
color={"primary"}
|
||||
variant={bulkSelect ? "contained" : "outlined"}
|
||||
/>
|
||||
|
||||
{bulkSelect && (
|
||||
<RBIconButton
|
||||
tooltip={
|
||||
selectedBuckets.length === filteredRecords.length
|
||||
? "Unselect All Buckets"
|
||||
: "Select All Buckets"
|
||||
}
|
||||
onClick={selectAllBuckets}
|
||||
text={""}
|
||||
icon={<SelectAllIcon />}
|
||||
color={"primary"}
|
||||
variant={"outlined"}
|
||||
/>
|
||||
{bulkSelect && (
|
||||
<RBIconButton
|
||||
tooltip={
|
||||
selectedBuckets.length === filteredRecords.length
|
||||
? "Unselect All Buckets"
|
||||
: "Select All Buckets"
|
||||
}
|
||||
onClick={selectAllBuckets}
|
||||
text={""}
|
||||
icon={<SelectAllIcon />}
|
||||
color={"primary"}
|
||||
variant={"outlined"}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RBIconButton
|
||||
tooltip={"Set Lifecycle"}
|
||||
onClick={() => {
|
||||
setLifecycleModalOpen(true);
|
||||
}}
|
||||
text={""}
|
||||
icon={<LifecycleConfigIcon />}
|
||||
disabled={selectedBuckets.length === 0}
|
||||
color={"primary"}
|
||||
variant={"outlined"}
|
||||
/>
|
||||
|
||||
<RBIconButton
|
||||
tooltip={"Set Replication"}
|
||||
onClick={() => {
|
||||
setReplicationModalOpen(true);
|
||||
}}
|
||||
text={""}
|
||||
icon={<MultipleBucketsIcon />}
|
||||
disabled={selectedBuckets.length === 0}
|
||||
color={"primary"}
|
||||
variant={"outlined"}
|
||||
/>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
<RBIconButton
|
||||
tooltip={"Set Lifecycle"}
|
||||
onClick={() => {
|
||||
setLifecycleModalOpen(true);
|
||||
}}
|
||||
text={""}
|
||||
icon={<LifecycleConfigIcon />}
|
||||
disabled={selectedBuckets.length === 0}
|
||||
color={"primary"}
|
||||
variant={"outlined"}
|
||||
/>
|
||||
|
||||
<RBIconButton
|
||||
tooltip={"Set Replication"}
|
||||
onClick={() => {
|
||||
setReplicationModalOpen(true);
|
||||
}}
|
||||
text={""}
|
||||
icon={<MultipleBucketsIcon />}
|
||||
disabled={selectedBuckets.length === 0}
|
||||
color={"primary"}
|
||||
variant={"outlined"}
|
||||
/>
|
||||
|
||||
<RBIconButton
|
||||
tooltip={"Refresh"}
|
||||
onClick={() => {
|
||||
@@ -290,17 +307,19 @@ const ListBuckets = ({ classes }: IListBucketsProps) => {
|
||||
variant={"outlined"}
|
||||
/>
|
||||
|
||||
<RBIconButton
|
||||
tooltip={"Create Bucket"}
|
||||
onClick={() => {
|
||||
navigate(IAM_PAGES.ADD_BUCKETS);
|
||||
}}
|
||||
text={"Create Bucket"}
|
||||
icon={<AddIcon />}
|
||||
color={"primary"}
|
||||
variant={"contained"}
|
||||
disabled={!canCreateBucket}
|
||||
/>
|
||||
{!obOnly && (
|
||||
<RBIconButton
|
||||
tooltip={"Create Bucket"}
|
||||
onClick={() => {
|
||||
navigate(IAM_PAGES.ADD_BUCKETS);
|
||||
}}
|
||||
text={"Create Bucket"}
|
||||
icon={<AddIcon />}
|
||||
color={"primary"}
|
||||
variant={"contained"}
|
||||
disabled={!canCreateBucket}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
|
||||
@@ -203,6 +203,8 @@ const Console = ({ classes }: IConsoleProps) => {
|
||||
const [openSnackbar, setOpenSnackbar] = useState<boolean>(false);
|
||||
|
||||
const ldapIsEnabled = (features && features.includes("ldap-idp")) || false;
|
||||
const obOnly = !!features?.includes("object-browser-only");
|
||||
|
||||
const restartServer = () => {
|
||||
dispatch(serverIsLoading(true));
|
||||
api
|
||||
@@ -461,16 +463,17 @@ const Console = ({ classes }: IConsoleProps) => {
|
||||
|
||||
const allowedRoutes = (
|
||||
operatorMode ? operatorConsoleRoutes : consoleAdminRoutes
|
||||
).filter(
|
||||
(route: any) =>
|
||||
(route.forceDisplay ||
|
||||
(route.customPermissionFnc
|
||||
? route.customPermissionFnc()
|
||||
: hasPermission(
|
||||
CONSOLE_UI_RESOURCE,
|
||||
IAM_PAGES_PERMISSIONS[route.path]
|
||||
))) &&
|
||||
!route.fsHidden
|
||||
).filter((route: any) =>
|
||||
obOnly
|
||||
? route.path.includes("buckets")
|
||||
: (route.forceDisplay ||
|
||||
(route.customPermissionFnc
|
||||
? route.customPermissionFnc()
|
||||
: hasPermission(
|
||||
CONSOLE_UI_RESOURCE,
|
||||
IAM_PAGES_PERMISSIONS[route.path]
|
||||
))) &&
|
||||
!route.fsHidden
|
||||
);
|
||||
|
||||
const closeSnackBar = () => {
|
||||
@@ -494,6 +497,8 @@ const Console = ({ classes }: IConsoleProps) => {
|
||||
hideMenu = true;
|
||||
} else if (pathname.endsWith("/hop")) {
|
||||
hideMenu = true;
|
||||
} else if (obOnly) {
|
||||
hideMenu = true;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
68
portal-ui/src/screens/LoginPage/LoginField.tsx
Normal file
68
portal-ui/src/screens/LoginPage/LoginField.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
// This file is part of MinIO Console Server
|
||||
// Copyright (c) 2022 MinIO, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import makeStyles from "@mui/styles/makeStyles";
|
||||
import { Theme } from "@mui/material/styles";
|
||||
import createStyles from "@mui/styles/createStyles";
|
||||
import { TextFieldProps } from "@mui/material";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import React from "react";
|
||||
|
||||
const inputStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
root: {
|
||||
"& .MuiOutlinedInput-root": {
|
||||
paddingLeft: 0,
|
||||
"& svg": {
|
||||
marginLeft: 4,
|
||||
height: 14,
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
"& input": {
|
||||
padding: 10,
|
||||
fontSize: 14,
|
||||
paddingLeft: 0,
|
||||
"&::placeholder": {
|
||||
fontSize: 12,
|
||||
},
|
||||
"@media (max-width: 900px)": {
|
||||
padding: 10,
|
||||
},
|
||||
},
|
||||
"& fieldset": {},
|
||||
|
||||
"& fieldset:hover": {
|
||||
borderBottom: "2px solid #000000",
|
||||
borderRadius: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
export const LoginField = (props: TextFieldProps) => {
|
||||
const classes = inputStyles();
|
||||
|
||||
return (
|
||||
<TextField
|
||||
classes={{
|
||||
root: classes.root,
|
||||
}}
|
||||
variant="standard"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -14,25 +14,16 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Box,
|
||||
InputAdornment,
|
||||
LinearProgress,
|
||||
TextFieldProps,
|
||||
} from "@mui/material";
|
||||
import { Box, InputAdornment, LinearProgress } from "@mui/material";
|
||||
import { Theme, useTheme } from "@mui/material/styles";
|
||||
import createStyles from "@mui/styles/createStyles";
|
||||
import makeStyles from "@mui/styles/makeStyles";
|
||||
import withStyles from "@mui/styles/withStyles";
|
||||
import Button from "@mui/material/Button";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import { ILoginDetails, loginStrategyType } from "./types";
|
||||
import { ErrorResponseHandler } from "../../common/types";
|
||||
import api from "../../common/api";
|
||||
import { loginStrategyType } from "./types";
|
||||
import RefreshIcon from "../../icons/RefreshIcon";
|
||||
import MainError from "../Console/Common/MainError/MainError";
|
||||
import {
|
||||
@@ -45,20 +36,22 @@ import {
|
||||
} from "../../icons";
|
||||
import { spacingUtils } from "../Console/Common/FormComponents/common/styleLibrary";
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import LockFilledIcon from "../../icons/LockFilledIcon";
|
||||
import UserFilledIcon from "../../icons/UsersFilledIcon";
|
||||
import { SupportMenuIcon } from "../../icons/SidebarMenus";
|
||||
import GithubIcon from "../../icons/GithubIcon";
|
||||
import clsx from "clsx";
|
||||
import Loader from "../Console/Common/Loader/Loader";
|
||||
import { AppState, useAppDispatch } from "../../store";
|
||||
import { useSelector } from "react-redux";
|
||||
import {
|
||||
setErrorSnackMessage,
|
||||
userLogged,
|
||||
showMarketplace,
|
||||
} from "../../systemSlice";
|
||||
import { useAppDispatch } from "../../store";
|
||||
doLoginAsync,
|
||||
getFetchConfigurationAsync,
|
||||
getVersionAsync,
|
||||
} from "./loginThunks";
|
||||
import { resetForm, setJwt } from "./loginSlice";
|
||||
import StrategyForm from "./StrategyForm";
|
||||
import { LoginField } from "./LoginField";
|
||||
|
||||
const styles = (theme: Theme) =>
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
root: {
|
||||
position: "absolute",
|
||||
@@ -78,18 +71,6 @@ const styles = (theme: Theme) =>
|
||||
boxShadow: "none",
|
||||
padding: "16px 30px",
|
||||
},
|
||||
learnMore: {
|
||||
textAlign: "center",
|
||||
fontSize: 10,
|
||||
"& a": {
|
||||
color: "#2781B0",
|
||||
},
|
||||
"& .min-icon": {
|
||||
marginLeft: 12,
|
||||
marginTop: 2,
|
||||
width: 10,
|
||||
},
|
||||
},
|
||||
separator: {
|
||||
marginLeft: 8,
|
||||
marginRight: 8,
|
||||
@@ -227,298 +208,89 @@ const styles = (theme: Theme) =>
|
||||
},
|
||||
},
|
||||
...spacingUtils,
|
||||
});
|
||||
|
||||
const inputStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
root: {
|
||||
"& .MuiOutlinedInput-root": {
|
||||
paddingLeft: 0,
|
||||
"& svg": {
|
||||
marginLeft: 4,
|
||||
height: 14,
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
"& input": {
|
||||
padding: 10,
|
||||
fontSize: 14,
|
||||
paddingLeft: 0,
|
||||
"&::placeholder": {
|
||||
fontSize: 12,
|
||||
},
|
||||
"@media (max-width: 900px)": {
|
||||
padding: 10,
|
||||
},
|
||||
},
|
||||
"& fieldset": {},
|
||||
|
||||
"& fieldset:hover": {
|
||||
borderBottom: "2px solid #000000",
|
||||
borderRadius: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
function LoginField(props: TextFieldProps) {
|
||||
const classes = inputStyles();
|
||||
|
||||
return (
|
||||
<TextField
|
||||
classes={{
|
||||
root: classes.root,
|
||||
}}
|
||||
variant="standard"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// The inferred type will look like:
|
||||
// {isOn: boolean, toggleOn: () => void}
|
||||
|
||||
interface ILoginProps {
|
||||
classes: any;
|
||||
}
|
||||
|
||||
interface LoginStrategyRoutes {
|
||||
export interface LoginStrategyRoutes {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
interface LoginStrategyPayload {
|
||||
export interface LoginStrategyPayload {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const Login = ({ classes }: ILoginProps) => {
|
||||
export const loginStrategyEndpoints: LoginStrategyRoutes = {
|
||||
form: "/api/v1/login",
|
||||
"service-account": "/api/v1/login/operator",
|
||||
};
|
||||
|
||||
export const getTargetPath = () => {
|
||||
let targetPath = "/";
|
||||
if (
|
||||
localStorage.getItem("redirect-path") &&
|
||||
localStorage.getItem("redirect-path") !== ""
|
||||
) {
|
||||
targetPath = `${localStorage.getItem("redirect-path")}`;
|
||||
localStorage.setItem("redirect-path", "");
|
||||
}
|
||||
return targetPath;
|
||||
};
|
||||
|
||||
const Login = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
const classes = useStyles();
|
||||
|
||||
const [accessKey, setAccessKey] = useState<string>("");
|
||||
const [jwt, setJwt] = useState<string>("");
|
||||
const [secretKey, setSecretKey] = useState<string>("");
|
||||
const [loginStrategy, setLoginStrategy] = useState<ILoginDetails>({
|
||||
loginStrategy: loginStrategyType.unknown,
|
||||
redirect: "",
|
||||
});
|
||||
const [loginSending, setLoginSending] = useState<boolean>(false);
|
||||
const [loadingFetchConfiguration, setLoadingFetchConfiguration] =
|
||||
useState<boolean>(true);
|
||||
const jwt = useSelector((state: AppState) => state.login.jwt);
|
||||
const loginStrategy = useSelector(
|
||||
(state: AppState) => state.login.loginStrategy
|
||||
);
|
||||
const loginSending = useSelector(
|
||||
(state: AppState) => state.login.loginSending
|
||||
);
|
||||
const loadingFetchConfiguration = useSelector(
|
||||
(state: AppState) => state.login.loadingFetchConfiguration
|
||||
);
|
||||
|
||||
const [latestMinIOVersion, setLatestMinIOVersion] = useState<string>("");
|
||||
const [loadingVersion, setLoadingVersion] = useState<boolean>(true);
|
||||
const latestMinIOVersion = useSelector(
|
||||
(state: AppState) => state.login.latestMinIOVersion
|
||||
);
|
||||
const loadingVersion = useSelector(
|
||||
(state: AppState) => state.login.loadingVersion
|
||||
);
|
||||
const navigateTo = useSelector((state: AppState) => state.login.navigateTo);
|
||||
|
||||
const isOperator =
|
||||
loginStrategy.loginStrategy === loginStrategyType.serviceAccount ||
|
||||
loginStrategy.loginStrategy === loginStrategyType.redirectServiceAccount;
|
||||
|
||||
const loginStrategyEndpoints: LoginStrategyRoutes = {
|
||||
form: "/api/v1/login",
|
||||
"service-account": "/api/v1/login/operator",
|
||||
};
|
||||
const loginStrategyPayload: LoginStrategyPayload = {
|
||||
form: { accessKey, secretKey },
|
||||
"service-account": { jwt },
|
||||
};
|
||||
|
||||
const fetchConfiguration = () => {
|
||||
setLoadingFetchConfiguration(true);
|
||||
};
|
||||
|
||||
const getTargetPath = () => {
|
||||
let targetPath = "/";
|
||||
if (
|
||||
localStorage.getItem("redirect-path") &&
|
||||
localStorage.getItem("redirect-path") !== ""
|
||||
) {
|
||||
targetPath = `${localStorage.getItem("redirect-path")}`;
|
||||
localStorage.setItem("redirect-path", "");
|
||||
}
|
||||
return targetPath;
|
||||
};
|
||||
|
||||
const redirectAfterLogin = () => {
|
||||
navigate(getTargetPath());
|
||||
};
|
||||
|
||||
const redirectToMarketplace = () => {
|
||||
api
|
||||
.invoke("GET", "/api/v1/mp-integration/")
|
||||
.then((res: any) => {
|
||||
redirectAfterLogin(); // Email already set, continue with normal flow
|
||||
})
|
||||
.catch((err: ErrorResponseHandler) => {
|
||||
if (err.statusCode === 404) {
|
||||
dispatch(showMarketplace(true));
|
||||
navigate("/marketplace");
|
||||
} else {
|
||||
// Unexpected error, continue with normal flow
|
||||
redirectAfterLogin();
|
||||
}
|
||||
});
|
||||
};
|
||||
if (navigateTo !== "") {
|
||||
navigate(navigateTo);
|
||||
dispatch(resetForm());
|
||||
}
|
||||
|
||||
const formSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setLoginSending(true);
|
||||
api
|
||||
.invoke(
|
||||
"POST",
|
||||
loginStrategyEndpoints[loginStrategy.loginStrategy] || "/api/v1/login",
|
||||
loginStrategyPayload[loginStrategy.loginStrategy]
|
||||
)
|
||||
.then(() => {
|
||||
// We set the state in redux
|
||||
dispatch(userLogged(true));
|
||||
if (loginStrategy.loginStrategy === loginStrategyType.form) {
|
||||
localStorage.setItem("userLoggedIn", accessKey);
|
||||
}
|
||||
if (isOperator) {
|
||||
redirectToMarketplace();
|
||||
} else {
|
||||
redirectAfterLogin();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
setLoginSending(false);
|
||||
dispatch(setErrorSnackMessage(err));
|
||||
});
|
||||
dispatch(doLoginAsync());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (loadingFetchConfiguration) {
|
||||
api
|
||||
.invoke("GET", "/api/v1/login")
|
||||
.then((loginDetails: ILoginDetails) => {
|
||||
setLoginStrategy(loginDetails);
|
||||
setLoadingFetchConfiguration(false);
|
||||
})
|
||||
.catch((err: ErrorResponseHandler) => {
|
||||
dispatch(setErrorSnackMessage(err));
|
||||
setLoadingFetchConfiguration(false);
|
||||
});
|
||||
dispatch(getFetchConfigurationAsync());
|
||||
}
|
||||
}, [loadingFetchConfiguration, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loadingVersion) {
|
||||
api
|
||||
.invoke("GET", "/api/v1/check-version")
|
||||
.then(
|
||||
({
|
||||
current_version,
|
||||
latest_version,
|
||||
}: {
|
||||
current_version: string;
|
||||
latest_version: string;
|
||||
}) => {
|
||||
setLatestMinIOVersion(latest_version);
|
||||
setLoadingVersion(false);
|
||||
}
|
||||
)
|
||||
.catch((err: ErrorResponseHandler) => {
|
||||
// try the operator version
|
||||
api
|
||||
.invoke("GET", "/api/v1/check-operator-version")
|
||||
.then(
|
||||
({
|
||||
current_version,
|
||||
latest_version,
|
||||
}: {
|
||||
current_version: string;
|
||||
latest_version: string;
|
||||
}) => {
|
||||
setLatestMinIOVersion(latest_version);
|
||||
setLoadingVersion(false);
|
||||
}
|
||||
)
|
||||
.catch((err: ErrorResponseHandler) => {
|
||||
setLoadingVersion(false);
|
||||
});
|
||||
});
|
||||
dispatch(getVersionAsync());
|
||||
}
|
||||
}, [loadingVersion, setLoadingVersion, setLatestMinIOVersion]);
|
||||
}, [dispatch, loadingVersion]);
|
||||
|
||||
let loginComponent = null;
|
||||
let loginComponent;
|
||||
|
||||
switch (loginStrategy.loginStrategy) {
|
||||
case loginStrategyType.form: {
|
||||
loginComponent = (
|
||||
<React.Fragment>
|
||||
<form className={classes.form} noValidate onSubmit={formSubmit}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} className={classes.spacerBottom}>
|
||||
<LoginField
|
||||
fullWidth
|
||||
id="accessKey"
|
||||
className={classes.inputField}
|
||||
value={accessKey}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setAccessKey(e.target.value)
|
||||
}
|
||||
placeholder={"Username"}
|
||||
name="accessKey"
|
||||
autoComplete="username"
|
||||
disabled={loginSending}
|
||||
variant={"outlined"}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment
|
||||
position="start"
|
||||
className={classes.iconColor}
|
||||
>
|
||||
<UserFilledIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<LoginField
|
||||
fullWidth
|
||||
className={classes.inputField}
|
||||
value={secretKey}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setSecretKey(e.target.value)
|
||||
}
|
||||
name="secretKey"
|
||||
type="password"
|
||||
id="secretKey"
|
||||
autoComplete="current-password"
|
||||
disabled={loginSending}
|
||||
placeholder={"Password"}
|
||||
variant={"outlined"}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment
|
||||
position="start"
|
||||
className={classes.iconColor}
|
||||
>
|
||||
<LockFilledIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid item xs={12} className={classes.submitContainer}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
id="do-login"
|
||||
className={classes.submit}
|
||||
disabled={secretKey === "" || accessKey === "" || loginSending}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item xs={12} className={classes.linearPredef}>
|
||||
{loginSending && <LinearProgress />}
|
||||
</Grid>
|
||||
</form>
|
||||
</React.Fragment>
|
||||
);
|
||||
loginComponent = <StrategyForm />;
|
||||
break;
|
||||
}
|
||||
case loginStrategyType.redirect:
|
||||
@@ -553,7 +325,7 @@ const Login = ({ classes }: ILoginProps) => {
|
||||
id="jwt"
|
||||
value={jwt}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setJwt(e.target.value)
|
||||
dispatch(setJwt(e.target.value))
|
||||
}
|
||||
name="jwt"
|
||||
autoComplete="off"
|
||||
@@ -607,7 +379,7 @@ const Login = ({ classes }: ILoginProps) => {
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
fetchConfiguration();
|
||||
dispatch(getFetchConfigurationAsync());
|
||||
}}
|
||||
endIcon={<RefreshIcon />}
|
||||
color={"primary"}
|
||||
@@ -771,4 +543,4 @@ const Login = ({ classes }: ILoginProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default withStyles(styles)(Login);
|
||||
export default Login;
|
||||
|
||||
231
portal-ui/src/screens/LoginPage/StrategyForm.tsx
Normal file
231
portal-ui/src/screens/LoginPage/StrategyForm.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
// This file is part of MinIO Console Server
|
||||
// Copyright (c) 2022 MinIO, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import Grid from "@mui/material/Grid";
|
||||
import React, { Fragment } from "react";
|
||||
import { setAccessKey, setSecretKey, setSTS, setUseSTS } from "./loginSlice";
|
||||
import { Box, InputAdornment, LinearProgress } from "@mui/material";
|
||||
import UserFilledIcon from "../../icons/UsersFilledIcon";
|
||||
import LockFilledIcon from "../../icons/LockFilledIcon";
|
||||
import Button from "@mui/material/Button";
|
||||
import { AppState, useAppDispatch } from "../../store";
|
||||
import { useSelector } from "react-redux";
|
||||
import { LoginField } from "./LoginField";
|
||||
import makeStyles from "@mui/styles/makeStyles";
|
||||
import { Theme, useTheme } from "@mui/material/styles";
|
||||
import createStyles from "@mui/styles/createStyles";
|
||||
import { spacingUtils } from "../Console/Common/FormComponents/common/styleLibrary";
|
||||
import { doLoginAsync } from "./loginThunks";
|
||||
import { PasswordKeyIcon } from "../../icons";
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
root: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
overflow: "auto",
|
||||
},
|
||||
form: {
|
||||
width: "100%", // Fix IE 11 issue.
|
||||
},
|
||||
submit: {
|
||||
margin: "30px 0px 8px",
|
||||
height: 40,
|
||||
width: "100%",
|
||||
boxShadow: "none",
|
||||
padding: "16px 30px",
|
||||
},
|
||||
submitContainer: {
|
||||
textAlign: "right",
|
||||
},
|
||||
linearPredef: {
|
||||
height: 10,
|
||||
},
|
||||
...spacingUtils,
|
||||
})
|
||||
);
|
||||
|
||||
const StrategyForm = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const classes = useStyles();
|
||||
const theme = useTheme();
|
||||
|
||||
const accessKey = useSelector((state: AppState) => state.login.accessKey);
|
||||
const secretKey = useSelector((state: AppState) => state.login.secretKey);
|
||||
const sts = useSelector((state: AppState) => state.login.sts);
|
||||
const useSTS = useSelector((state: AppState) => state.login.useSTS);
|
||||
|
||||
const loginSending = useSelector(
|
||||
(state: AppState) => state.login.loginSending
|
||||
);
|
||||
|
||||
const formSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
dispatch(doLoginAsync());
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<form className={classes.form} noValidate onSubmit={formSubmit}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} className={classes.spacerBottom}>
|
||||
<LoginField
|
||||
fullWidth
|
||||
id="accessKey"
|
||||
className={classes.inputField}
|
||||
value={accessKey}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
dispatch(setAccessKey(e.target.value))
|
||||
}
|
||||
placeholder={useSTS ? "STS Username" : "Username"}
|
||||
name="accessKey"
|
||||
autoComplete="username"
|
||||
disabled={loginSending}
|
||||
variant={"outlined"}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment
|
||||
position="start"
|
||||
className={classes.iconColor}
|
||||
>
|
||||
<UserFilledIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} className={useSTS ? classes.spacerBottom : ""}>
|
||||
<LoginField
|
||||
fullWidth
|
||||
className={classes.inputField}
|
||||
value={secretKey}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
dispatch(setSecretKey(e.target.value))
|
||||
}
|
||||
name="secretKey"
|
||||
type="password"
|
||||
id="secretKey"
|
||||
autoComplete="current-password"
|
||||
disabled={loginSending}
|
||||
placeholder={useSTS ? "STS Secret" : "Password"}
|
||||
variant={"outlined"}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment
|
||||
position="start"
|
||||
className={classes.iconColor}
|
||||
>
|
||||
<LockFilledIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
{useSTS && (
|
||||
<Grid item xs={12} className={classes.spacerBottom}>
|
||||
<LoginField
|
||||
fullWidth
|
||||
id="sts"
|
||||
className={classes.inputField}
|
||||
value={sts}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
dispatch(setSTS(e.target.value))
|
||||
}
|
||||
placeholder={"STS Token"}
|
||||
name="STS"
|
||||
autoComplete="sts"
|
||||
disabled={loginSending}
|
||||
variant={"outlined"}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment
|
||||
position="start"
|
||||
className={classes.iconColor}
|
||||
>
|
||||
<PasswordKeyIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} className={classes.submitContainer}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
id="do-login"
|
||||
className={classes.submit}
|
||||
disabled={
|
||||
(!useSTS && (accessKey === "" || secretKey === "")) ||
|
||||
(useSTS && sts === "") ||
|
||||
loginSending
|
||||
}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item xs={12} className={classes.linearPredef}>
|
||||
{loginSending && <LinearProgress />}
|
||||
</Grid>
|
||||
<Grid item xs={12} className={classes.linearPredef}>
|
||||
<Box
|
||||
style={{
|
||||
textAlign: "center",
|
||||
marginTop: 20,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
onClick={() => {
|
||||
dispatch(setUseSTS(!useSTS));
|
||||
}}
|
||||
style={{
|
||||
color: theme.colors.link,
|
||||
font: "normal normal normal 12px/15px Lato",
|
||||
textDecoration: "underline",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{!useSTS && <Fragment>Use STS</Fragment>}
|
||||
{useSTS && <Fragment>Use Credentials</Fragment>}
|
||||
</span>
|
||||
<span
|
||||
onClick={() => {
|
||||
dispatch(setUseSTS(!useSTS));
|
||||
}}
|
||||
style={{
|
||||
color: theme.colors.link,
|
||||
font: "normal normal normal 12px/15px Lato",
|
||||
textDecoration: "none",
|
||||
fontWeight: "bold",
|
||||
paddingLeft: 4,
|
||||
}}
|
||||
>
|
||||
➔
|
||||
</span>
|
||||
</Box>
|
||||
</Grid>
|
||||
</form>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default StrategyForm;
|
||||
135
portal-ui/src/screens/LoginPage/loginSlice.ts
Normal file
135
portal-ui/src/screens/LoginPage/loginSlice.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
// This file is part of MinIO Console Server
|
||||
// Copyright (c) 2022 MinIO, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
import { ILoginDetails, loginStrategyType } from "./types";
|
||||
import {
|
||||
doLoginAsync,
|
||||
getFetchConfigurationAsync,
|
||||
getVersionAsync,
|
||||
} from "./loginThunks";
|
||||
|
||||
export interface LoginState {
|
||||
accessKey: string;
|
||||
secretKey: string;
|
||||
sts: string;
|
||||
useSTS: boolean;
|
||||
|
||||
jwt: string;
|
||||
|
||||
loginStrategy: ILoginDetails;
|
||||
|
||||
loginSending: boolean;
|
||||
loadingFetchConfiguration: boolean;
|
||||
|
||||
latestMinIOVersion: string;
|
||||
loadingVersion: boolean;
|
||||
|
||||
navigateTo: string;
|
||||
}
|
||||
|
||||
const initialState: LoginState = {
|
||||
accessKey: "",
|
||||
secretKey: "",
|
||||
sts: "",
|
||||
useSTS: false,
|
||||
jwt: "",
|
||||
loginStrategy: {
|
||||
loginStrategy: loginStrategyType.unknown,
|
||||
redirect: "",
|
||||
},
|
||||
loginSending: false,
|
||||
loadingFetchConfiguration: true,
|
||||
latestMinIOVersion: "",
|
||||
loadingVersion: true,
|
||||
|
||||
navigateTo: "",
|
||||
};
|
||||
|
||||
export const loginSlice = createSlice({
|
||||
name: "login",
|
||||
initialState,
|
||||
reducers: {
|
||||
setAccessKey: (state, action: PayloadAction<string>) => {
|
||||
state.accessKey = action.payload;
|
||||
},
|
||||
setSecretKey: (state, action: PayloadAction<string>) => {
|
||||
state.secretKey = action.payload;
|
||||
},
|
||||
setUseSTS: (state, action: PayloadAction<boolean>) => {
|
||||
state.useSTS = action.payload;
|
||||
},
|
||||
setSTS: (state, action: PayloadAction<string>) => {
|
||||
state.sts = action.payload;
|
||||
},
|
||||
setJwt: (state, action: PayloadAction<string>) => {
|
||||
state.jwt = action.payload;
|
||||
},
|
||||
setNavigateTo: (state, action: PayloadAction<string>) => {
|
||||
state.navigateTo = action.payload;
|
||||
},
|
||||
resetForm: (state) => initialState,
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(getVersionAsync.pending, (state, action) => {
|
||||
state.loadingVersion = true;
|
||||
})
|
||||
.addCase(getVersionAsync.rejected, (state, action) => {
|
||||
state.loadingVersion = false;
|
||||
})
|
||||
.addCase(getVersionAsync.fulfilled, (state, action) => {
|
||||
state.loadingVersion = false;
|
||||
if (action.payload) {
|
||||
state.latestMinIOVersion = action.payload;
|
||||
}
|
||||
})
|
||||
.addCase(getFetchConfigurationAsync.pending, (state, action) => {
|
||||
state.loadingFetchConfiguration = true;
|
||||
})
|
||||
.addCase(getFetchConfigurationAsync.rejected, (state, action) => {
|
||||
state.loadingFetchConfiguration = false;
|
||||
})
|
||||
.addCase(getFetchConfigurationAsync.fulfilled, (state, action) => {
|
||||
state.loadingFetchConfiguration = false;
|
||||
if (action.payload) {
|
||||
state.loginStrategy = action.payload;
|
||||
}
|
||||
})
|
||||
.addCase(doLoginAsync.pending, (state, action) => {
|
||||
state.loginSending = true;
|
||||
})
|
||||
.addCase(doLoginAsync.rejected, (state, action) => {
|
||||
state.loginSending = false;
|
||||
})
|
||||
.addCase(doLoginAsync.fulfilled, (state, action) => {
|
||||
state.loginSending = false;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Action creators are generated for each case reducer function
|
||||
export const {
|
||||
setAccessKey,
|
||||
setSecretKey,
|
||||
setUseSTS,
|
||||
setSTS,
|
||||
setJwt,
|
||||
setNavigateTo,
|
||||
resetForm,
|
||||
} = loginSlice.actions;
|
||||
|
||||
export default loginSlice.reducer;
|
||||
145
portal-ui/src/screens/LoginPage/loginThunks.ts
Normal file
145
portal-ui/src/screens/LoginPage/loginThunks.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
// This file is part of MinIO Console Server
|
||||
// Copyright (c) 2022 MinIO, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { createAsyncThunk } from "@reduxjs/toolkit";
|
||||
import { AppState } from "../../store";
|
||||
import api from "../../common/api";
|
||||
import { ErrorResponseHandler } from "../../common/types";
|
||||
import {
|
||||
setErrorSnackMessage,
|
||||
showMarketplace,
|
||||
userLogged,
|
||||
} from "../../systemSlice";
|
||||
import { ILoginDetails, loginStrategyType } from "./types";
|
||||
import { setNavigateTo } from "./loginSlice";
|
||||
import {
|
||||
getTargetPath,
|
||||
loginStrategyEndpoints,
|
||||
LoginStrategyPayload,
|
||||
} from "./LoginPage";
|
||||
|
||||
export const doLoginAsync = createAsyncThunk(
|
||||
"login/doLoginAsync",
|
||||
async (_, { getState, rejectWithValue, dispatch }) => {
|
||||
const state = getState() as AppState;
|
||||
const loginStrategy = state.login.loginStrategy;
|
||||
const accessKey = state.login.accessKey;
|
||||
const secretKey = state.login.secretKey;
|
||||
const jwt = state.login.jwt;
|
||||
const sts = state.login.sts;
|
||||
const useSTS = state.login.useSTS;
|
||||
|
||||
const isOperator =
|
||||
loginStrategy.loginStrategy === loginStrategyType.serviceAccount ||
|
||||
loginStrategy.loginStrategy === loginStrategyType.redirectServiceAccount;
|
||||
|
||||
let loginStrategyPayload: LoginStrategyPayload = {
|
||||
form: { accessKey, secretKey },
|
||||
"service-account": { jwt },
|
||||
};
|
||||
if (useSTS) {
|
||||
loginStrategyPayload = {
|
||||
form: { accessKey, secretKey, sts },
|
||||
};
|
||||
}
|
||||
|
||||
return api
|
||||
.invoke(
|
||||
"POST",
|
||||
loginStrategyEndpoints[loginStrategy.loginStrategy] || "/api/v1/login",
|
||||
loginStrategyPayload[loginStrategy.loginStrategy]
|
||||
)
|
||||
.then(() => {
|
||||
// We set the state in redux
|
||||
dispatch(userLogged(true));
|
||||
if (loginStrategy.loginStrategy === loginStrategyType.form) {
|
||||
localStorage.setItem("userLoggedIn", accessKey);
|
||||
}
|
||||
if (isOperator) {
|
||||
api
|
||||
.invoke("GET", "/api/v1/mp-integration/")
|
||||
.then((res: any) => {
|
||||
dispatch(setNavigateTo(getTargetPath())); // Email already set, continue with normal flow
|
||||
})
|
||||
.catch((err: ErrorResponseHandler) => {
|
||||
if (err.statusCode === 404) {
|
||||
dispatch(showMarketplace(true));
|
||||
dispatch(setNavigateTo("/marketplace"));
|
||||
} else {
|
||||
// Unexpected error, continue with normal flow
|
||||
dispatch(setNavigateTo(getTargetPath()));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
dispatch(setNavigateTo(getTargetPath()));
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
dispatch(setErrorSnackMessage(err));
|
||||
});
|
||||
}
|
||||
);
|
||||
export const getFetchConfigurationAsync = createAsyncThunk(
|
||||
"login/getFetchConfigurationAsync",
|
||||
async (_, { getState, rejectWithValue, dispatch }) => {
|
||||
return api
|
||||
.invoke("GET", "/api/v1/login")
|
||||
.then((loginDetails: ILoginDetails) => {
|
||||
return loginDetails;
|
||||
})
|
||||
.catch((err: ErrorResponseHandler) => {
|
||||
dispatch(setErrorSnackMessage(err));
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const getVersionAsync = createAsyncThunk(
|
||||
"login/getVersionAsync",
|
||||
async (_, { getState, rejectWithValue, dispatch }) => {
|
||||
return api
|
||||
.invoke("GET", "/api/v1/check-version")
|
||||
.then(
|
||||
({
|
||||
current_version,
|
||||
latest_version,
|
||||
}: {
|
||||
current_version: string;
|
||||
latest_version: string;
|
||||
}) => {
|
||||
return latest_version;
|
||||
}
|
||||
)
|
||||
.catch((err: ErrorResponseHandler) => {
|
||||
// try the operator version
|
||||
api
|
||||
.invoke("GET", "/api/v1/check-operator-version")
|
||||
.then(
|
||||
({
|
||||
current_version,
|
||||
latest_version,
|
||||
}: {
|
||||
current_version: string;
|
||||
latest_version: string;
|
||||
}) => {
|
||||
return latest_version;
|
||||
}
|
||||
)
|
||||
.catch((err: ErrorResponseHandler) => {
|
||||
return err;
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -17,6 +17,7 @@ import { useDispatch } from "react-redux";
|
||||
import { combineReducers, configureStore } from "@reduxjs/toolkit";
|
||||
|
||||
import systemReducer from "./systemSlice";
|
||||
import loginReducer from "./screens/LoginPage/loginSlice";
|
||||
import traceReducer from "./screens/Console/Trace/traceSlice";
|
||||
import logReducer from "./screens/Console/Logs/logsSlice";
|
||||
import healthInfoReducer from "./screens/Console/HealthInfo/healthInfoSlice";
|
||||
@@ -36,6 +37,7 @@ import editTenantAuditLoggingReducer from "./screens/Console/Tenants/TenantDetai
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
system: systemReducer,
|
||||
login: loginReducer,
|
||||
trace: traceReducer,
|
||||
logs: logReducer,
|
||||
watch: watchReducer,
|
||||
|
||||
@@ -37,6 +37,7 @@ import (
|
||||
|
||||
"github.com/minio/console/pkg/logger"
|
||||
"github.com/minio/console/pkg/utils"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
|
||||
"github.com/klauspost/compress/gzhttp"
|
||||
|
||||
@@ -93,6 +94,7 @@ func configureAPI(api *operations.ConsoleAPI) http.Handler {
|
||||
STSSessionToken: claims.STSSessionToken,
|
||||
AccountAccessKey: claims.AccountAccessKey,
|
||||
Hm: claims.HideMenu,
|
||||
Ob: claims.ObjectBrowser,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -335,6 +337,7 @@ func (w *notFoundRedirectRespWr) Write(p []byte) (int, error) {
|
||||
return len(p), nil // Lie that we successfully wrote it
|
||||
}
|
||||
|
||||
// handleSPA handles the serving of the React Single Page Application
|
||||
func handleSPA(w http.ResponseWriter, r *http.Request) {
|
||||
basePath := "/"
|
||||
// For SPA mode we will replace root base with a sub path if configured unless we received cp=y and cpb=/NEW/BASE
|
||||
@@ -354,6 +357,29 @@ func handleSPA(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
sts := r.URL.Query().Get("sts")
|
||||
stsAccessKey := r.URL.Query().Get("sts_a")
|
||||
stsSecretKey := r.URL.Query().Get("sts_s")
|
||||
// if these three parameters are present we are being asked to issue a session with these values
|
||||
if sts != "" && stsAccessKey != "" && stsSecretKey != "" {
|
||||
creds := credentials.NewStaticV4(stsAccessKey, stsSecretKey, sts)
|
||||
consoleCreds := &ConsoleCredentials{
|
||||
ConsoleCredentials: creds,
|
||||
AccountAccessKey: stsAccessKey,
|
||||
}
|
||||
sf := &auth.SessionFeatures{}
|
||||
sf.HideMenu = true
|
||||
sf.ObjectBrowser = true
|
||||
|
||||
sessionID, err := login(consoleCreds, sf)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
} else {
|
||||
cookie := NewSessionCookieForConsole(*sessionID)
|
||||
http.SetCookie(w, &cookie)
|
||||
}
|
||||
}
|
||||
|
||||
indexPageBytes, err := io.ReadAll(indexPage)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
||||
@@ -51,6 +51,7 @@ const (
|
||||
PrometheusExtraLabels = "CONSOLE_PROMETHEUS_EXTRA_LABELS"
|
||||
ConsoleLogQueryURL = "CONSOLE_LOG_QUERY_URL"
|
||||
ConsoleLogQueryAuthToken = "CONSOLE_LOG_QUERY_AUTH_TOKEN"
|
||||
ConsoleObjectBrowserOnly = "CONSOLE_OBJECT_BROWSER_ONLY"
|
||||
LogSearchQueryAuthToken = "LOGSEARCH_QUERY_AUTH_TOKEN"
|
||||
SlashSeparator = "/"
|
||||
)
|
||||
|
||||
@@ -5412,10 +5412,6 @@ func init() {
|
||||
},
|
||||
"loginRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"accessKey",
|
||||
"secretKey"
|
||||
],
|
||||
"properties": {
|
||||
"accessKey": {
|
||||
"type": "string"
|
||||
@@ -5430,6 +5426,9 @@ func init() {
|
||||
},
|
||||
"secretKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"sts": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5970,6 +5969,9 @@ func init() {
|
||||
},
|
||||
"hm": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ob": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -12631,10 +12633,6 @@ func init() {
|
||||
},
|
||||
"loginRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"accessKey",
|
||||
"secretKey"
|
||||
],
|
||||
"properties": {
|
||||
"accessKey": {
|
||||
"type": "string"
|
||||
@@ -12649,6 +12647,9 @@ func init() {
|
||||
},
|
||||
"secretKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"sts": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -13189,6 +13190,9 @@ func init() {
|
||||
},
|
||||
"hm": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ob": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/minio/madmin-go"
|
||||
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
|
||||
"github.com/go-openapi/runtime"
|
||||
@@ -113,11 +112,23 @@ func getLoginResponse(params authApi.LoginParams) (*models.LoginResponse, *model
|
||||
ctx, cancel := context.WithCancel(params.HTTPRequest.Context())
|
||||
defer cancel()
|
||||
lr := params.Body
|
||||
// prepare console credentials
|
||||
consoleCreds, err := getConsoleCredentials(*lr.AccessKey, *lr.SecretKey)
|
||||
if err != nil {
|
||||
return nil, ErrorWithContext(ctx, err, ErrInvalidLogin, err)
|
||||
var err error
|
||||
var consoleCreds *ConsoleCredentials
|
||||
// if we receive an STS we use that instead of the credentials
|
||||
if lr.Sts != "" {
|
||||
creds := credentials.NewStaticV4(lr.AccessKey, lr.SecretKey, lr.Sts)
|
||||
consoleCreds = &ConsoleCredentials{
|
||||
ConsoleCredentials: creds,
|
||||
AccountAccessKey: lr.AccessKey,
|
||||
}
|
||||
} else {
|
||||
// prepare console credentials
|
||||
consoleCreds, err = getConsoleCredentials(lr.AccessKey, lr.SecretKey)
|
||||
if err != nil {
|
||||
return nil, ErrorWithContext(ctx, err, ErrInvalidLogin, err)
|
||||
}
|
||||
}
|
||||
|
||||
sf := &auth.SessionFeatures{}
|
||||
if lr.Features != nil {
|
||||
sf.HideMenu = lr.Features.HideMenu
|
||||
|
||||
@@ -268,6 +268,9 @@ func getListOfEnabledFeatures(session *models.Principal) []string {
|
||||
if session.Hm {
|
||||
features = append(features, "hide-menu")
|
||||
}
|
||||
if session.Ob {
|
||||
features = append(features, "object-browser-only")
|
||||
}
|
||||
|
||||
return features
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
swagger: "2.0"
|
||||
info:
|
||||
title: MinIO Console Server
|
||||
@@ -3677,14 +3676,13 @@ definitions:
|
||||
type: string
|
||||
loginRequest:
|
||||
type: object
|
||||
required:
|
||||
- accessKey
|
||||
- secretKey
|
||||
properties:
|
||||
accessKey:
|
||||
type: string
|
||||
secretKey:
|
||||
type: string
|
||||
sts:
|
||||
type: string
|
||||
features:
|
||||
type: object
|
||||
properties:
|
||||
@@ -3709,6 +3707,8 @@ definitions:
|
||||
type: string
|
||||
hm:
|
||||
type: boolean
|
||||
ob:
|
||||
type: boolean
|
||||
startProfilingItem:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -1394,14 +1394,13 @@ definitions:
|
||||
type: string
|
||||
loginRequest:
|
||||
type: object
|
||||
required:
|
||||
- accessKey
|
||||
- secretKey
|
||||
properties:
|
||||
accessKey:
|
||||
type: string
|
||||
secretKey:
|
||||
type: string
|
||||
sts:
|
||||
type: string
|
||||
features:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
Reference in New Issue
Block a user