Fixed issue using import (#393)
* add icons change description to use TextArea * add missing saml config * switch based on protocol * add capabilty config to import * fixed key element warning * fixed merge error * don't clear on file drag event * don't allow editing of json * prettier * fix tests * missing timeout
This commit is contained in:
parent
4ba5fcc723
commit
7bf85196e3
11 changed files with 281 additions and 156 deletions
|
@ -1,7 +1,14 @@
|
|||
import React from "react";
|
||||
import { FormGroup, TextInput, ValidatedOptions } from "@patternfly/react-core";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
FormGroup,
|
||||
TextArea,
|
||||
TextInput,
|
||||
ValidatedOptions,
|
||||
} from "@patternfly/react-core";
|
||||
|
||||
import { HelpItem } from "../components/help-enabler/HelpItem";
|
||||
|
||||
import { FormAccess } from "../components/form-access/FormAccess";
|
||||
import { ClientForm } from "./ClientDetails";
|
||||
|
@ -12,6 +19,13 @@ export const ClientDescription = () => {
|
|||
return (
|
||||
<FormAccess role="manage-clients" unWrap>
|
||||
<FormGroup
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText="clients-help:clientID"
|
||||
forLabel={t("clientID")}
|
||||
forID="kc-client-id"
|
||||
/>
|
||||
}
|
||||
label={t("clientID")}
|
||||
fieldId="kc-client-id"
|
||||
helperTextInvalid={t("common:required")}
|
||||
|
@ -30,10 +44,27 @@ export const ClientDescription = () => {
|
|||
}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup label={t("common:name")} fieldId="kc-name">
|
||||
<FormGroup
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText="clients-help:clientName"
|
||||
forLabel={t("common:name")}
|
||||
forID="kc-name"
|
||||
/>
|
||||
}
|
||||
label={t("common:name")}
|
||||
fieldId="kc-name"
|
||||
>
|
||||
<TextInput ref={register()} type="text" id="kc-name" name="name" />
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText="clients-help:description"
|
||||
forLabel={t("common:description")}
|
||||
forID="kc-description"
|
||||
/>
|
||||
}
|
||||
label={t("common:description")}
|
||||
fieldId="kc-description"
|
||||
validated={
|
||||
|
@ -41,7 +72,7 @@ export const ClientDescription = () => {
|
|||
}
|
||||
helperTextInvalid={errors.description?.message}
|
||||
>
|
||||
<TextInput
|
||||
<TextArea
|
||||
ref={register({
|
||||
maxLength: {
|
||||
value: 255,
|
||||
|
|
|
@ -74,7 +74,7 @@ export const ClientsSection = () => {
|
|||
<Link key={client.id} to={`/${realm}/clients/${client.id}/settings`}>
|
||||
{client.clientId}
|
||||
{!client.enabled && (
|
||||
<Badge isRead className="pf-u-ml-sm">
|
||||
<Badge key={`${client.id}-disabled`} isRead className="pf-u-ml-sm">
|
||||
Disabled
|
||||
</Badge>
|
||||
)}
|
||||
|
|
|
@ -8,129 +8,203 @@ import {
|
|||
GridItem,
|
||||
} from "@patternfly/react-core";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import { FormAccess } from "../../components/form-access/FormAccess";
|
||||
import { ClientForm } from "../ClientDetails";
|
||||
import { HelpItem } from "../../components/help-enabler/HelpItem";
|
||||
|
||||
export const CapabilityConfig = () => {
|
||||
type CapabilityConfigProps = {
|
||||
unWrap?: boolean;
|
||||
protocol?: string;
|
||||
};
|
||||
|
||||
export const CapabilityConfig = ({
|
||||
unWrap,
|
||||
protocol: type,
|
||||
}: CapabilityConfigProps) => {
|
||||
const { t } = useTranslation("clients");
|
||||
const { control } = useFormContext<ClientForm>();
|
||||
const { control, watch } = useFormContext<ClientForm>();
|
||||
const protocol = type || watch("protocol");
|
||||
|
||||
return (
|
||||
<FormAccess isHorizontal role="manage-clients">
|
||||
<FormGroup
|
||||
hasNoPaddingTop
|
||||
label={t("clientAuthentication")}
|
||||
fieldId="kc-authentication"
|
||||
>
|
||||
<Controller
|
||||
name="publicClient"
|
||||
defaultValue={false}
|
||||
control={control}
|
||||
render={({ onChange, value }) => (
|
||||
<Switch
|
||||
id="kc-authentication"
|
||||
name="publicClient"
|
||||
label={t("common:on")}
|
||||
labelOff={t("common:off")}
|
||||
isChecked={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
hasNoPaddingTop
|
||||
label={t("clientAuthorization")}
|
||||
fieldId="kc-authorization"
|
||||
>
|
||||
<Controller
|
||||
name="authorizationServicesEnabled"
|
||||
defaultValue={false}
|
||||
control={control}
|
||||
render={({ onChange, value }) => (
|
||||
<Switch
|
||||
id="kc-authorization"
|
||||
name="authorizationServicesEnabled"
|
||||
label={t("common:on")}
|
||||
labelOff={t("common:off")}
|
||||
isChecked={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
hasNoPaddingTop
|
||||
label={t("authenticationFlow")}
|
||||
fieldId="kc-flow"
|
||||
>
|
||||
<Grid>
|
||||
<GridItem lg={4} sm={6}>
|
||||
<Controller
|
||||
name="standardFlowEnabled"
|
||||
defaultValue={false}
|
||||
control={control}
|
||||
render={({ onChange, value }) => (
|
||||
<Checkbox
|
||||
label={t("standardFlow")}
|
||||
id="kc-flow-standard"
|
||||
name="standardFlowEnabled"
|
||||
isChecked={value}
|
||||
onChange={onChange}
|
||||
<FormAccess isHorizontal role="manage-clients" unWrap={unWrap}>
|
||||
<>
|
||||
{protocol === "openid-connect" && (
|
||||
<>
|
||||
<FormGroup
|
||||
hasNoPaddingTop
|
||||
label={t("clientAuthentication")}
|
||||
fieldId="kc-authentication"
|
||||
>
|
||||
<Controller
|
||||
name="publicClient"
|
||||
defaultValue={false}
|
||||
control={control}
|
||||
render={({ onChange, value }) => (
|
||||
<Switch
|
||||
id="kc-authentication"
|
||||
name="publicClient"
|
||||
label={t("common:on")}
|
||||
labelOff={t("common:off")}
|
||||
isChecked={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
hasNoPaddingTop
|
||||
label={t("clientAuthorization")}
|
||||
fieldId="kc-authorization"
|
||||
>
|
||||
<Controller
|
||||
name="authorizationServicesEnabled"
|
||||
defaultValue={false}
|
||||
control={control}
|
||||
render={({ onChange, value }) => (
|
||||
<Switch
|
||||
id="kc-authorization"
|
||||
name="authorizationServicesEnabled"
|
||||
label={t("common:on")}
|
||||
labelOff={t("common:off")}
|
||||
isChecked={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
hasNoPaddingTop
|
||||
label={t("authenticationFlow")}
|
||||
fieldId="kc-flow"
|
||||
>
|
||||
<Grid>
|
||||
<GridItem lg={4} sm={6}>
|
||||
<Controller
|
||||
name="standardFlowEnabled"
|
||||
defaultValue={false}
|
||||
control={control}
|
||||
render={({ onChange, value }) => (
|
||||
<Checkbox
|
||||
label={t("standardFlow")}
|
||||
id="kc-flow-standard"
|
||||
name="standardFlowEnabled"
|
||||
isChecked={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</GridItem>
|
||||
<GridItem lg={8} sm={6}>
|
||||
<Controller
|
||||
name="directAccessGrantsEnabled"
|
||||
defaultValue={false}
|
||||
control={control}
|
||||
render={({ onChange, value }) => (
|
||||
<Checkbox
|
||||
label={t("directAccess")}
|
||||
id="kc-flow-direct"
|
||||
name="directAccessGrantsEnabled"
|
||||
isChecked={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</GridItem>
|
||||
<GridItem lg={4} sm={6}>
|
||||
<Controller
|
||||
name="implicitFlowEnabled"
|
||||
defaultValue={false}
|
||||
control={control}
|
||||
render={({ onChange, value }) => (
|
||||
<Checkbox
|
||||
label={t("implicitFlow")}
|
||||
id="kc-flow-implicit"
|
||||
name="implicitFlowEnabled"
|
||||
isChecked={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</GridItem>
|
||||
<GridItem lg={8} sm={6}>
|
||||
<Controller
|
||||
name="serviceAccountsEnabled"
|
||||
defaultValue={false}
|
||||
control={control}
|
||||
render={({ onChange, value }) => (
|
||||
<Checkbox
|
||||
label={t("serviceAccount")}
|
||||
id="kc-flow-service-account"
|
||||
name="serviceAccountsEnabled"
|
||||
isChecked={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</FormGroup>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
<>
|
||||
{protocol === "saml" && (
|
||||
<>
|
||||
<FormGroup
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText="clients-help:encryptAssertions"
|
||||
forLabel={t("encryptAssertions")}
|
||||
forID="kc-encrypt"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</GridItem>
|
||||
<GridItem lg={8} sm={6}>
|
||||
<Controller
|
||||
name="directAccessGrantsEnabled"
|
||||
defaultValue={false}
|
||||
control={control}
|
||||
render={({ onChange, value }) => (
|
||||
<Checkbox
|
||||
label={t("directAccess")}
|
||||
id="kc-flow-direct"
|
||||
name="directAccessGrantsEnabled"
|
||||
isChecked={value}
|
||||
onChange={onChange}
|
||||
}
|
||||
label={t("encryptAssertions")}
|
||||
fieldId="kc-encrypt"
|
||||
>
|
||||
<Controller
|
||||
name="attributes.saml_encrypt"
|
||||
control={control}
|
||||
defaultValue="false"
|
||||
render={({ onChange, value }) => (
|
||||
<Switch
|
||||
id="kc-encrypt"
|
||||
label={t("common:on")}
|
||||
labelOff={t("common:off")}
|
||||
isChecked={value === "true"}
|
||||
onChange={(value) => onChange("" + value)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText="clients-help:clientSignature"
|
||||
forLabel={t("clientSignature")}
|
||||
forID="kc-client-signature"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</GridItem>
|
||||
<GridItem lg={4} sm={6}>
|
||||
<Controller
|
||||
name="implicitFlowEnabled"
|
||||
defaultValue={false}
|
||||
control={control}
|
||||
render={({ onChange, value }) => (
|
||||
<Checkbox
|
||||
label={t("implicitFlow")}
|
||||
id="kc-flow-implicit"
|
||||
name="implicitFlowEnabled"
|
||||
isChecked={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</GridItem>
|
||||
<GridItem lg={8} sm={6}>
|
||||
<Controller
|
||||
name="serviceAccountsEnabled"
|
||||
defaultValue={false}
|
||||
control={control}
|
||||
render={({ onChange, value }) => (
|
||||
<Checkbox
|
||||
label={t("serviceAccount")}
|
||||
id="kc-flow-service-account"
|
||||
name="serviceAccountsEnabled"
|
||||
isChecked={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</FormGroup>
|
||||
}
|
||||
label={t("clientSignature")}
|
||||
fieldId="kc-client-signature"
|
||||
>
|
||||
<Controller
|
||||
name="attributes.saml_client_signature"
|
||||
control={control}
|
||||
defaultValue="false"
|
||||
render={({ onChange, value }) => (
|
||||
<Switch
|
||||
id="kc-client-signature"
|
||||
label={t("common:on")}
|
||||
labelOff={t("common:off")}
|
||||
isChecked={value === "true"}
|
||||
onChange={(value) => onChange("" + value)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</FormAccess>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -111,11 +111,11 @@ export const NewClientForm = () => {
|
|||
},
|
||||
{
|
||||
name: t("capabilityConfig"),
|
||||
component: <CapabilityConfig />,
|
||||
component: <CapabilityConfig protocol={client.protocol} />,
|
||||
},
|
||||
]}
|
||||
footer={<Footer />}
|
||||
onSave={() => save()}
|
||||
onSave={save}
|
||||
/>
|
||||
</FormProvider>
|
||||
</PageSection>
|
||||
|
|
|
@ -2,6 +2,11 @@
|
|||
"clients-help": {
|
||||
"webOrigins": "Allowed CORS origins. To permit all origins of Valid Redirect URIs, add '+'. This does not include the '*' wildcard though. To permit all origins, explicitly add '*'.",
|
||||
"adminURL": "URL to the admin interface of the client. Set this if the client supports the adapter REST API. This REST API allows the auth server to push revocation policies and other administrative tasks. Usually this is set to the base URL of the client.",
|
||||
"clientID": "Specifies ID referenced in URI and tokens. For example 'my-client'. For SAML this is also the expected issuer value from authn requests",
|
||||
"clientName": "Specifies display name of the client. For example 'My Client'. Supports keys for localized values as well. For example: ${my_client}",
|
||||
"description": "Specifies description of the client. For example 'My Client for TimeSheets'. Supports keys for localized values as well. For example: ${my_client_description}",
|
||||
"encryptAssertions": "Should SAML assertions be encrypted with client's public key using AES?",
|
||||
"clientSignature": "Will the client sign their saml requests and responses? And should they be validated?",
|
||||
"downloadType": "this is information about the download type",
|
||||
"details": "this is information about the details",
|
||||
"createToken": "An initial access token can only be used to create clients",
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import React from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
PageSection,
|
||||
FormGroup,
|
||||
|
@ -8,7 +10,6 @@ import {
|
|||
AlertVariant,
|
||||
} from "@patternfly/react-core";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ClientDescription } from "../ClientDescription";
|
||||
import { JsonFileUpload } from "../../components/json-file-upload/JsonFileUpload";
|
||||
|
@ -17,10 +18,15 @@ import { ViewHeader } from "../../components/view-header/ViewHeader";
|
|||
import ClientRepresentation from "keycloak-admin/lib/defs/clientRepresentation";
|
||||
import { useAdminClient } from "../../context/auth/AdminClient";
|
||||
import { FormAccess } from "../../components/form-access/FormAccess";
|
||||
import { useRealm } from "../../context/realm-context/RealmContext";
|
||||
import { convertFormValuesToObject, convertToFormValues } from "../../util";
|
||||
import { CapabilityConfig } from "../add/CapabilityConfig";
|
||||
|
||||
export const ImportForm = () => {
|
||||
const { t } = useTranslation("clients");
|
||||
const history = useHistory();
|
||||
const adminClient = useAdminClient();
|
||||
const { realm } = useRealm();
|
||||
const form = useForm<ClientRepresentation>();
|
||||
const { register, handleSubmit, setValue } = form;
|
||||
|
||||
|
@ -36,18 +42,27 @@ export const ImportForm = () => {
|
|||
|
||||
const obj = value ? JSON.parse(value as string) : defaultClient;
|
||||
Object.keys(obj).forEach((k) => {
|
||||
setValue(k, obj[k]);
|
||||
if (k === "attributes") {
|
||||
convertToFormValues(obj[k], "attributes", form.setValue);
|
||||
} else {
|
||||
setValue(k, obj[k]);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const save = async (client: ClientRepresentation) => {
|
||||
try {
|
||||
await adminClient.clients.create({ ...client });
|
||||
const newClient = await adminClient.clients.create({
|
||||
...client,
|
||||
attributes: convertFormValuesToObject(client.attributes || {}),
|
||||
});
|
||||
addAlert(t("clientImportSuccess"), AlertVariant.success);
|
||||
history.push(`/${realm}/clients/${newClient.id}`);
|
||||
} catch (error) {
|
||||
addAlert(`${t("clientImportError")} '${error}'`, AlertVariant.danger);
|
||||
addAlert(t("clientImportError", { error }), AlertVariant.danger);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ViewHeader
|
||||
|
@ -72,11 +87,17 @@ export const ImportForm = () => {
|
|||
ref={register()}
|
||||
/>
|
||||
</FormGroup>
|
||||
<CapabilityConfig unWrap={true} />
|
||||
<ActionGroup>
|
||||
<Button variant="primary" type="submit">
|
||||
{t("common:save")}
|
||||
</Button>
|
||||
<Button variant="link">{t("common:cancel")}</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => history.push(`/${realm}/clients`)}
|
||||
>
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
</ActionGroup>
|
||||
</FormProvider>
|
||||
</FormAccess>
|
||||
|
|
|
@ -9,6 +9,8 @@
|
|||
"webOrigins": "Web origins",
|
||||
"adminURL": "Admin URL",
|
||||
"formatOption": "Format option",
|
||||
"encryptAssertions": "Encrypt assertions",
|
||||
"clientSignature": "Client signature required",
|
||||
"downloadAdaptorTitle": "Download adaptor configs",
|
||||
"credentials": "Credentials",
|
||||
"roles": "Roles",
|
||||
|
@ -61,7 +63,7 @@
|
|||
"clientsExplain": "Clients are applications and services that can request authentication of a user",
|
||||
"createSuccess": "Client created successfully",
|
||||
"createError": "Could not create client: '{{error}}'",
|
||||
"clientImportError": "Could not import client",
|
||||
"clientImportError": "Could not import client: {{error}}",
|
||||
"clientSaveSuccess": "Client successfully updated",
|
||||
"clientSaveError": "Client could not be updated:",
|
||||
"clientImportSuccess": "Client imported successfully",
|
||||
|
|
|
@ -22,23 +22,22 @@ export function AlertPanel({ alerts, onCloseAlert }: AlertPanelProps) {
|
|||
return (
|
||||
<AlertGroup isToast>
|
||||
{alerts.map(({ key, variant, message, description }) => (
|
||||
<>
|
||||
<Alert
|
||||
key={key}
|
||||
isLiveRegion
|
||||
variant={AlertVariant[variant]}
|
||||
variantLabel=""
|
||||
title={message}
|
||||
actionClose={
|
||||
<AlertActionCloseButton
|
||||
title={message}
|
||||
onClose={() => onCloseAlert(key)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{description && <p>{description}</p>}
|
||||
</Alert>
|
||||
</>
|
||||
<Alert
|
||||
timeout={true}
|
||||
key={key}
|
||||
isLiveRegion
|
||||
variant={AlertVariant[variant]}
|
||||
variantLabel=""
|
||||
title={message}
|
||||
actionClose={
|
||||
<AlertActionCloseButton
|
||||
title={message}
|
||||
onClose={() => onCloseAlert(key)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{description && <p>{description}</p>}
|
||||
</Alert>
|
||||
))}
|
||||
</AlertGroup>
|
||||
);
|
||||
|
|
|
@ -16,11 +16,6 @@ export const AlertContext = createContext<AlertProps>({
|
|||
|
||||
export const useAlerts = () => useContext(AlertContext);
|
||||
|
||||
type TimeOut = {
|
||||
key: number;
|
||||
timeOut: NodeJS.Timeout;
|
||||
};
|
||||
|
||||
export const AlertProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [alerts, setAlerts] = useState<AlertType[]>([]);
|
||||
|
||||
|
|
|
@ -49,7 +49,10 @@ export const JsonFileUpload = ({
|
|||
| React.ChangeEvent<HTMLTextAreaElement>
|
||||
| React.MouseEvent<HTMLButtonElement, MouseEvent>
|
||||
): void => {
|
||||
if (event.nativeEvent instanceof MouseEvent) {
|
||||
if (
|
||||
event.nativeEvent instanceof MouseEvent &&
|
||||
!(event.nativeEvent instanceof DragEvent)
|
||||
) {
|
||||
setFileUpload({ ...fileUpload, modal: true });
|
||||
} else {
|
||||
setFileUpload({
|
||||
|
@ -100,7 +103,6 @@ export const JsonFileUpload = ({
|
|||
value={fileUpload.value}
|
||||
filename={fileUpload.filename}
|
||||
onChange={handleChange}
|
||||
allowEditingUploadedText
|
||||
onReadStarted={() =>
|
||||
setFileUpload({ ...fileUpload, isLoading: true })
|
||||
}
|
||||
|
|
|
@ -32,7 +32,6 @@ exports[`<JsonFileUpload /> render 1`] = `
|
|||
className="pf-c-form__group-control"
|
||||
>
|
||||
<FileUpload
|
||||
allowEditingUploadedText={true}
|
||||
dropzoneProps={
|
||||
Object {
|
||||
"accept": ".json",
|
||||
|
@ -59,7 +58,6 @@ exports[`<JsonFileUpload /> render 1`] = `
|
|||
preventDropOnDocument={true}
|
||||
>
|
||||
<FileUploadField
|
||||
allowEditingUploadedText={true}
|
||||
containerRef={[Function]}
|
||||
filename=""
|
||||
id="test"
|
||||
|
@ -303,7 +301,6 @@ exports[`<JsonFileUpload /> upload file 1`] = `
|
|||
className="pf-c-form__group-control"
|
||||
>
|
||||
<FileUpload
|
||||
allowEditingUploadedText={true}
|
||||
dropzoneProps={
|
||||
Object {
|
||||
"accept": ".json",
|
||||
|
@ -330,7 +327,6 @@ exports[`<JsonFileUpload /> upload file 1`] = `
|
|||
preventDropOnDocument={true}
|
||||
>
|
||||
<FileUploadField
|
||||
allowEditingUploadedText={true}
|
||||
containerRef={[Function]}
|
||||
filename=""
|
||||
id="upload"
|
||||
|
|
Loading…
Reference in a new issue