Add the Client-side Input Validators for Stream Keys and the Admin Password (#2619)

* add the minimum stream key complexity rules on the client side

* add an admin password validator

* merge TextField and TextFieldAdmin components

* update Input Validators for Streak Keys and Admin Password

* fix a small regex typo

* code cleanup

* update Textfield and TextFieldWithSubmit

* Prettified Code!

* update the TextFieldWithSubmit component

* correct the admin password endpoind API

* refactor the Admin Password Input field and add a new boolean field for it

* refactor the Form Input field name from adminPassword to InputFieldPassword

* put password regex rules into config-constants.tsx

* regex constant typo fix

* change the boolean variable isAdminPwdField to hasComplexityRequirements

* fix a merge conflict

* Prettified Code!

---------

Co-authored-by: dorj222 <dorj222@users.noreply.github.com>
This commit is contained in:
Jambaldorj Ochirpurev 2023-03-03 06:20:53 +01:00 committed by GitHub
parent e3a63606eb
commit 3653db3a6a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 181 additions and 40 deletions

View file

@ -1,10 +1,11 @@
import React, { FC } from 'react'; import React, { FC, useEffect, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { Input, InputNumber } from 'antd'; import { Input, Form, InputNumber, Button } from 'antd';
import { FieldUpdaterFunc } from '../../types/config-section'; import { FieldUpdaterFunc } from '../../types/config-section';
// import InfoTip from '../info-tip'; // import InfoTip from '../info-tip';
import { StatusState } from '../../utils/input-statuses'; import { StatusState } from '../../utils/input-statuses';
import { FormStatusIndicator } from './FormStatusIndicator'; import { FormStatusIndicator } from './FormStatusIndicator';
import { PASSWORD_COMPLEXITY_RULES, REGEX_PASSWORD } from '../../utils/config-constants';
export const TEXTFIELD_TYPE_TEXT = 'default'; export const TEXTFIELD_TYPE_TEXT = 'default';
export const TEXTFIELD_TYPE_PASSWORD = 'password'; // Input.Password export const TEXTFIELD_TYPE_PASSWORD = 'password'; // Input.Password
@ -17,7 +18,7 @@ export type TextFieldProps = {
onSubmit?: () => void; onSubmit?: () => void;
onPressEnter?: () => void; onPressEnter?: () => void;
onHandleSubmit?: () => void;
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
label?: string; label?: string;
@ -31,6 +32,7 @@ export type TextFieldProps = {
useTrim?: boolean; useTrim?: boolean;
useTrimLead?: boolean; useTrimLead?: boolean;
value?: string | number; value?: string | number;
hasComplexityRequirements?: boolean;
onBlur?: FieldUpdaterFunc; onBlur?: FieldUpdaterFunc;
onChange?: FieldUpdaterFunc; onChange?: FieldUpdaterFunc;
}; };
@ -44,6 +46,7 @@ export const TextField: FC<TextFieldProps> = ({
onBlur, onBlur,
onChange, onChange,
onPressEnter, onPressEnter,
onHandleSubmit,
pattern, pattern,
placeholder, placeholder,
required, required,
@ -52,15 +55,30 @@ export const TextField: FC<TextFieldProps> = ({
type, type,
useTrim, useTrim,
value, value,
hasComplexityRequirements,
}) => { }) => {
const [hasPwdChanged, setHasPwdChanged] = useState(false);
const [showPwdButton, setShowPwdButton] = useState(false);
const [form] = Form.useForm();
const handleChange = (e: any) => { const handleChange = (e: any) => {
// if an extra onChange handler was sent in as a prop, let's run that too. // if an extra onChange handler was sent in as a prop, let's run that too.
if (onChange) { if (onChange) {
const val = type === TEXTFIELD_TYPE_NUMBER ? e : e.target.value; const val = type === TEXTFIELD_TYPE_NUMBER ? e : e.target.value;
setShowPwdButton(true);
if (hasComplexityRequirements && REGEX_PASSWORD.test(val)) {
setHasPwdChanged(true);
} else {
setHasPwdChanged(false);
}
onChange({ fieldName, value: useTrim ? val.trim() : val }); onChange({ fieldName, value: useTrim ? val.trim() : val });
} }
}; };
useEffect(() => {
form.setFieldsValue({ inputFieldPassword: value });
}, [value]);
// if you blur a required field with an empty value, restore its original value in state (parent's state), if an onChange from parent is available. // if you blur a required field with an empty value, restore its original value in state (parent's state), if an onChange from parent is available.
const handleBlur = (e: any) => { const handleBlur = (e: any) => {
const val = e.target.value; const val = e.target.value;
@ -74,7 +92,8 @@ export const TextField: FC<TextFieldProps> = ({
onPressEnter(); onPressEnter();
} }
}; };
// Password Complexity rules
const passwordComplexityRules = [];
// display the appropriate Ant text field // display the appropriate Ant text field
let Field = Input as let Field = Input as
| typeof Input | typeof Input
@ -88,6 +107,9 @@ export const TextField: FC<TextFieldProps> = ({
autoSize: true, autoSize: true,
}; };
} else if (type === TEXTFIELD_TYPE_PASSWORD) { } else if (type === TEXTFIELD_TYPE_PASSWORD) {
PASSWORD_COMPLEXITY_RULES.forEach(element => {
passwordComplexityRules.push(element);
});
Field = Input.Password; Field = Input.Password;
fieldProps = { fieldProps = {
visibilityToggle: true, visibilityToggle: true,
@ -128,25 +150,66 @@ export const TextField: FC<TextFieldProps> = ({
</div> </div>
) : null} ) : null}
<div className="input-side"> {!hasComplexityRequirements ? (
<div className="input-group"> <div className="input-side">
<Field <div className="input-group">
id={fieldId} <Field
className={`field ${className} ${fieldId}`} id={fieldId}
{...fieldProps} className={`field ${className} ${fieldId}`}
{...(type !== TEXTFIELD_TYPE_NUMBER && { allowClear: true })} {...fieldProps}
placeholder={placeholder} {...(type !== TEXTFIELD_TYPE_NUMBER && { allowClear: true })}
maxLength={maxLength} placeholder={placeholder}
onChange={handleChange} maxLength={maxLength}
onBlur={handleBlur} onChange={handleChange}
onPressEnter={handlePressEnter} onBlur={handleBlur}
disabled={disabled} onPressEnter={handlePressEnter}
value={value as number | (readonly string[] & number)} disabled={disabled}
/> value={value as number | (readonly string[] & number)}
/>
</div>
<FormStatusIndicator status={status} />
<p className="field-tip">{tip}</p>
</div> </div>
<FormStatusIndicator status={status} /> ) : (
<p className="field-tip">{tip}</p> <div className="input-side">
</div> <div className="input-group">
<Form
name="basic"
form={form}
initialValues={{ inputFieldPassword: value }}
style={{ width: '100%' }}
>
<Form.Item name="inputFieldPassword" rules={passwordComplexityRules}>
<Input.Password
id={fieldId}
className={`field ${className} ${fieldId}`}
onChange={handleChange}
onBlur={handleBlur}
placeholder={placeholder}
onPressEnter={handlePressEnter}
disabled={disabled}
value={value as number | (readonly string[] & number)}
/>
</Form.Item>
{showPwdButton && (
<div style={{ display: 'flex', flexDirection: 'row-reverse' }}>
<Button
type="primary"
size="small"
className="submit-button"
onClick={onHandleSubmit}
disabled={!hasPwdChanged}
>
Update
</Button>
</div>
)}
<FormStatusIndicator status={status} />
<p className="field-tip">{tip}</p>
</Form>
</div>
</div>
)}
</div> </div>
); );
}; };
@ -168,9 +231,11 @@ TextField.defaultProps = {
pattern: '', pattern: '',
useTrim: false, useTrim: false,
useTrimLead: false, useTrimLead: false,
hasComplexityRequirements: false,
onSubmit: () => {}, onSubmit: () => {},
onBlur: () => {}, onBlur: () => {},
onChange: () => {}, onChange: () => {},
onPressEnter: () => {}, onPressEnter: () => {},
onHandleSubmit: () => {},
}; };

