Merge pull request #340 from answerdev/feat/ui-1.1.0

Feat/UI 1.1.0
This commit is contained in:
dashuai 2023-05-06 16:15:20 +08:00 committed by GitHub
commit 5f113285a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 1621 additions and 330 deletions

View File

@ -322,6 +322,7 @@ ui:
upgrade: Answer Upgrade upgrade: Answer Upgrade
maintenance: Website Maintenance maintenance: Website Maintenance
users: Users users: Users
oauth_callback: Processing
http_404: HTTP Error 404 http_404: HTTP Error 404
http_50X: HTTP Error 500 http_50X: HTTP Error 500
http_403: HTTP Error 403 http_403: HTTP Error 403
@ -658,7 +659,6 @@ ui:
msg: msg:
empty: Cannot be empty. empty: Cannot be empty.
login: login:
page_title: Welcome to {{site_name}}
login_to_continue: Log in to continue login_to_continue: Log in to continue
info_sign: Don't have an account? <1>Sign up</1> info_sign: Don't have an account? <1>Sign up</1>
info_login: Already have an account? <1>Log in</1> info_login: Already have an account? <1>Log in</1>
@ -669,6 +669,7 @@ ui:
msg: msg:
empty: Name cannot be empty. empty: Name cannot be empty.
range: Name up to 30 characters. range: Name up to 30 characters.
character: 'Must use the character set "a-z", "0-9", " - . _"'
email: email:
label: Email label: Email
msg: msg:
@ -689,7 +690,6 @@ ui:
msg: msg:
empty: Email cannot be empty. empty: Email cannot be empty.
change_email: change_email:
page_title: Welcome to {{site_name}}
btn_cancel: Cancel btn_cancel: Cancel
btn_update: Update email address btn_update: Update email address
send_success: >- send_success: >-
@ -699,6 +699,17 @@ ui:
label: New Email label: New Email
msg: msg:
empty: Email cannot be empty. empty: Email cannot be empty.
oauth_bind_email:
subtitle: Add a recovery email to your account.
btn_update: Update email address
email:
label: Email
msg:
empty: Email cannot be empty.
modal_title: Email already existes.
modal_content: This email address already registered. Are you sure you want to connect to the existing account?
modal_cancel: Change email
modal_confirm: Connect to the existing account
password_reset: password_reset:
page_title: Password Reset page_title: Password Reset
btn_name: Reset my password btn_name: Reset my password
@ -770,6 +781,9 @@ ui:
email: email:
label: Email label: Email
msg: Email cannot be empty. msg: Email cannot be empty.
pass:
label: Current Password
msg: Password cannot be empty.
password_title: Password password_title: Password
current_pass: current_pass:
label: Current Password label: Current Password
@ -786,6 +800,13 @@ ui:
lang: lang:
label: Interface Language label: Interface Language
text: User interface language. It will change when you refresh the page. text: User interface language. It will change when you refresh the page.
my_logins:
title: My Logins
label: Log in or sign up on this site using these accounts.
modal_title: Remove Login
modal_content: Are you sure you want to remove this login from your account?
modal_confirm_btn: Remove
remove_success: Removed successfully
toast: toast:
update: update success update: update success
update_password: Password changed successfully. update_password: Password changed successfully.
@ -907,7 +928,6 @@ ui:
modal_confirm: modal_confirm:
title: Error... title: Error...
account_result: account_result:
page_title: Welcome to {{site_name}}
success: Your new account is confirmed; you will be redirected to the home page. success: Your new account is confirmed; you will be redirected to the home page.
link: Continue to homepage link: Continue to homepage
invalid: >- invalid: >-
@ -1034,6 +1054,7 @@ ui:
admin_name: admin_name:
label: Name label: Name
msg: Name cannot be empty. msg: Name cannot be empty.
character: 'Must use the character set "a-z", "0-9", " - . _"'
admin_password: admin_password:
label: Password label: Password
text: >- text: >-
@ -1097,6 +1118,14 @@ ui:
themes: Themes themes: Themes
css-html: CSS/HTML css-html: CSS/HTML
login: Login login: Login
plugins: Plugins
installed_plugins: Installed Plugins
website_welcome: Welcome to {{site_name}}
plugins:
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
admin: admin:
admin_header: admin_header:
title: Admin title: Admin
@ -1411,6 +1440,24 @@ ui:
title: Private title: Private
label: Login required label: Login required
text: Only logged in users can access this community. text: Only logged in users can access this community.
installed_plugins:
title: Installed Plugins
filter:
all: All
active: Active
inactive: Inactive
outdated: Outdated
plugins:
label: Plugins
text: Select an existing plugin.
name: Name
version: Version
status: Status
action: Action
deactivate: Deactivate
activate: Activate
settings: Settings
form: form:
optional: (optional) optional: (optional)
@ -1418,6 +1465,8 @@ ui:
invalid: is invalid invalid: is invalid
btn_submit: Save btn_submit: Save
not_found_props: "Required property {{ key }} not found." not_found_props: "Required property {{ key }} not found."
select: Select
page_review: page_review:
review: Review review: Review
proposed: proposed proposed: proposed

View File

@ -1053,6 +1053,7 @@ ui:
themes: 主题 themes: 主题
css-html: CSS/HTML css-html: CSS/HTML
login: 登录 login: 登录
website_welcome: 欢迎来到 {{site_name}}
admin: admin:
admin_header: admin_header:
title: 后台管理 title: 后台管理

View File

