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
maintenance: Website Maintenance
users: Users
oauth_callback: Processing
http_404: HTTP Error 404
http_50X: HTTP Error 500
http_403: HTTP Error 403
@ -658,7 +659,6 @@ ui:
msg:
empty: Cannot be empty.
login:
page_title: Welcome to {{site_name}}
login_to_continue: Log in to continue
info_sign: Don't have an account? <1>Sign up</1>
info_login: Already have an account? <1>Log in</1>
@ -669,6 +669,7 @@ ui:
msg:
empty: Name cannot be empty.
range: Name up to 30 characters.
character: 'Must use the character set "a-z", "0-9", " - . _"'
email:
label: Email
msg:
@ -689,7 +690,6 @@ ui:
msg:
empty: Email cannot be empty.
change_email:
page_title: Welcome to {{site_name}}
btn_cancel: Cancel
btn_update: Update email address
send_success: >-
@ -699,6 +699,17 @@ ui:
label: New Email
msg:
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:
page_title: Password Reset
btn_name: Reset my password
@ -770,6 +781,9 @@ ui:
email:
label: Email
msg: Email cannot be empty.
pass:
label: Current Password
msg: Password cannot be empty.
password_title: Password
current_pass:
label: Current Password
@ -786,6 +800,13 @@ ui:
lang:
label: Interface Language
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:
update: update success
update_password: Password changed successfully.
@ -907,7 +928,6 @@ ui:
modal_confirm:
title: Error...
account_result:
page_title: Welcome to {{site_name}}
success: Your new account is confirmed; you will be redirected to the home page.
link: Continue to homepage
invalid: >-
@ -1034,6 +1054,7 @@ ui:
admin_name:
label: Name
msg: Name cannot be empty.
character: 'Must use the character set "a-z", "0-9", " - . _"'
admin_password:
label: Password
text: >-
@ -1097,6 +1118,14 @@ ui:
themes: Themes
css-html: CSS/HTML
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_header:
title: Admin
@ -1411,6 +1440,24 @@ ui:
title: Private
label: Login required
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:
optional: (optional)
@ -1418,6 +1465,8 @@ ui:
invalid: is invalid
btn_submit: Save
not_found_props: "Required property {{ key }} not found."
select: Select
page_review:
review: Review
proposed: proposed

View File

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

View File

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

View File