View file

@ -24,6 +24,7 @@ export type TextFieldWithSubmitProps = TextFieldProps & {
apiPath: string; apiPath: string;
configPath?: string; configPath?: string;
initialValue?: string; initialValue?: string;
hasComplexityRequirements?: boolean;
}; };
export const TextFieldWithSubmit: FC<TextFieldWithSubmitProps> = ({ export const TextFieldWithSubmit: FC<TextFieldWithSubmitProps> = ({
@ -43,7 +44,8 @@ export const TextFieldWithSubmit: FC<TextFieldWithSubmitProps> = ({
let resetTimer = null; let resetTimer = null;
const { fieldName, required, tip, status, value, onChange, onSubmit } = textFieldProps; const { fieldName, required, tip, status, value, hasComplexityRequirements, onChange, onSubmit } =
textFieldProps;
// Clear out any validation states and messaging // Clear out any validation states and messaging
const resetStates = () => { const resetStates = () => {
@ -118,6 +120,7 @@ export const TextFieldWithSubmit: FC<TextFieldWithSubmitProps> = ({
'textfield-with-submit-container': true, 'textfield-with-submit-container': true,
submittable: hasChanged, submittable: hasChanged,
}); });
return ( return (
<div className={textfieldContainerClass}> <div className={textfieldContainerClass}>
<div className="textfield-component"> <div className="textfield-component">
@ -126,6 +129,7 @@ export const TextFieldWithSubmit: FC<TextFieldWithSubmitProps> = ({
onSubmit={null} onSubmit={null}
onBlur={handleBlur} onBlur={handleBlur}
onChange={handleChange} onChange={handleChange}
onHandleSubmit={handleSubmit}
/> />
</div> </div>
<div className="formfield-container lower-container"> <div className="formfield-container lower-container">
@ -134,15 +138,17 @@ export const TextFieldWithSubmit: FC<TextFieldWithSubmitProps> = ({
<div className="field-tip">{tip}</div> <div className="field-tip">{tip}</div>
<FormStatusIndicator status={status || submitStatus} /> <FormStatusIndicator status={status || submitStatus} />
<div className="update-button-container"> <div className="update-button-container">
<Button {!hasComplexityRequirements && (
type="primary" <Button
size="small" type="primary"
className="submit-button" size="small"
onClick={handleSubmit} className="submit-button"
disabled={!hasChanged} onClick={handleSubmit}
> disabled={!hasChanged}
Update >
</Button> Update
</Button>
)}
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,12 +1,12 @@
import React, { useContext, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import { Table, Space, Button, Typography, Alert, Input, Form } from 'antd'; import { Table, Space, Button, Typography, Alert, Input, Form } from 'antd';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { ServerStatusContext } from '../../../../utils/server-status-context'; import { ServerStatusContext } from '../../../../utils/server-status-context';
import { fetchData, UPDATE_STREAM_KEYS } from '../../../../utils/apis'; import { fetchData, UPDATE_STREAM_KEYS } from '../../../../utils/apis';
import { PASSWORD_COMPLEXITY_RULES, REGEX_PASSWORD } from '../../../../utils/config-constants';
const { Paragraph } = Typography; const { Paragraph } = Typography;
const { Item } = Form;
// Lazy loaded components // Lazy loaded components
@ -54,6 +54,18 @@ const generateRndKey = () => {
}; };
const AddKeyForm = ({ setShowAddKeyForm, setFieldInConfigState, streamKeys, setError }) => { const AddKeyForm = ({ setShowAddKeyForm, setFieldInConfigState, streamKeys, setError }) => {
const [hasChanged, setHasChanged] = useState(true);
const [form] = Form.useForm();
const { Item } = Form;
// Password Complexity rules
const passwordComplexityRules = [];
useEffect(() => {
PASSWORD_COMPLEXITY_RULES.forEach(element => {
passwordComplexityRules.push(element);
});
}, []);
const handleAddKey = (newkey: any) => { const handleAddKey = (newkey: any) => {
const updatedKeys = [...streamKeys, newkey]; const updatedKeys = [...streamKeys, newkey];
@ -67,19 +79,50 @@ const AddKeyForm = ({ setShowAddKeyForm, setFieldInConfigState, streamKeys, setE
setShowAddKeyForm(false); setShowAddKeyForm(false);
}; };
const handleInputChange = (event: any) => {
const val = event.target.value;
if (REGEX_PASSWORD.test(val)) {
setHasChanged(true);
} else {
setHasChanged(false);
}
};
// Default auto-generated key // Default auto-generated key
const defaultKey = generateRndKey(); const defaultKey = generateRndKey();
return ( return (
<Form layout="inline" autoComplete="off" onFinish={handleAddKey}> <Form
<Item label="Key" name="key" tooltip="The key you provide your broadcasting software"> layout="horizontal"
<Input placeholder="def456" defaultValue={defaultKey} /> autoComplete="off"
onFinish={handleAddKey}
form={form}
style={{ display: 'flex', flexDirection: 'row' }}
>
<Item
style={{ width: '60%', marginRight: '5px' }}
label="Key"
name="key"
tooltip={
<p>
The key you provide your broadcasting software. Please note that the key must be a
minimum of eight characters and must include at least one uppercase letter, at least one
lowercase letter, at least one special character, and at least one number.
</p>
}
rules={PASSWORD_COMPLEXITY_RULES}
>
<Input placeholder="def456" defaultValue={defaultKey} onChange={handleInputChange} />
</Item> </Item>
<Item label="Comment" name="comment" tooltip="For remembering why you added this key"> <Item
style={{ width: '60%', marginRight: '5px' }}
label="Comment"
name="comment"
tooltip="For remembering why you added this key"
>
<Input placeholder="My OBS Key" /> <Input placeholder="My OBS Key" />
</Item> </Item>
<Button type="primary" htmlType="submit" disabled={!hasChanged}>
<Button type="primary" htmlType="submit">
Add Add
</Button> </Button>
</Form> </Form>

View file

@ -122,6 +122,7 @@ export const TEXTFIELD_PROPS_ADMIN_PASSWORD = {
label: 'Admin Password', label: 'Admin Password',
tip: 'Save this password somewhere safe, you will need it to login to the admin dashboard!', tip: 'Save this password somewhere safe, you will need it to login to the admin dashboard!',
required: true, required: true,
hasComplexityRequirements: true,
}; };
export const TEXTFIELD_PROPS_FFMPEG = { export const TEXTFIELD_PROPS_FFMPEG = {
apiPath: API_FFMPEG, apiPath: API_FFMPEG,
@ -131,6 +132,7 @@ export const TEXTFIELD_PROPS_FFMPEG = {
label: 'FFmpeg Path', label: 'FFmpeg Path',
tip: 'Absolute file path of the FFMPEG application on your server', tip: 'Absolute file path of the FFMPEG application on your server',
required: true, required: true,
hasComplexityRequirements: false,
}; };
export const TEXTFIELD_PROPS_WEB_PORT = { export const TEXTFIELD_PROPS_WEB_PORT = {
apiPath: API_WEB_PORT, apiPath: API_WEB_PORT,
@ -140,6 +142,7 @@ export const TEXTFIELD_PROPS_WEB_PORT = {
label: 'Owncast port', label: 'Owncast port',
tip: 'What port is your Owncast web server listening? Default is 8080', tip: 'What port is your Owncast web server listening? Default is 8080',
required: true, required: true,
hasComplexityRequirements: false,
}; };
export const TEXTFIELD_PROPS_RTMP_PORT = { export const TEXTFIELD_PROPS_RTMP_PORT = {
apiPath: API_RTMP_PORT, apiPath: API_RTMP_PORT,
@ -149,6 +152,7 @@ export const TEXTFIELD_PROPS_RTMP_PORT = {
label: 'RTMP port', label: 'RTMP port',
tip: 'What port should accept inbound broadcasts? Default is 1935', tip: 'What port should accept inbound broadcasts? Default is 1935',
required: true, required: true,
hasComplexityRequirements: false,
}; };
export const TEXTFIELD_PROPS_INSTANCE_URL = { export const TEXTFIELD_PROPS_INSTANCE_URL = {
apiPath: API_INSTANCE_URL, apiPath: API_INSTANCE_URL,
@ -558,3 +562,26 @@ export const BROWSER_PUSH_CONFIG_FIELDS = {
placeholder: `I've gone live! Come watch!`, placeholder: `I've gone live! Come watch!`,
}, },
}; };
export const PASSWORD_COMPLEXITY_RULES = [
{ min: 8, message: '- minimum 8 characters' },
{ max: 192, message: '- maximum 192 characters' },
{
pattern: /^(?=.*[a-z])/,
message: '- at least one lowercase letter',
},
{
pattern: /^(?=.*[A-Z])/,
message: '- at least one uppercase letter',
},
{
pattern: /\d/,
message: '- at least one digit',
},
{
pattern: /^(?=.*?[#?!@$%^&*-])/,
message: '- at least one special character: !@#$%^&*',
},
];
export const REGEX_PASSWORD = /^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[!@#$%^&*]).{8,192}$/;