@ -92,10 +92,16 @@ export const ADMIN_NAV_MENUS = [
{ name: 'login' }, { name: 'login' },
], ],
}, },
{
name: 'plugins',
children: [
{
name: 'installed_plugins',
},
],
},
]; ];
export const ADMIN_LEGAL_MENUS = [{ name: 'tos' }, { name: 'privacy' }];
export const TIMEZONES = [ export const TIMEZONES = [
{ {
label: 'Africa', label: 'Africa',
@ -585,7 +591,7 @@ export const TIMEZONES = [
options: [{ value: 'UTC', label: 'UTC' }], options: [{ value: 'UTC', label: 'UTC' }],
}, },
]; ];
export const DEFAULT_TIMEZONE = 'UTC+0'; export const DEFAULT_TIMEZONE = 'UTC';
export const TIMELINE_NORMAL_ACTIVITY_TYPE = [ export const TIMELINE_NORMAL_ACTIVITY_TYPE = [
'undeleted', 'undeleted',

View File

@ -1,3 +1,5 @@
import { UIOptions, UIWidget } from '@/components/SchemaForm';
export interface FormValue<T = any> { export interface FormValue<T = any> {
value: T; value: T;
isInvalid: boolean; isInvalid: boolean;
@ -136,6 +138,7 @@ export interface UserInfoRes extends UserInfoBase {
mail_status: number; mail_status: number;
language: string; language: string;
e_mail?: string; e_mail?: string;
have_password: boolean;
[prop: string]: any; [prop: string]: any;
} }
@ -269,6 +272,11 @@ export type UserFilterBy =
| 'suspended' | 'suspended'
| 'deleted'; | 'deleted';
export type InstalledPluginsFilterBy =
| 'all'
| 'active'
| 'inactive'
| 'outdated';
/** /**
* @description interface for Flags * @description interface for Flags
*/ */
@ -527,6 +535,44 @@ export interface User {
avatar: string; avatar: string;
} }
export interface OauthBindEmailReq {
binding_key: string;
email: string;
must: boolean;
}
export interface OauthConnectorItem {
icon: string;
name: string;
link: string;
}
export interface UserOauthConnectorItem extends OauthConnectorItem {
binding: boolean;
external_id: string;
}
export interface PluginOption {
label: string;
value: string;
}
export interface PluginItem {
name: string;
type: UIWidget;
title: string;
description: string;
ui_options?: UIOptions;
options?: PluginOption[];
value?: string;
required?: boolean;
}
export interface PluginConfig {
name: string;
slug_name: string;
config_fields: PluginItem[];
}
export interface QuestionOperationReq { export interface QuestionOperationReq {
id: string; id: string;
operation: 'pin' | 'unpin' | 'hide' | 'show'; operation: 'pin' | 'unpin' | 'hide' | 'show';

View File

@ -21,7 +21,7 @@ function MenuNode({
const href = isLeaf ? `${path}${menu.name}` : '#'; const href = isLeaf ? `${path}${menu.name}` : '#';
return ( return (
<Nav.Item key={menu.name}> <Nav.Item key={menu.name} className="w-100">
<Nav.Link <Nav.Link
eventKey={menu.name} eventKey={menu.name}
as={isLeaf ? 'a' : 'button'} as={isLeaf ? 'a' : 'button'}
@ -33,7 +33,9 @@ function MenuNode({
'text-nowrap d-flex flex-nowrap align-items-center w-100', 'text-nowrap d-flex flex-nowrap align-items-center w-100',
{ expanding, 'link-dark': activeKey !== menu.name }, { expanding, 'link-dark': activeKey !== menu.name },
)}> )}>
<span className="me-auto">{t(menu.name)}</span> <span className="me-auto text-truncate">
{menu.displayName ? menu.displayName : t(menu.name)}
</span>
{menu.badgeContent ? ( {menu.badgeContent ? (
<span className="badge text-bg-dark">{menu.badgeContent}</span> <span className="badge text-bg-dark">{menu.badgeContent}</span>
) : null} ) : null}
@ -114,7 +116,7 @@ const AccordionNav: FC<AccordionProps> = ({ menus = [], path = '/' }) => {
}; };
useEffect(() => { useEffect(() => {
setOpenKey(getOpenKey()); setOpenKey(getOpenKey());
}, [activeKey]); }, [activeKey, menus]);
return ( return (
<Accordion activeKey={openKey} flush> <Accordion activeKey={openKey} flush>
<Nav variant="pills" className="flex-column" activeKey={activeKey}> <Nav variant="pills" className="flex-column" activeKey={activeKey}>

View File

@ -18,12 +18,16 @@ const Index = ({
title = '', title = '',
confirmText = '', confirmText = '',
content, content,
onCancel: onClose,
onConfirm, onConfirm,
cancelBtnVariant = 'link', cancelBtnVariant = 'link',
confirmBtnVariant = 'primary', confirmBtnVariant = 'primary',
...props ...props
}: Config) => { }: Config) => {
const onCancel = () => { const onCancel = () => {
if (typeof onClose === 'function') {
onClose();
}
render({ visible: false }); render({ visible: false });
div.remove(); div.remove();
}; };

View File

@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { loginToContinueStore, siteInfoStore } from '@/stores'; import { loginToContinueStore, siteInfoStore } from '@/stores';
import { WelcomeTitle } from '@/components';
interface IProps { interface IProps {
visible: boolean; visible: boolean;
@ -32,7 +33,7 @@ const Index: React.FC<IProps> = ({ visible = false }) => {
</Modal.Header> </Modal.Header>
<Modal.Body className="p-5"> <Modal.Body className="p-5">
<div className="d-flex flex-column align-items-center text-center text-body"> <div className="d-flex flex-column align-items-center text-center text-body">
<h3>{t('page_title', { site_name: siteInfo.name })}</h3> <WelcomeTitle className="mb-2" />
<p>{siteInfo.description}</p> <p>{siteInfo.description}</p>
</div> </div>
<div className="d-grid gap-2"> <div className="d-grid gap-2">

View File

@ -51,7 +51,7 @@ const Index: React.FC<IProps> = ({
type="text" type="text"
autoComplete="off" autoComplete="off"
placeholder={t('placeholder')} placeholder={t('placeholder')}
isInvalid={captcha.isInvalid} isInvalid={captcha?.isInvalid}
onChange={(e) => { onChange={(e) => {
Storage.set(CAPTCHA_CODE_STORAGE_KEY, e.target.value); Storage.set(CAPTCHA_CODE_STORAGE_KEY, e.target.value);
handleCaptcha({ handleCaptcha({

View File

@ -1,19 +1,21 @@
# SchemaForm User Guide ---
sidebar_position: 0
---
# Schema Form
## Introduction ## Introduction
SchemaForm is a component that can be used to render a form based on a [JSON schema](https://json-schema.org/understanding-json-schema/index.html). A React component capable of building HTML forms out of a [JSON schema](https://json-schema.org/understanding-json-schema/index.html).
## Usage ## Usage
### Basic Usage
```tsx ```tsx
import React from 'react'; import React, { useState } from 'react';
import { SchemaForm, initFormData, JSONSchema, UISchema } from '@/components'; import { SchemaForm, initFormData, JSONSchema, UISchema } from '@/components';
const schema: JSONSchema = { const schema: JSONSchema = {
type: 'object', title: 'General',
properties: { properties: {
name: { name: {
type: 'string', type: 'string',
@ -23,12 +25,12 @@ const schema: JSONSchema = {
type: 'number', type: 'number',
title: 'Age', title: 'Age',
}, },
sex:{ sex: {
type: 'boolean', type: 'string',
title: 'sex', title: 'sex',
enum: [1, 2], enum: [1, 2],
enumNames: ['male', 'female'], enumNames: ['male', 'female'],
} },
}, },
}; };
@ -39,41 +41,193 @@ const uiSchema: UISchema = {
age: { age: {
'ui:widget': 'input', 'ui:widget': 'input',
'ui:options': { 'ui:options': {
type: 'number' type: 'number',
} },
}, },
sex: { sex: {
'ui:widget': 'radio', 'ui:widget': 'radio',
} },
}; };
// form component
const Form = () => { const Form = () => {
const [formData, setFormData] = useState(initFormData(schema)); const [formData, setFormData] = useState(initFormData(schema));
const handleChange = (data) => {
setFormData(data);
};
return ( return (
<SchemaForm <SchemaForm
schema={schema} schema={schema}
uiSchema={uiSchema} uiSchema={uiSchema}
formData={formData} formData={formData}
onChange={console.log} onChange={handleChange}
/> />
); );
}; };
export default Form;
``` ```
## Props ## Props
| Property | Description | Type | Default | | Property | Description | Type | Default |
| -------- | ---------------------------------------- | ----------------------------------------- | ------- | | -------- | ---------------------------------------- | ------------------------------------- | ------- |
| schema | JSON schema | [JSONSchema](index.tsx#L9) | - | | schema | Describe the form structure with schema | [JSONSchema](#json-schema) | - |
| uiSchema | UI schema | [UISchema](index.tsx#L24) | - | | uiSchema | Describe the properties of the field | [UISchema](#uischema) | - |
| formData | Form data | [FormData](index.tsx#L66) | - | | formData | Describe form data | [FormData](#formdata) | - |
| onChange | Callback function when form data changes | (data: [FormData](index.tsx#L66)) => void | - | | onChange | Callback function when form data changes | (data: [FormData](#formdata)) => void | - |
| onSubmit | Callback function when form is submitted | (data: React.FormEvent) => void | - | | onSubmit | Callback function when form is submitted | (data: React.FormEvent) => void | - |
## Types Definition
### JSONSchema
```ts
export interface JSONSchema {
title: string;
description?: string;
required?: string[];
properties: {
[key: string]: {
type: 'string' | 'boolean' | 'number';
title: string;
label?: string;
description?: string;
enum?: Array<string | boolean | number>;
enumNames?: string[];
default?: string | boolean | number;
};
};
}
```
### UIOptions
```ts
export interface UIOptions {
empty?: string;
className?: string | string[];
validator?: (
value,
formData?,
) => Promise<string | true | void> | true | string;
}
```
### InputOptions
```ts
export interface InputOptions extends UIOptions {
placeholder?: string;
type?:
| 'color'
| 'date'
| 'datetime-local'
| 'email'
| 'month'
| 'number'
| 'password'
| 'range'
| 'search'
| 'tel'
| 'text'
| 'time'
| 'url'
| 'week';
}
```
### SelectOptions
```ts
export interface SelectOptions extends UIOptions {}
```
### UploadOptions
```ts
export interface UploadOptions extends UIOptions {
acceptType?: string;
imageType?: 'post' | 'avatar' | 'branding';
}
```
### SwitchOptions
```ts
export interface SwitchOptions extends UIOptions {}
```
### TimezoneOptions
```ts
export interface TimezoneOptions extends UIOptions {
placeholder?: string;
}
```
### CheckboxOptions
```ts
export interface CheckboxOptions extends UIOptions {}
```
### RadioOptions
```ts
export interface RadioOptions extends UIOptions {}
```
### TextareaOptions
```ts
export interface TextareaOptions extends UIOptions {
placeholder?: string;
rows?: number;
}
```
### UIWidget
```ts
export type UIWidget =
| 'textarea'
| 'input'
| 'checkbox'
| 'radio'
| 'select'
| 'upload'
| 'timezone'
| 'switch';
```
### UISchema
```ts
export interface UISchema {
[key: string]: {
'ui:widget'?: UIWidget;
'ui:options'?:
| InputOptions
| SelectOptions
| UploadOptions
| SwitchOptions
| TimezoneOptions
| CheckboxOptions
| RadioOptions
| TextareaOptions;
};
}
```
### FormData
```ts
export interface FormValue<T = any> {
value: T;
isInvalid: boolean;
errorMsg: string;
[prop: string]: any;
}
export interface FormDataType {
[prop: string]: FormValue;
}
```
## reference ## reference
- [json schema](https://json-schema.org/understanding-json-schema/index.html) - [json schema](https://json-schema.org/understanding-json-schema/index.html)
- [react-jsonschema-form](http://rjsf-team.github.io/react-jsonschema-form/) - [react-jsonschema-form](https://github.com/rjsf-team/react-jsonschema-form)
- [vue-json-schema-form](https://github.com/lljj-x/vue-json-schema-form/) - [vue-json-schema-form](https://github.com/lljj-x/vue-json-schema-form/)

View File

@ -2,6 +2,7 @@ import {
ForwardRefRenderFunction, ForwardRefRenderFunction,
forwardRef, forwardRef,
useImperativeHandle, useImperativeHandle,
useEffect,
} from 'react'; } from 'react';
import { Form, Button, Stack } from 'react-bootstrap'; import { Form, Button, Stack } from 'react-bootstrap';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -20,7 +21,6 @@ export interface JSONSchema {
[key: string]: { [key: string]: {
type: 'string' | 'boolean' | 'number'; type: 'string' | 'boolean' | 'number';
title: string; title: string;
label?: string;
description?: string; description?: string;
enum?: Array<string | boolean | number>; enum?: Array<string | boolean | number>;
enumNames?: string[]; enumNames?: string[];
@ -28,45 +28,79 @@ export interface JSONSchema {
}; };
}; };
} }
export interface BaseUIOptions {
empty?: string;
className?: string | string[];
validator?: (
value,
formData?,
) => Promise<string | true | void> | true | string;
}
export interface InputOptions extends BaseUIOptions {
placeholder?: string;
inputType?:
| 'color'
| 'date'
| 'datetime-local'
| 'email'
| 'month'
| 'number'
| 'password'
| 'range'
| 'search'
| 'tel'
| 'text'
| 'time'
| 'url'
| 'week';
}
export interface SelectOptions extends BaseUIOptions {}
export interface UploadOptions extends BaseUIOptions {
acceptType?: string;
imageType?: Type.UploadType;
}
export interface SwitchOptions extends BaseUIOptions {
label?: string;
}
export interface TimezoneOptions extends BaseUIOptions {
placeholder?: string;
}
export interface CheckboxOptions extends BaseUIOptions {}
export interface RadioOptions extends BaseUIOptions {}
export interface TextareaOptions extends BaseUIOptions {
placeholder?: string;
rows?: number;
}
export type UIOptions =
| InputOptions
| SelectOptions
| UploadOptions
| SwitchOptions
| TimezoneOptions
| CheckboxOptions
| RadioOptions
| TextareaOptions;
export type UIWidget =
| 'textarea'
| 'input'
| 'checkbox'
| 'radio'
| 'select'
| 'upload'
| 'timezone'
| 'switch';
export interface UISchema { export interface UISchema {
[key: string]: { [key: string]: {
'ui:widget'?: 'ui:widget'?: UIWidget;
| 'textarea' 'ui:options'?: UIOptions;
| 'text'
| 'checkbox'
| 'radio'
| 'select'
| 'upload'
| 'timezone'
| 'switch';
'ui:options'?: {
rows?: number;
placeholder?: string;
type?:
| 'color'
| 'date'
| 'datetime-local'
| 'email'
| 'month'
| 'number'
| 'password'
| 'range'
| 'search'
| 'tel'
| 'text'
| 'time'
| 'url'
| 'week';
empty?: string;
className?: string | string[];
validator?: (
value,
formData?,
) => Promise<string | true | void> | true | string;
textRender?: () => React.ReactElement;
imageType?: Type.UploadType;
acceptType?: string;
};
}; };
} }
@ -116,6 +150,30 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
} }
const keys = Object.keys(properties); const keys = Object.keys(properties);
/**
* Prevent components such as `select` from having default values,
* which are not generated on `formData`
*/
const setDefaultValueAsDomBehaviour = () => {
keys.forEach((k) => {
const fieldVal = formData[k]?.value;
const metaProp = properties[k];
const uiCtrl = uiSchema[k]?.['ui:widget'];
if (!metaProp || !uiCtrl || fieldVal !== undefined) {
return;
}
if (uiCtrl === 'select' && metaProp.enum?.[0] !== undefined) {
formData[k] = {
errorMsg: '',
isInvalid: false,
value: metaProp.enum?.[0],
};
}
});
};
useEffect(() => {
setDefaultValueAsDomBehaviour();
}, [formData]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target; const { name, value } = e.target;
@ -265,16 +323,17 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
} }
}; };
const handleCheckboxChange = ( const handleInputCheck = (
e: React.ChangeEvent<HTMLInputElement>, e: React.ChangeEvent<HTMLInputElement>,
index: number, index: number,
) => { ) => {
const { name } = e.target; const { name, checked } = e.currentTarget;
const freshVal = checked ? schema.properties[name]?.enum?.[index] : '';
const data = { const data = {
...formData, ...formData,
[name]: { [name]: {
...formData[name], ...formData[name],
value: schema.properties[name]?.enum?.[index], value: freshVal,
isInvalid: false, isInvalid: false,
}, },
}; };
@ -286,12 +345,11 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
validator, validator,
})); }));
return ( return (
<Form noValidate onSubmit={handleSubmit}> <Form noValidate onSubmit={handleSubmit}>
{keys.map((key) => { {keys.map((key) => {
const { title, description, label } = properties[key]; const { title, description } = properties[key];
const { 'ui:widget': widget = 'input', 'ui:options': options = {} } = const { 'ui:widget': widget = 'input', 'ui:options': uiOpt } =
uiSchema[key] || {}; uiSchema[key] || {};
if (widget === 'select') { if (widget === 'select') {
return ( return (
@ -303,7 +361,7 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
<Form.Select <Form.Select
aria-label={description} aria-label={description}
name={key} name={key}
value={formData[key]?.value} value={formData[key]?.value || ''}
onChange={handleSelectChange} onChange={handleSelectChange}
isInvalid={formData[key].isInvalid}> isInvalid={formData[key].isInvalid}>
{properties[key].enum?.map((item, index) => { {properties[key].enum?.map((item, index) => {
@ -323,6 +381,7 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
</Form.Group> </Form.Group>
); );
} }
if (widget === 'checkbox' || widget === 'radio') { if (widget === 'checkbox' || widget === 'radio') {
return ( return (
<Form.Group <Form.Group
@ -341,11 +400,11 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
name={key} name={key}
id={`form-${String(item)}`} id={`form-${String(item)}`}
label={properties[key].enumNames?.[index]} label={properties[key].enumNames?.[index]}
checked={formData[key]?.value === item} checked={(formData[key]?.value || '') === item}
feedback={formData[key]?.errorMsg} feedback={formData[key]?.errorMsg}
feedbackType="invalid" feedbackType="invalid"
isInvalid={formData[key].isInvalid} isInvalid={formData[key].isInvalid}
onChange={(e) => handleCheckboxChange(e, index)} onChange={(e) => handleInputCheck(e, index)}
/> />
); );
})} })}
@ -372,8 +431,8 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
id={`switch-${title}`} id={`switch-${title}`}
name={key} name={key}
type="switch" type="switch"
label={label} label={(uiOpt as SwitchOptions)?.label}
checked={formData[key]?.value} checked={formData[key]?.value || ''}
feedback={formData[key]?.errorMsg} feedback={formData[key]?.errorMsg}
feedbackType="invalid" feedbackType="invalid"
isInvalid={formData[key].isInvalid} isInvalid={formData[key].isInvalid}
@ -396,7 +455,7 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
controlId={key}> controlId={key}>
<Form.Label>{title}</Form.Label> <Form.Label>{title}</Form.Label>
<TimeZonePicker <TimeZonePicker
value={formData[key]?.value} value={formData[key]?.value || ''}
name={key} name={key}
onChange={handleSelectChange} onChange={handleSelectChange}
/> />
@ -416,6 +475,7 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
} }
if (widget === 'upload') { if (widget === 'upload') {
const options: UploadOptions = uiSchema[key]?.['ui:options'] || {};
return ( return (
<Form.Group <Form.Group
key={title} key={title}
@ -444,6 +504,8 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
} }
if (widget === 'textarea') { if (widget === 'textarea') {
const options: TextareaOptions = uiSchema[key]?.['ui:options'] || {};
return ( return (
<Form.Group <Form.Group
controlId={`form-${key}`} controlId={`form-${key}`}
@ -454,8 +516,7 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
as="textarea" as="textarea"
name={key} name={key}
placeholder={options?.placeholder || ''} placeholder={options?.placeholder || ''}
type={options?.type || 'text'} value={formData[key]?.value || ''}
value={formData[key]?.value}
onChange={handleInputChange} onChange={handleInputChange}
isInvalid={formData[key].isInvalid} isInvalid={formData[key].isInvalid}
rows={options?.rows || 3} rows={options?.rows || 3}
@ -471,6 +532,9 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
</Form.Group> </Form.Group>
); );
} }
const options: InputOptions = uiSchema[key]?.['ui:options'] || {};
return ( return (
<Form.Group <Form.Group
controlId={key} controlId={key}
@ -480,10 +544,10 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
<Form.Control <Form.Control
name={key} name={key}
placeholder={options?.placeholder || ''} placeholder={options?.placeholder || ''}
type={options?.type || 'text'} type={options?.inputType || 'text'}
value={formData[key]?.value} value={formData[key]?.value || ''}
onChange={handleInputChange} onChange={handleInputChange}
style={options?.type === 'color' ? { width: '6rem' } : {}} style={options?.inputType === 'color' ? { width: '6rem' } : {}}
isInvalid={formData[key].isInvalid} isInvalid={formData[key].isInvalid}
/> />
<Form.Control.Feedback type="invalid"> <Form.Control.Feedback type="invalid">
@ -507,10 +571,10 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
export const initFormData = (schema: JSONSchema): Type.FormDataType => { export const initFormData = (schema: JSONSchema): Type.FormDataType => {
const formData: Type.FormDataType = {}; const formData: Type.FormDataType = {};
Object.keys(schema.properties).forEach((key) => { Object.keys(schema.properties).forEach((key) => {
const v = schema.properties[key]?.default; const prop = schema.properties[key];
// TODO: set default value by property type const defaultVal = prop?.default;
formData[key] = { formData[key] = {
value: typeof v !== 'undefined' ? v : '', value: defaultVal,
isInvalid: false, isInvalid: false,
errorMsg: '', errorMsg: '',
}; };

View File

@ -0,0 +1,23 @@
import React, { FC, memo } from 'react';
import { useTranslation } from 'react-i18next';
import classnames from 'classnames';
import { siteInfoStore } from '@/stores';
interface Props {
as?: React.ElementType;
className?: string;
}
const Index: FC<Props> = ({ as: Component = 'h3', className = 'mb-5' }) => {
const { t } = useTranslation();
const { name: siteName } = siteInfoStore((_) => _.siteInfo);
return (
<Component className={classnames('text-center', className)}>
{t('website_welcome', { site_name: siteName })}
</Component>
);
};
export default memo(Index);

View File

@ -32,6 +32,7 @@ import CustomizeTheme from './CustomizeTheme';
import PageTags from './PageTags'; import PageTags from './PageTags';
import QuestionListLoader from './QuestionListLoader'; import QuestionListLoader from './QuestionListLoader';
import TagsLoader from './TagsLoader'; import TagsLoader from './TagsLoader';
import WelcomeTitle from './WelcomeTitle';
import Counts from './Counts'; import Counts from './Counts';
import QuestionList from './QuestionList'; import QuestionList from './QuestionList';
import HotQuestions from './HotQuestions'; import HotQuestions from './HotQuestions';
@ -74,6 +75,7 @@ export {
PageTags, PageTags,
QuestionListLoader, QuestionListLoader,
TagsLoader, TagsLoader,
WelcomeTitle,
Counts, Counts,
QuestionList, QuestionList,
HotQuestions, HotQuestions,

View File

@ -8,6 +8,7 @@ import useChangeUserRoleModal from './useChangeUserRoleModal';
import useUserModal from './useUserModal'; import useUserModal from './useUserModal';
import useChangePasswordModal from './useChangePasswordModal'; import useChangePasswordModal from './useChangePasswordModal';
import usePageTags from './usePageTags'; import usePageTags from './usePageTags';
import useLoginRedirect from './useLoginRedirect';
import usePromptWithUnload from './usePrompt'; import usePromptWithUnload from './usePrompt';
import useImgViewer from './useImgViewer'; import useImgViewer from './useImgViewer';
@ -22,6 +23,7 @@ export {
useUserModal, useUserModal,
useChangePasswordModal, useChangePasswordModal,
usePageTags, usePageTags,
useLoginRedirect,
usePromptWithUnload, usePromptWithUnload,
useImgViewer, useImgViewer,
}; };

View File

@ -37,7 +37,7 @@ const useChangePasswordModal = (props: IProps = {}) => {
const uiSchema: UISchema = { const uiSchema: UISchema = {
password: { password: {
'ui:options': { 'ui:options': {
type: 'password', inputType: 'password',
validator: (value) => { validator: (value) => {
const MIN_LENGTH = 8; const MIN_LENGTH = 8;
const MAX_LENGTH = 32; const MAX_LENGTH = 32;

View File

@ -0,0 +1,22 @@
import { useNavigate } from 'react-router-dom';
import { floppyNavigation } from '@/utils';
import Storage from '@/utils/storage';
import { RouteAlias } from '@/router/alias';
import { REDIRECT_PATH_STORAGE_KEY } from '@/common/constants';
const Index = () => {
const navigate = useNavigate();
const loginRedirect = () => {
const redirect = Storage.get(REDIRECT_PATH_STORAGE_KEY) || RouteAlias.home;
Storage.remove(REDIRECT_PATH_STORAGE_KEY);
floppyNavigation.navigate(redirect, () => {
navigate(redirect, { replace: true });
});
};
return { loginRedirect };
};
export default Index;

View File

@ -57,7 +57,7 @@ const useAddUserModal = (props: IProps = {}) => {
}, },
email: { email: {
'ui:options': { 'ui:options': {
type: 'email', inputType: 'email',
validator: (value) => { validator: (value) => {
if (value && !pattern.email.test(value)) { if (value && !pattern.email.test(value)) {
return t('form.fields.email.msg'); return t('form.fields.email.msg');
@ -68,7 +68,7 @@ const useAddUserModal = (props: IProps = {}) => {
}, },
password: { password: {
'ui:options': { 'ui:options': {
type: 'password', inputType: 'password',
validator: (value) => { validator: (value) => {
const MIN_LENGTH = 8; const MIN_LENGTH = 8;
const MAX_LENGTH = 32; const MAX_LENGTH = 32;

View File

@ -315,3 +315,13 @@ img:not(a img, img.broken) {
.bg-fade-out { .bg-fade-out {
animation: bg-fade-out 2s ease 0.3s; animation: bg-fade-out 2s ease 0.3s;
} }
.btnSvg, .btnSvg:hover {
display: inline-block;
font-size: 16px;
width: 16px;
height: 16px;
fill: currentColor;
vertical-align: -0.125em;
}

View File

@ -2,8 +2,6 @@ import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { guard } from '@/utils';
import App from './App'; import App from './App';
import './index.scss'; import './index.scss';
@ -12,13 +10,8 @@ const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement, document.getElementById('root') as HTMLElement,
); );
async function bootstrapApp() { root.render(
await guard.setupApp(); <React.StrictMode>
root.render( <App />
<React.StrictMode> </React.StrictMode>,
<App /> );
</React.StrictMode>,
);
}
bootstrapApp();

View File

@ -2,6 +2,7 @@ import { FC } from 'react';
import { Row, Col } from 'react-bootstrap'; import { Row, Col } from 'react-bootstrap';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { siteInfoStore } from '@/stores';
import { useDashBoard } from '@/services'; import { useDashBoard } from '@/services';
import { import {
@ -13,6 +14,7 @@ import {
const Dashboard: FC = () => { const Dashboard: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'admin.dashboard' }); const { t } = useTranslation('translation', { keyPrefix: 'admin.dashboard' });
const { name: siteName } = siteInfoStore((_) => _.siteInfo);
const { data } = useDashBoard(); const { data } = useDashBoard();
if (!data) { if (!data) {
@ -22,7 +24,7 @@ const Dashboard: FC = () => {
return ( return (
<> <>
<h3 className="text-capitalize">{t('title')}</h3> <h3 className="text-capitalize">{t('title')}</h3>
<p className="mt-4">{t('welcome')}</p> <p className="mt-4">{t('welcome', { site_name: siteName })}</p>
<Row> <Row>
<Col lg={6}> <Col lg={6}>
<Statistics data={data.info} /> <Statistics data={data.info} />

View File

@ -55,7 +55,7 @@ const General: FC = () => {
const uiSchema: UISchema = { const uiSchema: UISchema = {
site_url: { site_url: {
'ui:options': { 'ui:options': {
type: 'url', inputType: 'url',
validator: (value) => { validator: (value) => {
let url: URL | undefined; let url: URL | undefined;
try { try {
@ -79,7 +79,7 @@ const General: FC = () => {
}, },
contact_email: { contact_email: {
'ui:options': { 'ui:options': {
type: 'email', inputType: 'email',
validator: (value) => { validator: (value) => {
if (!Pattern.email.test(value)) { if (!Pattern.email.test(value)) {
return t('contact_email.validate'); return t('contact_email.validate');

View File

@ -40,11 +40,13 @@ const Interface: FC = () => {
description: t('language.text'), description: t('language.text'),
enum: langs?.map((lang) => lang.value), enum: langs?.map((lang) => lang.value),
enumNames: langs?.map((lang) => lang.label), enumNames: langs?.map((lang) => lang.label),
default: setting?.language || storeInterface.language,
}, },
time_zone: { time_zone: {
type: 'string', type: 'string',
title: t('time_zone.label'), title: t('time_zone.label'),
description: t('time_zone.text'), description: t('time_zone.text'),
default: setting?.time_zone || DEFAULT_TIMEZONE,
}, },
default_avatar: { default_avatar: {
type: 'string', type: 'string',

View File

@ -19,14 +19,12 @@ const Index: FC = () => {
allow_new_registrations: { allow_new_registrations: {
type: 'boolean', type: 'boolean',
title: t('membership.title'), title: t('membership.title'),
label: t('membership.label'),
description: t('membership.text'), description: t('membership.text'),
default: true, default: false,
}, },
login_required: { login_required: {
type: 'boolean', type: 'boolean',
title: t('private.title'), title: t('private.title'),
label: t('private.label'),
description: t('private.text'), description: t('private.text'),
default: false, default: false,
}, },
@ -35,9 +33,15 @@ const Index: FC = () => {
const uiSchema: UISchema = { const uiSchema: UISchema = {
allow_new_registrations: { allow_new_registrations: {
'ui:widget': 'switch', 'ui:widget': 'switch',
'ui:options': {
label: t('membership.label'),
},
}, },
login_required: { login_required: {
'ui:widget': 'switch', 'ui:widget': 'switch',
'ui:options': {
label: t('private.label'),
},
}, },
}; };
const [formData, setFormData] = useState(initFormData(schema)); const [formData, setFormData] = useState(initFormData(schema));

View File

@ -0,0 +1,115 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { isEmpty } from 'lodash';
import { useToast } from '@/hooks';
import type * as Types from '@/common/interface';
import { SchemaForm, JSONSchema, initFormData, UISchema } from '@/components';
import { useQueryPluginConfig, updatePluginConfig } from '@/services';
import { InputOptions } from '@/components/SchemaForm';
const Config = () => {
const { t } = useTranslation('translation');
const { slug_name } = useParams<{ slug_name: string }>();
const { data } = useQueryPluginConfig({ plugin_slug_name: slug_name });
const Toast = useToast();
const [schema, setSchema] = useState<JSONSchema | null>(null);
const [uiSchema, setUISchema] = useState<UISchema>();
const required: string[] = [];
const [formData, setFormData] = useState<Types.FormDataType | null>(null);
useEffect(() => {
if (!data) {
return;
}
const properties: JSONSchema['properties'] = {};
const uiConf: UISchema = {};
data.config_fields?.forEach((item) => {
properties[item.name] = {
type: 'string',
title: item.title,
description: item.description,
default: item.value,
};
if (item.options instanceof Array) {
properties[item.name].enum = item.options.map((option) => option.value);
properties[item.name].enumNames = item.options.map(
(option) => option.label,
);
}
uiConf[item.name] = {};
uiConf[item.name]['ui:widget'] = item.type;
if (item.ui_options) {
if ((item.ui_options as InputOptions & { input_type })?.input_type) {
(item.ui_options as InputOptions).inputType = (
item.ui_options as InputOptions & { input_type }
).input_type;
}
uiConf[item.name]['ui:options'] = item.ui_options;
}
if (item.required) {
required.push(item.name);
}
});
const result = {
title: data?.name || '',
required,
properties,
};
setSchema(result);
setUISchema(uiConf);
setFormData(initFormData(result));
}, [data?.config_fields]);
const onSubmit = (evt) => {
if (!formData) {
return;
}
evt.preventDefault();
evt.stopPropagation();
const config_fields = {};
Object.keys(formData).forEach((key) => {
config_fields[key] = formData[key].value;
});
const params = {
plugin_slug_name: slug_name,
config_fields,
};
updatePluginConfig(params).then(() => {
Toast.onShow({
msg: t('update', { keyPrefix: 'toast' }),
variant: 'success',
});
});
};
const handleOnChange = (form) => {
setFormData(form);
};
if (!data || !schema || !formData) {
return null;
}
if (isEmpty(schema.properties)) {
return <h3 className="mb-4">{data?.name}</h3>;
}
return (
<>
<h3 className="mb-4">{data?.name}</h3>
<SchemaForm
schema={schema}
uiSchema={uiSchema}
formData={formData}
onSubmit={onSubmit}
onChange={handleOnChange}
/>
</>
);
};
export default Config;

View File

@ -0,0 +1,146 @@
import { FC } from 'react';
import { Table, Dropdown, Stack } from 'react-bootstrap';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import { Empty, QueryGroup, Icon } from '@/components';
import * as Type from '@/common/interface';
import { useQueryPlugins, updatePluginStatus } from '@/services';
const InstalledPluginsFilterKeys: Type.InstalledPluginsFilterBy[] = [
'all',
'active',
'inactive',
];
const bgMap = {
active: 'text-bg-success',
inactive: 'text-bg-secondary',
};
const Users: FC = () => {
const { t } = useTranslation('translation', {
keyPrefix: 'admin.installed_plugins',
});
const navigate = useNavigate();
const [urlSearchParams] = useSearchParams();
const curFilter =
urlSearchParams.get('filter') || InstalledPluginsFilterKeys[0];
const {
data,
isLoading,
mutate: updatePlugins,
} = useQueryPlugins({
status: curFilter === 'all' ? undefined : curFilter,
});
const emitPluginChange = (type) => {
window.postMessage({
msgType: type,
});
};
const handleStatus = (plugin) => {
updatePluginStatus({
enabled: !plugin.enabled,
plugin_slug_name: plugin.slug_name,
}).then(() => {
updatePlugins();
if (plugin.have_config) {
emitPluginChange('refreshConfigurablePlugins');
}
});
};
const handleSettings = (plugin) => {
const url = `/admin/${plugin.slug_name}`;
navigate(url);
};
return (
<>
<h3 className="mb-4">{t('title')}</h3>
<div className="d-flex justify-content-between align-items-center mb-3">
<Stack direction="horizontal" gap={3}>
<QueryGroup
data={InstalledPluginsFilterKeys}
currentSort={curFilter}
sortKey="filter"
i18nKeyPrefix="admin.installed_plugins.filter"
/>
</Stack>
</div>
<Table>
<thead>
<tr>
<th>{t('name')}</th>
<th style={{ width: '17%' }}>{t('version')}</th>
<th style={{ width: '11%' }}>{t('status')}</th>
{curFilter !== 'deleted' ? (
<th style={{ width: '11%' }} className="text-end">
{t('action')}
</th>
) : null}
</tr>
</thead>
<tbody className="align-middle">
{data?.map((plugin) => {
return (
<tr key={plugin.slug_name}>
<td>
<div>
{plugin.link ? (
<a href={plugin.link} target="_blank" rel="noreferrer">
{plugin.name}
</a>
) : (
plugin.name
)}
</div>
<div className="fs-14">{plugin.description}</div>
</td>
<td className="text-break">{plugin.version}</td>
<td>
<span
className={classNames(
'badge',
bgMap[plugin.enabled ? 'active' : 'inactive'],
)}>
{t(`filter.${plugin.enabled ? 'active' : 'inactive'}`)}
</span>
</td>
{curFilter !== 'deleted' ? (
<td className="text-end">
<Dropdown>
<Dropdown.Toggle variant="link" className="no-toggle">
<Icon name="three-dots-vertical" />
</Dropdown.Toggle>
<Dropdown.Menu>
{plugin.enabled ? (
<Dropdown.Item onClick={() => handleStatus(plugin)}>
{t('deactivate')}
</Dropdown.Item>
) : (
<Dropdown.Item onClick={() => handleStatus(plugin)}>
{t('activate')}
</Dropdown.Item>
)}
{plugin.enabled && plugin.have_config && (
<Dropdown.Item onClick={() => handleSettings(plugin)}>
{t('settings')}
</Dropdown.Item>
)}
</Dropdown.Menu>
</Dropdown>
</td>
) : null}
</tr>
);
})}
</tbody>
</Table>
{Number(data?.length) <= 0 && !isLoading && <Empty />}
</>
);
};
export default Users;

View File

@ -33,7 +33,7 @@ const Smtp: FC = () => {
description: t('smtp_host.text'), description: t('smtp_host.text'),
}, },
encryption: { encryption: {
type: 'boolean', type: 'string',
title: t('encryption.label'), title: t('encryption.label'),
description: t('encryption.text'), description: t('encryption.text'),
enum: ['SSL', ''], enum: ['SSL', ''],
@ -47,7 +47,6 @@ const Smtp: FC = () => {
smtp_authentication: { smtp_authentication: {
type: 'boolean', type: 'boolean',
title: t('smtp_authentication.title'), title: t('smtp_authentication.title'),
label: t('smtp_authentication.label'),
enum: [true, false], enum: [true, false],
enumNames: [t('smtp_authentication.yes'), t('smtp_authentication.no')], enumNames: [t('smtp_authentication.yes'), t('smtp_authentication.no')],
}, },
@ -69,7 +68,7 @@ const Smtp: FC = () => {
const uiSchema: UISchema = { const uiSchema: UISchema = {
from_email: { from_email: {
'ui:options': { 'ui:options': {
type: 'email', inputType: 'email',
}, },
}, },
encryption: { encryption: {
@ -89,7 +88,7 @@ const Smtp: FC = () => {
}, },
smtp_password: { smtp_password: {
'ui:options': { 'ui:options': {
type: 'password', inputType: 'password',
validator: (value: string, formData) => { validator: (value: string, formData) => {
if (formData.smtp_authentication.value) { if (formData.smtp_authentication.value) {
if (!value) { if (!value) {
@ -102,10 +101,13 @@ const Smtp: FC = () => {
}, },
smtp_authentication: { smtp_authentication: {
'ui:widget': 'switch', 'ui:widget': 'switch',
'ui:options': {
label: t('smtp_authentication.label'),
},
}, },
smtp_port: { smtp_port: {
'ui:options': { 'ui:options': {
type: 'number', inputType: 'number',
validator: (value) => { validator: (value) => {
if (!/^[1-9][0-9]*$/.test(value) || Number(value) > 65535) { if (!/^[1-9][0-9]*$/.test(value) || Number(value) > 65535) {
return t('smtp_port.msg'); return t('smtp_port.msg');
@ -116,7 +118,7 @@ const Smtp: FC = () => {
}, },
test_email_recipient: { test_email_recipient: {
'ui:options': { 'ui:options': {
type: 'email', inputType: 'email',
validator: (value) => { validator: (value) => {
if (value && !pattern.email.test(value)) { if (value && !pattern.email.test(value)) {
return t('test_email_recipient.msg'); return t('test_email_recipient.msg');
@ -177,7 +179,7 @@ const Smtp: FC = () => {
}, [setting]); }, [setting]);
useEffect(() => { useEffect(() => {
if (formData.smtp_authentication.value === '') { if (!/true|false/.test(formData.smtp_authentication.value)) {
return; return;
} }
if (formData.smtp_authentication.value) { if (formData.smtp_authentication.value) {

View File

@ -50,7 +50,7 @@ const Index: FC = () => {
}, },
primary_color: { primary_color: {
'ui:options': { 'ui:options': {
type: 'color', inputType: 'color',
}, },
}, },
}; };

View File

@ -27,7 +27,6 @@ const Index: FC = () => {
required_tag: { required_tag: {
type: 'boolean', type: 'boolean',
title: t('required_tag.title'), title: t('required_tag.title'),
label: t('required_tag.label'),
description: t('required_tag.text'), description: t('required_tag.text'),
}, },
reserved_tags: { reserved_tags: {
@ -46,6 +45,9 @@ const Index: FC = () => {
}, },
required_tag: { required_tag: {
'ui:widget': 'switch', 'ui:widget': 'switch',
'ui:options': {
label: t('required_tag.label'),
},
}, },
reserved_tags: { reserved_tags: {
'ui:widget': 'textarea', 'ui:widget': 'textarea',

View File

@ -1,29 +1,68 @@
import { FC } from 'react'; import { FC, useEffect } from 'react';
import { Container, Row, Col } from 'react-bootstrap'; import { Container, Row, Col } from 'react-bootstrap';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Outlet, useLocation } from 'react-router-dom'; import { Outlet, useMatch } from 'react-router-dom';
import { cloneDeep } from 'lodash';
import { usePageTags } from '@/hooks'; import { usePageTags } from '@/hooks';
import { AccordionNav } from '@/components'; import { AccordionNav } from '@/components';
import { ADMIN_NAV_MENUS } from '@/common/constants'; import { ADMIN_NAV_MENUS } from '@/common/constants';
import { useQueryPlugins } from '@/services';
import { interfaceStore } from '@/stores';
import './index.scss'; import './index.scss';
const formPaths = [ const g10Paths = [
'general', 'dashboard',
'smtp', 'questions',
'interface', 'answers',
'branding', 'users',
'legal', 'flags',
'write', 'installed_plugins',
'seo',
'themes',
'css-html',
]; ];
const Index: FC = () => { const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'page_title' }); const { t } = useTranslation('translation', { keyPrefix: 'page_title' });
const { pathname } = useLocation(); const pathMatch = useMatch('/admin/:path');
const curPath = pathMatch?.params.path || 'dashboard';
const interfaceLang = interfaceStore((_) => _.interface.language);
const { data: configurablePlugins, mutate: updateConfigurablePlugins } =
useQueryPlugins({
status: 'active',
have_config: true,
});
const menus = cloneDeep(ADMIN_NAV_MENUS);
if (configurablePlugins && configurablePlugins.length > 0) {
menus.forEach((item) => {
if (item.name === 'plugins' && item.children) {
item.children = [
...item.children,
...configurablePlugins.map((plugin) => ({
name: plugin.slug_name,
displayName: plugin.name,
})),
];
}
});
}
const observePlugins = (evt) => {
if (evt.data.msgType === 'refreshConfigurablePlugins') {
updateConfigurablePlugins();
}
};
useEffect(() => {
window.addEventListener('message', observePlugins);
return () => {
window.removeEventListener('message', observePlugins);
};
}, []);
useEffect(() => {
updateConfigurablePlugins();
}, [interfaceLang]);
usePageTags({ usePageTags({
title: t('admin'), title: t('admin'),
}); });
@ -39,9 +78,9 @@ const Index: FC = () => {
<Container className="admin-container"> <Container className="admin-container">
<Row> <Row>
<Col lg={2}> <Col lg={2}>
<AccordionNav menus={ADMIN_NAV_MENUS} path="/admin/" /> <AccordionNav menus={menus} path="/admin/" />
</Col> </Col>
<Col lg={formPaths.find((v) => pathname.includes(v)) ? 6 : 10}> <Col lg={g10Paths.find((v) => curPath.includes(v)) ? 10 : 6}>
<Outlet /> <Outlet />
</Col> </Col>
</Row> </Row>

View File

@ -71,6 +71,13 @@ const Index: FC<Props> = ({ visible, data, changeCallback, nextCallback }) => {
isInvalid: true, isInvalid: true,
errorMsg: t('admin_name.msg'), errorMsg: t('admin_name.msg'),
}; };
} else if (/[^a-z0-9\-._]/.test(name.value)) {
bol = false;
data.name = {
value: name.value,
isInvalid: true,
errorMsg: t('admin_name.character'),
};
} }
if (!password.value) { if (!password.value) {

View File

@ -11,6 +11,7 @@ import * as Type from '@/common/interface';
const Questions: FC = () => { const Questions: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'question' }); const { t } = useTranslation('translation', { keyPrefix: 'question' });
const { t: t2 } = useTranslation('translation');
const { user: loggedUser } = loggedUserInfoStore((_) => _); const { user: loggedUser } = loggedUserInfoStore((_) => _);
const [urlSearchParams] = useSearchParams(); const [urlSearchParams] = useSearchParams();
const curPage = Number(urlSearchParams.get('page')) || 1; const curPage = Number(urlSearchParams.get('page')) || 1;
@ -46,8 +47,7 @@ const Questions: FC = () => {
<div className="card mb-4"> <div className="card mb-4">
<div className="card-body"> <div className="card-body">
<h5 className="card-title"> <h5 className="card-title">
{t('page_title', { {t2('website_welcome', {
keyPrefix: 'login',
site_name: siteInfo.name, site_name: siteInfo.name,
})} })}
</h5> </h5>

View File

@ -3,12 +3,11 @@ import { Container, Row, Col } from 'react-bootstrap';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { siteInfoStore } from '@/stores';
import { usePageTags } from '@/hooks'; import { usePageTags } from '@/hooks';
import { WelcomeTitle } from '@/components';
const Index: FC = () => { const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'account_result' }); const { t } = useTranslation('translation', { keyPrefix: 'account_result' });
const siteName = siteInfoStore((state) => state.siteInfo.name);
const location = useLocation(); const location = useLocation();
usePageTags({ usePageTags({
title: t('account_activation', { keyPrefix: 'page_title' }), title: t('account_activation', { keyPrefix: 'page_title' }),
@ -17,9 +16,7 @@ const Index: FC = () => {
<Container className="pt-4 mt-2 mb-5"> <Container className="pt-4 mt-2 mb-5">
<Row className="justify-content-center"> <Row className="justify-content-center">
<Col lg={6}> <Col lg={6}>
<h3 className="text-center mt-3 mb-5"> <WelcomeTitle className="mt-3 mb-5" />
{t('page_title', { site_name: siteName })}
</h3>
{location.pathname?.includes('success') && ( {location.pathname?.includes('success') && (
<> <>
<p className="text-center">{t('success')}</p> <p className="text-center">{t('success')}</p>

View File

@ -2,22 +2,19 @@ import { FC, memo } from 'react';
import { Container, Col } from 'react-bootstrap'; import { Container, Col } from 'react-bootstrap';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { siteInfoStore } from '@/stores';
import { usePageTags } from '@/hooks'; import { usePageTags } from '@/hooks';
import { WelcomeTitle } from '@/components';
import SendEmail from './components/sendEmail'; import SendEmail from './components/sendEmail';
const Index: FC = () => { const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'change_email' }); const { t } = useTranslation('translation', { keyPrefix: 'change_email' });
const siteName = siteInfoStore((state) => state.siteInfo.name);
usePageTags({ usePageTags({
title: t('change_email', { keyPrefix: 'page_title' }), title: t('change_email', { keyPrefix: 'page_title' }),
}); });
return ( return (
<Container style={{ paddingTop: '4rem', paddingBottom: '6rem' }}> <Container style={{ paddingTop: '4rem', paddingBottom: '6rem' }}>
<h3 className="text-center mb-5"> <WelcomeTitle />
{t('page_title', { site_name: siteName })}
</h3>
<Col className="mx-auto" md={3}> <Col className="mx-auto" md={3}>
<SendEmail /> <SendEmail />
</Col> </Col>

View File

@ -11,12 +11,9 @@ import type {
ImgCodeRes, ImgCodeRes,
FormDataType, FormDataType,
} from '@/common/interface'; } from '@/common/interface';
import { Unactivate } from '@/components'; import { Unactivate, WelcomeTitle } from '@/components';
import { import { PluginOauth } from '@/plugins';
loggedUserInfoStore, import { loggedUserInfoStore, loginSettingStore } from '@/stores';
loginSettingStore,
siteInfoStore,
} from '@/stores';
import { guard, floppyNavigation, handleFormError } from '@/utils'; import { guard, floppyNavigation, handleFormError } from '@/utils';
import { login, checkImgCode } from '@/services'; import { login, checkImgCode } from '@/services';
import { PicAuthCodeModal } from '@/components/Modal'; import { PicAuthCodeModal } from '@/components/Modal';
@ -27,7 +24,6 @@ const Index: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [refresh, setRefresh] = useState(0); const [refresh, setRefresh] = useState(0);
const { name: siteName } = siteInfoStore((_) => _.siteInfo);
const { user: storeUser, update: updateUser } = loggedUserInfoStore((_) => _); const { user: storeUser, update: updateUser } = loggedUserInfoStore((_) => _);
const loginSetting = loginSettingStore((state) => state.login); const loginSetting = loginSettingStore((state) => state.login);
const [formData, setFormData] = useState<FormDataType>({ const [formData, setFormData] = useState<FormDataType>({
@ -165,7 +161,7 @@ const Index: React.FC = () => {
useEffect(() => { useEffect(() => {
const isInactive = searchParams.get('status'); const isInactive = searchParams.get('status');
if ((storeUser.id && storeUser.mail_status === 2) || isInactive) { if (storeUser.id && (storeUser.mail_status === 2 || isInactive)) {
setStep(2); setStep(2);
} }
}, []); }, []);
@ -174,11 +170,10 @@ const Index: React.FC = () => {
}); });
return ( return (
<Container style={{ paddingTop: '4rem', paddingBottom: '5rem' }}> <Container style={{ paddingTop: '4rem', paddingBottom: '5rem' }}>
<h3 className="text-center mb-5"> <WelcomeTitle />
{t('page_title', { site_name: siteName })}
</h3>
{step === 1 && ( {step === 1 && (
<Col className="mx-auto" md={3}> <Col className="mx-auto" md={3}>
<PluginOauth className="mb-5" />
<Form noValidate onSubmit={handleSubmit}> <Form noValidate onSubmit={handleSubmit}>
<Form.Group controlId="email" className="mb-3"> <Form.Group controlId="email" className="mb-3">
<Form.Label>{t('email.label')}</Form.Label> <Form.Label>{t('email.label')}</Form.Label>

View File

@ -0,0 +1,188 @@
import { FC, memo, useState, useEffect } from 'react';
import { Container, Col, Form, Button } from 'react-bootstrap';
import { useTranslation, Trans } from 'react-i18next';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { Modal, WelcomeTitle } from '@/components';
import type { FormDataType } from '@/common/interface';
import { usePageTags } from '@/hooks';
import { loggedUserInfoStore } from '@/stores';
import { oAuthBindEmail, getLoggedUserInfo } from '@/services';
import Storage from '@/utils/storage';
import { LOGGED_TOKEN_STORAGE_KEY } from '@/common/constants';
import { handleFormError } from '@/utils';
const Index: FC = () => {
const { t } = useTranslation('translation', {
keyPrefix: 'oauth_bind_email',
});
const navigate = useNavigate();
const [searchParams, setUrlSearchParams] = useSearchParams();
const updateUser = loggedUserInfoStore((state) => state.update);
const binding_key = searchParams.get('binding_key') || '';
const [showResult, setShowResult] = useState(false);
usePageTags({
title: t('confirm_email', { keyPrefix: 'page_title' }),
});
const [formData, setFormData] = useState<FormDataType>({
email: {
value: '',
isInvalid: false,
errorMsg: '',
},
});
const handleChange = (params: FormDataType) => {
setFormData({ ...formData, ...params });
};
const checkValidated = (): boolean => {
let bol = true;
if (!formData.email.value) {
bol = false;
formData.email = {
value: '',
isInvalid: true,
errorMsg: t('email.msg.empty'),
};
}
setFormData({
...formData,
});
return bol;
};
const getUserInfo = (token) => {
Storage.set(LOGGED_TOKEN_STORAGE_KEY, token);
getLoggedUserInfo().then((user) => {
updateUser(user);
setTimeout(() => {
navigate('/users/login?status=inactive', { replace: true });
}, 0);
});
};
const connectConfirm = () => {
Modal.confirm({
title: t('modal_title'),
content: t('modal_content'),
cancelText: t('modal_cancel'),
confirmText: t('modal_confirm'),
onConfirm: () => {
// send activation email
oAuthBindEmail({
binding_key,
email: formData.email.value,
must: true,
}).then((result) => {
if (result.access_token) {
getUserInfo(result.access_token);
} else {
searchParams.delete('binding_key');
setUrlSearchParams('');
setShowResult(true);
}
});
},
onCancel: () => {
setFormData({
email: {
value: '',
isInvalid: false,
errorMsg: '',
},
});
},
});
};
const handleSubmit = (event: any) => {
event.preventDefault();
event.stopPropagation();
if (!checkValidated()) {
return;
}
if (binding_key) {
oAuthBindEmail({
binding_key,
email: formData.email.value,
must: false,
})
.then((res) => {
if (res.email_exist_and_must_be_confirmed) {
connectConfirm();
}
if (res.access_token) {
getUserInfo(res.access_token);
}
})
.catch((err) => {
if (err.isError) {
const data = handleFormError(err, formData);
setFormData({ ...data });
}
});
}
};
useEffect(() => {
if (!binding_key) {
navigate('/', { replace: true });
}
}, []);
return (
<Container style={{ paddingTop: '4rem', paddingBottom: '6rem' }}>
<WelcomeTitle />
{showResult ? (
<Col md={6} className="mx-auto text-center">
<p>
<Trans
i18nKey="inactive.first"
values={{ mail: formData.email.value }}
components={{ bold: <strong /> }}
/>
</p>
<p>{t('info', { keyPrefix: 'inactive' })}</p>
</Col>
) : (
<Col className="mx-auto" md={3}>
<div className="text-center mb-5">{t('subtitle')}</div>
<Form noValidate onSubmit={handleSubmit} autoComplete="off">
<Form.Group controlId="email" className="mb-3">
<Form.Label>{t('email.label')}</Form.Label>
<Form.Control
required
type="email"
value={formData.email.value}
isInvalid={formData.email.isInvalid}
onChange={(e) => {
handleChange({
email: {
value: e.target.value,
isInvalid: false,
errorMsg: '',
},
});
}}
/>
<Form.Control.Feedback type="invalid">
{formData.email.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<div className="d-grid mb-3">
<Button variant="primary" type="submit">
{t('btn_update')}
</Button>
</div>
</Form>
</Col>
)}
</Container>
);
};
export default memo(Index);

View File

@ -0,0 +1,46 @@
import { FC, memo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { usePageTags, useLoginRedirect } from '@/hooks';
import { loggedUserInfoStore } from '@/stores';
import { getLoggedUserInfo } from '@/services';
import Storage from '@/utils/storage';
import { LOGGED_TOKEN_STORAGE_KEY } from '@/common/constants';
import { guard } from '@/utils';
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'page_title' });
const [searchParams] = useSearchParams();
const { loginRedirect } = useLoginRedirect();
const updateUser = loggedUserInfoStore((state) => state.update);
const navigate = useNavigate();
useEffect(() => {
const token = searchParams.get('access_token');
if (token) {
Storage.set(LOGGED_TOKEN_STORAGE_KEY, token);
getLoggedUserInfo().then((res) => {
updateUser(res);
const userStat = guard.deriveLoginState();
if (userStat.isNotActivated) {
// inactive
navigate('/users/login?status=inactive', { replace: true });
} else {
setTimeout(() => {
loginRedirect();
}, 0);
}
});
} else {
navigate('/', { replace: true });
}
}, []);
usePageTags({
title: t('oauth_callback'),
});
return null;
};
export default memo(Index);

View File

@ -1,5 +1,5 @@
import React, { FormEvent, MouseEvent, useEffect, useState } from 'react'; import React, { FormEvent, MouseEvent, useEffect, useState } from 'react';
import { Form, Button, Col } from 'react-bootstrap'; import { Form, Button } from 'react-bootstrap';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
@ -12,7 +12,7 @@ import {
useLegalTos, useLegalTos,
useLegalPrivacy, useLegalPrivacy,
} from '@/services'; } from '@/services';
import userStore from '@/stores/userInfo'; import userStore from '@/stores/loggedUserInfoStore';
import { handleFormError } from '@/utils'; import { handleFormError } from '@/utils';
interface Props { interface Props {
@ -70,6 +70,13 @@ const Index: React.FC<Props> = ({ callback }) => {
isInvalid: true, isInvalid: true,
errorMsg: t('name.msg.empty'), errorMsg: t('name.msg.empty'),
}; };
} else if (/[^a-z0-9\-._]/.test(name.value)) {
bol = false;
formData.name = {
value: name.value,
isInvalid: true,
errorMsg: t('name.msg.character'),
};
} else if ([...name.value].length > 30) { } else if ([...name.value].length > 30) {
bol = false; bol = false;
formData.name = { formData.name = {
@ -170,112 +177,111 @@ const Index: React.FC<Props> = ({ callback }) => {
}, []); }, []);
return ( return (
<> <>
<Col className="mx-auto" md={3}> <Form noValidate onSubmit={handleSubmit} autoComplete="off">
<Form noValidate onSubmit={handleSubmit} autoComplete="off"> <Form.Group controlId="name" className="mb-3">
<Form.Group controlId="name" className="mb-3"> <Form.Label>{t('name.label')}</Form.Label>
<Form.Label>{t('name.label')}</Form.Label> <Form.Control
<Form.Control autoComplete="off"
autoComplete="off" required
required type="text"
type="text" isInvalid={formData.name.isInvalid}
isInvalid={formData.name.isInvalid} value={formData.name.value}
value={formData.name.value} onChange={(e) =>
onChange={(e) => handleChange({
handleChange({ name: {
name: { value: e.target.value,
value: e.target.value, isInvalid: false,
isInvalid: false, errorMsg: '',
errorMsg: '', },
}, })
}) }
} />
/> <Form.Control.Feedback type="invalid">
<Form.Control.Feedback type="invalid"> {formData.name.errorMsg}
{formData.name.errorMsg} </Form.Control.Feedback>
</Form.Control.Feedback> </Form.Group>
</Form.Group> <Form.Group controlId="email" className="mb-3">
<Form.Group controlId="email" className="mb-3"> <Form.Label>{t('email.label')}</Form.Label>
<Form.Label>{t('email.label')}</Form.Label> <Form.Control
<Form.Control autoComplete="off"
autoComplete="off" required
required type="e_mail"
type="e_mail" isInvalid={formData.e_mail.isInvalid}
isInvalid={formData.e_mail.isInvalid} value={formData.e_mail.value}
value={formData.e_mail.value} onChange={(e) =>
onChange={(e) => handleChange({
handleChange({ e_mail: {
e_mail: { value: e.target.value,
value: e.target.value, isInvalid: false,
isInvalid: false, errorMsg: '',
errorMsg: '', },
}, })
}) }
} />
/> <Form.Control.Feedback type="invalid">
<Form.Control.Feedback type="invalid"> {formData.e_mail.errorMsg}
{formData.e_mail.errorMsg} </Form.Control.Feedback>
</Form.Control.Feedback> </Form.Group>
</Form.Group>
<Form.Group controlId="password" className="mb-3"> <Form.Group controlId="password" className="mb-3">
<Form.Label>{t('password.label')}</Form.Label> <Form.Label>{t('password.label')}</Form.Label>
<Form.Control <Form.Control
autoComplete="off" autoComplete="off"
required required
type="password" type="password"
maxLength={32} maxLength={32}
isInvalid={formData.pass.isInvalid} isInvalid={formData.pass.isInvalid}
value={formData.pass.value} value={formData.pass.value}
onChange={(e) => onChange={(e) =>
handleChange({ handleChange({
pass: { pass: {
value: e.target.value, value: e.target.value,
isInvalid: false, isInvalid: false,
errorMsg: '', errorMsg: '',
}, },
}) })
} }
/> />
<Form.Control.Feedback type="invalid"> <Form.Control.Feedback type="invalid">
{formData.pass.errorMsg} {formData.pass.errorMsg}
</Form.Control.Feedback> </Form.Control.Feedback>
</Form.Group> </Form.Group>
<div className="d-grid"> <div className="d-grid">
<Button variant="primary" type="submit"> <Button variant="primary" type="submit">
{t('signup', { keyPrefix: 'btns' })} {t('signup', { keyPrefix: 'btns' })}
</Button> </Button>
</div>
</Form>
<div className="text-center fs-14 mt-3">
<Trans i18nKey="login.agreements" ns="translation">
By registering, you agree to the
<Link
to="/privacy"
onClick={(evt) => {
argumentClick(evt, 'privacy');
}}
target="_blank">
privacy policy
</Link>
and
<Link
to="/tos"
onClick={(evt) => {
argumentClick(evt, 'tos');
}}
target="_blank">
terms of service
</Link>
.
</Trans>
</div> </div>
<div className="text-center mt-5"> </Form>
<Trans i18nKey="login.info_login" ns="translation"> <div className="text-center fs-14 mt-3">
Already have an account? <Link to="/users/login">Log in</Link> <Trans i18nKey="login.agreements" ns="translation">
</Trans> By registering, you agree to the
</div> <Link
</Col> to="/privacy"
onClick={(evt) => {
argumentClick(evt, 'privacy');
}}
target="_blank">
privacy policy
</Link>
and
<Link
to="/tos"
onClick={(evt) => {
argumentClick(evt, 'tos');
}}
target="_blank">
terms of service
</Link>
.
</Trans>
</div>
<div className="text-center mt-5">
<Trans i18nKey="login.info_login" ns="translation">
Already have an account? <Link to="/users/login">Log in</Link>
</Trans>
</div>
<PicAuthCodeModal <PicAuthCodeModal
visible={showModal} visible={showModal}
data={{ data={{

View File

@ -1,17 +1,16 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Container } from 'react-bootstrap'; import { Container, Col } from 'react-bootstrap';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { usePageTags } from '@/hooks'; import { usePageTags } from '@/hooks';
import { Unactivate } from '@/components'; import { Unactivate, WelcomeTitle } from '@/components';
import { siteInfoStore } from '@/stores'; import { PluginOauth } from '@/plugins';
import SignUpForm from './components/SignUpForm'; import SignUpForm from './components/SignUpForm';
const Index: React.FC = () => { const Index: React.FC = () => {
const [showForm, setShowForm] = useState(true); const [showForm, setShowForm] = useState(true);
const { t } = useTranslation('translation', { keyPrefix: 'login' }); const { t } = useTranslation('translation', { keyPrefix: 'login' });
const { name: siteName } = siteInfoStore((_) => _.siteInfo);
const onStep = () => { const onStep = () => {
setShowForm((bol) => !bol); setShowForm((bol) => !bol);
}; };
@ -20,11 +19,13 @@ const Index: React.FC = () => {
}); });
return ( return (
<Container style={{ paddingTop: '4rem', paddingBottom: '5rem' }}> <Container style={{ paddingTop: '4rem', paddingBottom: '5rem' }}>
<h3 className="text-center mb-5"> <WelcomeTitle />
{t('page_title', { site_name: siteName })}
</h3>
{showForm ? ( {showForm ? (
<SignUpForm callback={onStep} /> <Col className="mx-auto" md={3}>
<PluginOauth className="mb-5" />
<SignUpForm callback={onStep} />
</Col>
) : ( ) : (
<Unactivate visible={!showForm} /> <Unactivate visible={!showForm} />
)} )}

View File

@ -4,20 +4,37 @@ import { useTranslation } from 'react-i18next';
import type * as Type from '@/common/interface'; import type * as Type from '@/common/interface';
import { useToast } from '@/hooks'; import { useToast } from '@/hooks';
import { getLoggedUserInfo, changeEmail } from '@/services'; import { getLoggedUserInfo, changeEmail, checkImgCode } from '@/services';
import { handleFormError } from '@/utils'; import { handleFormError } from '@/utils';
import { PicAuthCodeModal } from '@/components/Modal';
const Index: FC = () => { const Index: FC = () => {
const { t } = useTranslation('translation', { const { t } = useTranslation('translation', {
keyPrefix: 'settings.account', keyPrefix: 'settings.account',
}); });
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
const [showModal, setModalState] = useState(false);
const [imgCode, setImgCode] = useState<Type.ImgCodeRes>({
captcha_id: '',
captcha_img: '',
verify: false,
});
const [formData, setFormData] = useState<Type.FormDataType>({ const [formData, setFormData] = useState<Type.FormDataType>({
e_mail: { e_mail: {
value: '', value: '',
isInvalid: false, isInvalid: false,
errorMsg: '', errorMsg: '',
}, },
pass: {
value: '',
isInvalid: false,
errorMsg: '',
},
captcha_code: {
value: '',
isInvalid: false,
errorMsg: '',
},
}); });
const [userInfo, setUserInfo] = useState<Type.UserInfoRes>(); const [userInfo, setUserInfo] = useState<Type.UserInfoRes>();
const toast = useToast(); const toast = useToast();
@ -27,13 +44,21 @@ const Index: FC = () => {
}); });
}, []); }, []);
const getImgCode = () => {
checkImgCode({
action: 'e_mail',
}).then((res) => {
setImgCode(res);
});
};
const handleChange = (params: Type.FormDataType) => { const handleChange = (params: Type.FormDataType) => {
setFormData({ ...formData, ...params }); setFormData({ ...formData, ...params });
}; };
const checkValidated = (): boolean => { const checkValidated = (): boolean => {
let bol = true; let bol = true;
const { e_mail } = formData; const { e_mail, pass } = formData;
if (!e_mail.value) { if (!e_mail.value) {
bol = false; bol = false;
@ -43,41 +68,86 @@ const Index: FC = () => {
errorMsg: t('email.msg'), errorMsg: t('email.msg'),
}; };
} }
if (!pass.value) {
bol = false;
formData.pass = {
value: '',
isInvalid: true,
errorMsg: t('pass.msg'),
};
}
setFormData({ setFormData({
...formData, ...formData,
}); });
return bol; return bol;
}; };
const initFormData = () => {
setFormData({
e_mail: {
value: '',
isInvalid: false,
errorMsg: '',
},
pass: {
value: '',
isInvalid: false,
errorMsg: '',
},
captcha_code: {
value: '',
isInvalid: false,
errorMsg: '',
},
});
};
const postEmail = () => {
const params: any = {
e_mail: formData.e_mail.value,
pass: formData.pass.value,
};
if (imgCode.verify) {
params.captcha_code = formData.captcha_code.value;
params.captcha_id = imgCode.captcha_id;
}
changeEmail(params)
.then(() => {
setStep(1);
setModalState(false);
toast.onShow({
msg: t('change_email_info'),
variant: 'warning',
});
initFormData();
})
.catch((err) => {
if (err.isError) {
const data = handleFormError(err, formData);
setFormData({ ...data });
if (!err.list.find((v) => v.error_field.indexOf('captcha') >= 0)) {
setModalState(false);
}
}
})
.finally(() => {
getImgCode();
});
};
const handleSubmit = (event: FormEvent) => { const handleSubmit = (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (!checkValidated()) { if (!checkValidated()) {
return; return;
} }
changeEmail({
e_mail: formData.e_mail.value, if (imgCode.verify) {
}) setModalState(true);
.then(() => { }
setStep(1); postEmail();
toast.onShow({
msg: t('change_email_info'),
variant: 'warning',
});
setFormData({
e_mail: {
value: '',
isInvalid: false,
errorMsg: '',
},
});
})
.catch((err) => {
if (err.isError) {
const data = handleFormError(err, formData);
setFormData({ ...data });
}
});
}; };
return ( return (
@ -96,13 +166,41 @@ const Index: FC = () => {
/> />
</Form.Group> </Form.Group>
<Button variant="outline-secondary" onClick={() => setStep(2)}> <Button
variant="outline-secondary"
onClick={() => {
setStep(2);
getImgCode();
}}>
{t('change_email_btn')} {t('change_email_btn')}
</Button> </Button>
</Form> </Form>
)} )}
{step === 2 && ( {step === 2 && (
<Form noValidate onSubmit={handleSubmit}> <Form noValidate onSubmit={handleSubmit}>
<Form.Group controlId="currentPass" className="mb-3">
<Form.Label>{t('pass.label')}</Form.Label>
<Form.Control
autoComplete="new-password"
required
type="password"
maxLength={32}
isInvalid={formData.pass.isInvalid}
onChange={(e) =>
handleChange({
pass: {
value: e.target.value,
isInvalid: false,
errorMsg: '',
},
})
}
/>
<Form.Control.Feedback type="invalid">
{formData.pass.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="newEmail" className="mb-3"> <Form.Group controlId="newEmail" className="mb-3">
<Form.Label>{t('email.label')}</Form.Label> <Form.Label>{t('email.label')}</Form.Label>
<Form.Control <Form.Control
@ -126,6 +224,7 @@ const Index: FC = () => {
{formData.e_mail.errorMsg} {formData.e_mail.errorMsg}
</Form.Control.Feedback> </Form.Control.Feedback>
</Form.Group> </Form.Group>
<div> <div>
<Button type="submit" variant="primary" className="me-2"> <Button type="submit" variant="primary" className="me-2">
{t('save', { keyPrefix: 'btns' })} {t('save', { keyPrefix: 'btns' })}
@ -137,6 +236,18 @@ const Index: FC = () => {
</div> </div>
</Form> </Form>
)} )}
<PicAuthCodeModal
visible={showModal}
data={{
captcha: formData.captcha_code,
imgCode,
}}
handleCaptcha={handleChange}
clickSubmit={postEmail}
refreshImgCode={getImgCode}
onClose={() => setModalState(false)}
/>
</div> </div>
); );
}; };

View File

@ -2,15 +2,19 @@ import React, { FC, FormEvent, useState } from 'react';
import { Form, Button } from 'react-bootstrap'; import { Form, Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import classname from 'classnames';
import { useToast } from '@/hooks'; import { useToast } from '@/hooks';
import type { FormDataType } from '@/common/interface'; import type { FormDataType } from '@/common/interface';
import { modifyPassword } from '@/services'; import { modifyPassword } from '@/services';
import { handleFormError } from '@/utils'; import { handleFormError } from '@/utils';
import { loggedUserInfoStore } from '@/stores';
const Index: FC = () => { const Index: FC = () => {
const { t } = useTranslation('translation', { const { t } = useTranslation('translation', {
keyPrefix: 'settings.account', keyPrefix: 'settings.account',
}); });
const { user } = loggedUserInfoStore();
const [showForm, setFormState] = useState(false); const [showForm, setFormState] = useState(false);
const toast = useToast(); const toast = useToast();
const [formData, setFormData] = useState<FormDataType>({ const [formData, setFormData] = useState<FormDataType>({
@ -42,8 +46,7 @@ const Index: FC = () => {
const checkValidated = (): boolean => { const checkValidated = (): boolean => {
let bol = true; let bol = true;
const { old_pass, pass, pass2 } = formData; const { old_pass, pass, pass2 } = formData;
if (!old_pass.value && user.have_password) {
if (!old_pass.value) {
bol = false; bol = false;
formData.old_pass = { formData.old_pass = {
value: '', value: '',
@ -130,14 +133,15 @@ const Index: FC = () => {
<div className="mt-5"> <div className="mt-5">
{showForm ? ( {showForm ? (
<Form noValidate onSubmit={handleSubmit}> <Form noValidate onSubmit={handleSubmit}>
<Form.Group controlId="oldPass" className="mb-3"> <Form.Group
controlId="oldPass"
className={classname('mb-3', user.have_password ? '' : 'd-none')}>
<Form.Label>{t('current_pass.label')}</Form.Label> <Form.Label>{t('current_pass.label')}</Form.Label>
<Form.Control <Form.Control
autoComplete="off" autoComplete="off"
required required
type="password" type="password"
placeholder="" placeholder=""
// value={formData.password.value}
isInvalid={formData.old_pass.isInvalid} isInvalid={formData.old_pass.isInvalid}
onChange={(e) => onChange={(e) =>
handleChange({ handleChange({
@ -161,7 +165,6 @@ const Index: FC = () => {
required required
type="password" type="password"
maxLength={32} maxLength={32}
// value={formData.password.value}
isInvalid={formData.pass.isInvalid} isInvalid={formData.pass.isInvalid}
onChange={(e) => onChange={(e) =>
handleChange({ handleChange({
@ -185,7 +188,6 @@ const Index: FC = () => {
required required
type="password" type="password"
maxLength={32} maxLength={32}
// value={formData.password.value}
isInvalid={formData.pass2.isInvalid} isInvalid={formData.pass2.isInvalid}
onChange={(e) => onChange={(e) =>
handleChange({ handleChange({

View File

@ -0,0 +1,80 @@
import { memo } from 'react';
import { Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components';
import { useOauthConnectorInfoByUser, userOauthUnbind } from '@/services';
import { useToast } from '@/hooks';
import { base64ToSvg } from '@/utils';
import Storage from '@/utils/storage';
import { REDIRECT_PATH_STORAGE_KEY } from '@/common/constants';
const Index = () => {
const { data, mutate } = useOauthConnectorInfoByUser();
const toast = useToast();
const { t } = useTranslation('translation', {
keyPrefix: 'settings.my_logins',
});
const { t: t2 } = useTranslation('translation', {
keyPrefix: 'plugins.oauth',
});
const deleteLogins = (e, item) => {
if (!item.binding) {
Storage.set(REDIRECT_PATH_STORAGE_KEY, window.location.pathname);
return;
}
e.preventDefault();
Modal.confirm({
title: t('modal_title'),
content: t('modal_content'),
confirmBtnVariant: 'danger',
confirmText: t('modal_confirm_btn'),
onConfirm: () => {
userOauthUnbind({ external_id: item.external_id }).then(() => {
mutate();
toast.onShow({
msg: t('remove_success'),
variant: 'success',
});
});
},
});
};
if (!data?.length) return null;
return (
<div className="mt-5">
<div className="form-label">{t('title')}</div>
<small className="form-text mt-0">{t('label')}</small>
<div className="d-grid gap-2 mt-3">
{data?.map((item) => {
return (
<div key={item.name}>
<Button
variant={item.binding ? 'outline-danger' : 'outline-secondary'}
href={item.link}
onClick={(e) => deleteLogins(e, item)}>
<span
dangerouslySetInnerHTML={{
__html: base64ToSvg(item.icon),
}}
/>
<span>
{t2(item.binding ? 'remove' : 'connect', {
auth_name: item.name,
})}
</span>
</Button>
</div>
);
})}
</div>
</div>
);
};
export default memo(Index);

View File

@ -0,0 +1,5 @@
import ModifyEmail from './ModifyEmail';
import ModifyPassword from './ModifyPass';
import MyLogins from './MyLogins';
export { ModifyEmail, ModifyPassword, MyLogins };

View File

@ -1,8 +1,7 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import ModifyEmail from './components/ModifyEmail'; import { ModifyEmail, ModifyPassword, MyLogins } from './components';
import ModifyPassword from './components/ModifyPass';
const Index = () => { const Index = () => {
const { t } = useTranslation('translation', { const { t } = useTranslation('translation', {
@ -13,6 +12,7 @@ const Index = () => {
<h3 className="mb-4">{t('heading')}</h3> <h3 className="mb-4">{t('heading')}</h3>
<ModifyEmail /> <ModifyEmail />
<ModifyPassword /> <ModifyPassword />
<MyLogins />
</> </>
); );
}; };

View File

@ -17,7 +17,6 @@ const Index = () => {
notice_switch: { notice_switch: {
type: 'boolean', type: 'boolean',
title: t('email.label'), title: t('email.label'),
label: t('email.radio'),
default: false, default: false,
}, },
}, },
@ -25,6 +24,9 @@ const Index = () => {
const uiSchema: UISchema = { const uiSchema: UISchema = {
notice_switch: { notice_switch: {
'ui:widget': 'switch', 'ui:widget': 'switch',
'ui:options': {
label: t('email.radio'),
},
}, },
}; };
const [formData, setFormData] = useState<FormDataType>(initFormData(schema)); const [formData, setFormData] = useState<FormDataType>(initFormData(schema));

View File

@ -0,0 +1,37 @@
import { memo, FC } from 'react';
import { Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import classnames from 'classnames';
import { useGetStartUseOauthConnector } from '@/services';
import { base64ToSvg } from '@/utils';
interface Props {
className?: string;
}
const Index: FC<Props> = ({ className }) => {
const { t } = useTranslation('translation', { keyPrefix: 'plugins.oauth' });
const { data } = useGetStartUseOauthConnector();
if (!data?.length) return null;
return (
<div className={classnames('d-grid gap-2', className)}>
{data?.map((item) => {
return (
<Button variant="outline-secondary" href={item.link} key={item.name}>
<span
dangerouslySetInnerHTML={{
__html: base64ToSvg(item.icon),
}}
/>
<span>{t('connect', { auth_name: item.name })}</span>
</Button>
);
})}
</div>
);
};
export default memo(Index);

3
ui/src/plugins/index.ts Normal file
View File

@ -0,0 +1,3 @@
import PluginOauth from './PluginOauth';
export { PluginOauth };

View File

@ -26,6 +26,10 @@ const routes: RouteNode[] = [
{ {
path: '/', path: '/',
page: 'pages/Layout', page: 'pages/Layout',
loader: async () => {
await guard.setupApp();
return null;
},
guard: () => { guard: () => {
const gr = guard.shouldLoginRequired(); const gr = guard.shouldLoginRequired();
if (!gr.ok) { if (!gr.ok) {
@ -223,6 +227,14 @@ const routes: RouteNode[] = [
return guard.forbidden(); return guard.forbidden();
}, },
}, },
{
path: '/users/confirm-email',
page: 'pages/Users/OauthBindEmail',
},
{
path: '/users/oauth',
page: 'pages/Users/OauthCallback',
},
{ {
path: '/posts/:qid/timeline', path: '/posts/:qid/timeline',
page: 'pages/Timeline', page: 'pages/Timeline',
@ -324,6 +336,14 @@ const routes: RouteNode[] = [
path: 'login', path: 'login',
page: 'pages/Admin/Login', page: 'pages/Admin/Login',
}, },
{
path: 'installed_plugins',
page: 'pages/Admin/Plugins/Installed',
},
{
path: ':slug_name',
page: 'pages/Admin/Plugins/Config',
},
], ],
}, },
// for review // for review

View File

@ -4,3 +4,4 @@ export * from './question';
export * from './settings'; export * from './settings';
export * from './users'; export * from './users';
export * from './dashboard'; export * from './dashboard';
export * from './plugins';

View File

@ -0,0 +1,41 @@
import qs from 'qs';
import useSWR from 'swr';
import type * as Types from '@/common/interface';
import request from '@/utils/request';
export const useQueryPlugins = (params) => {
const apiUrl = `/answer/admin/api/plugins?${qs.stringify(params)}`;
const { data, error, mutate } = useSWR<any[], Error>(
apiUrl,
request.instance.get,
);
return {
data,
isLoading: !data && !error,
error,
mutate,
};
};
export const updatePluginStatus = (params) => {
return request.put('/answer/admin/api/plugin/status', params);
};
export const useQueryPluginConfig = (params) => {
const apiUrl = `/answer/admin/api/plugin/config?${qs.stringify(params)}`;
const { data, error, mutate } = useSWR<Types.PluginConfig, Error>(
apiUrl,
request.instance.get,
);
return {
data,
isLoading: !data && !error,
error,
mutate,
};
};
export const updatePluginConfig = (params) => {
return request.put('/answer/admin/api/plugin/config', params);
};

View File

@ -0,0 +1,36 @@
import useSWR from 'swr';
import request from '@/utils/request';
import type * as Type from '@/common/interface';
export const oAuthBindEmail = (data: Type.OauthBindEmailReq) => {
return request.post('/answer/api/v1/connector/binding/email', data);
};
export const useOauthConnectorInfoByUser = () => {
const { data, error, mutate } = useSWR<Type.UserOauthConnectorItem[]>(
'/answer/api/v1/connector/user/info',
request.instance.get,
);
return {
data,
mutate,
isLoading: !data && !error,
error,
};
};
export const userOauthUnbind = (data: { external_id: string }) => {
return request.delete('/answer/api/v1/connector/user/unbinding', data);
};
export const useGetStartUseOauthConnector = () => {
const { data, error } = useSWR<Type.OauthConnectorItem[]>(
'/answer/api/v1/connector/info',
request.instance.get,
);
return {
data,
error,
};
};

View File

@ -9,3 +9,4 @@ export * from './legal';
export * from './timeline'; export * from './timeline';
export * from './revision'; export * from './revision';
export * from './user'; export * from './user';
export * from './Oauth';

View File

@ -249,7 +249,7 @@ export const closeQuestion = (params: {
return request.put('/answer/api/v1/question/status', params); return request.put('/answer/api/v1/question/status', params);
}; };
export const changeEmail = (params: { e_mail: string }) => { export const changeEmail = (params: { e_mail: string; pass?: string }) => {
return request.post('/answer/api/v1/user/email/change/code', params); return request.post('/answer/api/v1/user/email/change/code', params);
}; };

View File

@ -2,7 +2,7 @@ import loginSettingStore from '@/stores/loginSetting';
import seoSettingStore from '@/stores/seoSetting'; import seoSettingStore from '@/stores/seoSetting';
import toastStore from './toast'; import toastStore from './toast';
import loggedUserInfoStore from './userInfo'; import loggedUserInfoStore from './loggedUserInfoStore';
import siteInfoStore from './siteInfo'; import siteInfoStore from './siteInfo';
import interfaceStore from './interface'; import interfaceStore from './interface';
import brandingStore from './branding'; import brandingStore from './branding';

View File

@ -26,12 +26,17 @@ const initUser: UserInfoRes = {
status: '', status: '',
mail_status: 1, mail_status: 1,
language: 'Default', language: 'Default',
is_admin: false,
have_password: true,
role_id: 1, role_id: 1,
}; };
const loggedUserInfoStore = create<UserInfoStore>((set) => ({ const loggedUserInfoStore = create<UserInfoStore>((set) => ({
user: initUser, user: initUser,
update: (params) => { update: (params) => {
if (typeof params !== 'object' || !params) {
return;
}
if (!params?.language) { if (!params?.language) {
params.language = 'Default'; params.language = 'Default';
} }

View File

@ -233,6 +233,26 @@ function diffText(newText: string, oldText?: string): string {
return result.join(''); return result.join('');
} }
function base64ToSvg(base64: string) {
// base64 to svg xml
const svgxml = atob(base64);
// svg add class btnSvg
const parser = new DOMParser();
const doc = parser.parseFromString(svgxml, 'image/svg+xml');
const svg = doc.querySelector('svg');
let str = '';
if (svg) {
svg.classList.add('btnSvg');
svg.classList.add('me-2');
// transform svg to string
const serializer = new XMLSerializer();
str = serializer.serializeToString(doc);
}
return str;
}
export { export {
thousandthDivision, thousandthDivision,
formatCount, formatCount,
@ -248,4 +268,5 @@ export {
labelStyle, labelStyle,
handleFormError, handleFormError,
diffText, diffText,
base64ToSvg,
}; };

View File

@ -11,10 +11,10 @@ import {
loginToContinueStore, loginToContinueStore,
} from '@/stores'; } from '@/stores';
import { RouteAlias } from '@/router/alias'; import { RouteAlias } from '@/router/alias';
import Storage from '@/utils/storage';
import { LOGGED_USER_STORAGE_KEY } from '@/common/constants'; import { LOGGED_USER_STORAGE_KEY } from '@/common/constants';
import { setupAppLanguage, setupAppTimeZone } from '@/utils/localize';
import Storage from './storage';
import { setupAppLanguage, setupAppTimeZone } from './localize';
import { floppyNavigation } from './floppyNavigation'; import { floppyNavigation } from './floppyNavigation';
type TLoginState = { type TLoginState = {
@ -98,7 +98,6 @@ export const pullLoggedUser = async (forceRePull = false) => {
if (Date.now() - dedupeTimestamp < 1000 * 10) { if (Date.now() - dedupeTimestamp < 1000 * 10) {
return; return;
} }
dedupeTimestamp = Date.now(); dedupeTimestamp = Date.now();
const loggedUserInfo = await getLoggedUserInfo().catch((ex) => { const loggedUserInfo = await getLoggedUserInfo().catch((ex) => {
dedupeTimestamp = 0; dedupeTimestamp = 0;
@ -298,6 +297,10 @@ export const tryLoggedAndActivated = () => {
return gr; return gr;
}; };
/**
* Initialize app configuration
*/
let appInitialized = false;
export const initAppSettingsStore = async () => { export const initAppSettingsStore = async () => {
const appSettings = await getAppSettings(); const appSettings = await getAppSettings();
if (appSettings) { if (appSettings) {
@ -314,24 +317,18 @@ export const initAppSettingsStore = async () => {
} }
}; };
export const shouldInitAppFetchData = () => {
if (isIgnoredPath('/install') && window.location.pathname === '/install') {
return false;
}
return true;
};
export const setupApp = async () => { export const setupApp = async () => {
if (appInitialized) {
return;
}
/** /**
* WARN: * WARN:
* 1. must pre init logged user info for router guard * 1. must pre init logged user info for router guard
* 2. must pre init app settings for app render * 2. must pre init app settings for app render
*/ */
// TODO: optimize `initAppSettingsStore` by server render // TODO: optimize `initAppSettingsStore` by server render
if (shouldInitAppFetchData()) { await Promise.allSettled([pullLoggedUser(), initAppSettingsStore()]);
await Promise.allSettled([pullLoggedUser(), initAppSettingsStore()]); setupAppLanguage();
await setupAppLanguage(); setupAppTimeZone();
setupAppTimeZone(); appInitialized = true;
}
}; };

View File

@ -4,7 +4,7 @@ export { floppyNavigation } from './floppyNavigation';
export { default as storageExpires } from './storageWithExpires'; export { default as storageExpires } from './storageWithExpires';
export { default as SaveDraft } from './saveDraft'; export { default as SaveDraft } from './saveDraft';
export * as guard from './guard';
export * as localize from './localize';
export * from './common'; export * from './common';
export * from './color'; export * from './color';
export * as localize from './localize';
export * as guard from './guard';

View File

@ -9,13 +9,14 @@ import {
DEFAULT_LANG, DEFAULT_LANG,
LANG_RESOURCE_STORAGE_KEY, LANG_RESOURCE_STORAGE_KEY,
} from '@/common/constants'; } from '@/common/constants';
import { Storage } from '@/utils';
import { import {
getAdminLanguageOptions, getAdminLanguageOptions,
getLanguageConfig, getLanguageConfig,
getLanguageOptions, getLanguageOptions,
} from '@/services'; } from '@/services';
import Storage from './storage';
export const loadLanguageOptions = async (forAdmin = false) => { export const loadLanguageOptions = async (forAdmin = false) => {
const languageOptions = forAdmin const languageOptions = forAdmin
? await getAdminLanguageOptions() ? await getAdminLanguageOptions()