feat(admin): adapt new config system (#11360)
feat(server): add test mail api feat(admin): adapt new config system
This commit is contained in:
@@ -6,6 +6,49 @@ Generated by [AVA](https://avajs.dev).
|
|||||||
|
|
||||||
## should render emails
|
## should render emails
|
||||||
|
|
||||||
|
> Test Email from AFFiNE
|
||||||
|
|
||||||
|
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||||
|
<!--$-->␊
|
||||||
|
<table␊
|
||||||
|
align="center"␊
|
||||||
|
width="100%"␊
|
||||||
|
border="0"␊
|
||||||
|
cellpadding="0"␊
|
||||||
|
cellspacing="0"␊
|
||||||
|
role="presentation">␊
|
||||||
|
<tbody>␊
|
||||||
|
<tr>␊
|
||||||
|
<td>␊
|
||||||
|
<p␊
|
||||||
|
style="font-size:20px;line-height:28px;margin-bottom:0;margin-top:24px;font-weight:600;font-family:Inter, Arial, Helvetica, sans-serif;color:#141414">␊
|
||||||
|
Test Email from AFFiNE␊
|
||||||
|
</p>␊
|
||||||
|
</td>␊
|
||||||
|
</tr>␊
|
||||||
|
</tbody>␊
|
||||||
|
</table>␊
|
||||||
|
<table␊
|
||||||
|
align="center"␊
|
||||||
|
width="100%"␊
|
||||||
|
border="0"␊
|
||||||
|
cellpadding="0"␊
|
||||||
|
cellspacing="0"␊
|
||||||
|
role="presentation">␊
|
||||||
|
<tbody>␊
|
||||||
|
<tr>␊
|
||||||
|
<td>␊
|
||||||
|
<p␊
|
||||||
|
style="font-size:15px;line-height:24px;margin-bottom:0;margin-top:24px;font-weight:400;font-family:Inter, Arial, Helvetica, sans-serif;color:#141414">␊
|
||||||
|
This is a test email from your AFFiNE instance.␊
|
||||||
|
</p>␊
|
||||||
|
</td>␊
|
||||||
|
</tr>␊
|
||||||
|
</tbody>␊
|
||||||
|
</table>␊
|
||||||
|
<!--/$-->␊
|
||||||
|
`
|
||||||
|
|
||||||
> Sign in to AFFiNE
|
> Sign in to AFFiNE
|
||||||
|
|
||||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||||
|
|||||||
Binary file not shown.
@@ -14,13 +14,8 @@ defineModuleConfig('graphql', {
|
|||||||
apolloDriverConfig: {
|
apolloDriverConfig: {
|
||||||
desc: 'The config for underlying nestjs GraphQL and apollo driver engine.',
|
desc: 'The config for underlying nestjs GraphQL and apollo driver engine.',
|
||||||
default: {
|
default: {
|
||||||
buildSchemaOptions: {
|
// @TODO(@forehalo): need a flag to tell user `Restart Required` configs
|
||||||
numberScalarMode: 'integer',
|
|
||||||
},
|
|
||||||
useGlobalPrefix: true,
|
|
||||||
playground: true,
|
|
||||||
introspection: true,
|
introspection: true,
|
||||||
sortSchema: true,
|
|
||||||
},
|
},
|
||||||
link: 'https://docs.nestjs.com/graphql/quick-start',
|
link: 'https://docs.nestjs.com/graphql/quick-start',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ export type GraphqlContext = {
|
|||||||
useFactory: (config: Config) => {
|
useFactory: (config: Config) => {
|
||||||
return {
|
return {
|
||||||
...config.graphql.apolloDriverConfig,
|
...config.graphql.apolloDriverConfig,
|
||||||
|
buildSchemaOptions: {
|
||||||
|
numberScalarMode: 'integer',
|
||||||
|
},
|
||||||
|
useGlobalPrefix: true,
|
||||||
|
playground: true,
|
||||||
|
sortSchema: true,
|
||||||
autoSchemaFile: join(
|
autoSchemaFile: join(
|
||||||
env.projectRoot,
|
env.projectRoot,
|
||||||
env.testing
|
env.testing
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import { DocStorageModule } from '../doc';
|
|||||||
import { StorageModule } from '../storage';
|
import { StorageModule } from '../storage';
|
||||||
import { MailJob } from './job';
|
import { MailJob } from './job';
|
||||||
import { Mailer } from './mailer';
|
import { Mailer } from './mailer';
|
||||||
|
import { MailResolver } from './resolver';
|
||||||
import { MailSender } from './sender';
|
import { MailSender } from './sender';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DocStorageModule, StorageModule],
|
imports: [DocStorageModule, StorageModule],
|
||||||
providers: [MailSender, Mailer, MailJob],
|
providers: [MailSender, Mailer, MailJob, MailResolver],
|
||||||
exports: [Mailer],
|
exports: [Mailer],
|
||||||
})
|
})
|
||||||
export class MailModule {}
|
export class MailModule {}
|
||||||
|
|||||||
49
packages/backend/server/src/core/mail/resolver.ts
Normal file
49
packages/backend/server/src/core/mail/resolver.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Args, Mutation, Resolver } from '@nestjs/graphql';
|
||||||
|
import { GraphQLJSONObject } from 'graphql-scalars';
|
||||||
|
|
||||||
|
import { BadRequest } from '../../base';
|
||||||
|
import { Renderers } from '../../mails';
|
||||||
|
import { CurrentUser } from '../auth/session';
|
||||||
|
import { Admin } from '../common';
|
||||||
|
import { MailSender } from './sender';
|
||||||
|
|
||||||
|
@Admin()
|
||||||
|
@Resolver(() => Boolean)
|
||||||
|
export class MailResolver {
|
||||||
|
@Mutation(() => Boolean)
|
||||||
|
async sendTestEmail(
|
||||||
|
@CurrentUser() user: CurrentUser,
|
||||||
|
@Args('config', { type: () => GraphQLJSONObject })
|
||||||
|
config: AppConfig['mailer']['SMTP']
|
||||||
|
) {
|
||||||
|
const smtp = MailSender.create(config);
|
||||||
|
|
||||||
|
using _disposable = {
|
||||||
|
[Symbol.dispose]: () => {
|
||||||
|
smtp.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await smtp.verify();
|
||||||
|
} catch (e) {
|
||||||
|
throw new BadRequest(
|
||||||
|
`Failed to verify your SMTP configuration. Cause: ${(e as Error).message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await smtp.sendMail({
|
||||||
|
from: config.sender,
|
||||||
|
to: user.email,
|
||||||
|
...(await Renderers.TestMail({})),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw new BadRequest(
|
||||||
|
`Failed to send test email. Cause: ${(e as Error).message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,22 @@ export type SendOptions = Omit<SendMailOptions, 'to' | 'subject' | 'html'> & {
|
|||||||
html: string;
|
html: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function configToSMTPOptions(
|
||||||
|
config: AppConfig['mailer']['SMTP']
|
||||||
|
): SMTPTransport.Options {
|
||||||
|
return {
|
||||||
|
host: config.host,
|
||||||
|
port: config.port,
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: !config.ignoreTLS,
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
user: config.username,
|
||||||
|
pass: config.password,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MailSender {
|
export class MailSender {
|
||||||
private readonly logger = new Logger(MailSender.name);
|
private readonly logger = new Logger(MailSender.name);
|
||||||
@@ -23,6 +39,10 @@ export class MailSender {
|
|||||||
private usingTestAccount = false;
|
private usingTestAccount = false;
|
||||||
constructor(private readonly config: Config) {}
|
constructor(private readonly config: Config) {}
|
||||||
|
|
||||||
|
static create(config: Config['mailer']['SMTP']) {
|
||||||
|
return createTransport(configToSMTPOptions(config));
|
||||||
|
}
|
||||||
|
|
||||||
@OnEvent('config.init')
|
@OnEvent('config.init')
|
||||||
onConfigInit() {
|
onConfigInit() {
|
||||||
this.setup();
|
this.setup();
|
||||||
@@ -43,17 +63,7 @@ export class MailSender {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const opts: SMTPTransport.Options = {
|
const opts = configToSMTPOptions(SMTP);
|
||||||
host: SMTP.host,
|
|
||||||
port: SMTP.port,
|
|
||||||
tls: {
|
|
||||||
rejectUnauthorized: !SMTP.ignoreTLS,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
user: SMTP.username,
|
|
||||||
pass: SMTP.password,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (SMTP.host) {
|
if (SMTP.host) {
|
||||||
this.smtp = createTransport(opts);
|
this.smtp = createTransport(opts);
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ export function Template(props: PropsWithChildren) {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (env.testing) {
|
if (globalThis.env?.testing) {
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
TeamWorkspaceDeleted,
|
TeamWorkspaceDeleted,
|
||||||
TeamWorkspaceUpgraded,
|
TeamWorkspaceUpgraded,
|
||||||
} from './teams';
|
} from './teams';
|
||||||
|
import TestMail from './test-mail';
|
||||||
import {
|
import {
|
||||||
ChangeEmail,
|
ChangeEmail,
|
||||||
ChangeEmailNotification,
|
ChangeEmailNotification,
|
||||||
@@ -45,7 +46,7 @@ function render(component: React.ReactElement) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props<T> = T extends React.FC<infer P> ? P : never;
|
type Props<T> = T extends React.ComponentType<infer P> ? P : never;
|
||||||
export type EmailRenderer<Props> = (props: Props) => Promise<EmailContent>;
|
export type EmailRenderer<Props> = (props: Props) => Promise<EmailContent>;
|
||||||
|
|
||||||
function make<T extends React.ComponentType<any>>(
|
function make<T extends React.ComponentType<any>>(
|
||||||
@@ -65,6 +66,10 @@ function make<T extends React.ComponentType<any>>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Renderers = {
|
export const Renderers = {
|
||||||
|
//#region Test
|
||||||
|
TestMail: make(TestMail, 'Test Email from AFFiNE'),
|
||||||
|
//#endregion
|
||||||
|
|
||||||
//#region User
|
//#region User
|
||||||
SignIn: make(SignIn, 'Sign in to AFFiNE'),
|
SignIn: make(SignIn, 'Sign in to AFFiNE'),
|
||||||
SignUp: make(SignUp, 'Your AFFiNE account is waiting for you!'),
|
SignUp: make(SignUp, 'Your AFFiNE account is waiting for you!'),
|
||||||
|
|||||||
12
packages/backend/server/src/mails/test-mail.tsx
Normal file
12
packages/backend/server/src/mails/test-mail.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Content, P, Template, Title } from './components';
|
||||||
|
|
||||||
|
export default function TestMail() {
|
||||||
|
return (
|
||||||
|
<Template>
|
||||||
|
<Title>Test Email from AFFiNE</Title>
|
||||||
|
<Content>
|
||||||
|
<P>This is a test email from your AFFiNE instance.</P>
|
||||||
|
</Content>
|
||||||
|
</Template>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -998,6 +998,7 @@ type Mutation {
|
|||||||
sendChangeEmail(callbackUrl: String!, email: String): Boolean!
|
sendChangeEmail(callbackUrl: String!, email: String): Boolean!
|
||||||
sendChangePasswordEmail(callbackUrl: String!, email: String @deprecated(reason: "fetched from signed in user")): Boolean!
|
sendChangePasswordEmail(callbackUrl: String!, email: String @deprecated(reason: "fetched from signed in user")): Boolean!
|
||||||
sendSetPasswordEmail(callbackUrl: String!, email: String @deprecated(reason: "fetched from signed in user")): Boolean!
|
sendSetPasswordEmail(callbackUrl: String!, email: String @deprecated(reason: "fetched from signed in user")): Boolean!
|
||||||
|
sendTestEmail(config: JSONObject!): Boolean!
|
||||||
sendVerifyChangeEmail(callbackUrl: String!, email: String!, token: String!): Boolean!
|
sendVerifyChangeEmail(callbackUrl: String!, email: String!, token: String!): Boolean!
|
||||||
sendVerifyEmail(callbackUrl: String!): Boolean!
|
sendVerifyEmail(callbackUrl: String!): Boolean!
|
||||||
setBlob(blob: Upload!, workspaceId: String!): String!
|
setBlob(blob: Upload!, workspaceId: String!): String!
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
mutation sendTestEmail($host: String!, $port: Int!, $sender: String!, $username: String!, $password: String!, $ignoreTLS: Boolean!) {
|
||||||
|
sendTestEmail(config: {
|
||||||
|
host: $host,
|
||||||
|
port: $port,
|
||||||
|
sender: $sender,
|
||||||
|
username: $username,
|
||||||
|
password: $password,
|
||||||
|
ignoreTLS: $ignoreTLS,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -220,6 +220,16 @@ export const listUsersQuery = {
|
|||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const sendTestEmailMutation = {
|
||||||
|
id: 'sendTestEmailMutation' as const,
|
||||||
|
op: 'sendTestEmail',
|
||||||
|
query: `mutation sendTestEmail($host: String!, $port: Int!, $sender: String!, $username: String!, $password: String!, $ignoreTLS: Boolean!) {
|
||||||
|
sendTestEmail(
|
||||||
|
config: {host: $host, port: $port, sender: $sender, username: $username, password: $password, ignoreTLS: $ignoreTLS}
|
||||||
|
)
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
|
||||||
export const updateAccountFeaturesMutation = {
|
export const updateAccountFeaturesMutation = {
|
||||||
id: 'updateAccountFeaturesMutation' as const,
|
id: 'updateAccountFeaturesMutation' as const,
|
||||||
op: 'updateAccountFeatures',
|
op: 'updateAccountFeatures',
|
||||||
|
|||||||
@@ -1111,6 +1111,7 @@ export interface Mutation {
|
|||||||
sendChangeEmail: Scalars['Boolean']['output'];
|
sendChangeEmail: Scalars['Boolean']['output'];
|
||||||
sendChangePasswordEmail: Scalars['Boolean']['output'];
|
sendChangePasswordEmail: Scalars['Boolean']['output'];
|
||||||
sendSetPasswordEmail: Scalars['Boolean']['output'];
|
sendSetPasswordEmail: Scalars['Boolean']['output'];
|
||||||
|
sendTestEmail: Scalars['Boolean']['output'];
|
||||||
sendVerifyChangeEmail: Scalars['Boolean']['output'];
|
sendVerifyChangeEmail: Scalars['Boolean']['output'];
|
||||||
sendVerifyEmail: Scalars['Boolean']['output'];
|
sendVerifyEmail: Scalars['Boolean']['output'];
|
||||||
setBlob: Scalars['String']['output'];
|
setBlob: Scalars['String']['output'];
|
||||||
@@ -1404,6 +1405,10 @@ export interface MutationSendSetPasswordEmailArgs {
|
|||||||
email?: InputMaybe<Scalars['String']['input']>;
|
email?: InputMaybe<Scalars['String']['input']>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MutationSendTestEmailArgs {
|
||||||
|
config: Scalars['JSONObject']['input'];
|
||||||
|
}
|
||||||
|
|
||||||
export interface MutationSendVerifyChangeEmailArgs {
|
export interface MutationSendVerifyChangeEmailArgs {
|
||||||
callbackUrl: Scalars['String']['input'];
|
callbackUrl: Scalars['String']['input'];
|
||||||
email: Scalars['String']['input'];
|
email: Scalars['String']['input'];
|
||||||
@@ -2509,6 +2514,20 @@ export type ListUsersQuery = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SendTestEmailMutationVariables = Exact<{
|
||||||
|
host: Scalars['String']['input'];
|
||||||
|
port: Scalars['Int']['input'];
|
||||||
|
sender: Scalars['String']['input'];
|
||||||
|
username: Scalars['String']['input'];
|
||||||
|
password: Scalars['String']['input'];
|
||||||
|
ignoreTLS: Scalars['Boolean']['input'];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type SendTestEmailMutation = {
|
||||||
|
__typename?: 'Mutation';
|
||||||
|
sendTestEmail: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type UpdateAccountFeaturesMutationVariables = Exact<{
|
export type UpdateAccountFeaturesMutationVariables = Exact<{
|
||||||
userId: Scalars['String']['input'];
|
userId: Scalars['String']['input'];
|
||||||
features: Array<FeatureType> | FeatureType;
|
features: Array<FeatureType> | FeatureType;
|
||||||
@@ -4587,6 +4606,11 @@ export type Mutations =
|
|||||||
variables: ImportUsersMutationVariables;
|
variables: ImportUsersMutationVariables;
|
||||||
response: ImportUsersMutation;
|
response: ImportUsersMutation;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
name: 'sendTestEmailMutation';
|
||||||
|
variables: SendTestEmailMutationVariables;
|
||||||
|
response: SendTestEmailMutation;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
name: 'updateAccountFeaturesMutation';
|
name: 'updateAccountFeaturesMutation';
|
||||||
variables: UpdateAccountFeaturesMutationVariables;
|
variables: UpdateAccountFeaturesMutationVariables;
|
||||||
|
|||||||
@@ -85,8 +85,8 @@ export const router = _createBrowserRouter(
|
|||||||
lazy: () => import('./modules/ai'),
|
lazy: () => import('./modules/ai'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'config',
|
path: 'about',
|
||||||
lazy: () => import('./modules/config'),
|
lazy: () => import('./modules/about'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ export function Layout({ children }: PropsWithChildren) {
|
|||||||
const leftPanelRef = useRef<ImperativePanelHandle>(null);
|
const leftPanelRef = useRef<ImperativePanelHandle>(null);
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState('');
|
const [activeTab, setActiveTab] = useState('');
|
||||||
const [activeSubTab, setActiveSubTab] = useState('auth');
|
const [activeSubTab, setActiveSubTab] = useState('server');
|
||||||
const [currentModule, setCurrentModule] = useState('auth');
|
const [currentModule, setCurrentModule] = useState('server');
|
||||||
|
|
||||||
const handleLeftExpand = useCallback(() => {
|
const handleLeftExpand = useCallback(() => {
|
||||||
if (leftPanelRef.current?.getSize() === 0) {
|
if (leftPanelRef.current?.getSize() === 0) {
|
||||||
|
|||||||
@@ -1,62 +1,25 @@
|
|||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger,
|
|
||||||
} from '@affine/admin/components/ui/accordion';
|
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
|
|
||||||
import { buttonVariants } from '../../components/ui/button';
|
import { buttonVariants } from '../../components/ui/button';
|
||||||
import { cn } from '../../utils';
|
import { cn } from '../../utils';
|
||||||
|
|
||||||
export const CollapsibleItem = ({
|
|
||||||
title,
|
|
||||||
changeModule,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
changeModule?: (module: string) => void;
|
|
||||||
}) => {
|
|
||||||
const handleClick = useCallback(() => {
|
|
||||||
changeModule?.(title);
|
|
||||||
}, [changeModule, title]);
|
|
||||||
return (
|
|
||||||
<Accordion type="multiple" className="w-full">
|
|
||||||
<AccordionItem value="item-1" className="border-b-0 ml-7">
|
|
||||||
<NavLink
|
|
||||||
to={`/admin/settings/${title}`}
|
|
||||||
className={({ isActive }) => {
|
|
||||||
return isActive
|
|
||||||
? 'w-full bg-zinc-100 inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50'
|
|
||||||
: '';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AccordionTrigger
|
|
||||||
onClick={handleClick}
|
|
||||||
className="py-2 px-2 rounded [&[data-state=closed]>svg]:rotate-270 [&[data-state=open]>svg]:rotate-360"
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</AccordionTrigger>
|
|
||||||
</NavLink>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const NormalSubItem = ({
|
export const NormalSubItem = ({
|
||||||
|
module,
|
||||||
title,
|
title,
|
||||||
changeModule,
|
changeModule,
|
||||||
}: {
|
}: {
|
||||||
|
module: string;
|
||||||
title: string;
|
title: string;
|
||||||
changeModule?: (module: string) => void;
|
changeModule?: (module: string) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
changeModule?.(title);
|
changeModule?.(module);
|
||||||
}, [changeModule, title]);
|
}, [changeModule, module]);
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex">
|
<div className="w-full flex">
|
||||||
<NavLink
|
<NavLink
|
||||||
to={`/admin/settings/${title}`}
|
to={`/admin/settings/${module}`}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className={({ isActive }) => {
|
className={({ isActive }) => {
|
||||||
return cn(
|
return cn(
|
||||||
@@ -72,30 +35,3 @@ export const NormalSubItem = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const OtherModules = ({
|
|
||||||
moduleList,
|
|
||||||
changeModule,
|
|
||||||
}: {
|
|
||||||
moduleList: string[];
|
|
||||||
changeModule?: (module: string) => void;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<Accordion type="multiple" className="w-full">
|
|
||||||
<AccordionItem value="item-1" className="border-b-0">
|
|
||||||
<AccordionTrigger className="ml-8 py-2 px-2 rounded [&[data-state=closed]>svg]:rotate-270 [&[data-state=open]>svg]:rotate-360">
|
|
||||||
Other
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="flex flex-col gap-1 py-1">
|
|
||||||
{moduleList.map(module => (
|
|
||||||
<NormalSubItem
|
|
||||||
key={module}
|
|
||||||
title={module}
|
|
||||||
changeModule={changeModule}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -98,9 +98,9 @@ export function Nav({ isCollapsed = false }: NavProps) {
|
|||||||
/>
|
/>
|
||||||
<SettingsItem isCollapsed={isCollapsed} />
|
<SettingsItem isCollapsed={isCollapsed} />
|
||||||
<NavItem
|
<NavItem
|
||||||
to="/admin/config"
|
to="/admin/about"
|
||||||
icon={<SelfhostIcon fontSize={20} />}
|
icon={<SelfhostIcon fontSize={20} />}
|
||||||
label="Server"
|
label="About"
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
/>
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -12,15 +12,10 @@ import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
|||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
|
|
||||||
import { ALL_CONFIGURABLE_MODULES } from '../settings/config';
|
import { KNOWN_CONFIG_GROUPS, UNKNOWN_CONFIG_GROUPS } from '../settings/config';
|
||||||
import { NormalSubItem, OtherModules } from './collapsible-item';
|
import { NormalSubItem } from './collapsible-item';
|
||||||
import { useNav } from './context';
|
import { useNav } from './context';
|
||||||
|
|
||||||
const authModule = ALL_CONFIGURABLE_MODULES.find(module => module === 'auth');
|
|
||||||
const otherModules = ALL_CONFIGURABLE_MODULES.filter(
|
|
||||||
module => module !== 'auth'
|
|
||||||
);
|
|
||||||
|
|
||||||
export const SettingsItem = ({ isCollapsed }: { isCollapsed: boolean }) => {
|
export const SettingsItem = ({ isCollapsed }: { isCollapsed: boolean }) => {
|
||||||
const { setCurrentModule } = useNav();
|
const { setCurrentModule } = useNav();
|
||||||
|
|
||||||
@@ -59,10 +54,10 @@ export const SettingsItem = ({ isCollapsed }: { isCollapsed: boolean }) => {
|
|||||||
borderColor: cssVarV2('layer/insideBorder/blackBorder'),
|
borderColor: cssVarV2('layer/insideBorder/blackBorder'),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{authModule ? (
|
{KNOWN_CONFIG_GROUPS.map(group => (
|
||||||
<li key={authModule} className="flex">
|
<li key={group.module} className="flex">
|
||||||
<NavLink
|
<NavLink
|
||||||
to={`/admin/settings/${authModule}`}
|
to={`/admin/settings/${group.module}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({
|
buttonVariants({
|
||||||
variant: 'ghost',
|
variant: 'ghost',
|
||||||
@@ -75,16 +70,16 @@ export const SettingsItem = ({ isCollapsed }: { isCollapsed: boolean }) => {
|
|||||||
? cssVarV2('selfhost/button/sidebarButton/bg/select')
|
? cssVarV2('selfhost/button/sidebarButton/bg/select')
|
||||||
: undefined,
|
: undefined,
|
||||||
})}
|
})}
|
||||||
onClick={() => setCurrentModule?.(authModule)}
|
onClick={() => setCurrentModule?.(group.module)}
|
||||||
>
|
>
|
||||||
{authModule}
|
{group.name}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</li>
|
</li>
|
||||||
) : null}
|
))}
|
||||||
{otherModules.map(module => (
|
{UNKNOWN_CONFIG_GROUPS.map(group => (
|
||||||
<li key={module} className="flex">
|
<li key={group.module} className="flex">
|
||||||
<NavLink
|
<NavLink
|
||||||
to={`/admin/settings/${module}`}
|
to={`/admin/settings/${group.module}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({
|
buttonVariants({
|
||||||
variant: 'ghost',
|
variant: 'ghost',
|
||||||
@@ -97,9 +92,9 @@ export const SettingsItem = ({ isCollapsed }: { isCollapsed: boolean }) => {
|
|||||||
? cssVarV2('selfhost/button/sidebarButton/bg/select')
|
? cssVarV2('selfhost/button/sidebarButton/bg/select')
|
||||||
: undefined,
|
: undefined,
|
||||||
})}
|
})}
|
||||||
onClick={() => setCurrentModule?.(module)}
|
onClick={() => setCurrentModule?.(group.module)}
|
||||||
>
|
>
|
||||||
{module}
|
{group.name}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -151,18 +146,31 @@ export const SettingsItem = ({ isCollapsed }: { isCollapsed: boolean }) => {
|
|||||||
className={cn('relative overflow-hidden w-full h-full')}
|
className={cn('relative overflow-hidden w-full h-full')}
|
||||||
>
|
>
|
||||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit] [&>div]:!block">
|
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit] [&>div]:!block">
|
||||||
{authModule && (
|
{KNOWN_CONFIG_GROUPS.map(group => (
|
||||||
<NormalSubItem
|
<NormalSubItem
|
||||||
title={authModule}
|
key={group.module}
|
||||||
|
module={group.module}
|
||||||
|
title={group.name}
|
||||||
changeModule={setCurrentModule}
|
changeModule={setCurrentModule}
|
||||||
/>
|
/>
|
||||||
)}
|
))}
|
||||||
{otherModules.length > 0 && (
|
<Accordion type="multiple" className="w-full">
|
||||||
<OtherModules
|
<AccordionItem value="item-1" className="border-b-0">
|
||||||
moduleList={otherModules}
|
<AccordionTrigger className="ml-8 py-2 px-2 rounded [&[data-state=closed]>svg]:rotate-270 [&[data-state=open]>svg]:rotate-360">
|
||||||
changeModule={setCurrentModule}
|
Experimental
|
||||||
/>
|
</AccordionTrigger>
|
||||||
)}
|
<AccordionContent className="flex flex-col gap-1 py-1">
|
||||||
|
{UNKNOWN_CONFIG_GROUPS.map(group => (
|
||||||
|
<NormalSubItem
|
||||||
|
key={group.module}
|
||||||
|
module={group.module}
|
||||||
|
title={group.name}
|
||||||
|
changeModule={setCurrentModule}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
</ScrollAreaPrimitive.Viewport>
|
</ScrollAreaPrimitive.Viewport>
|
||||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { Input } from '@affine/admin/components/ui/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@affine/admin/components/ui/select';
|
||||||
|
import { Switch } from '@affine/admin/components/ui/switch';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import { Textarea } from '../../components/ui/textarea';
|
||||||
|
import { isEqual } from './utils';
|
||||||
|
|
||||||
|
export type ConfigInputProps = {
|
||||||
|
field: string;
|
||||||
|
desc: string;
|
||||||
|
defaultValue: any;
|
||||||
|
onChange: (key: string, value: any) => void;
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
type: 'String' | 'Number' | 'Boolean' | 'JSON';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'Enum';
|
||||||
|
options: string[];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const Inputs: Record<
|
||||||
|
ConfigInputProps['type'],
|
||||||
|
React.ComponentType<{
|
||||||
|
defaultValue: any;
|
||||||
|
onChange: (value?: any) => void;
|
||||||
|
options?: string[];
|
||||||
|
}>
|
||||||
|
> = {
|
||||||
|
Boolean: function SwitchInput({ defaultValue, onChange }) {
|
||||||
|
const handleSwitchChange = (checked: boolean) => {
|
||||||
|
onChange(checked);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch
|
||||||
|
defaultChecked={defaultValue}
|
||||||
|
onCheckedChange={handleSwitchChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
String: function StringInput({ defaultValue, onChange }) {
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
minLength={1}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
Number: function NumberInput({ defaultValue, onChange }) {
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange(parseInt(e.target.value));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
JSON: function ObjectInput({ defaultValue, onChange }) {
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(e.target.value);
|
||||||
|
onChange(value);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
defaultValue={JSON.stringify(defaultValue)}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
Enum: function EnumInput({ defaultValue, onChange, options }) {
|
||||||
|
return (
|
||||||
|
<Select defaultValue={defaultValue} onValueChange={onChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select an option" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options?.map(option => (
|
||||||
|
<SelectItem key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ConfigRow = ({
|
||||||
|
field,
|
||||||
|
desc,
|
||||||
|
type,
|
||||||
|
defaultValue,
|
||||||
|
onChange,
|
||||||
|
...props
|
||||||
|
}: ConfigInputProps) => {
|
||||||
|
const [value, setValue] = useState(defaultValue);
|
||||||
|
|
||||||
|
const isValueChanged = !isEqual(value, defaultValue);
|
||||||
|
|
||||||
|
const onValueChange = useCallback(
|
||||||
|
(value?: any) => {
|
||||||
|
onChange(field, value);
|
||||||
|
setValue(value);
|
||||||
|
},
|
||||||
|
[field, onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const Input = Inputs[type] ?? Inputs.JSON;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex justify-between flex-grow space-y-[10px]
|
||||||
|
${type === 'Boolean' ? 'flex-row' : 'flex-col'}`}
|
||||||
|
>
|
||||||
|
<div className="text-base font-bold flex-3">{desc}</div>
|
||||||
|
<div className="flex flex-col items-end relative flex-1">
|
||||||
|
<Input
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
onChange={onValueChange}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{isValueChanged && (
|
||||||
|
<div className="absolute bottom-[-25px] text-sm right-0 break-words">
|
||||||
|
<span
|
||||||
|
className="line-through"
|
||||||
|
style={{
|
||||||
|
color: 'rgba(198, 34, 34, 1)',
|
||||||
|
backgroundColor: 'rgba(254, 213, 213, 1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{JSON.stringify(defaultValue)}
|
||||||
|
</span>{' '}
|
||||||
|
=>{' '}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: 'rgba(20, 147, 67, 1)',
|
||||||
|
backgroundColor: 'rgba(225, 250, 177, 1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{JSON.stringify(value)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import { Input } from '@affine/admin/components/ui/input';
|
|
||||||
import { Switch } from '@affine/admin/components/ui/switch';
|
|
||||||
import { useCallback, useState } from 'react';
|
|
||||||
|
|
||||||
import { Textarea } from '../../components/ui/textarea';
|
|
||||||
import { isEqual } from './utils';
|
|
||||||
|
|
||||||
interface ConfigInputProps {
|
|
||||||
module: string;
|
|
||||||
field: string;
|
|
||||||
type: string;
|
|
||||||
defaultValue: any;
|
|
||||||
onChange: (module: string, field: string, value: any) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Inputs: Record<
|
|
||||||
string,
|
|
||||||
React.ComponentType<{
|
|
||||||
defaultValue: any;
|
|
||||||
onChange: (value?: any) => void;
|
|
||||||
}>
|
|
||||||
> = {
|
|
||||||
Boolean: function SwitchInput({ defaultValue, onChange }) {
|
|
||||||
const handleSwitchChange = (checked: boolean) => {
|
|
||||||
onChange(checked);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Switch
|
|
||||||
defaultChecked={defaultValue}
|
|
||||||
onCheckedChange={handleSwitchChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
String: function StringInput({ defaultValue, onChange }) {
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
onChange(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
minLength={1}
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
Number: function NumberInput({ defaultValue, onChange }) {
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
onChange(parseInt(e.target.value));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
JSON: function ObjectInput({ defaultValue, onChange }) {
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
try {
|
|
||||||
const value = JSON.parse(e.target.value);
|
|
||||||
onChange(value);
|
|
||||||
} catch {}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Textarea
|
|
||||||
defaultValue={JSON.stringify(defaultValue)}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ConfigInput = ({
|
|
||||||
module,
|
|
||||||
field,
|
|
||||||
type,
|
|
||||||
defaultValue,
|
|
||||||
onChange,
|
|
||||||
}: ConfigInputProps) => {
|
|
||||||
const [value, setValue] = useState(defaultValue);
|
|
||||||
|
|
||||||
const onValueChange = useCallback(
|
|
||||||
(value?: any) => {
|
|
||||||
onChange(module, field, value);
|
|
||||||
setValue(value);
|
|
||||||
},
|
|
||||||
[module, field, onChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
const Input = Inputs[type] ?? Inputs.JSON;
|
|
||||||
|
|
||||||
const isValueEqual = isEqual(value, defaultValue);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-end gap-2 w-full">
|
|
||||||
<Input defaultValue={defaultValue} onChange={onValueChange} />
|
|
||||||
<div
|
|
||||||
className="w-full break-words"
|
|
||||||
style={{
|
|
||||||
opacity: isValueEqual ? 0 : 1,
|
|
||||||
height: isValueEqual ? 0 : 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="line-through"
|
|
||||||
style={{
|
|
||||||
color: 'rgba(198, 34, 34, 1)',
|
|
||||||
backgroundColor: 'rgba(254, 213, 213, 1)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{JSON.stringify(defaultValue)}
|
|
||||||
</span>{' '}
|
|
||||||
=>{' '}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
color: 'rgba(20, 147, 67, 1)',
|
|
||||||
backgroundColor: 'rgba(225, 250, 177, 1)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{JSON.stringify(value)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,23 +1,43 @@
|
|||||||
import CONFIG from '../../config.json';
|
import { upperFirst } from 'lodash-es';
|
||||||
|
import type { ComponentType } from 'react';
|
||||||
|
|
||||||
export type ConfigDescriptor = {
|
import CONFIG_DESCRIPTORS from '../../config.json';
|
||||||
|
import type { ConfigInputProps } from './config-input-row';
|
||||||
|
import { SendTestEmail } from './operations/send-test-email';
|
||||||
|
export type ConfigType = 'String' | 'Number' | 'Boolean' | 'JSON' | 'Enum';
|
||||||
|
|
||||||
|
type ConfigDescriptor = {
|
||||||
desc: string;
|
desc: string;
|
||||||
type: 'String' | 'Number' | 'Boolean' | 'Array' | 'Object';
|
type: ConfigType;
|
||||||
env?: string;
|
env?: string;
|
||||||
link?: string;
|
link?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppConfig = typeof CONFIG;
|
export type AppConfig = Record<string, Record<string, any>>;
|
||||||
export type AvailableConfig = {
|
|
||||||
[K in keyof AppConfig]: {
|
|
||||||
module: K;
|
|
||||||
fields: Array<keyof AppConfig[K]>;
|
|
||||||
};
|
|
||||||
}[keyof AppConfig];
|
|
||||||
|
|
||||||
|
type AppConfigDescriptors = typeof CONFIG_DESCRIPTORS;
|
||||||
|
type AppConfigModule = keyof AppConfigDescriptors;
|
||||||
|
type ModuleConfigDescriptors<M extends AppConfigModule> =
|
||||||
|
AppConfigDescriptors[M];
|
||||||
|
type ConfigGroup<T extends AppConfigModule> = {
|
||||||
|
name: string;
|
||||||
|
module: T;
|
||||||
|
fields: Array<
|
||||||
|
| keyof ModuleConfigDescriptors<T>
|
||||||
|
| ({
|
||||||
|
key: keyof ModuleConfigDescriptors<T>;
|
||||||
|
sub?: string;
|
||||||
|
desc?: string;
|
||||||
|
} & Partial<ConfigInputProps>)
|
||||||
|
>;
|
||||||
|
operations?: ComponentType<{
|
||||||
|
appConfig: AppConfig;
|
||||||
|
}>[];
|
||||||
|
};
|
||||||
const IGNORED_MODULES: (keyof AppConfig)[] = [
|
const IGNORED_MODULES: (keyof AppConfig)[] = [
|
||||||
'db',
|
'db',
|
||||||
'redis',
|
'redis',
|
||||||
|
'graphql',
|
||||||
'copilot', // not ready
|
'copilot', // not ready
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -25,7 +45,117 @@ if (!environment.isSelfHosted) {
|
|||||||
IGNORED_MODULES.push('payment');
|
IGNORED_MODULES.push('payment');
|
||||||
}
|
}
|
||||||
|
|
||||||
export { CONFIG as ALL_CONFIG };
|
const ALL_CONFIGURABLE_MODULES = Object.keys(CONFIG_DESCRIPTORS).filter(
|
||||||
export const ALL_CONFIGURABLE_MODULES = Object.keys(CONFIG).filter(
|
|
||||||
key => !IGNORED_MODULES.includes(key as keyof AppConfig)
|
key => !IGNORED_MODULES.includes(key as keyof AppConfig)
|
||||||
) as (keyof AppConfig)[];
|
);
|
||||||
|
|
||||||
|
export const KNOWN_CONFIG_GROUPS = [
|
||||||
|
{
|
||||||
|
name: 'Server',
|
||||||
|
module: 'server',
|
||||||
|
fields: ['externalUrl', 'name'],
|
||||||
|
} as ConfigGroup<'server'>,
|
||||||
|
{
|
||||||
|
name: 'Auth',
|
||||||
|
module: 'auth',
|
||||||
|
fields: [
|
||||||
|
'allowSignup',
|
||||||
|
// nested json object
|
||||||
|
{
|
||||||
|
key: 'passwordRequirements',
|
||||||
|
sub: 'min',
|
||||||
|
type: 'Number',
|
||||||
|
desc: 'Minimum length requirement of password',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'passwordRequirements',
|
||||||
|
sub: 'max',
|
||||||
|
type: 'Number',
|
||||||
|
desc: 'Maximum length requirement of password',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as ConfigGroup<'auth'>,
|
||||||
|
{
|
||||||
|
name: 'Notification',
|
||||||
|
module: 'mailer',
|
||||||
|
fields: [
|
||||||
|
'enabled',
|
||||||
|
'SMTP.host',
|
||||||
|
'SMTP.port',
|
||||||
|
'SMTP.username',
|
||||||
|
'SMTP.password',
|
||||||
|
'SMTP.ignoreTLS',
|
||||||
|
'SMTP.sender',
|
||||||
|
],
|
||||||
|
operations: [SendTestEmail],
|
||||||
|
} as ConfigGroup<'mailer'>,
|
||||||
|
{
|
||||||
|
name: 'Storage',
|
||||||
|
module: 'storages',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'blob.storage',
|
||||||
|
desc: 'The storage provider for user uploaded blobs',
|
||||||
|
sub: 'provider',
|
||||||
|
type: 'Enum',
|
||||||
|
options: ['fs', 'aws-s3', 'cloudflare-r2'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'blob.storage',
|
||||||
|
sub: 'bucket',
|
||||||
|
type: 'String',
|
||||||
|
desc: 'The bucket name for user uploaded blobs storage',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'blob.storage',
|
||||||
|
sub: 'config',
|
||||||
|
type: 'JSON',
|
||||||
|
desc: 'The config passed directly to the storage provider(e.g. aws-sdk)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'avatar.storage',
|
||||||
|
desc: 'The storage provider for user avatars',
|
||||||
|
sub: 'provider',
|
||||||
|
type: 'Enum',
|
||||||
|
options: ['fs', 'aws-s3', 'cloudflare-r2'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'avatar.storage',
|
||||||
|
sub: 'bucket',
|
||||||
|
type: 'String',
|
||||||
|
desc: 'The bucket name for user avatars storage',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'avatar.storage',
|
||||||
|
sub: 'config',
|
||||||
|
type: 'JSON',
|
||||||
|
desc: 'The config passed directly to the storage provider(e.g. aws-sdk)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as ConfigGroup<'storages'>,
|
||||||
|
{
|
||||||
|
name: 'OAuth',
|
||||||
|
module: 'oauth',
|
||||||
|
fields: ['providers.google', 'providers.github', 'providers.oidc'],
|
||||||
|
} as ConfigGroup<'oauth'>,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const UNKNOWN_CONFIG_GROUPS = ALL_CONFIGURABLE_MODULES.filter(
|
||||||
|
module => !KNOWN_CONFIG_GROUPS.some(group => group.module === module)
|
||||||
|
).map(module => ({
|
||||||
|
name: upperFirst(module),
|
||||||
|
module,
|
||||||
|
// @ts-expect-error allow
|
||||||
|
fields: Object.keys(CONFIG_DESCRIPTORS[module]),
|
||||||
|
operations: undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const ALL_SETTING_GROUPS = [
|
||||||
|
...KNOWN_CONFIG_GROUPS,
|
||||||
|
...UNKNOWN_CONFIG_GROUPS,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ALL_CONFIG_DESCRIPTORS = CONFIG_DESCRIPTORS as Record<
|
||||||
|
string,
|
||||||
|
Record<string, ConfigDescriptor>
|
||||||
|
>;
|
||||||
|
|||||||
@@ -9,13 +9,15 @@ import {
|
|||||||
} from '@affine/admin/components/ui/dialog';
|
} from '@affine/admin/components/ui/dialog';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import type { AppConfig } from './config';
|
||||||
|
|
||||||
export const ConfirmChanges = ({
|
export const ConfirmChanges = ({
|
||||||
updates,
|
updates,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: {
|
}: {
|
||||||
updates: Record<string, { from: any; to: any }>;
|
updates: AppConfig;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Button } from '@affine/admin/components/ui/button';
|
import { Button } from '@affine/admin/components/ui/button';
|
||||||
import { ScrollArea } from '@affine/admin/components/ui/scroll-area';
|
import { ScrollArea } from '@affine/admin/components/ui/scroll-area';
|
||||||
import { Separator } from '@affine/admin/components/ui/separator';
|
|
||||||
import { get } from 'lodash-es';
|
import { get } from 'lodash-es';
|
||||||
import { CheckIcon } from 'lucide-react';
|
import { CheckIcon } from 'lucide-react';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
@@ -8,17 +7,16 @@ import { useCallback, useState } from 'react';
|
|||||||
import { Header } from '../header';
|
import { Header } from '../header';
|
||||||
import { useNav } from '../nav/context';
|
import { useNav } from '../nav/context';
|
||||||
import {
|
import {
|
||||||
ALL_CONFIG,
|
ALL_CONFIG_DESCRIPTORS,
|
||||||
ALL_CONFIGURABLE_MODULES,
|
ALL_SETTING_GROUPS,
|
||||||
type ConfigDescriptor,
|
type AppConfig,
|
||||||
} from './config';
|
} from './config';
|
||||||
import { ConfigInput } from './config-input';
|
import { type ConfigInputProps, ConfigRow } from './config-input-row';
|
||||||
import { ConfirmChanges } from './confirm-changes';
|
import { ConfirmChanges } from './confirm-changes';
|
||||||
import { RuntimeSettingRow } from './runtime-setting-row';
|
|
||||||
import { useAppConfig } from './use-app-config';
|
import { useAppConfig } from './use-app-config';
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const { appConfig, update, save, updates } = useAppConfig();
|
const { appConfig, update, save, patchedAppConfig, updates } = useAppConfig();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const onOpen = useCallback(() => setOpen(true), [setOpen]);
|
const onOpen = useCallback(() => setOpen(true), [setOpen]);
|
||||||
|
|
||||||
@@ -28,22 +26,12 @@ export function SettingsPage() {
|
|||||||
if (disableSave) {
|
if (disableSave) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
save(
|
save();
|
||||||
Object.entries(updates).map(([key, { to }]) => {
|
|
||||||
const splitAt = key.indexOf('.');
|
|
||||||
const [module, field] = [key.slice(0, splitAt), key.slice(splitAt + 1)];
|
|
||||||
return {
|
|
||||||
module,
|
|
||||||
key: field,
|
|
||||||
value: to,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}, [save, disableSave, updates]);
|
}, [save, disableSave]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className=" h-screen flex-1 flex-col flex">
|
<div className="h-screen flex-1 flex-col flex">
|
||||||
<Header
|
<Header
|
||||||
title="Settings"
|
title="Settings"
|
||||||
endFix={
|
endFix={
|
||||||
@@ -59,7 +47,11 @@ export function SettingsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<AdminPanel onUpdate={update} appConfig={appConfig} />
|
<AdminPanel
|
||||||
|
onUpdate={update}
|
||||||
|
appConfig={appConfig}
|
||||||
|
patchedAppConfig={patchedAppConfig}
|
||||||
|
/>
|
||||||
<ConfirmChanges
|
<ConfirmChanges
|
||||||
updates={updates}
|
updates={updates}
|
||||||
open={open}
|
open={open}
|
||||||
@@ -72,58 +64,66 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
export const AdminPanel = ({
|
export const AdminPanel = ({
|
||||||
appConfig,
|
appConfig,
|
||||||
|
patchedAppConfig,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
}: {
|
}: {
|
||||||
appConfig: Record<string, any>;
|
appConfig: AppConfig;
|
||||||
onUpdate: (module: string, field: string, value: any) => void;
|
patchedAppConfig: AppConfig;
|
||||||
|
onUpdate: (path: string, value: any) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const { currentModule } = useNav();
|
const { currentModule } = useNav();
|
||||||
|
const group = ALL_SETTING_GROUPS.find(
|
||||||
|
group => group.module === currentModule
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, module, fields, operations } = group;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea>
|
<ScrollArea className="h-full">
|
||||||
<div className="flex flex-col h-full gap-3 py-5 px-6 w-full max-w-[800px] mx-auto">
|
<div className="flex flex-col h-full gap-5 py-5 px-6 w-full max-w-[800px] mx-auto">
|
||||||
{ALL_CONFIGURABLE_MODULES.filter(
|
<div className="text-2xl font-semibold">{name}</div>
|
||||||
module => module === currentModule
|
<div className="flex flex-col gap-10" id={`config-module-${module}`}>
|
||||||
).map(module => {
|
{fields.map(field => {
|
||||||
const fields = Object.keys(ALL_CONFIG[module]);
|
let desc: string;
|
||||||
return (
|
let props: ConfigInputProps;
|
||||||
<div
|
if (typeof field === 'string') {
|
||||||
className="flex flex-col gap-5"
|
const descriptor = ALL_CONFIG_DESCRIPTORS[module][field];
|
||||||
id={`config-module-${module}`}
|
desc = descriptor.desc;
|
||||||
key={module}
|
props = {
|
||||||
>
|
type: descriptor.type,
|
||||||
<div className="text-xl font-semibold">{module}</div>
|
defaultValue: get(appConfig[module], field),
|
||||||
{fields.map((field, index) => {
|
field: `${module}/${field}`,
|
||||||
// @ts-expect-error allow
|
desc,
|
||||||
const { desc, type } = ALL_CONFIG[module][
|
onChange: onUpdate,
|
||||||
field
|
options: [],
|
||||||
] as ConfigDescriptor;
|
};
|
||||||
|
} else {
|
||||||
|
const descriptor = ALL_CONFIG_DESCRIPTORS[module][field.key];
|
||||||
|
|
||||||
return (
|
props = {
|
||||||
<div key={field} className="flex flex-col gap-10">
|
desc: field.desc ?? descriptor.desc,
|
||||||
{index !== 0 && <Separator />}
|
type: field.type ?? descriptor.type,
|
||||||
<RuntimeSettingRow
|
// @ts-expect-error for enum type
|
||||||
key={field}
|
options: field.options,
|
||||||
id={field}
|
defaultValue: get(
|
||||||
description={desc}
|
appConfig[module],
|
||||||
orientation={
|
field.key + (field.sub ? '.' + field.sub : '')
|
||||||
type === 'Boolean' ? 'horizontal' : 'vertical'
|
),
|
||||||
}
|
field: `${module}/${field.key}${field.sub ? `/${field.sub}` : ''}`,
|
||||||
>
|
onChange: onUpdate,
|
||||||
<ConfigInput
|
};
|
||||||
module={module}
|
}
|
||||||
field={field}
|
|
||||||
type={type}
|
return <ConfigRow key={props.field} {...props} />;
|
||||||
defaultValue={get(appConfig[module], field)}
|
})}
|
||||||
onChange={onUpdate}
|
{operations?.map(Operation => (
|
||||||
/>
|
<Operation key={Operation.name} appConfig={patchedAppConfig} />
|
||||||
</RuntimeSettingRow>
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Button } from '@affine/admin/components/ui/button';
|
||||||
|
import { useMutation } from '@affine/admin/use-mutation';
|
||||||
|
import { notify } from '@affine/component';
|
||||||
|
import type { UserFriendlyError } from '@affine/error';
|
||||||
|
import { sendTestEmailMutation } from '@affine/graphql';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import type { AppConfig } from '../config';
|
||||||
|
|
||||||
|
export function SendTestEmail({ appConfig }: { appConfig: AppConfig }) {
|
||||||
|
const { trigger } = useMutation({
|
||||||
|
mutation: sendTestEmailMutation,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onClick = useCallback(() => {
|
||||||
|
trigger(appConfig.mailer.SMTP)
|
||||||
|
.then(() => {
|
||||||
|
notify.success({
|
||||||
|
title: 'Test email sent',
|
||||||
|
message: 'The test email has been successfully sent.',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err: UserFriendlyError) => {
|
||||||
|
notify.error({
|
||||||
|
title: 'Failed to send test email',
|
||||||
|
message: err.message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [appConfig, trigger]);
|
||||||
|
|
||||||
|
return <Button onClick={onClick}>Send Test Email</Button>;
|
||||||
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { type ReactNode } from 'react';
|
|
||||||
|
|
||||||
export const RuntimeSettingRow = ({
|
|
||||||
id,
|
|
||||||
description,
|
|
||||||
orientation = 'horizontal',
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
id: string;
|
|
||||||
description: string;
|
|
||||||
orientation?: 'horizontal' | 'vertical';
|
|
||||||
children: ReactNode;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`flex justify-between flex-grow space-y-[10px] gap-5 ${orientation === 'vertical' ? 'flex-col' : 'flex-row'}`}
|
|
||||||
id={id}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<div className="text-base font-bold">{description}</div>
|
|
||||||
<div className="">
|
|
||||||
<code className="text-xs bg-zinc-100 text-gray-500 px-[4px] py-[2px] rounded">
|
|
||||||
{id}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-end gap-2 mr-1">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -8,11 +8,15 @@ import {
|
|||||||
type UpdateAppConfigInput,
|
type UpdateAppConfigInput,
|
||||||
updateAppConfigMutation,
|
updateAppConfigMutation,
|
||||||
} from '@affine/graphql';
|
} from '@affine/graphql';
|
||||||
import { get, merge } from 'lodash-es';
|
import { cloneDeep, get, merge, set } from 'lodash-es';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import type { AppConfig } from './config';
|
||||||
|
|
||||||
export { type UpdateAppConfigInput };
|
export { type UpdateAppConfigInput };
|
||||||
|
|
||||||
|
export type AppConfigUpdates = Record<string, { from: any; to: any }>;
|
||||||
|
|
||||||
export const useAppConfig = () => {
|
export const useAppConfig = () => {
|
||||||
const {
|
const {
|
||||||
data: { appConfig },
|
data: { appConfig },
|
||||||
@@ -25,49 +29,81 @@ export const useAppConfig = () => {
|
|||||||
mutation: updateAppConfigMutation,
|
mutation: updateAppConfigMutation,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [updates, setUpdates] = useState<
|
const [updates, setUpdates] = useState<AppConfigUpdates>({});
|
||||||
Record<string, { from: any; to: any }>
|
const [patchedAppConfig, setPatchedAppConfig] = useState<AppConfig>(() =>
|
||||||
>({});
|
cloneDeep(appConfig)
|
||||||
|
|
||||||
const save = useAsyncCallback(
|
|
||||||
async (updates: UpdateAppConfigInput[]) => {
|
|
||||||
try {
|
|
||||||
const savedUpdates = await trigger({
|
|
||||||
updates,
|
|
||||||
});
|
|
||||||
await mutate({ appConfig: merge({}, appConfig, savedUpdates) });
|
|
||||||
setUpdates({});
|
|
||||||
notify.success({
|
|
||||||
title: 'Saved successfully',
|
|
||||||
message: 'Runtime configurations have been saved successfully.',
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
const error = UserFriendlyError.fromAny(e);
|
|
||||||
notify.error({
|
|
||||||
title: 'Failed to save',
|
|
||||||
message: error.message,
|
|
||||||
});
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[appConfig, mutate, trigger]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const save = useAsyncCallback(async () => {
|
||||||
|
const updateInputs: UpdateAppConfigInput[] = Object.entries(updates).map(
|
||||||
|
([key, value]) => {
|
||||||
|
const splitIndex = key.indexOf('.');
|
||||||
|
const module = key.slice(0, splitIndex);
|
||||||
|
const field = key.slice(splitIndex + 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
module,
|
||||||
|
key: field,
|
||||||
|
value: value.to,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const savedUpdates = await trigger({
|
||||||
|
updates: updateInputs,
|
||||||
|
});
|
||||||
|
await mutate(prev => {
|
||||||
|
return { appConfig: merge({}, prev, savedUpdates) };
|
||||||
|
});
|
||||||
|
setUpdates({});
|
||||||
|
notify.success({
|
||||||
|
title: 'Saved',
|
||||||
|
message: 'Settings have been saved successfully.',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const error = UserFriendlyError.fromAny(e);
|
||||||
|
notify.error({
|
||||||
|
title: 'Failed to save',
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}, [updates, mutate, trigger]);
|
||||||
|
|
||||||
const update = useCallback(
|
const update = useCallback(
|
||||||
(module: string, field: string, value: any) => {
|
(path: string, value: any) => {
|
||||||
setUpdates(prev => ({
|
const [module, field, subField] = path.split('/');
|
||||||
...prev,
|
const key = `${module}.${field}`;
|
||||||
[`${module}.${field}`]: {
|
const from = get(appConfig, key);
|
||||||
from: get(appConfig, `${module}.${field}`),
|
setUpdates(prev => {
|
||||||
to: value,
|
const to = subField
|
||||||
},
|
? set(prev[key]?.to ?? { ...from }, subField, value)
|
||||||
}));
|
: value;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[key]: {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setPatchedAppConfig(prev => {
|
||||||
|
return set(
|
||||||
|
prev,
|
||||||
|
`${module}.${field}${subField ? `.${subField}` : ''}`,
|
||||||
|
value
|
||||||
|
);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[appConfig]
|
[appConfig]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appConfig,
|
appConfig: appConfig as AppConfig,
|
||||||
|
patchedAppConfig,
|
||||||
update,
|
update,
|
||||||
save,
|
save,
|
||||||
updates,
|
updates,
|
||||||
|
|||||||
Reference in New Issue
Block a user