@ -1,3 +1,5 @@
import { UIOptions, UIWidget } from '@/components/SchemaForm';
export interface FormValue<T = any> {
value: T;
isInvalid: boolean;
@ -136,6 +138,7 @@ export interface UserInfoRes extends UserInfoBase {
mail_status: number;
language: string;
e_mail?: string;
have_password: boolean;
[prop: string]: any;
}
@ -269,6 +272,11 @@ export type UserFilterBy =
| 'suspended'
| 'deleted';
export type InstalledPluginsFilterBy =
| 'all'
| 'active'
| 'inactive'
| 'outdated';
/**
* @description interface for Flags
*/
@ -527,6 +535,44 @@ export interface User {
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 {
id: string;
operation: 'pin' | 'unpin' | 'hide' | 'show';

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { loginToContinueStore, siteInfoStore } from '@/stores';
import { WelcomeTitle } from '@/components';
interface IProps {
visible: boolean;
@ -32,7 +33,7 @@ const Index: React.FC<IProps> = ({ visible = false }) => {
</Modal.Header>
<Modal.Body className="p-5">
<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>
</div>
<div className="d-grid gap-2">

View File

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

View File

@ -1,19 +1,21 @@
# SchemaForm User Guide
---
sidebar_position: 0
---
# Schema Form
## 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
### Basic Usage
```tsx
import React from 'react';
import React, { useState } from 'react';
import { SchemaForm, initFormData, JSONSchema, UISchema } from '@/components';
const schema: JSONSchema = {
type: 'object',
title: 'General',
properties: {
name: {
type: 'string',
@ -24,11 +26,11 @@ const schema: JSONSchema = {
title: 'Age',
},
sex: {
type: 'boolean',
type: 'string',
title: 'sex',
enum: [1, 2],
enumNames: ['male', 'female'],
}
},
},
};
@ -39,41 +41,193 @@ const uiSchema: UISchema = {
age: {
'ui:widget': 'input',
'ui:options': {
type: 'number'
}
type: 'number',
},
},
sex: {
'ui:widget': 'radio',
}
},
};
// form component
const Form = () => {
const [formData, setFormData] = useState(initFormData(schema));
const handleChange = (data) => {
setFormData(data);
};
return (
<SchemaForm
schema={schema}
uiSchema={uiSchema}
formData={formData}
onChange={console.log}
onChange={handleChange}
/>
);
};
export default Form;
```
## Props
| Property | Description | Type | Default |
| -------- | ---------------------------------------- | ----------------------------------------- | ------- |
| schema | JSON schema | [JSONSchema](index.tsx#L9) | - |
| uiSchema | UI schema | [UISchema](index.tsx#L24) | - |
| formData | Form data | [FormData](index.tsx#L66) | - |
| onChange | Callback function when form data changes | (data: [FormData](index.tsx#L66)) => void | - |
| -------- | ---------------------------------------- | ------------------------------------- | ------- |
| schema | Describe the form structure with schema | [JSONSchema](#json-schema) | - |
| uiSchema | Describe the properties of the field | [UISchema](#uischema) | - |
| formData | Describe form data | [FormData](#formdata) | - |
| onChange | Callback function when form data changes | (data: [FormData](#formdata)) => 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
- [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/)

View File

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

View File

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

View File

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

View File

@ -315,3 +315,13 @@ img:not(a img, img.broken) {
.bg-fade-out {
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 { guard } from '@/utils';
import App from './App';
import './index.scss';
@ -12,13 +10,8 @@ const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement,
);
async function bootstrapApp() {
await guard.setupApp();
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
}
bootstrapApp();

View File

@ -2,6 +2,7 @@ import { FC } from 'react';
import { Row, Col } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { siteInfoStore } from '@/stores';
import { useDashBoard } from '@/services';
import {
@ -13,6 +14,7 @@ import {
const Dashboard: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'admin.dashboard' });
const { name: siteName } = siteInfoStore((_) => _.siteInfo);
const { data } = useDashBoard();
if (!data) {
@ -22,7 +24,7 @@ const Dashboard: FC = () => {
return (
<>
<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>
<Col lg={6}>
<Statistics data={data.info} />

View File

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

View File

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

View File

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

View File

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

View File

@ -27,7 +27,6 @@ const Index: FC = () => {
required_tag: {
type: 'boolean',
title: t('required_tag.title'),
label: t('required_tag.label'),
description: t('required_tag.text'),
},
reserved_tags: {
@ -46,6 +45,9 @@ const Index: FC = () => {
},
required_tag: {
'ui:widget': 'switch',
'ui:options': {
label: t('required_tag.label'),
},
},
reserved_tags: {
'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 { 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 { AccordionNav } from '@/components';
import { ADMIN_NAV_MENUS } from '@/common/constants';
import { useQueryPlugins } from '@/services';
import { interfaceStore } from '@/stores';
import './index.scss';
const formPaths = [
'general',
'smtp',
'interface',
'branding',
'legal',
'write',
'seo',
'themes',
'css-html',
const g10Paths = [
'dashboard',
'questions',
'answers',
'users',
'flags',
'installed_plugins',
];
const Index: FC = () => {
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({
title: t('admin'),
});
@ -39,9 +78,9 @@ const Index: FC = () => {
<Container className="admin-container">
<Row>
<Col lg={2}>
<AccordionNav menus={ADMIN_NAV_MENUS} path="/admin/" />
<AccordionNav menus={menus} path="/admin/" />
</Col>
<Col lg={formPaths.find((v) => pathname.includes(v)) ? 6 : 10}>
<Col lg={g10Paths.find((v) => curPath.includes(v)) ? 10 : 6}>
<Outlet />
</Col>
</Row>

View File

@ -71,6 +71,13 @@ const Index: FC<Props> = ({ visible, data, changeCallback, nextCallback }) => {
isInvalid: true,
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) {

View File

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

View File

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

View File

@ -11,12 +11,9 @@ import type {
ImgCodeRes,
FormDataType,
} from '@/common/interface';
import { Unactivate } from '@/components';
import {
loggedUserInfoStore,
loginSettingStore,
siteInfoStore,
} from '@/stores';
import { Unactivate, WelcomeTitle } from '@/components';
import { PluginOauth } from '@/plugins';
import { loggedUserInfoStore, loginSettingStore } from '@/stores';
import { guard, floppyNavigation, handleFormError } from '@/utils';
import { login, checkImgCode } from '@/services';
import { PicAuthCodeModal } from '@/components/Modal';
@ -27,7 +24,6 @@ const Index: React.FC = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [refresh, setRefresh] = useState(0);
const { name: siteName } = siteInfoStore((_) => _.siteInfo);
const { user: storeUser, update: updateUser } = loggedUserInfoStore((_) => _);
const loginSetting = loginSettingStore((state) => state.login);
const [formData, setFormData] = useState<FormDataType>({
@ -165,7 +161,7 @@ const Index: React.FC = () => {
useEffect(() => {
const isInactive = searchParams.get('status');
if ((storeUser.id && storeUser.mail_status === 2) || isInactive) {
if (storeUser.id && (storeUser.mail_status === 2 || isInactive)) {
setStep(2);
}
}, []);
@ -174,11 +170,10 @@ const Index: React.FC = () => {
});
return (
<Container style={{ paddingTop: '4rem', paddingBottom: '5rem' }}>
<h3 className="text-center mb-5">
{t('page_title', { site_name: siteName })}
</h3>
<WelcomeTitle />
{step === 1 && (
<Col className="mx-auto" md={3}>
<PluginOauth className="mb-5" />
<Form noValidate onSubmit={handleSubmit}>
<Form.Group controlId="email" className="mb-3">
<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 { Form, Button, Col } from 'react-bootstrap';
import { Form, Button } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next';
@ -12,7 +12,7 @@ import {
useLegalTos,
useLegalPrivacy,
} from '@/services';
import userStore from '@/stores/userInfo';
import userStore from '@/stores/loggedUserInfoStore';
import { handleFormError } from '@/utils';
interface Props {
@ -70,6 +70,13 @@ const Index: React.FC<Props> = ({ callback }) => {
isInvalid: true,
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) {
bol = false;
formData.name = {
@ -170,7 +177,6 @@ const Index: React.FC<Props> = ({ callback }) => {
}, []);
return (
<>
<Col className="mx-auto" md={3}>
<Form noValidate onSubmit={handleSubmit} autoComplete="off">
<Form.Group controlId="name" className="mb-3">
<Form.Label>{t('name.label')}</Form.Label>
@ -275,7 +281,7 @@ const Index: React.FC<Props> = ({ callback }) => {
Already have an account? <Link to="/users/login">Log in</Link>
</Trans>
</div>
</Col>
<PicAuthCodeModal
visible={showModal}
data={{

View File

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

View File

@ -4,20 +4,37 @@ import { useTranslation } from 'react-i18next';
import type * as Type from '@/common/interface';
import { useToast } from '@/hooks';
import { getLoggedUserInfo, changeEmail } from '@/services';
import { getLoggedUserInfo, changeEmail, checkImgCode } from '@/services';
import { handleFormError } from '@/utils';
import { PicAuthCodeModal } from '@/components/Modal';
const Index: FC = () => {
const { t } = useTranslation('translation', {
keyPrefix: 'settings.account',
});
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>({
e_mail: {
value: '',
isInvalid: false,
errorMsg: '',
},
pass: {
value: '',
isInvalid: false,
errorMsg: '',
},
captcha_code: {
value: '',
isInvalid: false,
errorMsg: '',
},
});
const [userInfo, setUserInfo] = useState<Type.UserInfoRes>();
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) => {
setFormData({ ...formData, ...params });
};
const checkValidated = (): boolean => {
let bol = true;
const { e_mail } = formData;
const { e_mail, pass } = formData;
if (!e_mail.value) {
bol = false;
@ -43,41 +68,86 @@ const Index: FC = () => {
errorMsg: t('email.msg'),
};
}
if (!pass.value) {
bol = false;
formData.pass = {
value: '',
isInvalid: true,
errorMsg: t('pass.msg'),
};
}
setFormData({
...formData,
});
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) => {
event.preventDefault();
event.stopPropagation();
if (!checkValidated()) {
return;
}
changeEmail({
e_mail: formData.e_mail.value,
})
.then(() => {
setStep(1);
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 });
if (imgCode.verify) {
setModalState(true);
}
});
postEmail();
};
return (
@ -96,13 +166,41 @@ const Index: FC = () => {
/>
</Form.Group>
<Button variant="outline-secondary" onClick={() => setStep(2)}>
<Button
variant="outline-secondary"
onClick={() => {
setStep(2);
getImgCode();
}}>
{t('change_email_btn')}
</Button>
</Form>
)}
{step === 2 && (
<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.Label>{t('email.label')}</Form.Label>
<Form.Control
@ -126,6 +224,7 @@ const Index: FC = () => {
{formData.e_mail.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<div>
<Button type="submit" variant="primary" className="me-2">
{t('save', { keyPrefix: 'btns' })}
@ -137,6 +236,18 @@ const Index: FC = () => {
</div>
</Form>
)}
<PicAuthCodeModal
visible={showModal}
data={{
captcha: formData.captcha_code,
imgCode,
}}
handleCaptcha={handleChange}
clickSubmit={postEmail}
refreshImgCode={getImgCode}
onClose={() => setModalState(false)}
/>
</div>
);
};

View File

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

View File

@ -17,7 +17,6 @@ const Index = () => {
notice_switch: {
type: 'boolean',
title: t('email.label'),
label: t('email.radio'),
default: false,
},
},
@ -25,6 +24,9 @@ const Index = () => {
const uiSchema: UISchema = {
notice_switch: {
'ui:widget': 'switch',
'ui:options': {
label: t('email.radio'),
},
},
};
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: '/',
page: 'pages/Layout',
loader: async () => {
await guard.setupApp();
return null;
},
guard: () => {
const gr = guard.shouldLoginRequired();
if (!gr.ok) {
@ -223,6 +227,14 @@ const routes: RouteNode[] = [
return guard.forbidden();
},
},
{
path: '/users/confirm-email',
page: 'pages/Users/OauthBindEmail',
},
{
path: '/users/oauth',
page: 'pages/Users/OauthCallback',
},
{
path: '/posts/:qid/timeline',
page: 'pages/Timeline',
@ -324,6 +336,14 @@ const routes: RouteNode[] = [
path: 'login',
page: 'pages/Admin/Login',
},
{
path: 'installed_plugins',
page: 'pages/Admin/Plugins/Installed',
},
{
path: ':slug_name',
page: 'pages/Admin/Plugins/Config',
},
],
},
// for review

View File

@ -4,3 +4,4 @@ export * from './question';
export * from './settings';
export * from './users';
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 './revision';
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);
};
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);
};

View File

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

View File

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

View File

@ -233,6 +233,26 @@ function diffText(newText: string, oldText?: string): string {
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 {
thousandthDivision,
formatCount,
@ -248,4 +268,5 @@ export {
labelStyle,
handleFormError,
diffText,
base64ToSvg,
};

View File

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

View File

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

View File

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