From 23661f7e2882053a127e429c612335d394e60368 Mon Sep 17 00:00:00 2001 From: aliang <1098486429@qq.com> Date: Fri, 9 Feb 2024 07:52:29 +0800 Subject: [PATCH] feat(ui, webserver): add general settings (#1405) * feat(ui): init general settings [autofix.ci] apply automated fixes feat: useFieldArray fix: format fix: text * fix: Separator * [autofix.ci] apply automated fixes * update * fix: domain list keydown * update * update schema.graphql * connect network form * connect security form * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * [autofix.ci] apply automated fixes (attempt 3/3) --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Meng Zhang --- .../general/components/form-section.tsx | 26 ++ .../settings/general/components/general.tsx | 40 +++ .../general/components/network-form.tsx | 135 ++++++++++ .../general/components/security-form.tsx | 242 ++++++++++++++++++ .../app/(dashboard)/settings/general/page.tsx | 10 + ee/tabby-ui/components/ui/checkbox.tsx | 31 +++ ee/tabby-ui/package.json | 1 + ee/tabby-ui/yarn.lock | 15 ++ ee/tabby-webserver/graphql/schema.graphql | 39 ++- ee/tabby-webserver/src/schema/mod.rs | 8 +- ee/tabby-webserver/src/schema/setting.rs | 22 +- ee/tabby-webserver/src/service/setting.rs | 3 - 12 files changed, 552 insertions(+), 20 deletions(-) create mode 100644 ee/tabby-ui/app/(dashboard)/settings/general/components/form-section.tsx create mode 100644 ee/tabby-ui/app/(dashboard)/settings/general/components/general.tsx create mode 100644 ee/tabby-ui/app/(dashboard)/settings/general/components/network-form.tsx create mode 100644 ee/tabby-ui/app/(dashboard)/settings/general/components/security-form.tsx create mode 100644 ee/tabby-ui/app/(dashboard)/settings/general/page.tsx create mode 100644 ee/tabby-ui/components/ui/checkbox.tsx diff --git a/ee/tabby-ui/app/(dashboard)/settings/general/components/form-section.tsx b/ee/tabby-ui/app/(dashboard)/settings/general/components/form-section.tsx new file mode 100644 index 000000000000..a7dbb323aee6 --- /dev/null +++ b/ee/tabby-ui/app/(dashboard)/settings/general/components/form-section.tsx @@ -0,0 +1,26 @@ +import React from 'react' + +import { cn } from '@/lib/utils' + +interface GeneralFormSectionProps + extends Omit, 'title'> { + title: string +} + +export const GeneralFormSection: React.FC = ({ + title, + className, + children, + ...props +}) => { + return ( +
+
+

{title}

+
+
+
{children}
+
+
+ ) +} diff --git a/ee/tabby-ui/app/(dashboard)/settings/general/components/general.tsx b/ee/tabby-ui/app/(dashboard)/settings/general/components/general.tsx new file mode 100644 index 000000000000..e3d8e1c427df --- /dev/null +++ b/ee/tabby-ui/app/(dashboard)/settings/general/components/general.tsx @@ -0,0 +1,40 @@ +'use client' + +import React from 'react' + +import { Separator } from '@/components/ui/separator' +import { ListSkeleton } from '@/components/skeleton' + +import { GeneralFormSection } from './form-section' +import { GeneralNetworkForm } from './network-form' +import { GeneralSecurityForm } from './security-form' + +export default function General() { + // todo usequery + + const [initialized, setInitialized] = React.useState(false) + + React.useEffect(() => { + setTimeout(() => { + // get data from query and then setInitialized + setInitialized(true) + }, 500) + }, []) + + // makes it convenient to set the defaultValues of forms + if (!initialized) return + + return ( +
+ + {/* todo pass defualtValues from useQuery */} + + + + + {/* todo pass defualtValues from useQuery */} + + +
+ ) +} diff --git a/ee/tabby-ui/app/(dashboard)/settings/general/components/network-form.tsx b/ee/tabby-ui/app/(dashboard)/settings/general/components/network-form.tsx new file mode 100644 index 000000000000..99ad7f85e754 --- /dev/null +++ b/ee/tabby-ui/app/(dashboard)/settings/general/components/network-form.tsx @@ -0,0 +1,135 @@ +'use client' + +import React from 'react' +import { zodResolver } from '@hookform/resolvers/zod' +import { isEmpty } from 'lodash-es' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import { useQuery } from 'urql' +import * as z from 'zod' + +import { graphql } from '@/lib/gql/generates' +import { useMutation } from '@/lib/tabby/gql' +import { Button } from '@/components/ui/button' +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' + +const updateNetworkSettingMutation = graphql(/* GraphQL */ ` + mutation updateNetworkSettingMutation($input: NetworkSettingInput!) { + updateNetworkSetting(input: $input) + } +`) + +export const networkSetting = graphql(/* GraphQL */ ` + query NetworkSetting { + networkSetting { + externalUrl + } + } +`) + +const formSchema = z.object({ + externalUrl: z.string() +}) + +type NetworkFormValues = z.infer + +interface NetworkFormProps { + defaultValues?: Partial + onSuccess?: () => void +} + +const NetworkForm: React.FC = ({ + onSuccess, + defaultValues: propsDefaultValues +}) => { + const defaultValues = React.useMemo(() => { + return { + ...(propsDefaultValues || {}) + } + }, [propsDefaultValues]) + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues + }) + + const isDirty = !isEmpty(form.formState.dirtyFields) + + const updateNetworkSetting = useMutation(updateNetworkSettingMutation, { + form, + onCompleted(values) { + if (values?.updateNetworkSetting) { + onSuccess?.() + form.reset(form.getValues()) + } + } + }) + + const onSubmit = async () => { + await updateNetworkSetting({ + input: form.getValues() + }) + } + + return ( +
+
+ + ( + + External URL + + The external URL where user visits Tabby, must start with + http:// or https://. + + + + + + + )} + /> +
+ +
+ + +
+ + ) +} + +export const GeneralNetworkForm = () => { + const [{ data: data }] = useQuery({ query: networkSetting }) + const onSuccess = () => { + toast.success('Network configuration is updated') + } + return ( + data && ( + + ) + ) +} diff --git a/ee/tabby-ui/app/(dashboard)/settings/general/components/security-form.tsx b/ee/tabby-ui/app/(dashboard)/settings/general/components/security-form.tsx new file mode 100644 index 000000000000..0fb12b9cf799 --- /dev/null +++ b/ee/tabby-ui/app/(dashboard)/settings/general/components/security-form.tsx @@ -0,0 +1,242 @@ +'use client' + +import React from 'react' +import { zodResolver } from '@hookform/resolvers/zod' +import { compact, isEmpty } from 'lodash-es' +import { useFieldArray, useForm } from 'react-hook-form' +import { toast } from 'sonner' +import { useQuery } from 'urql' +import * as z from 'zod' + +import { graphql } from '@/lib/gql/generates' +import { useMutation } from '@/lib/tabby/gql' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from '@/components/ui/form' +import { IconTrash } from '@/components/ui/icons' +import { Input } from '@/components/ui/input' + +const updateSecuritySettingMutation = graphql(/* GraphQL */ ` + mutation updateSecuritySetting($input: SecuritySettingInput!) { + updateSecuritySetting(input: $input) + } +`) + +export const securitySetting = graphql(/* GraphQL */ ` + query SecuritySetting { + securitySetting { + allowedRegisterDomainList + disableClientSideTelemetry + } + } +`) + +const formSchema = z.object({ + disableClientSideTelemetry: z.boolean(), + // https://github.com/shadcn-ui/ui/issues/384 + // https://github.com/shadcn-ui/ui/blob/main/apps/www/app/examples/forms/profile-form.tsx + allowedRegisterDomainList: z + .array( + z.object({ + value: z.string() + }) + ) + .optional() +}) + +type SecurityFormValues = z.infer + +interface SecurityFormProps { + defaultValues?: SecurityFormValues + onSuccess?: () => void +} + +const SecurityForm: React.FC = ({ + onSuccess, + defaultValues: propsDefaultValues +}) => { + const defaultValues = React.useMemo(() => { + return { + ...(propsDefaultValues || {}) + } + }, [propsDefaultValues]) + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues + }) + + const { fields, append, remove, update } = useFieldArray({ + control: form.control, + name: 'allowedRegisterDomainList' + }) + + const isDirty = !isEmpty(form.formState.dirtyFields) + + const onRemoveDomainItem = (index: number) => { + if (fields?.length === 1 && index === 0) { + update(index, { value: '' }) + } else { + remove(index) + } + } + + const handleDomainListKeyDown: React.KeyboardEventHandler< + HTMLInputElement + > = e => { + if (e.key === 'Enter' && !e.nativeEvent.isComposing) { + e.preventDefault() + append({ value: '' }) + } + } + + const updateSecuritySetting = useMutation(updateSecuritySettingMutation, { + form, + onCompleted(values) { + if (values?.updateSecuritySetting) { + onSuccess?.() + form.reset(form.getValues()) + } + } + }) + + const onSubmit = async ({ + allowedRegisterDomainList, + ...values + }: SecurityFormValues) => { + await updateSecuritySetting({ + input: { + allowedRegisterDomainList: buildListValuesFromField( + allowedRegisterDomainList + ), + ...values + } + }) + } + + return ( +
+
+ + ( + +
+ + + + + Disabling Client Side Telemetry + +
+ + When activated, the client-side telemetry (IDE/Extensions) + will be disabled, regardless of the client-side settings. + +
+ )} + /> +
+ {fields.map((field, index) => ( + ( + + + Domain List for Register (without an Invitation) + + + Add domains for register without an invitation. + +
+ + + + +
+ +
+ )} + /> + ))} +
+ +
+
+
+ +
+ + +
+ + ) +} + +function buildListFieldFromValues(list: string[] | undefined) { + const domains = list?.map(item => ({ value: item })) + if (!domains || domains.length === 0) { + return [{ value: '' }] + } else { + return domains + } +} + +function buildListValuesFromField(fieldListValue?: Array<{ value: string }>) { + const list = compact(fieldListValue?.map(item => item.value)) + return list +} + +export const GeneralSecurityForm = () => { + const [{ data }] = useQuery({ query: securitySetting }) + const onSuccess = () => { + toast.success('Network configuration is updated') + } + const defaultValues = data && { + ...data.securitySetting, + allowedRegisterDomainList: buildListFieldFromValues( + data.securitySetting.allowedRegisterDomainList + ) + } + return ( + data && + ) +} diff --git a/ee/tabby-ui/app/(dashboard)/settings/general/page.tsx b/ee/tabby-ui/app/(dashboard)/settings/general/page.tsx new file mode 100644 index 000000000000..42023774537e --- /dev/null +++ b/ee/tabby-ui/app/(dashboard)/settings/general/page.tsx @@ -0,0 +1,10 @@ +import General from './components/general' + +export default function GeneralSettings() { + // todo abstract settings-layout after email was merged + return ( +
+ +
+ ) +} diff --git a/ee/tabby-ui/components/ui/checkbox.tsx b/ee/tabby-ui/components/ui/checkbox.tsx new file mode 100644 index 000000000000..2d9206b74184 --- /dev/null +++ b/ee/tabby-ui/components/ui/checkbox.tsx @@ -0,0 +1,31 @@ +'use client' + +import * as React from 'react' +import * as CheckboxPrimitive from '@radix-ui/react-checkbox' + +import { cn } from '@/lib/utils' + +import { IconCheck } from './icons' + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/ee/tabby-ui/package.json b/ee/tabby-ui/package.json index b6c416633f1c..68ca5009d6a8 100644 --- a/ee/tabby-ui/package.json +++ b/ee/tabby-ui/package.json @@ -25,6 +25,7 @@ "@codemirror/view": "^6.23.0", "@hookform/resolvers": "^3.3.2", "@radix-ui/react-alert-dialog": "1.0.4", + "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-dialog": "1.0.4", "@radix-ui/react-dropdown-menu": "^2.0.5", diff --git a/ee/tabby-ui/yarn.lock b/ee/tabby-ui/yarn.lock index 9d69d2c90fac..c301faf0c116 100644 --- a/ee/tabby-ui/yarn.lock +++ b/ee/tabby-ui/yarn.lock @@ -1861,6 +1861,21 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-primitive" "1.0.3" +"@radix-ui/react-checkbox@^1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@radix-ui/react-checkbox/-/react-checkbox-1.0.4.tgz#98f22c38d5010dd6df4c5744cac74087e3275f4b" + integrity sha512-CBuGQa52aAYnADZVt/KBQzXrwx6TqnlwtcIPGtVt5JkkzQwMOLJjPukimhfKEr4GQNd43C+djUh5Ikopj8pSLg== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-presence" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-controllable-state" "1.0.1" + "@radix-ui/react-use-previous" "1.0.1" + "@radix-ui/react-use-size" "1.0.1" + "@radix-ui/react-collapsible@^1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@radix-ui/react-collapsible/-/react-collapsible-1.0.3.tgz#df0e22e7a025439f13f62d4e4a9e92c4a0df5b81" diff --git a/ee/tabby-webserver/graphql/schema.graphql b/ee/tabby-webserver/graphql/schema.graphql index ed65dc139b75..0b4151202b0c 100644 --- a/ee/tabby-webserver/graphql/schema.graphql +++ b/ee/tabby-webserver/graphql/schema.graphql @@ -1,3 +1,8 @@ +type TokenAuthResponse { + accessToken: String! + refreshToken: String! +} + enum WorkerKind { COMPLETION CHAT @@ -19,6 +24,8 @@ type Mutation { updateOauthCredential(provider: OAuthProvider!, clientId: String!, clientSecret: String!, redirectUri: String): Boolean! deleteOauthCredential(provider: OAuthProvider!): Boolean! updateEmailSetting(smtpUsername: String!, smtpPassword: String, smtpServer: String!): Boolean! + updateSecuritySetting(input: SecuritySettingInput!): Boolean! + updateNetworkSetting(input: NetworkSettingInput!): Boolean! deleteEmailSetting: Boolean! } @@ -43,9 +50,10 @@ type JWTPayload { type JobRun { id: ID! - jobName: String! - startTime: DateTimeUtc! - finishTime: DateTimeUtc + job: String! + createdAt: DateTimeUtc! + updatedAt: DateTimeUtc! + finishedAt: DateTimeUtc exitCode: Int stdout: String! stderr: String! @@ -60,10 +68,16 @@ type Query { invitations(after: String, before: String, first: Int, last: Int): InvitationConnection! jobRuns(after: String, before: String, first: Int, last: Int): JobRunConnection! emailSetting: EmailSetting + networkSetting: NetworkSetting! + securitySetting: SecuritySetting! repositories(after: String, before: String, first: Int, last: Int): RepositoryConnection! oauthCredential(provider: OAuthProvider!): OAuthCredential } +input NetworkSettingInput { + externalUrl: String! +} + type UserEdge { node: User! cursor: String! @@ -90,6 +104,16 @@ type RepositoryConnection { pageInfo: PageInfo! } +input SecuritySettingInput { + allowedRegisterDomainList: [String!]! + disableClientSideTelemetry: Boolean! +} + +type SecuritySetting { + allowedRegisterDomainList: [String!]! + disableClientSideTelemetry: Boolean! +} + type UserConnection { edges: [UserEdge!]! pageInfo: PageInfo! @@ -129,11 +153,6 @@ type User { active: Boolean! } -type TokenAuthResponse { - accessToken: String! - refreshToken: String! -} - type Worker { kind: WorkerKind! name: String! @@ -162,6 +181,10 @@ type PageInfo { endCursor: String } +type NetworkSetting { + externalUrl: String! +} + type JobRunEdge { node: JobRun! cursor: String! diff --git a/ee/tabby-webserver/src/schema/mod.rs b/ee/tabby-webserver/src/schema/mod.rs index 4a7ade4d1d7a..07a037fad69a 100644 --- a/ee/tabby-webserver/src/schema/mod.rs +++ b/ee/tabby-webserver/src/schema/mod.rs @@ -22,7 +22,7 @@ use juniper_axum::{ }; use tabby_common::api::{code::CodeSearch, event::RawEventLogger}; use tracing::{error, warn}; -use validator::ValidationErrors; +use validator::{Validate, ValidationErrors}; use worker::{Worker, WorkerService}; use self::{ @@ -70,6 +70,9 @@ pub enum CoreError { #[error("Invalid ID Error")] InvalidIDError, + #[error("Invalid input parameters")] + InvalidInput(#[from] ValidationErrors), + #[error(transparent)] Other(#[from] anyhow::Error), } @@ -80,6 +83,7 @@ impl IntoFieldError for CoreError { Self::Unauthorized(msg) => { FieldError::new(msg, graphql_value!({"code": "UNAUTHORIZED"})) } + Self::InvalidInput(errors) => from_validation_errors(errors), _ => self.into(), } } @@ -483,6 +487,7 @@ impl Mutation { "Only admin can access server settings", )); }; + input.validate()?; ctx.locator.setting().update_security_setting(input).await?; Ok(true) } @@ -493,6 +498,7 @@ impl Mutation { "Only admin can access server settings", )); }; + input.validate()?; ctx.locator.setting().update_network_setting(input).await?; Ok(true) } diff --git a/ee/tabby-webserver/src/schema/setting.rs b/ee/tabby-webserver/src/schema/setting.rs index b383b99fa10d..a62cc5300cfe 100644 --- a/ee/tabby-webserver/src/schema/setting.rs +++ b/ee/tabby-webserver/src/schema/setting.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use anyhow::Result; use async_trait::async_trait; @@ -34,23 +34,29 @@ pub struct NetworkSetting { #[derive(GraphQLInputObject, Validate)] pub struct NetworkSettingInput { - #[validate(url)] + #[validate(url(code = "externalUrl", message = "URL is malformed"))] pub external_url: String, } fn validate_unique_domains(domains: &[String]) -> Result<(), ValidationError> { let unique: HashSet<_> = domains.iter().collect(); if unique.len() != domains.len() { - let collision = domains.iter().find(|s| unique.contains(s)).unwrap(); - let mut err = ValidationError::new("securityAllowedRegisterDomainList"); - err.message = Some(format!("Duplicate domain: {collision}").into()); + let i = domains.iter().position(|s| unique.contains(s)).unwrap(); + let err = ValidationError { + code: format!("allowedRegisterDomainList.{i}.value").into(), + message: Some("Duplicate domain".into()), + params: HashMap::default(), + }; return Err(err); } - for domain in domains { + for (i, domain) in domains.iter().enumerate() { let email = format!("noreply@{domain}"); if !validate_email(email) { - let mut err = ValidationError::new("securityAllowedRegisterDomainList"); - err.message = Some(format!("Invalid domain name: {domain}").into()); + let err = ValidationError { + code: format!("allowedRegisterDomainList.{i}.value").into(), + message: Some("Invalid domain".into()), + params: HashMap::default(), + }; return Err(err); } } diff --git a/ee/tabby-webserver/src/service/setting.rs b/ee/tabby-webserver/src/service/setting.rs index 4e0f654d8994..bdb2282afb9a 100644 --- a/ee/tabby-webserver/src/service/setting.rs +++ b/ee/tabby-webserver/src/service/setting.rs @@ -1,7 +1,6 @@ use anyhow::Result; use async_trait::async_trait; use tabby_db::DbConn; -use validator::Validate; use crate::schema::setting::{ NetworkSetting, NetworkSettingInput, SecuritySetting, SecuritySettingInput, SettingService, @@ -14,7 +13,6 @@ impl SettingService for DbConn { } async fn update_security_setting(&self, input: SecuritySettingInput) -> Result<()> { - input.validate()?; let domains = if input.allowed_register_domain_list.is_empty() { None } else { @@ -30,7 +28,6 @@ impl SettingService for DbConn { } async fn update_network_setting(&self, input: NetworkSettingInput) -> Result<()> { - input.validate()?; self.update_network_setting(input.external_url).await } }