From bbfbd39c1489618179374c3077f931b1965764ce Mon Sep 17 00:00:00 2001 From: jimmycwc Date: Mon, 1 Jun 2026 15:03:08 +0800 Subject: [PATCH 01/10] [Portal] Extend v2 TextField, FormField, and SecondaryButton Add password type and plain suffix support on TextField, optional labelSize on FormField, and match disabled SecondaryButton styling to the design system. Co-authored-by: Cursor --- .../SecondaryButton/SecondaryButton.module.css | 8 ++++++++ .../SecondaryButton/SecondaryButton.stories.ts | 7 +++++++ portal/src/components/v2/FormField/FormField.tsx | 5 ++++- .../components/v2/TextField/TextField.module.css | 5 +++++ portal/src/components/v2/TextField/TextField.tsx | 15 ++++++++++++++- 5 files changed, 38 insertions(+), 2 deletions(-) diff --git a/portal/src/components/v2/Button/SecondaryButton/SecondaryButton.module.css b/portal/src/components/v2/Button/SecondaryButton/SecondaryButton.module.css index ccd84d48ac4..25a59dd31f8 100644 --- a/portal/src/components/v2/Button/SecondaryButton/SecondaryButton.module.css +++ b/portal/src/components/v2/Button/SecondaryButton/SecondaryButton.module.css @@ -1,3 +1,11 @@ .secondaryButton { font-weight: var(--font-weight-medium); } + +.secondaryButton:where([data-disabled]) { + background-color: var(--gray-a3); + color: var(--gray-a8); + box-shadow: none; + opacity: 1; + cursor: not-allowed; +} diff --git a/portal/src/components/v2/Button/SecondaryButton/SecondaryButton.stories.ts b/portal/src/components/v2/Button/SecondaryButton/SecondaryButton.stories.ts index 579255a949b..a4c214f6cd3 100644 --- a/portal/src/components/v2/Button/SecondaryButton/SecondaryButton.stories.ts +++ b/portal/src/components/v2/Button/SecondaryButton/SecondaryButton.stories.ts @@ -26,3 +26,10 @@ export const Default: Story = { disabled: false, }, }; + +export const Disabled: Story = { + args: { + size: "2", + disabled: true, + }, +}; diff --git a/portal/src/components/v2/FormField/FormField.tsx b/portal/src/components/v2/FormField/FormField.tsx index 66587556393..bdab6d152cd 100644 --- a/portal/src/components/v2/FormField/FormField.tsx +++ b/portal/src/components/v2/FormField/FormField.tsx @@ -13,6 +13,8 @@ type FormFieldLabelSpace = "1" | "2"; export interface FormFieldProps { darkMode?: boolean; size: FormFieldSize; + /** Label typography size; defaults to `size` when omitted. */ + labelSize?: FormFieldSize; label?: React.ReactNode; optional?: boolean; error?: React.ReactNode; @@ -29,6 +31,7 @@ export interface FormFieldProps { export function FormField({ darkMode, size, + labelSize, label, optional, error: propsError, @@ -68,7 +71,7 @@ export function FormField({ {label ? ( diff --git a/portal/src/components/v2/TextField/TextField.module.css b/portal/src/components/v2/TextField/TextField.module.css index caad22bf244..6ac915cc466 100644 --- a/portal/src/components/v2/TextField/TextField.module.css +++ b/portal/src/components/v2/TextField/TextField.module.css @@ -12,3 +12,8 @@ border-left: 1px solid var(--gray-a6); color: var(--gray-a11); } + +.textField__suffix--plain { + background-color: transparent; + border-left: none; +} diff --git a/portal/src/components/v2/TextField/TextField.tsx b/portal/src/components/v2/TextField/TextField.tsx index 305778c0093..98e4e08aad0 100644 --- a/portal/src/components/v2/TextField/TextField.tsx +++ b/portal/src/components/v2/TextField/TextField.tsx @@ -25,6 +25,7 @@ export enum TextFieldIcon { export interface TextInputProps { size: TextFieldSize; + type?: "text" | "password" | "email" | "number" | "search" | "tel" | "url" | "hidden" | "date" | "time" | "datetime-local" | "month" | "week"; disabled?: boolean; readOnly?: boolean; placeholder?: string; @@ -39,8 +40,12 @@ export interface TextInputProps { export interface TextFieldProps extends TextInputProps { darkMode?: boolean; label?: React.ReactNode; + /** Label typography size; defaults to `size` when omitted. */ + labelSize?: TextFieldSize; optional?: boolean; suffix?: React.ReactNode; + /** Icon-only suffix (e.g. password visibility) without chip background/border. */ + suffixPlain?: boolean; iconStart?: TextFieldIcon; iconEnd?: TextFieldIcon; hint?: React.ReactNode; @@ -58,12 +63,14 @@ function TextField_(props: TextFieldProps): React.ReactElement { darkMode, size, label, + labelSize, optional, error, hint, iconStart, iconEnd, suffix, + suffixPlain, parentJSONPointer = "", fieldName, @@ -87,6 +94,7 @@ function TextField_(props: TextFieldProps): React.ReactElement { {suffix} @@ -125,6 +136,7 @@ function TextField_(props: TextFieldProps): React.ReactElement { function Input({ size, + type, disabled, readOnly, placeholder, @@ -140,6 +152,7 @@ function Input({ className={cn(error != null ? styles["textField--error"] : null)} variant="surface" size={size} + type={type} placeholder={placeholder} disabled={disabled} readOnly={readOnly} From 3c8602ebbc3f8af610ebbab1263859edf9ba7d88 Mon Sep 17 00:00:00 2001 From: jimmycwc Date: Mon, 1 Jun 2026 15:03:17 +0800 Subject: [PATCH 02/10] [Portal] Add v2 SaveFunctionBar for unsaved form changes Introduce a floating save bar aligned to content width, with discard confirmation and Storybook coverage. Co-authored-by: Cursor --- .../SaveFunctionBar.module.css | 38 +++++++ .../SaveFunctionBar.stories.tsx | 70 ++++++++++++ .../v2/SaveFunctionBar/SaveFunctionBar.tsx | 106 ++++++++++++++++++ .../useSaveFunctionBarAlignment.ts | 55 +++++++++ 4 files changed, 269 insertions(+) create mode 100644 portal/src/components/v2/SaveFunctionBar/SaveFunctionBar.module.css create mode 100644 portal/src/components/v2/SaveFunctionBar/SaveFunctionBar.stories.tsx create mode 100644 portal/src/components/v2/SaveFunctionBar/SaveFunctionBar.tsx create mode 100644 portal/src/components/v2/SaveFunctionBar/useSaveFunctionBarAlignment.ts diff --git a/portal/src/components/v2/SaveFunctionBar/SaveFunctionBar.module.css b/portal/src/components/v2/SaveFunctionBar/SaveFunctionBar.module.css new file mode 100644 index 00000000000..aac6b69281a --- /dev/null +++ b/portal/src/components/v2/SaveFunctionBar/SaveFunctionBar.module.css @@ -0,0 +1,38 @@ +.root { + position: fixed; + bottom: var(--space-6); + z-index: 100; + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + padding: var(--space-2) var(--space-4); + border: 1px solid var(--gray-6); + border-radius: 12px; + background-color: #fff; + box-shadow: var(--shadow-5); + box-sizing: border-box; +} + +.message { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + gap: var(--space-2); +} + +.messageIcon { + flex-shrink: 0; + width: var(--space-4); + height: var(--space-4); + color: var(--gray-a9); +} + +.actions { + display: flex; + flex-shrink: 0; + align-items: center; + gap: var(--space-3); +} diff --git a/portal/src/components/v2/SaveFunctionBar/SaveFunctionBar.stories.tsx b/portal/src/components/v2/SaveFunctionBar/SaveFunctionBar.stories.tsx new file mode 100644 index 00000000000..bf30537b085 --- /dev/null +++ b/portal/src/components/v2/SaveFunctionBar/SaveFunctionBar.stories.tsx @@ -0,0 +1,70 @@ +import React, { useCallback, useRef, useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + FormContainerBase, + FormContainerBaseProps, +} from "../../../FormContainerBase"; +import { SaveFunctionBar } from "./SaveFunctionBar"; + +function SaveFunctionBarStoryHarness({ + startClean = false, +}: { + startClean?: boolean; +}): React.ReactElement { + const anchorRef = useRef(null); + const [value, setValue] = useState(startClean ? "hello" : "hello!"); + const [savedValue, setSavedValue] = useState("hello"); + const isDirty = value !== savedValue; + + const form: FormContainerBaseProps["form"] = { + isDirty, + isUpdating: false, + updateError: null, + reset: useCallback(() => { + setValue(savedValue); + }, [savedValue]), + save: useCallback(async () => { + setSavedValue(value); + }, [value]), + }; + + return ( + +
+
+ +
+ + + ); +} + +const meta = { + component: SaveFunctionBar, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => , +}; + +export const HiddenWhenClean: Story = { + render: () => , +}; diff --git a/portal/src/components/v2/SaveFunctionBar/SaveFunctionBar.tsx b/portal/src/components/v2/SaveFunctionBar/SaveFunctionBar.tsx new file mode 100644 index 00000000000..13303d63607 --- /dev/null +++ b/portal/src/components/v2/SaveFunctionBar/SaveFunctionBar.tsx @@ -0,0 +1,106 @@ +import React, { useCallback, useContext, useMemo, useState } from "react"; +import cn from "classnames"; +import { Text } from "@radix-ui/themes"; +import { InfoCircledIcon } from "@radix-ui/react-icons"; +import { Dialog, DialogFooter } from "@fluentui/react"; +import { Context, FormattedMessage } from "../../../intl"; +import { useFormContainerBaseContext } from "../../../FormContainerBase"; +import { useSystemConfig } from "../../../context/SystemConfigContext"; +import { PrimaryButton } from "../Button/PrimaryButton/PrimaryButton"; +import { SecondaryButton } from "../Button/SecondaryButton/SecondaryButton"; +import FluentPrimaryButton from "../../../PrimaryButton"; +import DefaultButton from "../../../DefaultButton"; +import styles from "./SaveFunctionBar.module.css"; +import { useSaveFunctionBarAlignment } from "./useSaveFunctionBarAlignment"; + +export interface SaveFunctionBarProps { + className?: string; + anchorRef?: React.RefObject; +} + +export function SaveFunctionBar({ + className, + anchorRef, +}: SaveFunctionBarProps): React.ReactElement | null { + const { canReset, canSave, isDirty, isUpdating, onReset, onSave } = + useFormContainerBaseContext(); + const alignStyle = useSaveFunctionBarAlignment(anchorRef); + const { themes } = useSystemConfig(); + const { renderToString } = useContext(Context); + + const [isDiscardDialogVisible, setIsDiscardDialogVisible] = useState(false); + const onOpenDiscardDialog = useCallback(() => { + setIsDiscardDialogVisible(true); + }, []); + const onDismissDiscardDialog = useCallback(() => { + setIsDiscardDialogVisible(false); + }, []); + const onConfirmDiscard = useCallback(() => { + onReset(); + setTimeout(() => setIsDiscardDialogVisible(false), 0); + }, [onReset]); + + const discardDialogContentProps = useMemo(() => { + return { + title: , + subText: renderToString("FormContainer.reset-dialog.message"), + }; + }, [renderToString]); + + if (!isDirty) { + return null; + } + + const fixedStyle = + anchorRef != null && alignStyle != null ? alignStyle : undefined; + + return ( + <> +
+
+ + + + +
+
+ } + onClick={onOpenDiscardDialog} + /> + } + onClick={onSave} + /> +
+
+ + + ); +} diff --git a/portal/src/components/v2/SaveFunctionBar/useSaveFunctionBarAlignment.ts b/portal/src/components/v2/SaveFunctionBar/useSaveFunctionBarAlignment.ts new file mode 100644 index 00000000000..aed75f94747 --- /dev/null +++ b/portal/src/components/v2/SaveFunctionBar/useSaveFunctionBarAlignment.ts @@ -0,0 +1,55 @@ +import { useLayoutEffect, useState } from "react"; + +function findScrollParent(element: HTMLElement | null): HTMLElement | null { + let parent = element?.parentElement ?? null; + while (parent != null) { + const { overflowY } = getComputedStyle(parent); + if (overflowY === "auto" || overflowY === "scroll") { + return parent; + } + parent = parent.parentElement; + } + return null; +} + +export function useSaveFunctionBarAlignment( + anchorRef: React.RefObject | undefined +): React.CSSProperties | undefined { + const [style, setStyle] = useState(() => + anchorRef != null ? { visibility: "hidden" } : undefined + ); + + useLayoutEffect(() => { + const anchor = anchorRef?.current; + if (anchor == null) { + setStyle(undefined); + return; + } + + const update = () => { + const rect = anchor.getBoundingClientRect(); + setStyle({ + left: rect.left, + width: rect.width, + visibility: "visible", + }); + }; + + update(); + + const resizeObserver = new ResizeObserver(update); + resizeObserver.observe(anchor); + window.addEventListener("resize", update); + + const scrollParent = findScrollParent(anchor); + scrollParent?.addEventListener("scroll", update, { passive: true }); + + return () => { + resizeObserver.disconnect(); + window.removeEventListener("resize", update); + scrollParent?.removeEventListener("scroll", update); + }; + }, [anchorRef]); + + return style; +} From 057f5abd5877a33e4cdf48831fd5ad6e1172619a Mon Sep 17 00:00:00 2001 From: jimmycwc Date: Mon, 1 Jun 2026 15:03:22 +0800 Subject: [PATCH 03/10] [Portal] Migrate Custom Email Provider screen to Radix UI Replace the enable toggle with provider cards, adopt v2 form controls and SaveFunctionBar, update typography and spacing to match design, and swap provider logos to SVG assets. Co-authored-by: Cursor --- .../portal/SMTPConfigurationScreen.module.css | 39 + .../portal/SMTPConfigurationScreen.tsx | 775 ++++++++++-------- portal/src/images/authgear_logo.svg | 3 + portal/src/images/sendgrid_logo.png | Bin 351 -> 0 bytes portal/src/images/sendgrid_logo.svg | 3 + portal/src/locale-data/en.json | 5 + 6 files changed, 464 insertions(+), 361 deletions(-) create mode 100644 portal/src/images/authgear_logo.svg delete mode 100644 portal/src/images/sendgrid_logo.png create mode 100644 portal/src/images/sendgrid_logo.svg diff --git a/portal/src/graphql/portal/SMTPConfigurationScreen.module.css b/portal/src/graphql/portal/SMTPConfigurationScreen.module.css index 6d167d4fa06..377e5452748 100644 --- a/portal/src/graphql/portal/SMTPConfigurationScreen.module.css +++ b/portal/src/graphql/portal/SMTPConfigurationScreen.module.css @@ -26,3 +26,42 @@ @apply tablet:col-span-full; } + +.providerSelector { + @apply flex flex-col gap-1; +} + +.pageHeader { + @apply flex flex-col gap-2; +} + +.pageTitle { + margin: 0; + font-size: 20px; + line-height: 28px; + font-weight: 600; + color: var(--gray-12); +} + +.pageDescription, +.providerDescription { + color: var(--gray-a11); +} + +.providerDescription { + font-size: 12px; + line-height: 16px; +} + +.contentWithSaveBar { + padding-bottom: 5.5rem; +} + +.contentWidthAnchor { + grid-column: 1 / span 8; + height: 0; + overflow: hidden; + pointer-events: none; + + @apply tablet:col-span-full; +} diff --git a/portal/src/graphql/portal/SMTPConfigurationScreen.tsx b/portal/src/graphql/portal/SMTPConfigurationScreen.tsx index 3489c7f3529..2db689769ee 100644 --- a/portal/src/graphql/portal/SMTPConfigurationScreen.tsx +++ b/portal/src/graphql/portal/SMTPConfigurationScreen.tsx @@ -1,25 +1,18 @@ import cn from "classnames"; -import React, { useCallback, useContext, useState, useMemo } from "react"; +import React, { useCallback, useContext, useState, useMemo, useRef } from "react"; import { useLocation, useParams, useNavigate } from "react-router-dom"; import { produce } from "immer"; import { Dialog, DialogFooter } from "@fluentui/react"; import { FormattedMessage, Context } from "../../intl"; import { parseSender } from "email-addresses"; -import { useTextFieldTooltip } from "../../useTextFieldTooltip"; import ShowError from "../../ShowError"; import ShowLoading from "../../ShowLoading"; import FormContainer from "../../FormContainer"; -import FormTextField from "../../FormTextField"; import { AppSecretConfigFormModel, useAppSecretConfigForm, } from "../../hook/useAppSecretConfigForm"; import ScreenContent from "../../ScreenContent"; -import ScreenTitle from "../../ScreenTitle"; -import ScreenDescription from "../../ScreenDescription"; -import Widget from "../../Widget"; -import TextField from "../../TextField"; -import Toggle from "../../Toggle"; import { startReauthentication } from "./Authenticated"; import { PortalAPIAppConfig, @@ -32,27 +25,32 @@ import { useSendTestEmailMutation, UseSendTestEmailMutationReturnType, } from "./mutations/sendTestEmail"; -import logoSendgrid from "../../images/sendgrid_logo.png"; +import logoSendgrid from "../../images/sendgrid_logo.svg"; +import logoAuthgear from "../../images/authgear_logo.svg"; import styles from "./SMTPConfigurationScreen.module.css"; -import PrimaryButton from "../../PrimaryButton"; -import DefaultButton from "../../DefaultButton"; import ExternalLink from "../../ExternalLink"; - import { AppSecretKey } from "./globalTypes.generated"; import { useLocationEffect } from "../../hook/useLocationEffect"; import { useAppSecretVisitToken } from "./mutations/generateAppSecretVisitTokenMutation"; import { useAppAndSecretConfigQuery } from "./query/appAndSecretConfigQuery"; import { useAppFeatureConfigQuery } from "./query/appFeatureConfigQuery"; import FeatureDisabledMessageBar from "./FeatureDisabledMessageBar"; -import { - ProviderCard, - ProviderCardDescription, -} from "../../components/common/ProviderCard"; import { ErrorParseRule, ErrorParseRuleResult } from "../../error/parse"; import { APIError, APISMTPTestFailedError } from "../../error/error"; import { useSystemConfig } from "../../context/SystemConfigContext"; import { RedMessageBar_RemindConfigureSMTPInSMTPConfigurationScreen } from "../../RedMessageBar"; import { useCalloutToast } from "../../components/v2/Callout/Callout"; +import { + IconRadioCards, + IconRadioCardOption, +} from "../../components/v2/IconRadioCards/IconRadioCards"; +import { TextField } from "../../components/v2/TextField/TextField"; +import { PrimaryButton } from "../../components/v2/Button/PrimaryButton/PrimaryButton"; +import { SecondaryButton } from "../../components/v2/Button/SecondaryButton/SecondaryButton"; +import { Text } from "@radix-ui/themes"; +import { EnvelopeClosedIcon, EyeNoneIcon, EyeOpenIcon } from "@radix-ui/react-icons"; +import { SaveFunctionBar } from "../../components/v2/SaveFunctionBar/SaveFunctionBar"; +import { useFormContainerBaseContext } from "../../FormContainerBase"; interface LocationState { isEdit: boolean; @@ -66,6 +64,7 @@ function isLocationState(raw: unknown): raw is LocationState { } enum ProviderType { + Authgear = "authgear", Sendgrid = "sendgrid", Custom = "custom", } @@ -76,6 +75,9 @@ const SENDGRID_HOST = "smtp.sendgrid.net"; const SENDGRID_PORT_STRING = "587"; const SENDGRID_USERNAME = "apikey"; +// Matches v2 IconRadioCards storybook inner icon size (SquareIcon iconSize). +const PROVIDER_RADIO_ICON_SIZE = "1.375rem"; + interface ConfigFormState { enabled: boolean; providerType: ProviderType; @@ -108,7 +110,11 @@ function constructFormState( secrets.smtpSecret?.host === SENDGRID_HOST && String(secrets.smtpSecret.port) === SENDGRID_PORT_STRING && secrets.smtpSecret.username === SENDGRID_USERNAME; - const providerType = isSendgrid ? ProviderType.Sendgrid : ProviderType.Custom; + const providerType = !enabled + ? ProviderType.Authgear + : isSendgrid + ? ProviderType.Sendgrid + : ProviderType.Custom; const isPasswordMasked = enabled && secrets.smtpSecret?.password == null; let sendgridAPIKey = ""; @@ -251,10 +257,6 @@ function constructSecretUpdateInstruction( }; } -const CUSTOM_PROVIDER_ICON_PROPS = { - iconName: "Mail", -}; - const ERROR_RULES: ErrorParseRule[] = [ (apiError: APIError): ErrorParseRuleResult => { if (apiError.reason === "SMTPTestFailed") { @@ -290,108 +292,69 @@ const SMTPConfigurationScreenContent: React.VFC(null); + + const onChangeProviderType = useCallback( + (value: ProviderType) => { + setState((s) => ({ + ...s, + enabled: value !== ProviderType.Authgear, + providerType: value, + })); + }, + [setState] + ); - const openSendTestEmailDialogButtonEnabled = useMemo(() => { - if (!state.enabled) { - return false; - } - switch (state.providerType) { - case ProviderType.Sendgrid: - return ( - state.sendgridAPIKey !== "" && state.sendgridSenderAddress !== "" - ); - case ProviderType.Custom: - return ( - state.customHost !== "" && - state.customPortString !== "" && - state.customUsername !== "" && - state.customPassword !== "" && - state.customSenderAddress !== "" - ); - } - }, [state]); - - const sendTestEmailButtonEnabled = useMemo(() => { - return toAddress !== ""; - }, [toAddress]); - - const onStringChangeCallbacks = useMemo(() => { - const callbackFactory = ( - key: - | "sendgridAPIKey" - | "sendgridSenderName" - | "sendgridSenderAddress" - | "customHost" - | "customUsername" - | "customPassword" - | "customSenderName" - | "customSenderAddress" - ) => { - return (_: unknown, value?: string) => { - if (value != null) { - setState((state) => { - const s: FormState = { - ...state, - }; - s[key] = value; - return s; - }); - } + const fieldCallbacks = useMemo(() => { + const make = + ( + key: + | "sendgridAPIKey" + | "sendgridSenderName" + | "sendgridSenderAddress" + | "customHost" + | "customUsername" + | "customPassword" + | "customSenderName" + | "customSenderAddress" + ) => + (e: React.ChangeEvent) => { + const value = e.target.value; + setState((s) => { + const next: FormState = { ...s }; + next[key] = value; + return next; + }); }; - }; return { - sendgridAPIKey: callbackFactory("sendgridAPIKey"), - sendgridSenderName: callbackFactory("sendgridSenderName"), - sendgridSenderAddress: callbackFactory("sendgridSenderAddress"), - customHost: callbackFactory("customHost"), - customUsername: callbackFactory("customUsername"), - customPassword: callbackFactory("customPassword"), - customSenderName: callbackFactory("customSenderName"), - customSenderAddress: callbackFactory("customSenderAddress"), + sendgridAPIKey: make("sendgridAPIKey"), + sendgridSenderName: make("sendgridSenderName"), + sendgridSenderAddress: make("sendgridSenderAddress"), + customHost: make("customHost"), + customUsername: make("customUsername"), + customPassword: make("customPassword"), + customSenderName: make("customSenderName"), + customSenderAddress: make("customSenderAddress"), }; }, [setState]); const onCustomPortChange = useCallback( - (_: unknown, value?: string) => { - if (value != null) { - let newValue: string | undefined; - if (value !== "") { - const port = Number(value); - if (!isNaN(port)) { - newValue = value; - } - } else { - newValue = ""; - } - if (newValue !== undefined) { - const v = newValue; - setState((state) => { - return { - ...state, - customPortString: v, - }; - }); - } + (e: React.ChangeEvent) => { + const value = e.target.value; + if (value === "" || !isNaN(Number(value))) { + setState((s) => ({ ...s, customPortString: value })); } }, [setState] ); - const onChangeEnabled = useCallback( - (_event, checked?: boolean) => { - if (checked != null) { - setState((state) => { - return { - ...state, - enabled: checked, - }; - }); - } - }, - [setState] - ); + const onToggleShowPassword = useCallback(() => { + setShowPassword((v) => !v); + }, []); const navigate = useNavigate(); @@ -400,23 +363,44 @@ const SMTPConfigurationScreenContent: React.VFC { - // Normally there should not be any error. + startReauthentication(navigate, locationState).catch((e) => { console.error(e); }); }, [navigate] ); + const openSendTestEmailDialogButtonEnabled = useMemo(() => { + switch (state.providerType) { + case ProviderType.Authgear: + return false; + case ProviderType.Sendgrid: + return ( + state.sendgridAPIKey !== "" && state.sendgridSenderAddress !== "" + ); + case ProviderType.Custom: + return ( + state.customHost !== "" && + state.customPortString !== "" && + state.customUsername !== "" && + state.customPassword !== "" && + state.customSenderAddress !== "" + ); + default: + return false; + } + }, [state]); + + const sendTestEmailButtonEnabled = useMemo(() => toAddress !== "", [toAddress]); + const onClickSendTestEmail = useCallback( (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - setToAddress(viewer?.email ?? ""); setIsDialogHidden(false); }, @@ -427,7 +411,6 @@ const SMTPConfigurationScreenContent: React.VFC) => { e?.preventDefault(); e?.stopPropagation(); - if (!loading) { setIsDialogHidden(true); } @@ -442,6 +425,8 @@ const SMTPConfigurationScreenContent: React.VFC { - if (value != null) { - setToAddress(value); - } - }, []); + const onChangeToAddress = useCallback( + (e: React.ChangeEvent) => { + setToAddress(e.target.value); + }, + [] + ); - const dialogContentProps = useMemo(() => { - return { + const dialogContentProps = useMemo( + () => ({ title: renderToString( "SMTPConfigurationScreen.send-test-email-dialog.title" ), subText: renderToString( "SMTPConfigurationScreen.send-test-email-dialog.description" ), - }; - }, [renderToString]); - - const onClickProviderSendgrid = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - setState((state) => { - return { - ...state, - providerType: ProviderType.Sendgrid, - }; - }); - }, - [setState] + }), + [renderToString] ); - const onClickProviderCustom = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - setState((state) => { - return { - ...state, - providerType: ProviderType.Custom, - }; - }); - }, - [setState] + const providerOptions = useMemo( + (): IconRadioCardOption[] => [ + { + value: ProviderType.Authgear, + icon: ( + + ), + title: ( + + ), + disabled: state.isPasswordMasked, + }, + { + value: ProviderType.Sendgrid, + icon: ( + + ), + title: ( + + ), + disabled: state.isPasswordMasked || isCustomSMTPDisabled, + }, + { + value: ProviderType.Custom, + icon: ( + + ), + title: ( + + ), + disabled: state.isPasswordMasked || isCustomSMTPDisabled, + }, + ], + [isCustomSMTPDisabled, state.isPasswordMasked] ); - const hostProps = useTextFieldTooltip({ - tooltipLabel: renderToString("SMTPConfigurationScreen.host.tooltip"), - }); - - const portProps = useTextFieldTooltip({ - tooltipLabel: renderToString("SMTPConfigurationScreen.port.tooltip"), - }); + const providerDescription = useMemo(() => { + switch (state.providerType) { + case ProviderType.Authgear: + return ( + + ); + case ProviderType.Sendgrid: + return ( + ( + + {chunks} + + ), + }} + /> + ); + case ProviderType.Custom: + return ( + ( + + {chunks} + + ), + }} + /> + ); + default: + return null; + } + }, [state.providerType]); - const sendgridAPIKeyProps = useTextFieldTooltip({ - tooltipLabel: renderToString( - "SMTPConfigurationScreen.sendgrid.api-key.tooltip" - ), - }); + const showSettings = + state.providerType === ProviderType.Sendgrid || + state.providerType === ProviderType.Custom; return ( - - - {isAuthgearOnce ? ( - - ) : ( - - )} - - - - + +
+
+

+ {isAuthgearOnce ? ( + + ) : ( + + )} +

+ + + +
{isCustomSMTPDisabled ? (
) : null} - - + - {state.enabled ? ( - <> - - - - - - - {form.state.providerType === ProviderType.Custom ? ( + {providerDescription != null ? ( + + {providerDescription} + + ) : null} +
+ + {showSettings ? ( +
+ + + +
+ {state.providerType === ProviderType.Sendgrid ? ( <> - - ( - - {chunks} - - ), - }} - /> - - - - - + } + hint={renderToString( + "SMTPConfigurationScreen.sendgrid.api-key.tooltip" )} value={ state.isPasswordMasked ? MASKED_PASSWORD_VALUE - : state.customPassword + : state.sendgridAPIKey } disabled={state.isPasswordMasked} - required={true} - onChange={onStringChangeCallbacks.customPassword} + onChange={fieldCallbacks.sendgridAPIKey} parentJSONPointer={/\/secrets\/\d+\/data/} fieldName="password" /> - + } + value={state.sendgridSenderName} disabled={state.isPasswordMasked} - onChange={onStringChangeCallbacks.customSenderName} + onChange={fieldCallbacks.sendgridSenderName} parentJSONPointer={/\/secrets\/\d+\/data/} - /* Otherwise, the field is registered twice, and the error will be shown twice. */ - /* Luckily, this field will not have any error so we can work around this way. */ fieldName="__THIS_IS_INTENTIONALLY_CHANGED_TO_A_NONEXISTENT_FIELD_NAME__" /> - + } + value={state.sendgridSenderAddress} disabled={state.isPasswordMasked} - required={true} - onChange={onStringChangeCallbacks.customSenderAddress} + onChange={fieldCallbacks.sendgridSenderAddress} parentJSONPointer={/\/secrets\/\d+\/data/} fieldName="sender" /> ) : null} - {form.state.providerType === ProviderType.Sendgrid ? ( + {state.providerType === ProviderType.Custom ? ( <> - - ( - - {chunks} - - ), - }} - /> - - + } + hint={renderToString( + "SMTPConfigurationScreen.host.tooltip" + )} + value={state.customHost} + disabled={state.isPasswordMasked} + onChange={fieldCallbacks.customHost} + parentJSONPointer={/\/secrets\/\d+\/data/} + fieldName="host" + /> + + } + hint={renderToString( + "SMTPConfigurationScreen.port.tooltip" )} + value={state.customPortString} + disabled={state.isPasswordMasked} + onChange={onCustomPortChange} + parentJSONPointer={/\/secrets\/\d+\/data/} + fieldName="port" + /> + + } + value={state.customUsername} + disabled={state.isPasswordMasked} + onChange={fieldCallbacks.customUsername} + parentJSONPointer={/\/secrets\/\d+\/data/} + fieldName="username" + /> + + } value={ state.isPasswordMasked ? MASKED_PASSWORD_VALUE - : state.sendgridAPIKey + : state.customPassword } - required={true} disabled={state.isPasswordMasked} - onChange={onStringChangeCallbacks.sendgridAPIKey} + onChange={fieldCallbacks.customPassword} parentJSONPointer={/\/secrets\/\d+\/data/} fieldName="password" - {...sendgridAPIKeyProps} + suffixPlain + suffix={ + !state.isPasswordMasked ? ( + + ) : undefined + } /> - - + } + value={state.customSenderName} disabled={state.isPasswordMasked} - onChange={onStringChangeCallbacks.sendgridSenderName} + onChange={fieldCallbacks.customSenderName} parentJSONPointer={/\/secrets\/\d+\/data/} - fieldName="sender" + fieldName="__THIS_IS_INTENTIONALLY_CHANGED_TO_A_NONEXISTENT_FIELD_NAME__" /> - + } + value={state.customSenderAddress} disabled={state.isPasswordMasked} - required={true} - onChange={onStringChangeCallbacks.sendgridSenderAddress} + onChange={fieldCallbacks.customSenderAddress} parentJSONPointer={/\/secrets\/\d+\/data/} fieldName="sender" /> ) : null} {state.isPasswordMasked ? ( - } - /> - ) : ( - - } - /> - )} - - - ) : null} - +
+ )} +
+ + ) : null} + + + ); }; @@ -909,6 +961,7 @@ const SMTPConfigurationScreen1: React.VFC<{ return ( diff --git a/portal/src/images/authgear_logo.svg b/portal/src/images/authgear_logo.svg new file mode 100644 index 00000000000..7430b6d419b --- /dev/null +++ b/portal/src/images/authgear_logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/portal/src/images/sendgrid_logo.png b/portal/src/images/sendgrid_logo.png deleted file mode 100644 index 66c3cbd1100e00e12a90f16ff7175b6619452240..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 351 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=oCO|{#S9E$svykh8Km+7D9BhG z4(**%)S)_H8G+xvH=`Eq`yyHmPu zs|o8)53*vZ)?)4Bop`Q%=3(xKfAAP^;`7nYjP+~9;`6_|+?tvdY3{>0?f7Qfi~siB u{!?csq`B1O?cVLXSw1sEt;FV%-3&UH)VJ75PL%-qpTX1B&t;ucLK6VxbANOI diff --git a/portal/src/images/sendgrid_logo.svg b/portal/src/images/sendgrid_logo.svg new file mode 100644 index 00000000000..eac62cb5619 --- /dev/null +++ b/portal/src/images/sendgrid_logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/portal/src/locale-data/en.json b/portal/src/locale-data/en.json index fffd39f314c..00c64bd0cdf 100644 --- a/portal/src/locale-data/en.json +++ b/portal/src/locale-data/en.json @@ -1296,6 +1296,8 @@ "FormContainer.reset-dialog.title": "Discard unsaved changes", "FormContainer.reset-dialog.message": "Are you sure you want to discard unsaved changes?", "FormContainer.reset-dialog.confirm": "Yes, discard", + "SaveFunctionBar.message": "You have unsaved changes.", + "SaveFunctionBar.discard": "Discard", "AuthenticatorType.primary.password": "Password", "AuthenticatorType.primary.passkey": "Passkey", "AuthenticatorType.primary.oob-otp-email": "Passwordless via Email", @@ -1601,6 +1603,9 @@ "SMTPConfigurationScreen.title--authgearonce": "Email Provider", "SMTPConfigurationScreen.description": "Optimize for email deliverability by using your own SMTP server to send Authgear Emails (such as password reset code, verification) in your own domains", "SMTPConfigurationScreen.enable.label": "Use my own provider", + "SMTPConfigurationScreen.provider.authgear": "Authgear", + "SMTPConfigurationScreen.authgear.description": "Send emails directly with Authgear's built-in service. No extra setup needed.", + "SMTPConfigurationScreen.settings.label": "Settings", "SMTPConfigurationScreen.host.label": "Host", "SMTPConfigurationScreen.host.tooltip": "Hostname or IP address of your SMTP server", "SMTPConfigurationScreen.port.label": "Port", From 49b4cd8838d092a3529d114a047313a73784dd3c Mon Sep 17 00:00:00 2001 From: jimmycwc Date: Tue, 2 Jun 2026 15:03:29 +0800 Subject: [PATCH 04/10] [Portal] Fix Admin API keys section overflow on small screens Use v2 components for Admin API Configuration screen and make the keys table horizontally scrollable on narrow viewports. Co-authored-by: Cursor --- .../components/v2/Callout/Callout.module.css | 6 + .../ConfirmationDialog.module.css | 5 + .../ConfirmationDialog.stories.tsx | 82 ++ .../ConfirmationDialog/ConfirmationDialog.tsx | 57 ++ .../SaveFunctionBar.stories.tsx | 45 +- .../src/components/v2/TextField/TextField.tsx | 12 +- .../AdminAPIConfigurationScreen.module.css | 174 ++++- .../portal/AdminAPIConfigurationScreen.tsx | 721 ++++++++++-------- portal/src/locale-data/en.json | 6 +- 9 files changed, 749 insertions(+), 359 deletions(-) create mode 100644 portal/src/components/v2/ConfirmationDialog/ConfirmationDialog.module.css create mode 100644 portal/src/components/v2/ConfirmationDialog/ConfirmationDialog.stories.tsx create mode 100644 portal/src/components/v2/ConfirmationDialog/ConfirmationDialog.tsx diff --git a/portal/src/components/v2/Callout/Callout.module.css b/portal/src/components/v2/Callout/Callout.module.css index c3fac0469d8..230e1f509d7 100644 --- a/portal/src/components/v2/Callout/Callout.module.css +++ b/portal/src/components/v2/Callout/Callout.module.css @@ -7,6 +7,10 @@ grid-template-columns: auto 1fr auto; align-items: center; + + /* Prevent the grid from overflowing its flex parent on small screens */ + min-width: 0; + overflow: hidden; } .calloutRoot--toast { @@ -19,6 +23,8 @@ .calloutText { grid-area: text; + min-width: 0; + word-break: break-word; } .calloutAction { diff --git a/portal/src/components/v2/ConfirmationDialog/ConfirmationDialog.module.css b/portal/src/components/v2/ConfirmationDialog/ConfirmationDialog.module.css new file mode 100644 index 00000000000..b150d8c13cb --- /dev/null +++ b/portal/src/components/v2/ConfirmationDialog/ConfirmationDialog.module.css @@ -0,0 +1,5 @@ +.actions { + @apply flex flex-row flex-wrap justify-end; + gap: var(--space-3); + margin-top: var(--space-4); +} diff --git a/portal/src/components/v2/ConfirmationDialog/ConfirmationDialog.stories.tsx b/portal/src/components/v2/ConfirmationDialog/ConfirmationDialog.stories.tsx new file mode 100644 index 00000000000..d84c1d902bc --- /dev/null +++ b/portal/src/components/v2/ConfirmationDialog/ConfirmationDialog.stories.tsx @@ -0,0 +1,82 @@ +import React, { useCallback, useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FormattedMessage } from "../../../intl"; +import { SecondaryButton } from "../Button/SecondaryButton/SecondaryButton"; +import { ConfirmationDialog } from "./ConfirmationDialog"; + +function ConfirmationDialogDemo({ + loading = false, + confirmColor = "red", +}: { + loading?: boolean; + confirmColor?: "red" | "indigo"; +}): React.ReactElement { + const [open, setOpen] = useState(false); + + const onOpenChange = useCallback( + (nextOpen: boolean) => { + if (!loading) { + setOpen(nextOpen); + } + }, + [loading] + ); + + const onCancel = useCallback(() => { + setOpen(false); + }, []); + + const onConfirm = useCallback(() => { + setOpen(false); + }, []); + + return ( + <> + setOpen(true)} + /> + + } + description={ + + } + confirmText={ + + } + cancelText={} + onConfirm={onConfirm} + onCancel={onCancel} + loading={loading} + confirmColor={confirmColor} + /> + + ); +} + +const meta = { + component: ConfirmationDialogDemo, + tags: ["autodocs"], + args: { + loading: false, + confirmColor: "red", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const DeleteAdminAPIKey: Story = { + name: "Delete Admin API key", +}; + +export const Loading: Story = { + args: { + loading: true, + }, +}; diff --git a/portal/src/components/v2/ConfirmationDialog/ConfirmationDialog.tsx b/portal/src/components/v2/ConfirmationDialog/ConfirmationDialog.tsx new file mode 100644 index 00000000000..518da76e64c --- /dev/null +++ b/portal/src/components/v2/ConfirmationDialog/ConfirmationDialog.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { Button, Dialog } from "@radix-ui/themes"; +import { SecondaryButton } from "../Button/SecondaryButton/SecondaryButton"; +import styles from "./ConfirmationDialog.module.css"; + +export interface ConfirmationDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: React.ReactNode; + description: React.ReactNode; + confirmText: React.ReactNode; + cancelText: React.ReactNode; + onConfirm: () => void; + onCancel: () => void; + loading?: boolean; + confirmColor?: "red" | "indigo"; +} + +export function ConfirmationDialog({ + open, + onOpenChange, + title, + description, + confirmText, + cancelText, + onConfirm, + onCancel, + loading = false, + confirmColor = "red", +}: ConfirmationDialogProps): React.ReactElement { + return ( + + + {title} + {description} +
+ + +
+
+
+ ); +} diff --git a/portal/src/components/v2/SaveFunctionBar/SaveFunctionBar.stories.tsx b/portal/src/components/v2/SaveFunctionBar/SaveFunctionBar.stories.tsx index bf30537b085..2e6da58ab78 100644 --- a/portal/src/components/v2/SaveFunctionBar/SaveFunctionBar.stories.tsx +++ b/portal/src/components/v2/SaveFunctionBar/SaveFunctionBar.stories.tsx @@ -1,11 +1,16 @@ import React, { useCallback, useRef, useState } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; +import { createMemoryRouter, RouterProvider } from "react-router-dom"; import { FormContainerBase, FormContainerBaseProps, } from "../../../FormContainerBase"; +import { SystemConfigContext } from "../../../context/SystemConfigContext"; +import { defaultSystemConfig, instantiateSystemConfig } from "../../../system-config"; import { SaveFunctionBar } from "./SaveFunctionBar"; +const systemConfig = instantiateSystemConfig(defaultSystemConfig); + function SaveFunctionBarStoryHarness({ startClean = false, }: { @@ -16,16 +21,20 @@ function SaveFunctionBarStoryHarness({ const [savedValue, setSavedValue] = useState("hello"); const isDirty = value !== savedValue; + const reset = useCallback(() => { + setValue(savedValue); + }, [savedValue]); + + const save = useCallback(async () => { + setSavedValue(value); + }, [value]); + const form: FormContainerBaseProps["form"] = { isDirty, isUpdating: false, updateError: null, - reset: useCallback(() => { - setValue(savedValue); - }, [savedValue]), - save: useCallback(async () => { - setSavedValue(value); - }, [value]), + reset, + save, }; return ( @@ -50,11 +59,25 @@ const meta = { component: SaveFunctionBar, tags: ["autodocs"], decorators: [ - (Story) => ( -
- -
- ), + (Story) => { + const router = createMemoryRouter( + [ + { + path: "/", + element: ( + +
+ +
+
+ ), + }, + ], + { initialEntries: ["/"] } + ); + + return ; + }, ], } satisfies Meta; diff --git a/portal/src/components/v2/TextField/TextField.tsx b/portal/src/components/v2/TextField/TextField.tsx index 98e4e08aad0..b43a4e74a5a 100644 --- a/portal/src/components/v2/TextField/TextField.tsx +++ b/portal/src/components/v2/TextField/TextField.tsx @@ -56,6 +56,7 @@ export interface TextFieldProps extends TextInputProps { parentJSONPointer?: string | RegExp; fieldName?: string; errorRules?: ErrorParseRule[]; + inputClassName?: string; } function TextField_(props: TextFieldProps): React.ReactElement { @@ -145,11 +146,18 @@ function Input({ onChange, onBlur, onFocus, + inputClassName, children, -}: TextInputProps & { children: React.ReactNode }): React.ReactElement { +}: TextInputProps & { + children: React.ReactNode; + inputClassName?: string; +}): React.ReactElement { return ( +
+ + {title} + +
{children}
+
+ + ); +} + +function CopyIconButton({ + textToCopy, +}: { + textToCopy: string; +}): React.ReactElement { + const { renderToString } = useContext(Context); + const [copied, setCopied] = useState(false); + const timerRef = useRef | null>(null); + + const handleCopy = useCallback(() => { + copyToClipboard(textToCopy); + setCopied(true); + if (timerRef.current != null) { + clearTimeout(timerRef.current); + } + timerRef.current = setTimeout(() => { + setCopied(false); + }, 2000); + }, [textToCopy]); + + return ( + + + + + + ); +} + +interface ReadOnlyCopyFieldProps { + label: React.ReactNode; + value: string; + placeholder?: string; + hint?: React.ReactNode; + truncate?: boolean; +} + +function ReadOnlyCopyField({ + label, + value, + placeholder, + hint, + truncate, +}: ReadOnlyCopyFieldProps): React.ReactElement { + const showCopy = value.length > 0; + + return ( + : undefined} + /> + ); +} + +interface AdminAPIKeysTableProps { + items: Item[]; + onDownload: (keyID: string) => void; + onDelete: (keyID: string) => void; + canDelete: boolean; +} + +function AdminAPIKeysTable({ + items, + onDownload, + onDelete, + canDelete, +}: AdminAPIKeysTableProps): React.ReactElement { + return ( +
+
+
+
+ +
+
+ +
+
+
+ {items.map((item) => ( +
+
+
+ + {item.keyID} + + +
+
+
+ + {item.createdAt ?? ""} + +
+
+ + + + + + + + { + onDownload(item.keyID); + }} + > + + + + { + if (canDelete) { + onDelete(item.keyID); + } + }} + > + + + + + +
+
+ ))} +
+
+ ); +} + const AdminAPIConfigurationScreenContent: React.VFC = function AdminAPIConfigurationScreenContent(props) { const { appID, secretToken, queryResult, generateKey, deleteKey } = props; const { locale, renderToString } = useContext(Context); const { effectiveAppConfig } = useAppAndSecretConfigQuery(appID); - const { themes } = useSystemConfig(); const isLoading = useIsLoading(); const [deleteKeyID, setDeleteKeyID] = useState(null); const [isDeleteDialogVisible, setIsDeleteDialogVisible] = useState(false); const [shortLivedAdminAPIToken, setShortLivedAdminAPIToken] = useState< string | null >(null); - const theme = useTheme(); const [shortLivedAdminAPITokenError, setShortLivedAdminAPITokenError] = useState([]); @@ -133,14 +297,11 @@ const AdminAPIConfigurationScreenContent: React.VFC { const base = makeGraphQLEndpoint(appID); @@ -196,8 +357,6 @@ const AdminAPIConfigurationScreenContent: React.VFC { if (secretToken == null) { - // generateShortLivedAdminAPITokenHandle should be called only - // when there is a secret token console.error("secret token should not be null"); return; } @@ -220,10 +379,6 @@ const AdminAPIConfigurationScreenContent: React.VFC { const apiErrors = parseRawError(e); @@ -258,29 +413,19 @@ const AdminAPIConfigurationScreenContent: React.VFC { - return { - title: renderToString( - "AdminAPIConfigurationScreen.keys.delete-dialog.title" - ), - subText: renderToString( - "AdminAPIConfigurationScreen.keys.delete-dialog.message" - ), - }; - }, [renderToString]); - - const showDialogAndSetDeleteKeyID = useCallback( - (keyID: string) => { - setDeleteKeyID(keyID); - setIsDeleteDialogVisible(true); - }, - [setIsDeleteDialogVisible] - ); - const dismissDialogAndResetDeleteKeyID = useCallback(() => { setIsDeleteDialogVisible(false); setDeleteKeyID(null); - }, [setIsDeleteDialogVisible]); + }, []); + + const onDeleteDialogOpenChange = useCallback( + (open: boolean) => { + if (!open && !isLoading) { + dismissDialogAndResetDeleteKeyID(); + } + }, + [dismissDialogAndResetDeleteKeyID, isLoading] + ); const onConfirmDelete = useCallback(() => { if (deleteKeyID == null) { @@ -293,113 +438,42 @@ const AdminAPIConfigurationScreenContent: React.VFC { - return ( - - - - ); - }, []); - - const createdAtColumnOnRender = useCallback((item?: Item) => { - return ( - - {item?.createdAt ?? ""} - - ); + const showDialogAndSetDeleteKeyID = useCallback((keyID: string) => { + setDeleteKeyID(keyID); + setIsDeleteDialogVisible(true); }, []); - const actionColumnOnRender = useCallback( - (item?: Item, index?: number) => { - const deleteButtonID = `delete-button-${index}`; - const calloutProps = { - target: `#${deleteButtonID}`, - }; - return ( -
- ) => { - e.preventDefault(); - e.stopPropagation(); - if (item != null) { - downloadItem(item.keyID); - } - }} - text={} - /> - {items.length > 1 ? ( - ) => { - e.preventDefault(); - e.stopPropagation(); - if (item != null) { - showDialogAndSetDeleteKeyID(item.keyID); - } - }} - text={} - /> - ) : ( - - } - calloutProps={calloutProps} - > - } - /> - - )} -
- ); - }, - [ - downloadItem, - items.length, - showDialogAndSetDeleteKeyID, - themes.actionButton, - themes.destructive, - ] + const apiEndpointDocHint = ( + ( + + {chunks} + + ), + // eslint-disable-next-line react/no-unstable-nested-components + code: (chunks: React.ReactNode) => {chunks}, + }} + /> ); - const columns: IColumn[] = useMemo(() => { - return [ - { - key: "keyID", - fieldName: "keyID", - name: renderToString("AdminAPIConfigurationScreen.column.key-id"), - minWidth: 150, - onRender: keyIDColumnOnRender, - }, - { - key: "createdAt", - fieldName: "createdAt", - name: renderToString("AdminAPIConfigurationScreen.column.created-at"), - minWidth: 220, - onRender: createdAtColumnOnRender, - }, - { - key: "action", - name: renderToString("action"), - minWidth: 150, - onRender: actionColumnOnRender, - }, - ]; - }, [ - renderToString, - keyIDColumnOnRender, - createdAtColumnOnRender, - actionColumnOnRender, - ]); + const graphiqlWarning = ( + {chunks}, + }} + /> + } + /> + ); return ( <> @@ -413,191 +487,164 @@ const AdminAPIConfigurationScreenContent: React.VFC {chunks}, + b: (chunks: React.ReactNode) => {chunks}, }} /> - - - - - - - - ( - - {chunks} - - ), - }} + +
+ + } + > + + } + value={adminAPIEndpoint} /> - - - - - - - - - {items.length >= 2 ? ( - - - - ) : ( - + } + value={rawAppID} /> - )} -
-
-
- - {shortLivedAdminAPIToken ? ( - <> - + {apiEndpointDocHint} + + + + + } + > + 1} + /> + {items.length >= 2 ? ( + + } + /> + ) : ( + + )} + +
+
+
+
+ + } + value={shortLivedAdminAPIToken ?? ""} + truncate={true} + placeholder={renderToString( + "AdminAPIConfigurationScreen.short-lived-admin-api-token.generate.placeholder" + )} /> - - - ) : null} +
+ + } + onClick={onClickGenerateShortLivedAdminAPIToken} + disabled={generatingShortLivedAdminAPITokenLoading} + loading={generatingShortLivedAdminAPITokenLoading} + /> +
+ + +
- + ) : null} +
+
+ + + } + > + {graphiqlWarning} + +
, + }} + /> +
+
+ + } - disabled={generatingShortLivedAdminAPITokenLoading} + onClick={() => { + window.open( + graphqlEndpoint, + DEFAULT_EXTERNAL_LINK_PROPS.target, + DEFAULT_EXTERNAL_LINK_PROPS.rel + ); + }} />
- {shortLivedAdminAPITokenFieldErrors ? ( - - {shortLivedAdminAPITokenFieldErrors} - - ) : null} - - {} - - -
- - - - - - - - {chunks}, - }} - /> - - - ( - - {chunks} - - ), - }} - /> - -
- - } - /> -
-
+ +
- + + } + description={ + + } + confirmText={ + + } + cancelText={} + onConfirm={onConfirmDelete} + onCancel={dismissDialogAndResetDeleteKeyID} + loading={isLoading} + /> ); }; diff --git a/portal/src/locale-data/en.json b/portal/src/locale-data/en.json index 00c64bd0cdf..33aa9a2f47f 100644 --- a/portal/src/locale-data/en.json +++ b/portal/src/locale-data/en.json @@ -1268,7 +1268,7 @@ "HookConfigurationScreen.signature.secret-key": "Secret Key", "AdminAPIConfigurationScreen.title": "Admin API", "AdminAPIConfigurationScreen.description": "The Admin API allows your server to manage your project and users via a GraphQL endpoint.", - "AdminAPIConfigurationScreen.keys.title": "Admin API keys", + "AdminAPIConfigurationScreen.keys.title": "API Keys", "AdminAPIConfigurationScreen.keys.generate.label": "Generate new key pair", "AdminAPIConfigurationScreen.keys.generate.warning": "Max. 2 keys per project", "AdminAPIConfigurationScreen.keys.delete.tooltip": "At least 1 key per project", @@ -1282,12 +1282,14 @@ "AdminAPIConfigurationScreen.column.key-id": "Key ID", "AdminAPIConfigurationScreen.column.created-at": "Created At", "AdminAPIConfigurationScreen.graphiql.title": "GraphiQL Explorer", - "AdminAPIConfigurationScreen.graphiql.description": "Inspect the GraphQL schema and build queries with GraphiQL.

On your server you should always use the Admin API endpoint and the API keys.", + "AdminAPIConfigurationScreen.graphiql.description": "Inspect the GraphQL schema and build queries with GraphiQL.

On your server you should always use the Admin API endpoint and the API keys.", "AdminAPIConfigurationScreen.graphiql.warning": "Please beware this is NOT a sandbox environment, changes are made to REAL DATA.", "AdminAPIConfigurationScreen.graphiql.open": "Open GraphiQL Explorer", "AdminAPIConfigurationScreen.details.title": "Details", "AdminAPIConfigurationScreen.details.description": "Accessing the Admin API GraphQL endpoint requires your server to generate a valid JWT and include it as Authorization HTTP header. Read the Admin API documentation for more details.", "AdminAPIConfigurationScreen.api-endpoint.title": "API Endpoint", + "AdminAPIConfigurationScreen.api-endpoint.section-title": "API Endpoint", + "AdminAPIConfigurationScreen.graphql-url.label": "GraphQL URL", "AdminAPIConfigurationScreen.project-id.title": "Project ID", "PasswordStrengthMeter.password-strength": "Password Strength", "ModifiedIndicator.message": "You have made unsaved changes", From c2aee8342bd21afd03cdb5c3b4b31ba617c60474 Mon Sep 17 00:00:00 2001 From: jimmycwc Date: Thu, 4 Jun 2026 15:07:02 +0800 Subject: [PATCH 05/10] [Portal] Migrate shared discard dialogs to Radix ConfirmationDialog Use ConfirmationDialog for SaveFunctionBar, FormContainer reset, and navigation blocker flows. Raise dialog overlay z-index so Monaco editor UI does not paint above the modal mask. Co-authored-by: Cursor --- portal/src/BlockerDialog.tsx | 67 ++++++------------- portal/src/FormContainer.tsx | 49 ++++++-------- portal/src/NavigationBlockerDialog.tsx | 2 +- .../ConfirmationDialog/ConfirmationDialog.tsx | 16 +++-- .../v2/SaveFunctionBar/SaveFunctionBar.tsx | 58 ++++++---------- portal/src/radix-theme-overrides.css | 6 ++ 6 files changed, 82 insertions(+), 116 deletions(-) diff --git a/portal/src/BlockerDialog.tsx b/portal/src/BlockerDialog.tsx index e1b95c534ca..76da39ac8f9 100644 --- a/portal/src/BlockerDialog.tsx +++ b/portal/src/BlockerDialog.tsx @@ -1,23 +1,14 @@ -import React, { useContext, useMemo } from "react"; -import { - IDialogContentProps, - Dialog, - DialogType, - DialogFooter, - IDialogProps, - IButtonProps, -} from "@fluentui/react"; -import PrimaryButton from "./PrimaryButton"; -import DefaultButton from "./DefaultButton"; -import { Context, FormattedMessage } from "./intl"; -import { useSystemConfig } from "./context/SystemConfigContext"; +import React from "react"; +import { FormattedMessage } from "./intl"; +import { ConfirmationDialog } from "./components/v2/ConfirmationDialog/ConfirmationDialog"; -export interface BlockerDialogProps extends IDialogProps { +export interface BlockerDialogProps { + open: boolean; contentTitleId: string; contentSubTextId: string; contentConfirmId?: string; contentCancelId?: string; - onDialogConfirm?: IButtonProps["onClick"]; + onDialogConfirm?: () => void; onDialogDismiss?: () => void; } @@ -25,45 +16,31 @@ const BlockerDialog: React.VFC = function BlockerDialog( props ) { const { + open, contentTitleId, contentSubTextId, contentConfirmId, contentCancelId, onDialogConfirm, onDialogDismiss, - ...rest } = props; - const { themes } = useSystemConfig(); - const { renderToString } = useContext(Context); - - const dialogContentProps: IDialogContentProps = useMemo( - () => ({ - type: DialogType.normal, - title: , - subText: renderToString(contentSubTextId), - }), - [renderToString, contentTitleId, contentSubTextId] - ); - return ( - - - } - /> - } - /> - - + { + if (!isOpen) { + onDialogDismiss?.(); + } + }} + title={} + description={} + confirmText={} + cancelText={} + confirmColor="red" + onConfirm={() => onDialogConfirm?.()} + onCancel={() => onDialogDismiss?.()} + /> ); }; diff --git a/portal/src/FormContainer.tsx b/portal/src/FormContainer.tsx index cf0848317d1..c6d6f4574be 100644 --- a/portal/src/FormContainer.tsx +++ b/portal/src/FormContainer.tsx @@ -1,10 +1,9 @@ -import React, { useCallback, useContext, useMemo, useState } from "react"; -import { Dialog, DialogFooter, Spinner, SpinnerSize } from "@fluentui/react"; +import React, { useCallback, useContext, useState } from "react"; +import { Spinner, SpinnerSize } from "@fluentui/react"; import { Context, FormattedMessage } from "./intl"; import { useSystemConfig } from "./context/SystemConfigContext"; import { FormErrorMessageBar } from "./FormErrorMessageBar"; import PrimaryButton from "./PrimaryButton"; -import DefaultButton from "./DefaultButton"; import DefaultLayout from "./DefaultLayout"; import { FormContainerBase, @@ -13,6 +12,7 @@ import { } from "./FormContainerBase"; import ActionButton from "./ActionButton"; import styles from "./FormContainer.module.css"; +import { ConfirmationDialog } from "./components/v2/ConfirmationDialog/ConfirmationDialog"; export interface SaveButtonProps { labelId: string; @@ -60,13 +60,6 @@ const FormContainer_: React.VFC = function FormContainer_( setTimeout(() => setIsResetDialogVisible(false), 0); }, [onReset]); - const resetDialogContentProps = useMemo(() => { - return { - title: , - subText: renderToString("FormContainer.reset-dialog.message"), - }; - }, [renderToString]); - return ( <> = function FormContainer_( {props.children} - + { + if (!open) { + onDismissResetDialog(); + } + }} + title={} + description={ + + } + confirmText={ + + } + cancelText={} + confirmColor="red" + onConfirm={doReset} + onCancel={onDismissResetDialog} + /> ); }; diff --git a/portal/src/NavigationBlockerDialog.tsx b/portal/src/NavigationBlockerDialog.tsx index 58433b8d214..6f141082bad 100644 --- a/portal/src/NavigationBlockerDialog.tsx +++ b/portal/src/NavigationBlockerDialog.tsx @@ -64,7 +64,7 @@ const NavigationBlockerDialog: React.VFC = return (
- + { + if (!open) { + setIsDiscardDialogOpen(false); + } + }} + title={} + description={} + confirmText={} + cancelText={} + confirmColor="red" + onConfirm={onConfirmDiscard} + onCancel={onDismissDiscardDialog} + /> ); } diff --git a/portal/src/radix-theme-overrides.css b/portal/src/radix-theme-overrides.css index ac312a1b4c3..d9ec2f87570 100644 --- a/portal/src/radix-theme-overrides.css +++ b/portal/src/radix-theme-overrides.css @@ -16,3 +16,9 @@ --cursor-slider-thumb-active: grabbing; --cursor-switch: pointer; } + +/* Radix dialog overlay has no z-index by default; Monaco editor minimap (z-index: 5) + can paint above the mask without this. Keep above ScreenHeader (9999). */ +.rt-BaseDialogOverlay { + z-index: 10000; +} From e3ed33dd7bebf03cf3ae88d434b5c91081747377 Mon Sep 17 00:00:00 2001 From: jimmycwc Date: Thu, 4 Jun 2026 15:07:05 +0800 Subject: [PATCH 06/10] [Portal] Add Toggle Storybook stories Document default, checked, with-text, and disabled Toggle variants. Co-authored-by: Cursor --- .../components/v2/Toggle/Toggle.stories.tsx | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/portal/src/components/v2/Toggle/Toggle.stories.tsx b/portal/src/components/v2/Toggle/Toggle.stories.tsx index 55f1b9f3090..278f4afe291 100644 --- a/portal/src/components/v2/Toggle/Toggle.stories.tsx +++ b/portal/src/components/v2/Toggle/Toggle.stories.tsx @@ -15,8 +15,36 @@ type Story = StoryObj; export const Default: Story = {}; +export const Checked: Story = { + args: { + checked: true, + }, +}; + export const WithText: Story = { args: { - text: "On", + text: "Allow account deletion", + }, +}; + +export const CheckedWithText: Story = { + args: { + checked: true, + text: "Allow account deletion", + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + text: "Allow account deletion", + }, +}; + +export const DisabledChecked: Story = { + args: { + checked: true, + disabled: true, + text: "Allow account deletion", }, }; From 2902b770aa8cd09725fc457595b633ad609980b3 Mon Sep 17 00:00:00 2001 From: jimmycwc Date: Thu, 4 Jun 2026 15:07:09 +0800 Subject: [PATCH 07/10] [Portal] Migrate Advanced settings screens to Radix UI Refactor Account Deletion, Account Anonymization, Cookie Lifetime, Endpoint Direct Access, SMTP, SMS Provider, Admin API, Edit Config, and SAML Certificate to v2 layout patterns with SaveFunctionBar. Align content width to Admin API grid span 9 and add page descriptions. Co-authored-by: Cursor --- .../saml/EditSAMLCertificateForm.module.css | 122 ++- .../saml/EditSAMLCertificateForm.tsx | 459 ++++----- ...nonymizationConfigurationScreen.module.css | 35 +- ...ccountAnonymizationConfigurationScreen.tsx | 100 +- ...ountDeletionConfigurationScreen.module.css | 35 +- .../AccountDeletionConfigurationScreen.tsx | 163 +-- .../AdminAPIConfigurationScreen.module.css | 16 + .../portal/AdminAPIConfigurationScreen.tsx | 34 +- ...okieLifetimeConfigurationScreen.module.css | 35 +- .../CookieLifetimeConfigurationScreen.tsx | 168 ++-- .../portal/EditConfigurationScreen.module.css | 49 +- .../portal/EditConfigurationScreen.tsx | 180 ++-- .../EndpointDirectAccessScreen.module.css | 38 +- .../portal/EndpointDirectAccessScreen.tsx | 556 +++++----- .../portal/SAMLCertificateScreen.module.css | 41 +- .../graphql/portal/SAMLCertificateScreen.tsx | 62 +- .../SMSProviderConfigurationScreen.module.css | 52 +- .../portal/SMSProviderConfigurationScreen.tsx | 947 ++++++++++-------- .../portal/SMTPConfigurationScreen.module.css | 8 +- .../portal/SMTPConfigurationScreen.tsx | 18 +- portal/src/locale-data/en.json | 10 + 21 files changed, 1812 insertions(+), 1316 deletions(-) diff --git a/portal/src/components/saml/EditSAMLCertificateForm.module.css b/portal/src/components/saml/EditSAMLCertificateForm.module.css index 8cbfc80e6ff..74dc7da0b33 100644 --- a/portal/src/components/saml/EditSAMLCertificateForm.module.css +++ b/portal/src/components/saml/EditSAMLCertificateForm.module.css @@ -1,3 +1,121 @@ -.addButtonIcon { - margin: 0 10px 0 0; +.keysTableWrapper { + width: 100%; + max-width: 100%; + min-width: 0; + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +@media (min-width: 1081px) { + .keysTableWrapper { + scrollbar-width: none; + -ms-overflow-style: none; + } + + .keysTableWrapper::-webkit-scrollbar { + display: none; + } +} + +.keysTable { + min-width: 100%; +} + +.keysTableHeader { + @apply flex w-full items-center; + min-width: 100%; + min-height: 2.75rem; +} + +.keysTableHeaderCellFingerprint, +.keysTableHeaderCellStatus, +.keysTableHeaderCellActions { + @apply flex items-center px-3 py-3 text-sm font-semibold; + color: var(--gray-12); +} + +.keysTableHeaderCellFingerprint { + @apply flex-1 min-w-0; +} + +.keysTableHeaderCellStatus { + width: 6.5rem; + min-width: 6.5rem; + flex-shrink: 0; +} + +.keysTableHeaderCellActions { + width: 3.5rem; + flex-shrink: 0; + @apply justify-end; +} + +.keysTableRow { + @apply flex w-full items-center; + min-width: 100%; + min-height: 2.75rem; + border-top: 1px solid var(--gray-a6); + background-color: var(--gray-a2); +} + +.keysTableCellFingerprint, +.keysTableCellStatus, +.keysTableCellActions { + @apply flex items-center px-3 py-3 text-sm; + color: var(--gray-12); +} + +.keysTableCellFingerprint { + @apply flex min-w-0 flex-1 items-center overflow-hidden; +} + +.keysTableCellFingerprintText { + @apply min-w-0 truncate; +} + +.keysTableCellStatus { + width: 6.5rem; + min-width: 6.5rem; + flex-shrink: 0; +} + +.keysTableCellActions { + width: 3.5rem; + flex-shrink: 0; + @apply justify-end; +} + +.activeStatus { + position: relative; + width: fit-content; +} + +.activeStatusText { + color: var(--green-11); +} + +.activeStatusSpinner { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.activateButton { + @apply inline-flex cursor-pointer items-center border-0 bg-transparent p-0 text-sm font-semibold whitespace-nowrap; + color: var(--accent-9); +} + +.activateButton:disabled { + @apply cursor-not-allowed opacity-50; +} + +.generateKeyButton { + @apply inline-flex cursor-pointer items-center gap-1 border-0 bg-transparent p-0 text-sm font-semibold; + color: var(--accent-9); +} + +.generateKeyButton:disabled { + @apply cursor-not-allowed opacity-50; } diff --git a/portal/src/components/saml/EditSAMLCertificateForm.tsx b/portal/src/components/saml/EditSAMLCertificateForm.tsx index 4d49cec1e4a..3a1a8066a29 100644 --- a/portal/src/components/saml/EditSAMLCertificateForm.tsx +++ b/portal/src/components/saml/EditSAMLCertificateForm.tsx @@ -1,37 +1,26 @@ -import React, { - useCallback, - useContext, - useMemo, - useRef, - useState, -} from "react"; +import React, { useCallback, useState } from "react"; +import cn from "classnames"; +import { + DotsVerticalIcon, + DownloadIcon, + PlusIcon, + TrashIcon, +} from "@radix-ui/react-icons"; +import { + DropdownMenu, + IconButton as RadixIconButton, + Spinner, + Text, +} from "@radix-ui/themes"; import { AppSecretConfigFormModel } from "../../hook/useAppSecretConfigForm"; import { SAMLIdpSigningCertificate } from "../../types"; import { FormState } from "../../hook/useSAMLCertificateForm"; -import WidgetTitle from "../../WidgetTitle"; -import { FormattedMessage, Context as MessageContext } from "../../intl"; +import { FormattedMessage } from "../../intl"; import { useFormContainerBaseContext } from "../../FormContainerBase"; -import { - DetailsList, - IColumn, - SelectionMode, - Text, - ILinkStyles, - Dialog, - IDialogContentProps, - DialogFooter, - Spinner, - SpinnerSize, -} from "@fluentui/react"; -import cn from "classnames"; -import LinkButton from "../../LinkButton"; import { downloadStringAsFile } from "../../util/download"; -import { useSystemConfig } from "../../context/SystemConfigContext"; -import styles from "./EditSAMLCertificateForm.module.css"; -import ActionButton from "../../ActionButton"; -import ButtonWithLoading from "../../ButtonWithLoading"; -import DefaultButton from "../../DefaultButton"; import { formatCertificateFilename } from "../../model/saml"; +import styles from "./EditSAMLCertificateForm.module.css"; +import { ConfirmationDialog } from "../v2/ConfirmationDialog/ConfirmationDialog"; interface EditSAMLCertificateFormProps { configAppID: string; @@ -40,7 +29,102 @@ interface EditSAMLCertificateFormProps { onGenerateNewCertitificate: () => Promise; } -const actionLinkButtonStyle: ILinkStyles = { root: { fontSize: 14 } }; +interface SAMLCertificatesTableProps { + certificates: SAMLIdpSigningCertificate[]; + activeKeyID: string | undefined; + activatingKeyID: string | null; + formDisabled: boolean; + onDownload: (cert: SAMLIdpSigningCertificate) => void; + onRemove: (cert: SAMLIdpSigningCertificate) => void; + onActivate: (cert: SAMLIdpSigningCertificate) => void; +} + +function SAMLCertificatesTable({ + certificates, + activeKeyID, + activatingKeyID, + formDisabled, + onDownload, + onRemove, + onActivate, +}: SAMLCertificatesTableProps): React.ReactElement { + return ( +
+
+
+
+ +
+
+ +
+
+
+ {certificates.map((cert) => { + const isActive = activeKeyID === cert.keyID; + const isActivating = activatingKeyID === cert.keyID; + return ( +
+
+ + {cert.certificateFingerprint} + +
+
+ {isActive || isActivating ? ( + + ) : ( + + )} +
+
+ + + + + + + + { + onDownload(cert); + }} + > + + + + { + if (!isActive) { + onRemove(cert); + } + }} + > + + + + + +
+
+ ); + })} +
+
+ ); +} export function EditSAMLCertificateForm({ configAppID, @@ -48,162 +132,62 @@ export function EditSAMLCertificateForm({ certificates, onGenerateNewCertitificate, }: EditSAMLCertificateFormProps): React.ReactElement { - const submitElRef = useRef(null); const { onSubmit } = useFormContainerBaseContext(); - const { renderToString } = useContext(MessageContext); - const { themes } = useSystemConfig(); - const [isLoading, setIsLoading] = useState(false); + const [isGenerating, setIsGenerating] = useState(false); + const [activatingKeyID, setActivatingKeyID] = useState(null); const generateNewCert = useCallback(async () => { - setIsLoading(true); + setIsGenerating(true); try { await onGenerateNewCertitificate(); } finally { - setIsLoading(false); + setIsGenerating(false); } }, [onGenerateNewCertitificate]); - const onClickDownloadCert = useMemo(() => { - const callbacks: Record void> = {}; - for (const cert of certificates) { - callbacks[cert.keyID] = () => { - downloadStringAsFile({ - content: cert.certificatePEM, - mimeType: "application/x-pem-file", - filename: formatCertificateFilename( - configAppID, - cert.certificateFingerprint - ), - }); - }; - } - return callbacks; - }, [configAppID, certificates]); + const onClickDownloadCert = useCallback( + (cert: SAMLIdpSigningCertificate) => { + downloadStringAsFile({ + content: cert.certificatePEM, + mimeType: "application/x-pem-file", + filename: formatCertificateFilename( + configAppID, + cert.certificateFingerprint + ), + }); + }, + [configAppID] + ); - const onRemoveCert = useMemo(() => { - const callbacks: Record void> = {}; - for (const cert of certificates) { - callbacks[cert.keyID] = () => { - form.setState((prevState) => { - return { - ...prevState, - removingCertificateKeyID: cert.keyID, - }; - }); - }; - } - return callbacks; - }, [certificates, form]); + const onRemoveCert = useCallback( + (cert: SAMLIdpSigningCertificate) => { + form.setState((prevState) => ({ + ...prevState, + removingCertificateKeyID: cert.keyID, + })); + }, + [form] + ); - const onChangeActiveKey = useMemo(() => { - const callbacks: Record Promise> = {}; - for (const cert of certificates) { - callbacks[cert.keyID] = async () => { - form.setState((prevState) => ({ - ...prevState, + const onChangeActiveKey = useCallback( + async (cert: SAMLIdpSigningCertificate) => { + if (form.isUpdating || activatingKeyID != null) { + return; + } + setActivatingKeyID(cert.keyID); + try { + await form.saveWithState({ + ...form.state, isUpdatingActiveKeyID: true, activeKeyID: cert.keyID, - })); - // Submit the form after the state is updated and all rerendering completed, i.e. next tick. - setTimeout(() => { - submitElRef.current?.click(); - }, 0); - }; - } - return callbacks; - }, [certificates, form]); - - const columns: IColumn[] = useMemo(() => { - const renderFingerprint = ( - item?: SAMLIdpSigningCertificate, - _index?: number, - _column?: IColumn - ) => { - if (!item) { - return null; - } - return ( -
- - {item.certificateFingerprint} - -
- - - - {form.state.activeKeyID !== item.keyID ? ( - - - - ) : null} -
-
- ); - }; - const renderStatus = ( - item?: SAMLIdpSigningCertificate, - _index?: number, - _column?: IColumn - ) => { - if (!item) { - return null; - } - if (form.state.activeKeyID === item.keyID) { - return ( - - ); + }); + } finally { + setActivatingKeyID(null); } - return ( - - - - ); - }; - return [ - { - key: "certificateFingerprint", - fieldName: "certificateFingerprint", - name: renderToString( - "EditSAMLCertificateForm.certificates.column.fingerprint" - ), - minWidth: 150, - onRender: renderFingerprint, - }, - { - key: "status", - name: renderToString( - "EditSAMLCertificateForm.certificates.column.status" - ), - minWidth: 150, - onRender: renderStatus, - }, - ]; - }, [ - renderToString, - onClickDownloadCert, - form.state.activeKeyID, - form.state.isUpdatingActiveKeyID, - form.isLoading, - form.isUpdating, - onRemoveCert, - themes.destructive, - onChangeActiveKey, - ]); + }, + [activatingKeyID, form] + ); const dismissRemoveCertificateDialog = useCallback(() => { form.setState((state) => ({ @@ -224,97 +208,80 @@ export function EditSAMLCertificateForm({ ); }, [form, dismissRemoveCertificateDialog]); - const removeCertDialogContentProps: IDialogContentProps = useMemo(() => { - return { - title: renderToString( - "EditSAMLCertificateForm.removeCertificateDialog.title" - ), - subText: renderToString( - "EditSAMLCertificateForm.removeCertificateDialog.description" - ), - }; - }, [renderToString]); - - const isRemoveCertificateDialogVisible = + const isRemoveCertificateDialogOpen = form.state.removingCertificateKeyID != null; + const formDisabled = + form.isLoading || form.isUpdating || activatingKeyID != null; + return (
- + + { + if (!open) { + dismissRemoveCertificateDialog(); + } + }} + title={ + + } + description={ + + } + confirmText={} + cancelText={} + loading={form.isUpdating} + confirmColor="red" + onConfirm={onConfirmRemoveCertificate} + onCancel={dismissRemoveCertificateDialog} + /> ); } + function CertificateActiveStatus({ isLoading }: { isLoading: boolean }) { return ( -
+
- - -
- -
+ + + {isLoading ? ( +
+ +
+ ) : null}
); } diff --git a/portal/src/graphql/portal/AccountAnonymizationConfigurationScreen.module.css b/portal/src/graphql/portal/AccountAnonymizationConfigurationScreen.module.css index f4320eb8421..e283b50668b 100644 --- a/portal/src/graphql/portal/AccountAnonymizationConfigurationScreen.module.css +++ b/portal/src/graphql/portal/AccountAnonymizationConfigurationScreen.module.css @@ -1,4 +1,37 @@ .widget { - grid-column: 1 / span 8; + grid-column: 1 / span 9; + @apply tablet:col-span-full; +} + +.pageHeader { + @apply flex flex-col gap-2; +} + +.pageTitle { + margin: 0; + font-size: 20px; + line-height: 28px; + font-weight: 600; + color: var(--gray-12); +} + +.pageDescription { + color: var(--gray-a11); +} + +.contentWithSaveBar { + padding-bottom: 5.5rem; +} + +.settingsCardSaveBarClearance { + margin-bottom: 5.5rem; +} + +.contentWidthAnchor { + grid-column: 1 / span 9; + height: 0; + overflow: hidden; + pointer-events: none; + @apply tablet:col-span-full; } diff --git a/portal/src/graphql/portal/AccountAnonymizationConfigurationScreen.tsx b/portal/src/graphql/portal/AccountAnonymizationConfigurationScreen.tsx index a7137c95ea0..7ff647c3d36 100644 --- a/portal/src/graphql/portal/AccountAnonymizationConfigurationScreen.tsx +++ b/portal/src/graphql/portal/AccountAnonymizationConfigurationScreen.tsx @@ -1,6 +1,8 @@ -import React, { useCallback, useContext } from "react"; +import React, { useCallback, useRef } from "react"; import { useParams } from "react-router-dom"; -import { FormattedMessage, Context } from "../../intl"; +import cn from "classnames"; +import { Text } from "@radix-ui/themes"; +import { FormattedMessage } from "../../intl"; import { produce } from "immer"; import { AppConfigFormModel, @@ -10,13 +12,12 @@ import ShowLoading from "../../ShowLoading"; import ShowError from "../../ShowError"; import FormContainer from "../../FormContainer"; import ScreenContent from "../../ScreenContent"; -import ScreenTitle from "../../ScreenTitle"; import { PortalAPIAppConfig } from "../../types"; import styles from "./AccountAnonymizationConfigurationScreen.module.css"; -import Widget from "../../Widget"; -import WidgetTitle from "../../WidgetTitle"; -import FormTextField from "../../FormTextField"; +import { TextField } from "../../components/v2/TextField/TextField"; +import { SaveFunctionBar } from "../../components/v2/SaveFunctionBar/SaveFunctionBar"; import { checkIntegerInput } from "../../util/input"; +import { useFormContainerBaseContext } from "../../FormContainerBase"; interface FormState { grace_period_days: string; @@ -56,47 +57,68 @@ const AccountAnonymizationConfigurationContent: React.VFC(null); const onChangeGracePeriod = useCallback( - (_e, value?: string) => { - if (value != null) { - if (checkIntegerInput(value)) { - setState((prev) => { - return { - ...prev, - grace_period_days: value, - }; - }); - } + (e: React.ChangeEvent) => { + const value = e.target.value; + if (checkIntegerInput(value)) { + setState((prev) => ({ + ...prev, + grace_period_days: value, + })); } }, [setState] ); return ( - - - - - - + +
+
+ + + + + + +
+ +
+ - - - + +
+ + } + hint={ + + } + value={grace_period_days} + onChange={onChangeGracePeriod} + parentJSONPointer="/account_anonymization" + fieldName="grace_period_days" + /> +
+
+ + ); }; @@ -119,7 +141,7 @@ const AccountAnonymizationConfigurationScreen: React.VFC = } return ( - + ); diff --git a/portal/src/graphql/portal/AccountDeletionConfigurationScreen.module.css b/portal/src/graphql/portal/AccountDeletionConfigurationScreen.module.css index f4320eb8421..e283b50668b 100644 --- a/portal/src/graphql/portal/AccountDeletionConfigurationScreen.module.css +++ b/portal/src/graphql/portal/AccountDeletionConfigurationScreen.module.css @@ -1,4 +1,37 @@ .widget { - grid-column: 1 / span 8; + grid-column: 1 / span 9; + @apply tablet:col-span-full; +} + +.pageHeader { + @apply flex flex-col gap-2; +} + +.pageTitle { + margin: 0; + font-size: 20px; + line-height: 28px; + font-weight: 600; + color: var(--gray-12); +} + +.pageDescription { + color: var(--gray-a11); +} + +.contentWithSaveBar { + padding-bottom: 5.5rem; +} + +.settingsCardSaveBarClearance { + margin-bottom: 5.5rem; +} + +.contentWidthAnchor { + grid-column: 1 / span 9; + height: 0; + overflow: hidden; + pointer-events: none; + @apply tablet:col-span-full; } diff --git a/portal/src/graphql/portal/AccountDeletionConfigurationScreen.tsx b/portal/src/graphql/portal/AccountDeletionConfigurationScreen.tsx index eec1eef0ed8..ad976d4c0f6 100644 --- a/portal/src/graphql/portal/AccountDeletionConfigurationScreen.tsx +++ b/portal/src/graphql/portal/AccountDeletionConfigurationScreen.tsx @@ -1,7 +1,9 @@ -import React, { useCallback, useContext } from "react"; +import React, { useCallback, useRef } from "react"; import { useParams } from "react-router-dom"; -import { MessageBar } from "@fluentui/react"; -import { FormattedMessage, Context } from "../../intl"; +import cn from "classnames"; +import { Callout, Text } from "@radix-ui/themes"; +import { InfoCircledIcon } from "@radix-ui/react-icons"; +import { FormattedMessage } from "../../intl"; import { produce } from "immer"; import { AppConfigFormModel, @@ -11,15 +13,14 @@ import ShowLoading from "../../ShowLoading"; import ShowError from "../../ShowError"; import FormContainer from "../../FormContainer"; import ScreenContent from "../../ScreenContent"; -import ScreenTitle from "../../ScreenTitle"; import { PortalAPIAppConfig } from "../../types"; import styles from "./AccountDeletionConfigurationScreen.module.css"; -import Widget from "../../Widget"; -import WidgetTitle from "../../WidgetTitle"; -import FormTextField from "../../FormTextField"; -import Toggle from "../../Toggle"; +import { TextField } from "../../components/v2/TextField/TextField"; +import { Toggle } from "../../components/v2/Toggle/Toggle"; +import { SaveFunctionBar } from "../../components/v2/SaveFunctionBar/SaveFunctionBar"; import { checkIntegerInput } from "../../util/input"; import ExternalLink from "../../ExternalLink"; +import { useFormContainerBaseContext } from "../../FormContainerBase"; interface FormState { scheduled_by_end_user_enabled: boolean; @@ -66,83 +67,103 @@ const AccountDeletionConfigurationContent: React.VFC(null); const onChangeGracePeriod = useCallback( - (_e, value?: string) => { - if (value != null) { - if (checkIntegerInput(value)) { - setState((prev) => { - return { - ...prev, - grace_period_days: value, - }; - }); - } + (e: React.ChangeEvent) => { + const value = e.target.value; + if (checkIntegerInput(value)) { + setState((prev) => ({ + ...prev, + grace_period_days: value, + })); } }, [setState] ); const onChangeEnabled = useCallback( - (_e, checked?: boolean) => { - if (checked != null) { - setState((prev) => { - return { - ...prev, - scheduled_by_end_user_enabled: checked, - }; - }); - } + (checked: boolean) => { + setState((prev) => ({ + ...prev, + scheduled_by_end_user_enabled: checked, + })); }, [setState] ); return ( - - - - - - + +
+
+ + + + + + +
+ +
+ - - - - - - ( - - {chunks} - - ), - }} + +
+ + } + hint={ + + } + value={grace_period_days} + onChange={onChangeGracePeriod} + parentJSONPointer="/account_deletion" + fieldName="grace_period_days" + /> + + } /> - - + + + + + + ( + + {chunks} + + ), + }} + /> + + +
+
+ + ); }; @@ -165,7 +186,7 @@ const AccountDeletionConfigurationScreen: React.VFC = } return ( - + ); diff --git a/portal/src/graphql/portal/AdminAPIConfigurationScreen.module.css b/portal/src/graphql/portal/AdminAPIConfigurationScreen.module.css index a36f18d4fb6..b5a550c0f30 100644 --- a/portal/src/graphql/portal/AdminAPIConfigurationScreen.module.css +++ b/portal/src/graphql/portal/AdminAPIConfigurationScreen.module.css @@ -4,6 +4,22 @@ @apply tablet:col-span-full; } +.pageHeader { + @apply flex flex-col gap-2; +} + +.pageTitle { + margin: 0; + font-size: 20px; + line-height: 28px; + font-weight: 600; + color: var(--gray-12); +} + +.pageDescription { + color: var(--gray-a11); +} + .sections { grid-column: 1 / span 9; @apply tablet:col-span-full; diff --git a/portal/src/graphql/portal/AdminAPIConfigurationScreen.tsx b/portal/src/graphql/portal/AdminAPIConfigurationScreen.tsx index 76445a54b2c..0aab993bc99 100644 --- a/portal/src/graphql/portal/AdminAPIConfigurationScreen.tsx +++ b/portal/src/graphql/portal/AdminAPIConfigurationScreen.tsx @@ -1,4 +1,5 @@ import React, { useContext, useMemo, useCallback, useState, useRef } from "react"; +import cn from "classnames"; import { useParams, useLocation, useNavigate } from "react-router-dom"; import { CopyIcon, @@ -16,8 +17,6 @@ import { } from "@radix-ui/themes"; import { FormattedMessage, Context } from "../../intl"; import ScreenContent from "../../ScreenContent"; -import ScreenTitle from "../../ScreenTitle"; -import ScreenDescription from "../../ScreenDescription"; import ShowLoading from "../../ShowLoading"; import ShowError from "../../ShowError"; import { @@ -479,18 +478,25 @@ const AdminAPIConfigurationScreenContent: React.VFC - - - - - {chunks}, - }} - /> - +
+

+ +

+ + {chunks}, + }} + /> + +
; } -const SessionConfigurationWidget: React.VFC = - function SessionConfigurationWidget(props: SessionConfigurationWidgetProps) { - const { state, setState } = props.form; - - const { renderToString } = useContext(Context); +const CookieLifetimeConfigurationScreenContent: React.VFC = + function CookieLifetimeConfigurationScreenContent(props) { + const { form } = props; + const { state, setState } = form; + const { isDirty } = useFormContainerBaseContext(); + const contentWidthAnchorRef = useRef(null); const hostname = useMemo( () => getHostname(state.publicOrigin), @@ -76,100 +77,115 @@ const SessionConfigurationWidget: React.VFC = ); const onSessionLifetimeSecondsChange = useCallback( - (_, value?: string) => { + (e: React.ChangeEvent) => { setState((prev) => ({ ...prev, - sessionLifetimeSeconds: parseIntegerAllowLeadingZeros(value), + sessionLifetimeSeconds: parseIntegerAllowLeadingZeros(e.target.value), })); }, [setState] ); const onIdleTimeoutEnabledChange = useCallback( - (_, value?: boolean) => { + (checked: boolean) => { setState((state) => ({ ...state, - idleTimeoutEnabled: value ?? false, + idleTimeoutEnabled: checked, })); }, [setState] ); const onIdleTimeoutSecondsChange = useCallback( - (_, value?: string) => { + (e: React.ChangeEvent) => { setState((prev) => ({ ...prev, - idleTimeoutSeconds: parseIntegerAllowLeadingZeros(value), + idleTimeoutSeconds: parseIntegerAllowLeadingZeros(e.target.value), })); }, [setState] ); return ( - - +
- - - - ); - }; - -interface CookieLifetimeConfigurationScreenContentProps { - form: AppConfigFormModel; -} - -const CookieLifetimeConfigurationScreenContent: React.VFC = - function CookieLifetimeConfigurationScreenContent(props) { - const { form } = props; - return ( - - - - - - +
+ + + + {chunks}, }} /> - - +
+ +
+ + + +
+ + } + hint={ + + } + value={state.sessionLifetimeSeconds?.toFixed(0) ?? ""} + onChange={onSessionLifetimeSecondsChange} + /> +
+ + } + /> + + + +
+ + } + hint={ + + } + value={state.idleTimeoutSeconds?.toFixed(0) ?? ""} + onChange={onIdleTimeoutSecondsChange} + /> +
+
+ +
); }; @@ -193,7 +209,7 @@ const CookieLifetimeConfigurationScreen: React.VFC = } return ( - + ); diff --git a/portal/src/graphql/portal/EditConfigurationScreen.module.css b/portal/src/graphql/portal/EditConfigurationScreen.module.css index 9abbeaa0a6c..8b0593063c6 100644 --- a/portal/src/graphql/portal/EditConfigurationScreen.module.css +++ b/portal/src/graphql/portal/EditConfigurationScreen.module.css @@ -1,7 +1,52 @@ .widget { - @apply col-span-8 tablet:col-span-full; + grid-column: 1 / span 9; + + @apply tablet:col-span-full; +} + +.pageHeader { + @apply flex flex-col gap-2; +} + +.pageTitle { + margin: 0; + font-size: 20px; + line-height: 28px; + font-weight: 600; + color: var(--gray-12); +} + +.contentWithSaveBar { + padding-bottom: 5.5rem; +} + +.settingsCardSaveBarClearance { + margin-bottom: 5.5rem; +} + +.contentWidthAnchor { + grid-column: 1 / span 9; + height: 0; + overflow: hidden; + pointer-events: none; + + @apply tablet:col-span-full; +} + +.editorCard { + @apply min-w-0 overflow-hidden rounded-xl border border-solid; + border-color: var(--gray-a6); +} + +.editorCardHeader { + @apply border-b border-solid px-6 py-4; + border-color: var(--gray-a6); } .codeEditor { - height: 70vh; /* 680 / 960 ref https://www.figma.com/design/msiE4O5imHONAG5EjhZeiZ/Authgear-UI?node-id=10346-12112&t=ubpO59nWLEfmakyL-0 */ + display: block; + height: 70vh; + margin: 0; + border: none; + border-radius: 0; } diff --git a/portal/src/graphql/portal/EditConfigurationScreen.tsx b/portal/src/graphql/portal/EditConfigurationScreen.tsx index 90d60101221..0295b197e12 100644 --- a/portal/src/graphql/portal/EditConfigurationScreen.tsx +++ b/portal/src/graphql/portal/EditConfigurationScreen.tsx @@ -1,15 +1,9 @@ -import React, { useCallback, useMemo, useState } from "react"; +import React, { useCallback, useMemo, useRef, useState } from "react"; +import cn from "classnames"; +import { Text } from "@radix-ui/themes"; import ScreenContent from "../../ScreenContent"; -import ScreenTitle from "../../ScreenTitle"; import { FormattedMessage } from "../../intl"; -import EditTemplatesWidget, { - EditTemplatesWidgetSection, -} from "./EditTemplatesWidget"; -import { - Dialog, - DialogFooter, - IDialogContentProps, -} from "@fluentui/react/lib/Dialog"; +import CodeEditor from "../../CodeEditor"; import styles from "./EditConfigurationScreen.module.css"; import { useParams, useNavigate } from "react-router-dom"; @@ -28,8 +22,9 @@ import { } from "../../util/resource"; import { RESOURCE_AUTHGEAR_YAML } from "../../resources"; import { useAppAndSecretConfigQuery } from "./query/appAndSecretConfigQuery"; -import DefaultButton from "../../DefaultButton"; -import PrimaryButton from "../../PrimaryButton"; +import { ConfirmationDialog } from "../../components/v2/ConfirmationDialog/ConfirmationDialog"; +import { SaveFunctionBar } from "../../components/v2/SaveFunctionBar/SaveFunctionBar"; +import { useFormContainerBaseContext } from "../../FormContainerBase"; interface FormModel { isLoading: boolean; @@ -51,6 +46,90 @@ const AUTHGEAR_YAML_RESOURCE_SPECIFIER: ResourceSpecifier = { extension: null, }; +interface EditConfigurationContentProps { + rawAuthgearYAML: string | null; + onChange: (value: string | undefined, e: unknown) => void; + isWarningDialogVisible: boolean; + onDismissWarning: () => void; + onCancelWarning: () => void; +} + +const EditConfigurationContent: React.VFC = + function EditConfigurationContent(props) { + const { + rawAuthgearYAML, + onChange, + isWarningDialogVisible, + onDismissWarning, + onCancelWarning, + } = props; + const { isDirty } = useFormContainerBaseContext(); + const contentWidthAnchorRef = useRef(null); + + return ( + <> + {}} + maxWidth="500px" + title={ + + } + description={ +
, + }} + /> + } + confirmText={ + + } + cancelText={} + onConfirm={onDismissWarning} + onCancel={onCancelWarning} + confirmColor="indigo" + /> + +
+
+

+ +

+
+
+
+ + + +
+ +
+ + + + ); + }; + const EditConfigurationScreen: React.VFC = function EditConfigurationScreen() { const { appID } = useParams() as { appID: string }; const navigate = useNavigate(); @@ -60,26 +139,14 @@ const EditConfigurationScreen: React.VFC = function EditConfigurationScreen() { const [isWarningDialogVisible, setWarningDialogVisible] = useState(true); - const onDismiss = useCallback(() => { + const onDismissWarning = useCallback(() => { setWarningDialogVisible(false); }, []); - const onCancel = useCallback(() => { + const onCancelWarning = useCallback(() => { navigate(-1); }, [navigate]); - const dialogContentProps: IDialogContentProps = useMemo(() => { - return { - title: ( - - ) as unknown as string, - subText: ( - - ) as unknown as string, - showCloseButton: false, - }; - }, []); - const form: FormModel = useMemo( () => ({ ...resourceForm, @@ -131,60 +198,15 @@ const EditConfigurationScreen: React.VFC = function EditConfigurationScreen() { return ; } - const authgearYAMLSections: [EditTemplatesWidgetSection] = [ - { - key: "authgear.yaml", - title: null, - items: [ - { - key: "authgear.yaml", - title: null, - editor: "code", - language: "yaml", - - value: rawAuthgearYAML ?? "", - onChange, - }, - ], - }, - ]; - return ( - - - - - - - - + + ); }; diff --git a/portal/src/graphql/portal/EndpointDirectAccessScreen.module.css b/portal/src/graphql/portal/EndpointDirectAccessScreen.module.css index 876e663d9e9..8aa39d595d1 100644 --- a/portal/src/graphql/portal/EndpointDirectAccessScreen.module.css +++ b/portal/src/graphql/portal/EndpointDirectAccessScreen.module.css @@ -1,9 +1,43 @@ .widget { - grid-column: 1 / span 8; + grid-column: 1 / span 9; + + @apply tablet:col-span-full; +} + +.pageHeader { + @apply flex flex-col gap-2; +} + +.pageTitle { + margin: 0; + font-size: 20px; + line-height: 28px; + font-weight: 600; + color: var(--gray-12); +} + +.pageDescription { + color: var(--gray-a11); +} + +.contentWithSaveBar { + padding-bottom: 5.5rem; +} + +/* Space below the settings card when the fixed Save bar is visible */ +.settingsCardSaveBarClearance { + margin-bottom: 5.5rem; +} + +.contentWidthAnchor { + grid-column: 1 / span 9; + height: 0; + overflow: hidden; + pointer-events: none; @apply tablet:col-span-full; } .textFieldInOption { - @apply ml-6 my-1; + @apply ml-6; } diff --git a/portal/src/graphql/portal/EndpointDirectAccessScreen.tsx b/portal/src/graphql/portal/EndpointDirectAccessScreen.tsx index 5d7af8f1287..b7f6b11074b 100644 --- a/portal/src/graphql/portal/EndpointDirectAccessScreen.tsx +++ b/portal/src/graphql/portal/EndpointDirectAccessScreen.tsx @@ -1,39 +1,26 @@ -import React, { useCallback, useContext, useMemo } from "react"; +import React, { useCallback, useMemo, useRef } from "react"; import cn from "classnames"; -import { - Text, - ChoiceGroup, - IChoiceGroupOption, - IChoiceGroupOptionProps, - IChoiceGroupStyles, - ITextFieldStyles, - useTheme, -} from "@fluentui/react"; +import { Flex, RadioGroup, Text } from "@radix-ui/themes"; import { AppConfigFormModel, useAppConfigForm, } from "../../hook/useAppConfigForm"; -import { Context, FormattedMessage } from "../../intl"; -import { - FormContainerBase, - useFormContainerBaseContext, -} from "../../FormContainerBase"; +import { FormattedMessage } from "../../intl"; import { produce } from "immer"; -import { useId } from "../../hook/useId"; -import FormTextField from "../../FormTextField"; -import PrimaryButton from "../../PrimaryButton"; +import FormContainer from "../../FormContainer"; import { PortalAPIAppConfig } from "../../types"; import styles from "./EndpointDirectAccessScreen.module.css"; -import { useParams, Link } from "react-router-dom"; +import { useParams } from "react-router-dom"; import { useDomainsQuery } from "./query/domainsQuery"; import ShowLoading from "../../ShowLoading"; import ShowError from "../../ShowError"; -import ScreenLayoutScrollView from "../../ScreenLayoutScrollView"; import ScreenContent from "../../ScreenContent"; import { Domain } from "./globalTypes.generated"; -import NavBreadcrumb, { BreadcrumbItem } from "../../NavBreadcrumb"; -import HorizontalDivider from "../../HorizontalDivider"; import { getHostFromOrigin } from "../../util/domain"; +import { TextField } from "../../components/v2/TextField/TextField"; +import { SaveFunctionBar } from "../../components/v2/SaveFunctionBar/SaveFunctionBar"; +import { useFormContainerBaseContext } from "../../FormContainerBase"; +import Link from "../../Link"; type ChoiceOption = | "ShowError" @@ -89,8 +76,8 @@ function makeConstructRedirectURLFormState(domains: Domain[]) { ? "ShowError" : "ShowBrandPage" : default_redirect_uri === "" - ? "ShowLoginAndRedirectToSettings" - : "ShowLoginAndRedirectToCustomURL"; + ? "ShowLoginAndRedirectToSettings" + : "ShowLoginAndRedirectToCustomURL"; return { public_origin, @@ -148,256 +135,186 @@ function constructConfigFromRedirectURLFormState( }); } -interface RedirectURLTextFieldProps { - fieldName: string; - value: string; - onChangeValue: (value: string) => void; - - className?: string; - label?: React.ReactNode; - description?: React.ReactNode; - disabled?: boolean; - required?: boolean; -} -const RedirectURLTextField: React.VFC = - function RedirectURLTextField(props) { - const { - fieldName, - className, - label, - description, - value, - onChangeValue, - disabled, - required, - } = props; - const id = useId(); - const theme = useTheme(); - const onChange = useCallback( - (_e: React.FormEvent, value?: string) => { - onChangeValue(value ?? ""); - }, - [onChangeValue] - ); - - const textFieldStyles: Partial = { - description: { - color: disabled ? theme.semanticColors.disabledText : "", - }, - }; - - return ( - - ); - }; - -// Workaround for hidden input of ChoiceGroupOption -// ref: https://github.com/microsoft/fluentui/issues/21252#issuecomment-1168690443 -const WORKAROUND_HIDDEN_INPUT_OF_ChoiceGroupOption = { - input: { - zIndex: -1, - }, -}; - interface EndpointDirectAccessConfigOptionSelectorProps { - className?: string; form: AppConfigFormModel; onChangeDirectAccessOption: (key: ChoiceOption) => void; onChangeBrandPageURL: (url: string) => void; onChangePostLoginURL: (url: string) => void; - onChangePostLogoutURL: (url: string) => void; } const EndpointDirectAccessConfigOptionSelector: React.VFC = function EndpointDirectAccessConfigOptionSelector(props) { - const { - className, - form, - onChangeDirectAccessOption, - onChangeBrandPageURL, - onChangePostLoginURL, - } = props; - const { renderToString } = useContext(Context); + const { form, onChangeDirectAccessOption, onChangeBrandPageURL, onChangePostLoginURL } = + props; const { appID } = useParams() as { appID: string }; const { + currentChoiceOption, ShowLoginAndRedirectToSettingsDisabled, ShowLoginAndRedirectToCustomURLDisabled, } = form.state; const onOptionsChange = useCallback( - ( - _?: React.FormEvent, - option?: IChoiceGroupOption - ) => { - if (!option?.key) return; - onChangeDirectAccessOption(option.key as ChoiceOption); + (value: string) => { + onChangeDirectAccessOption(value as ChoiceOption); }, [onChangeDirectAccessOption] ); - const ChoiceGroupStyles: Partial = { - flexContainer: { - selectors: { - ".ms-ChoiceField": { - display: "block", - }, - }, + const onChangeBrandPage = useCallback( + (e: React.ChangeEvent) => { + onChangeBrandPageURL(e.target.value); }, - }; - - const onRenderFieldShowBrandPage = useCallback( - ( - props?: IChoiceGroupOption & IChoiceGroupOptionProps, - render?: ( - props?: IChoiceGroupOption & IChoiceGroupOptionProps - ) => JSX.Element | null - ) => { - const checked = props?.checked ?? false; - return ( - <> - {render?.(props)} - {checked ? ( - - } - value={form.state.brand_page_uri} - onChangeValue={onChangeBrandPageURL} - /> - ) : null} - - ); - }, - [form.state.brand_page_uri, onChangeBrandPageURL] + [onChangeBrandPageURL] ); - const onRenderFieldShowLoginAndRedirectToCustomURL = useCallback( - ( - props?: IChoiceGroupOption & IChoiceGroupOptionProps, - render?: ( - props?: IChoiceGroupOption & IChoiceGroupOptionProps - ) => JSX.Element | null - ) => { - const checked = props?.checked ?? false; - return ( - <> - {render?.(props)} - {checked ? ( - - } - description={ - - } - value={form.state.default_redirect_uri} - onChangeValue={onChangePostLoginURL} - /> - ) : null} - - ); + const onChangePostLogin = useCallback( + (e: React.ChangeEvent) => { + onChangePostLoginURL(e.target.value); }, - [form.state.default_redirect_uri, onChangePostLoginURL] + [onChangePostLoginURL] ); - const options: IChoiceGroupOption[] = [ - { - key: "ShowError", - text: renderToString( - "EndpointDirectAccessScreen.section1.option.ShowError.label" - ), - }, - { - key: "ShowBrandPage", - text: renderToString( - "EndpointDirectAccessScreen.section1.option.ShowBrandPage.label" - ), - styles: WORKAROUND_HIDDEN_INPUT_OF_ChoiceGroupOption, - onRenderField: onRenderFieldShowBrandPage, - }, - { - key: "ShowLoginAndRedirectToSettings", - disabled: ShowLoginAndRedirectToSettingsDisabled, - // @ts-expect-error text can be React.Element. - text: ShowLoginAndRedirectToSettingsDisabled ? ( - ( - - {chunks} - - ), - }} - /> - ) : ( - - ), - }, - { - key: "ShowLoginAndRedirectToCustomURL", - disabled: ShowLoginAndRedirectToCustomURLDisabled, - // @ts-expect-error text can be React.Element. - text: ShowLoginAndRedirectToSettingsDisabled ? ( - ( - - {chunks} - - ), - }} - /> - ) : ( - - ), - onRenderField: onRenderFieldShowLoginAndRedirectToCustomURL, - }, - ]; + const customDomainsLink = useCallback( + (chunks: React.ReactNode) => ( + {chunks} + ), + [appID] + ); return ( - + + + + + + + + + +
+ + + + + + + {currentChoiceOption === "ShowBrandPage" ? ( +
+ + } + value={form.state.brand_page_uri} + onChange={onChangeBrandPage} + parentJSONPointer="/ui" + fieldName="brand_page_uri" + /> +
+ ) : null} +
+ +
+ + +
+ + + + {ShowLoginAndRedirectToSettingsDisabled ? ( + + + + ) : null} +
+
+
+ +
+ + +
+ + + + {ShowLoginAndRedirectToCustomURLDisabled ? ( + + + + ) : null} +
+
+ {currentChoiceOption === "ShowLoginAndRedirectToCustomURL" ? ( +
+ + } + hint={ + + } + value={form.state.default_redirect_uri} + onChange={onChangePostLogin} + parentJSONPointer="/ui" + fieldName="default_redirect_uri" + /> +
+ ) : null} +
+
+
); }; -interface RedirectURLFormProps { +interface EndpointDirectAccessContentProps { form: AppConfigFormModel; } -const RedirectURLForm: React.VFC = - function RedirectURLForm(props) { + +const EndpointDirectAccessContent: React.VFC = + function EndpointDirectAccessContent(props) { const { form } = props; const { public_origin: publicOrigin } = form.state; - - const { canSave, onSubmit } = useFormContainerBaseContext(); + const { isDirty } = useFormContainerBaseContext(); + const contentWidthAnchorRef = useRef(null); const onChangeBrandPageURL = useCallback( (url: string) => { @@ -422,10 +339,10 @@ const RedirectURLForm: React.VFC = ); const onChangePostLogoutURL = useCallback( - (url: string) => { + (e: React.ChangeEvent) => { form.setState((prev) => produce(prev, (draft) => { - draft.default_post_logout_redirect_uri = url; + draft.default_post_logout_redirect_uri = e.target.value; }) ); }, @@ -444,94 +361,83 @@ const RedirectURLForm: React.VFC = ); return ( -
-
-
- - ( - {chunks} - ), - }} - /> - - -
+ +
+
+ + + + + + +
- - -
- - ( - {chunks} - ), - }} +
+ + + +
+
+ + {chunks}, + }} + /> + + - - - - } - onChangeValue={onChangePostLogoutURL} - /> +
+ +
+ +
+ + {chunks}, + }} + /> + + + } + value={form.state.default_post_logout_redirect_uri} + onChange={onChangePostLogoutURL} + parentJSONPointer="/ui" + fieldName="default_post_logout_redirect_uri" + /> +
- } - /> - - ); - }; - -interface EndpointDirectAccessContentProps { - form: AppConfigFormModel; -} - -const EndpointDirectAccessContent: React.VFC = - function EndpointDirectAccessContent(props) { - const { form } = props; - - const navBreadcrumbItems: BreadcrumbItem[] = useMemo(() => { - return [ - { - to: ".", - label: , - }, - ]; - }, []); - - return ( - - - - - - + + ); }; @@ -566,9 +472,9 @@ function EndpointDirectAccessScreen1(props: EndpointDirectAccessScreen1Props) { } return ( - + - + ); } diff --git a/portal/src/graphql/portal/SAMLCertificateScreen.module.css b/portal/src/graphql/portal/SAMLCertificateScreen.module.css index 684649c6d16..19278569006 100644 --- a/portal/src/graphql/portal/SAMLCertificateScreen.module.css +++ b/portal/src/graphql/portal/SAMLCertificateScreen.module.css @@ -1,5 +1,44 @@ .widget { - grid-column: 1 / span 8; + grid-column: 1 / span 9; @apply tablet:col-span-full; } + +.pageHeader { + @apply flex flex-col gap-2; +} + +.pageTitle { + margin: 0; + font-size: 20px; + line-height: 28px; + font-weight: 600; + color: var(--gray-12); +} + +.pageDescription { + color: var(--gray-a11); +} + +.contentWithSaveBar { + padding-bottom: 5.5rem; +} + +.contentWidthAnchor { + grid-column: 1 / span 9; + height: 0; + overflow: hidden; + pointer-events: none; + + @apply tablet:col-span-full; +} + +.settingsCardSaveBarClearance { + margin-bottom: 5.5rem; +} + +.sectionHeading { + flex-shrink: 0; + width: 8.75rem; + color: var(--gray-12); +} diff --git a/portal/src/graphql/portal/SAMLCertificateScreen.tsx b/portal/src/graphql/portal/SAMLCertificateScreen.tsx index 95f5441e454..9a5d85788b2 100644 --- a/portal/src/graphql/portal/SAMLCertificateScreen.tsx +++ b/portal/src/graphql/portal/SAMLCertificateScreen.tsx @@ -1,4 +1,6 @@ -import React, { useCallback, useMemo } from "react"; +import React, { useCallback, useRef } from "react"; +import cn from "classnames"; +import { Text } from "@radix-ui/themes"; import { useParams } from "react-router-dom"; import { useSAMLCertificateForm, @@ -7,18 +9,17 @@ import { import { useAppAndSecretConfigQuery } from "./query/appAndSecretConfigQuery"; import ShowError from "../../ShowError"; import ShowLoading from "../../ShowLoading"; -import { FormContainerBase } from "../../FormContainerBase"; +import FormContainer from "../../FormContainer"; import { SAMLIdpSigningCertificate } from "../../types"; import { useUpdateAppAndSecretConfigMutation } from "./mutations/updateAppAndSecretMutation"; import { AppSecretConfigFormModel } from "../../hook/useAppSecretConfigForm"; -import ScreenLayoutScrollView from "../../ScreenLayoutScrollView"; import ScreenContent from "../../ScreenContent"; -import NavBreadcrumb, { BreadcrumbItem } from "../../NavBreadcrumb"; -import ScreenDescription from "../../ScreenDescription"; import { FormattedMessage } from "../../intl"; import styles from "./SAMLCertificateScreen.module.css"; import { EditSAMLCertificateForm } from "../../components/saml/EditSAMLCertificateForm"; import { AutoGenerateFirstCertificate } from "../../components/saml/AutoGenerateFirstCertificate"; +import { SaveFunctionBar } from "../../components/v2/SaveFunctionBar/SaveFunctionBar"; +import { useFormContainerBaseContext } from "../../FormContainerBase"; function EditSAMLCertificateContent({ configAppID, @@ -31,23 +32,36 @@ function EditSAMLCertificateContent({ certificates: SAMLIdpSigningCertificate[]; generateNewCertificate: () => Promise; }) { - const navBreadcrumbItems: BreadcrumbItem[] = useMemo(() => { - return [ - { - to: ".", - label: , - }, - ]; - }, []); + const { isDirty } = useFormContainerBaseContext(); + const contentWidthAnchorRef = useRef(null); return ( - - - - + +
+
+ + + + - -
+ +
+ +
+ + + +
- - +
+ + + ); } @@ -93,14 +109,14 @@ function EditSAMLCertificateFormContainer({ } return ( - + - + ); } diff --git a/portal/src/graphql/portal/SMSProviderConfigurationScreen.module.css b/portal/src/graphql/portal/SMSProviderConfigurationScreen.module.css index 9c057b9bff2..921e2a9f97f 100644 --- a/portal/src/graphql/portal/SMSProviderConfigurationScreen.module.css +++ b/portal/src/graphql/portal/SMSProviderConfigurationScreen.module.css @@ -1,19 +1,57 @@ .widget { - grid-column: 1 / span 8; + grid-column: 1 / span 9; @apply tablet:col-span-full; } -.columnFull { - grid-column: 1 / span 8; +.providerSelector { + @apply flex flex-col gap-1; +} + +.pageHeader { + @apply flex flex-col gap-2; +} + +.pageTitle { + margin: 0; + font-size: 20px; + line-height: 28px; + font-weight: 600; + color: var(--gray-12); +} + +.pageDescription, +.providerDescription { + color: var(--gray-a11); +} + +.providerDescription { + font-size: 12px; + line-height: 16px; +} + +.contentWithSaveBar { + padding-bottom: 8rem; +} + +.contentWidthAnchor { + grid-column: 1 / span 9; + height: 0; + overflow: hidden; + pointer-events: none; @apply tablet:col-span-full; } -.providerGrid { - @apply grid gap-4 grid-cols-2; +.senderInfoIcon { + cursor: default; + color: var(--accent-9); } -.textFieldInOption { - @apply ml-6 my-1; +.copyIconButton { + @apply shrink-0; + background: none; + border: none; + box-shadow: none; + color: var(--gray-11); } diff --git a/portal/src/graphql/portal/SMSProviderConfigurationScreen.tsx b/portal/src/graphql/portal/SMSProviderConfigurationScreen.tsx index ee232c64b4c..ce132129954 100644 --- a/portal/src/graphql/portal/SMSProviderConfigurationScreen.tsx +++ b/portal/src/graphql/portal/SMSProviderConfigurationScreen.tsx @@ -12,6 +12,7 @@ import React, { useContext, useEffect, useMemo, + useRef, useState, } from "react"; import { useLocationEffect } from "../../hook/useLocationEffect"; @@ -23,7 +24,7 @@ import { useAppSecretConfigForm, } from "../../hook/useAppSecretConfigForm"; import { useAppFeatureConfigQuery } from "./query/appFeatureConfigQuery"; -import FormContainer, { FormSaveButton } from "../../FormContainer"; +import FormContainer from "../../FormContainer"; import { PortalAPIAppConfig, PortalAPISecretConfig, @@ -34,31 +35,15 @@ import { } from "../../types"; import { produce } from "immer"; import { FormattedMessage, Context as MessageContext } from "../../intl"; -import { - ChoiceGroup, - IChoiceGroupOption, - IChoiceGroupOptionProps, - IChoiceGroupStyles, - Text, -} from "@fluentui/react"; import ScreenContent from "../../ScreenContent"; -import ScreenTitle from "../../ScreenTitle"; import styles from "./SMSProviderConfigurationScreen.module.css"; -import Widget from "../../Widget"; -import ScreenDescription from "../../ScreenDescription"; -import Toggle from "../../Toggle"; -import { ProviderCard } from "../../components/common/ProviderCard"; import logoTwilio from "../../images/twilio_logo.svg"; import logoWebhook from "../../images/webhook_logo.svg"; -import logoDeno from "../../images/deno_logo.svg"; -import FormTextField from "../../FormTextField"; -import PrimaryButton from "../../PrimaryButton"; +import logoAuthgear from "../../images/authgear_logo.svg"; import { startReauthentication } from "./Authenticated"; import { CodeField } from "../../components/common/CodeField"; -import TextField from "../../TextField"; -import DefaultButton from "../../DefaultButton"; -import { useCopyFeedback } from "../../hook/useCopyFeedback"; import CodeEditor from "../../CodeEditor"; +import { copyToClipboard } from "../../util/clipboard"; import { useResourceForm } from "../../hook/useResourceForm"; import { Resource, @@ -75,12 +60,35 @@ import { useCheckDenoHookMutation } from "./mutations/checkDenoHook"; import FeatureDisabledMessageBar from "./FeatureDisabledMessageBar"; import { ErrorParseRule, makeLocalErrorParseRule } from "../../error/parse"; import { APIError, LocalError } from "../../error/error"; -import { ReauthDialog } from "../../components/common/ReauthDialog"; +import { ConfirmationDialog } from "../../components/v2/ConfirmationDialog/ConfirmationDialog"; import { TestSMSDialog } from "../../components/sms-provider/TestSMSDialog"; -import Tooltip from "../../Tooltip"; import { useSystemConfig } from "../../context/SystemConfigContext"; import { RedMessageBar_RemindConfigureSMSProviderInSMSProviderScreen } from "../../RedMessageBar"; import ExternalLink from "../../ExternalLink"; +import { + IconRadioCards, + IconRadioCardOption, +} from "../../components/v2/IconRadioCards/IconRadioCards"; +import { TextField } from "../../components/v2/TextField/TextField"; +import { PrimaryButton } from "../../components/v2/Button/PrimaryButton/PrimaryButton"; +import { SecondaryButton } from "../../components/v2/Button/SecondaryButton/SecondaryButton"; +import { + Flex, + IconButton as RadixIconButton, + RadioGroup, + Text, + Tooltip as RadixTooltip, +} from "@radix-ui/themes"; +import { + CodeIcon, + CopyIcon, + EyeOpenIcon, + InfoCircledIcon, +} from "@radix-ui/react-icons"; +import { FormField } from "../../components/v2/FormField/FormField"; +import { Tooltip } from "../../components/v2/Tooltip/Tooltip"; +import { SaveFunctionBar } from "../../components/v2/SaveFunctionBar/SaveFunctionBar"; +import { useFormContainerBaseContext } from "../../FormContainerBase"; const SECRETS = [AppSecretKey.SmsProviderSecrets, AppSecretKey.WebhookSecret]; @@ -102,6 +110,7 @@ export type FormModel = Omit< >; enum SMSProviderType { + Authgear = "authgear", Twilio = "twilio", Webhook = "webhook", Deno = "deno", @@ -114,6 +123,9 @@ enum TwilioSenderType { const MASK = "********"; +// Matches v2 IconRadioCards storybook inner icon size (SquareIcon iconSize). +const PROVIDER_RADIO_ICON_SIZE = "1.375rem"; + interface ConfigFormState { enabled: boolean; providerType: SMSProviderType; @@ -183,7 +195,7 @@ function constructFormState( : SMSProviderType.Webhook; } else { enabled = false; - providerType = SMSProviderType.Twilio; + providerType = SMSProviderType.Authgear; } let twilioCredentialType: TwilioCredentialType = TwilioCredentialType.ApiKey; @@ -295,6 +307,10 @@ function constructConfig( let newProvider: SMSProvider; switch (currentState.providerType) { + case SMSProviderType.Authgear: + config.messaging.sms_gateway = undefined; + config.messaging.sms_provider = undefined; + return; case SMSProviderType.Twilio: newProvider = "twilio"; break; @@ -318,6 +334,9 @@ function constructConfig( secrets.smsProviderSecrets = null; } else { switch (currentState.providerType) { + case SMSProviderType.Authgear: + secrets.smsProviderSecrets = null; + break; case SMSProviderType.Twilio: { const twilioCredentials: SMSProviderTwilioCredentials = { credentialType: currentState.twilioCredentialType, @@ -382,6 +401,13 @@ function constructSecretUpdateInstruction( } switch (currentState.providerType) { + case SMSProviderType.Authgear: + return { + smsProviderSecrets: { + action: "set", + setData: {}, + }, + }; case SMSProviderType.Twilio: if (secrets.smsProviderSecrets.twilioCredentials == null) { console.error("unexpected null twilioCredentials"); @@ -547,6 +573,8 @@ function useTestSMSConfig( return null; } switch (state.providerType) { + case SMSProviderType.Authgear: + return null; case SMSProviderType.Twilio: { if (!state.twilioSID) { return null; @@ -637,6 +665,8 @@ function computeIsSecretMasked(state: FormState): boolean { return false; } switch (state.providerType) { + case SMSProviderType.Authgear: + return false; case SMSProviderType.Twilio: switch (state.twilioCredentialType) { case TwilioCredentialType.ApiKey: @@ -885,10 +915,11 @@ function SMSProviderConfigurationContent(props: { const { appID } = useParams() as { appID: string }; const { state, setState } = form; const { isSMSRequiredForSomeEnabledFeatures, smsProviderConfigured } = state; - const { renderToString } = useContext(MessageContext); + const { isDirty } = useFormContainerBaseContext(); const navigate = useNavigate(); + const contentWidthAnchorRef = useRef(null); - const [isReauthDialogHidden, setIsReauthDialogHidden] = useState(true); + const [isReauthDialogOpen, setIsReauthDialogOpen] = useState(false); const [isTestSMSDialogHidden, setIsTestSMSDialogHidden] = useState(true); const { checkDenoHook, loading: checkDenoHookLoading } = checkDenoHookHandle; @@ -898,20 +929,17 @@ function SMSProviderConfigurationContent(props: { [form.state] ); - const onChangeEnabled = useCallback( - (_event, checked?: boolean) => { - if (checked != null) { - if (isSecretMasked) { - setIsReauthDialogHidden(false); - return; - } - setState((state) => { - return { - ...state, - enabled: checked, - }; - }); + const onChangeProviderType = useCallback( + (value: SMSProviderType) => { + if (isSecretMasked) { + setIsReauthDialogOpen(true); + return; } + setState((s) => ({ + ...s, + enabled: value !== SMSProviderType.Authgear, + providerType: value, + })); }, [isSecretMasked, setState] ); @@ -929,14 +957,14 @@ function SMSProviderConfigurationContent(props: { }, [navigate, form]); const onRevealSecrets = useCallback(() => { - setIsReauthDialogHidden(false); + setIsReauthDialogOpen(true); }, []); const testConfig = useTestSMSConfig(form.state); const onTestSMS = useCallback(async () => { if (isSecretMasked) { - setIsReauthDialogHidden(false); + setIsReauthDialogOpen(true); return; } if (form.state.providerType === SMSProviderType.Deno) { @@ -954,167 +982,86 @@ function SMSProviderConfigurationContent(props: { setIsTestSMSDialogHidden(true); }, []); - return ( - <> - - - {isAuthgearOnce ? ( - - ) : ( - - )} - - - - - {isCustomSMSProviderDisabled ? ( - [] => [ + { + value: SMSProviderType.Authgear, + icon: ( + - ) : null} - {isAuthgearOnce && - isSMSRequiredForSomeEnabledFeatures && - !smsProviderConfigured ? ( -
- -
- ) : null} - - + ), + }, + { + value: SMSProviderType.Twilio, + icon: ( + - - - {state.enabled ? ( - - - - - ) : null} - - {form.state.enabled ? ( - <> -
- - } - disabled={testConfig == null || checkDenoHookLoading} - onClick={onTestSMS} - /> - {isSecretMasked ? ( - } - /> - ) : ( - - )} -
- - ) : ( -
- -
- )} -
- { - triggerReauth(); - }, [triggerReauth])} - onCancel={useCallback(() => { - setIsReauthDialogHidden(true); - }, [])} - /> - {testConfig != null ? ( - - ) : null} - - ); -} - -function ProviderSection({ - isSecretMasked, - form, - onRevealSecrets, -}: { - isSecretMasked: boolean; - form: FormModel; - onRevealSecrets: () => void; -}) { - const onSelectProviderCallbacks = useMemo(() => { - const makeCallback = (provider: SMSProviderType) => { - return () => { - if (isSecretMasked) { - onRevealSecrets(); - return; - } - form.setState((state) => { - return { ...state, providerType: provider }; - }); - }; - }; - - return { - twilio: makeCallback(SMSProviderType.Twilio), - webhook: makeCallback(SMSProviderType.Webhook), - deno: makeCallback(SMSProviderType.Deno), - }; - }, [form, isSecretMasked, onRevealSecrets]); - - return ( -
- - - -
- + ), + title: ( - - + ), + disabled: isCustomSMSProviderDisabled, + }, + { + value: SMSProviderType.Webhook, + icon: ( + + ), + title: ( - - + ), + disabled: isCustomSMSProviderDisabled, + }, + { + value: SMSProviderType.Deno, + icon: ( + + ), + title: ( - -
- - {form.state.providerType === SMSProviderType.Twilio ? ( + ), + disabled: isCustomSMSProviderDisabled, + }, + ], + [isCustomSMSProviderDisabled] + ); + + const providerDescription = useMemo(() => { + switch (state.providerType) { + case SMSProviderType.Authgear: + return ( + + ); + case SMSProviderType.Twilio: + return ( - ) : form.state.providerType === SMSProviderType.Webhook ? ( + ); + case SMSProviderType.Webhook: + return ( - ) : ( + ); + case SMSProviderType.Deno: + return ( - )} - -
+ ); + default: + return null; + } + }, [state.providerType]); + + const showSettings = state.providerType !== SMSProviderType.Authgear; + + return ( + <> + +
+
+

+ {isAuthgearOnce ? ( + + ) : ( + + )} +

+ + + +
+ {isCustomSMSProviderDisabled ? ( + + ) : null} + {isAuthgearOnce && + isSMSRequiredForSomeEnabledFeatures && + !smsProviderConfigured ? ( +
+ +
+ ) : null} + +
+ + {providerDescription != null ? ( + + {providerDescription} + + ) : null} +
+ + {showSettings ? ( +
+ + + +
+ + {isSecretMasked ? ( +
+ } + /> +
+ ) : ( +
+ + } + /> +
+ )} +
+
+ ) : null} + + + + { + if (!open) { + setIsReauthDialogOpen(false); + } + }} + title={} + description={} + confirmText={} + cancelText={} + confirmColor="indigo" + onConfirm={triggerReauth} + onCancel={useCallback(() => { + setIsReauthDialogOpen(false); + }, [])} + /> + {testConfig != null ? ( + + ) : null} + ); } @@ -1164,6 +1246,8 @@ function FormSection({ onRevealSecrets: () => void; }) { switch (form.state.providerType) { + case SMSProviderType.Authgear: + return null; case SMSProviderType.Twilio: return ; case SMSProviderType.Webhook: @@ -1186,10 +1270,8 @@ function TwilioForm({ form }: { form: FormModel }) { | "twilioMessagingServiceSID" | "twilioFrom" ) => { - return ( - event: React.FormEvent - ) => { - const value = event.currentTarget.value; + return (e: React.ChangeEvent) => { + const value = e.target.value; form.setState((prevState) => { const s: FormState = { ...prevState, @@ -1214,137 +1296,91 @@ function TwilioForm({ form }: { form: FormModel }) { ? form.state.twilioAuthToken == null : form.state.twilioAPIKeySecret == null; - const credentialTypeOptions = useMemo(() => { - return [ - { - key: TwilioCredentialType.AuthToken, - text: renderToString( - "SMSProviderConfigurationScreen.form.twilio.credentialType.options.authToken" - ), - }, - { - key: TwilioCredentialType.ApiKey, - text: renderToString( - "SMSProviderConfigurationScreen.form.twilio.credentialType.options.apiKey" - ), - }, - ]; - }, [renderToString]); - const onCredentialTypeChange = useCallback( - (_: unknown, option?: IChoiceGroupOption) => { - if (option == null) { - return; - } - form.setState((prev) => { - return { - ...prev, - twilioCredentialType: option.key as TwilioCredentialType, - }; - }); + (value: string) => { + form.setState((prev) => ({ + ...prev, + twilioCredentialType: value as TwilioCredentialType, + })); }, [form] ); - const senderOptions = useMemo(() => { - return [ - { - key: TwilioSenderType.MessagingServiceSID, - text: renderToString( - "SMSProviderConfigurationScreen.form.twilio.twilioMessagingServiceSID" - ), - // eslint-disable-next-line react/no-unstable-nested-components - onRenderLabel: ( - props?: IChoiceGroupOption & IChoiceGroupOptionProps, - render?: ( - props?: IChoiceGroupOption & IChoiceGroupOptionProps - ) => JSX.Element | null - ) => ( - <> - {render?.(props)} -
- -
- - ), - }, - { - key: TwilioSenderType.From, - text: renderToString( - "SMSProviderConfigurationScreen.form.twilio.twilioFrom" - ), // eslint-disable-next-line react/no-unstable-nested-components - onRenderLabel: ( - props?: IChoiceGroupOption & IChoiceGroupOptionProps, - render?: ( - props?: IChoiceGroupOption & IChoiceGroupOptionProps - ) => JSX.Element | null - ) => ( - <> - {render?.(props)} -
- -
- - ), - }, - ]; - }, [renderToString]); - const onSenderTypeChange = useCallback( - (_: unknown, option?: IChoiceGroupOption) => { - if (option == null) { - return; - } - form.setState((prev) => { - return { - ...prev, - twilioSenderType: option.key as TwilioSenderType, - }; - }); + (value: string) => { + form.setState((prev) => ({ + ...prev, + twilioSenderType: value as TwilioSenderType, + })); }, [form] ); - const horizontalChoiceGroupStyles: Partial = useMemo( - () => ({ - flexContainer: { - display: "flex", - columnGap: "16px", - }, - }), - [] - ); - return (
- + } value={form.state.twilioSID} - required={true} onChange={onStringChangeCallbacks.twilioSID} disabled={isTwilioSecretMasked} parentJSONPointer={/\/secrets\/\d+\/data/} fieldName="account_sid" />
- + + } + labelSpace="1" + > + + + + + + + + } + > + + + + + + + + + + } + > + + + + + + + {form.state.twilioSenderType === TwilioSenderType.MessagingServiceSID ? ( -
- + - +
) : ( - )}
-
- +
+ + } + labelSpace="1" + > + + + + + + + + + + + + + + + + + {form.state.twilioCredentialType === TwilioCredentialType.AuthToken ? ( - - + } value={form.state.twilioAPIKeySID} onChange={onStringChangeCallbacks.twilioAPIKeySID} disabled={isTwilioSecretMasked} parentJSONPointer={/\/secrets\/\d+\/data/} fieldName="api_key_sid" /> - + } value={form.state.twilioAPIKeySecret ?? MASK} onChange={onStringChangeCallbacks.twilioAPIKeySecret} disabled={isTwilioSecretMasked} @@ -1441,6 +1503,74 @@ function TwilioForm({ form }: { form: FormModel }) { ); } +function CopyIconButton({ + textToCopy, +}: { + textToCopy: string; +}): React.ReactElement { + const { renderToString } = useContext(MessageContext); + const [copied, setCopied] = useState(false); + const timerRef = useRef | null>(null); + + const handleCopy = useCallback(() => { + copyToClipboard(textToCopy); + setCopied(true); + if (timerRef.current != null) { + clearTimeout(timerRef.current); + } + timerRef.current = setTimeout(() => { + setCopied(false); + }, 2000); + }, [textToCopy]); + + return ( + + + + + + ); +} + +function RevealIconButton({ + onClick, +}: { + onClick: () => void; +}): React.ReactElement { + const { renderToString } = useContext(MessageContext); + + return ( + + + + + + ); +} + function WebhookForm({ form, onRevealSecrets, @@ -1451,8 +1581,8 @@ function WebhookForm({ const { renderToString } = useContext(MessageContext); const onURLChange = useCallback( - (event: React.FormEvent) => { - const value = event.currentTarget.value; + (e: React.ChangeEvent) => { + const value = e.target.value; form.setState((prevState) => { return { ...prevState, @@ -1464,8 +1594,8 @@ function WebhookForm({ ); const onTimeoutChange = useCallback( - (event: React.FormEvent) => { - const value = parseInt(event.currentTarget.value, 10); + (e: React.ChangeEvent) => { + const value = parseInt(e.target.value, 10); if (isNaN(value)) { return; } @@ -1479,21 +1609,19 @@ function WebhookForm({ [form] ); - const { copyButtonProps, Feedback: CopyFeedbackComponent } = useCopyFeedback({ - textToCopy: form.state.webhookSecretKey ?? "", - }); - const isWebhookSecretMasked = form.state.webhookSecretKey == null; + const webhookSecretKey = form.state.webhookSecretKey ?? ""; return (
- + } value={form.state.webhookURL} - required={true} onChange={onURLChange} disabled={isWebhookSecretMasked} parentJSONPointer={/\/secrets\/\d+\/data/} @@ -1512,39 +1640,24 @@ function WebhookForm({ "body": "You OTP is 123456" }`} -
-
- - - ) : ( - - ) - } - /> - -
- + + } + value={isWebhookSecretMasked ? MASK : webhookSecretKey} + readOnly={true} + suffixPlain={true} + suffix={ + isWebhookSecretMasked ? ( + + ) : webhookSecretKey.length > 0 ? ( + + ) : undefined + } + hint={ - -
- + + } + hint={renderToString( + "SMSProviderConfigurationScreen.form.webhook.timeout.description" )} value={String(form.state.webhookTimeout)} onChange={onTimeoutChange} disabled={isWebhookSecretMasked} parentJSONPointer={/\/secrets\/\d+\/data/} fieldName="timeout" - description={renderToString( - "SMSProviderConfigurationScreen.form.webhook.timeout.description" - )} />
); @@ -1581,8 +1696,8 @@ function DenoHookForm({ form }: { form: FormModel }) { const { state, setState } = form; const onTimeoutChange = useCallback( - (event: React.FormEvent) => { - const value = parseInt(event.currentTarget.value, 10); + (e: React.ChangeEvent) => { + const value = parseInt(e.target.value, 10); if (isNaN(value)) { return; } @@ -1645,34 +1760,40 @@ function DenoHookForm({ form }: { form: FormModel }) { return (
-
- - - - +
+
+ + + +
+
+ +
- + } + hint={renderToString( + "SMSProviderConfigurationScreen.form.deno.timeout.description" )} value={String(form.state.denoHookTimeout)} onChange={onTimeoutChange} parentJSONPointer={/\/secrets\/\d+\/data/} fieldName="timeout" - description={renderToString( - "SMSProviderConfigurationScreen.form.deno.timeout.description" - )} />
); diff --git a/portal/src/graphql/portal/SMTPConfigurationScreen.module.css b/portal/src/graphql/portal/SMTPConfigurationScreen.module.css index 377e5452748..56f1683d614 100644 --- a/portal/src/graphql/portal/SMTPConfigurationScreen.module.css +++ b/portal/src/graphql/portal/SMTPConfigurationScreen.module.css @@ -1,11 +1,11 @@ .widget { - grid-column: 1 / span 8; + grid-column: 1 / span 9; @apply tablet:col-span-full; } .columnFull { - grid-column: 1 / span 8; + grid-column: 1 / span 9; @apply tablet:col-span-full; } @@ -54,11 +54,11 @@ } .contentWithSaveBar { - padding-bottom: 5.5rem; + padding-bottom: 8rem; } .contentWidthAnchor { - grid-column: 1 / span 8; + grid-column: 1 / span 9; height: 0; overflow: hidden; pointer-events: none; diff --git a/portal/src/graphql/portal/SMTPConfigurationScreen.tsx b/portal/src/graphql/portal/SMTPConfigurationScreen.tsx index 2db689769ee..f3fa1e0771d 100644 --- a/portal/src/graphql/portal/SMTPConfigurationScreen.tsx +++ b/portal/src/graphql/portal/SMTPConfigurationScreen.tsx @@ -662,7 +662,7 @@ const SMTPConfigurationScreenContent: React.VFC @@ -694,7 +694,7 @@ const SMTPConfigurationScreenContent: React.VFC @@ -710,7 +710,7 @@ const SMTPConfigurationScreenContent: React.VFC @@ -725,7 +725,7 @@ const SMTPConfigurationScreenContent: React.VFC @@ -740,7 +740,7 @@ const SMTPConfigurationScreenContent: React.VFC @@ -752,7 +752,7 @@ const SMTPConfigurationScreenContent: React.VFC @@ -793,7 +793,7 @@ const SMTPConfigurationScreenContent: React.VFC diff --git a/portal/src/locale-data/en.json b/portal/src/locale-data/en.json index 33aa9a2f47f..50f492eb9a3 100644 --- a/portal/src/locale-data/en.json +++ b/portal/src/locale-data/en.json @@ -994,12 +994,15 @@ "CustomDomainListScreen.verify-success-message": "Verified domain successfully", "CustomDomainListScreen.rediect-endpoint-direct-access.message": "Please go to Endpoint Direct Access to configure what happens when the users access the endpoint URL directly", "EndpointDirectAccessScreen.title": "Endpoint Direct Access", + "EndpointDirectAccessScreen.description": "Control what users see when they access Authgear's hosted pages directly from a browser, outside of your app's normal login flow.", + "EndpointDirectAccessScreen.settings.label": "Settings", "EndpointDirectAccessScreen.section1.description": "When users visit the endpoint {endpoint} from a browser unexpectedly:", "EndpointDirectAccessScreen.section1.option.ShowError.label": "Show an error page", "EndpointDirectAccessScreen.section1.option.ShowBrandPage.label": "Show a simple brand page with a back to home link", "EndpointDirectAccessScreen.section1.option.ShowBrandPage.input.label": "Home URL", "EndpointDirectAccessScreen.section1.option.ShowLoginAndRedirectToSettings.label": "Show a Login page, after login show the hosted settings page", "EndpointDirectAccessScreen.section1.option.ShowLoginAndRedirectToSettings.label--disabled": "Show a Login page, after login show the hosted settings page

Only available when custom domain is set", + "EndpointDirectAccessScreen.section1.option.requires-custom-domain.hint": "Only available when custom domain is set", "EndpointDirectAccessScreen.section1.option.ShowLoginAndRedirectToCustomURL.label": "Show a Login page, after login redirect to a custom URL", "EndpointDirectAccessScreen.section1.option.ShowLoginAndRedirectToCustomURL.label--disabled": "Show a Login page, after login redirect to a custom URL

Only available when custom domain is set", "EndpointDirectAccessScreen.section1.option.ShowLoginAndRedirectToCustomURL.input.label": "Post-login URL", @@ -1229,6 +1232,7 @@ "AcceptAdminInvitationScreen.accept-error.title": "Accept Invitation Error", "GeneralSettings.app-id.label": "App ID", "CookieLifetimeConfigurationScreen.title": "Cookie Lifetime", + "CookieLifetimeConfigurationScreen.settings.label": "Settings", "CookieLifetimeConfigurationScreen.description": "Change cookie session lifetime and timeout for {hostname}. If you use token-based authentication only (single page applications and native apps), you can simply ignore this config.", "CookieLifetimeConfigurationScreen.persistent-cookie.label": "Persistent Cookie", "CookieLifetimeConfigurationScreen.persistent-cookie.description": "The cookie persists after the browser is closed.", @@ -1632,12 +1636,15 @@ "SMSProviderConfigurationScreen.enable.label": "Enable Custom SMS Gateway", "SMSProviderConfigurationScreen.enable.label--authgearonce": "Enable SMS Gateway", "SMSProviderConfigurationScreen.provider.title": "SMS Gateway Provider", + "SMSProviderConfigurationScreen.provider.authgear": "Authgear", + "SMSProviderConfigurationScreen.provider.authgear.description": "Send SMS directly with Authgear's built-in service. No extra setup needed.", "SMSProviderConfigurationScreen.provider.twilio": "Twilio", "SMSProviderConfigurationScreen.provider.twilio.description": "Learn more about Twilio integration.", "SMSProviderConfigurationScreen.provider.webhook": "Webhook", "SMSProviderConfigurationScreen.provider.webhook.description": "Learn more about Webhook integration.", "SMSProviderConfigurationScreen.provider.deno": "Custom JS/TS", "SMSProviderConfigurationScreen.provider.deno.description": "Learn more about Custom JS/TS integration.", + "SMSProviderConfigurationScreen.settings.label": "Settings", "SMSProviderConfigurationScreen.form.twilio.credentialType": "Credentials", "SMSProviderConfigurationScreen.form.twilio.credentialType.options.authToken": "Auth Token", "SMSProviderConfigurationScreen.form.twilio.credentialType.options.apiKey": "API Key", @@ -1700,12 +1707,14 @@ "UserProfileAttributesList.dialog.adjustment.condition": "If you change the {party, select, portal_ui{Portal Admin} bearer{Token Bearer} end_user{End-user} other{}} Access Right of “{fieldName}” to {level}, then:", "UserProfileAttributesList.dialog.adjustment.consequence": "The Access Right of {party, select, end_user{End-user} bearer{Token Bearer} portal_ui{Portal Admin} other{}} will also be changed to {level}.", "AccountDeletionConfigurationScreen.title": "Account Deletion", + "AccountDeletionConfigurationScreen.description": "Allow end-users to delete their own accounts. Accounts are disabled immediately upon deletion request and permanently removed after the configured grace period.", "AccountDeletionConfigurationScreen.deletion-schedule.title": "Deletion Schedule", "AccountDeletionConfigurationScreen.grace-period.label": "Grace Period (Days)", "AccountDeletionConfigurationScreen.grace-period.description": "The end-user account will be disabled immediately upon request and deleted after the grace period", "AccountDeletionConfigurationScreen.scheduled-by-end-user.label": "Show \"Delete Account\" button in the end-user settings page", "AccountDeletionConfigurationScreen.apple-app-store.description": "Apps submitted to Apple App Store must offer account deletion within the app. Please refer to App Store Review Guidelines 5.1.1(v)", "AccountAnonymizationConfigurationScreen.title": "Account Anonymization", + "AccountAnonymizationConfigurationScreen.description": "Replace personally identifiable information with anonymized data after the grace period, while preserving activity records for compliance purposes.", "AccountAnonymizationConfigurationScreen.anonymization-schedule.title": "Anonymization Schedule", "AccountAnonymizationConfigurationScreen.grace-period.label": "Grace Period (Days)", "AccountAnonymizationConfigurationScreen.grace-period.description": "The end-user account will be disabled immediately upon request and anonymized after the grace period", @@ -2207,6 +2216,7 @@ "LoginMethodConfigurationScreen.combineLoginSignup.title": "Automatically signup a new user if a login ID is not found during login", "LoginMethodConfigurationScreen.custom-login-methods.authenticator.password-warning": "If no password authenticator is configured, users logging in with a username may be unable to access their accounts.", "EditConfigurationScreen.title": "Edit Project Configurations", + "EditConfigurationScreen.config.label": "Project configurations", "EditConfigurationScreen.warning.title": "Warning!", "EditConfigurationScreen.warning.content": "This page allows users to directly edit the Authgear project configuration in the Portal, but it is not recommended. Direct modifications to the config may cause incompatibilities that could lead to service downtime.



If you decide to proceed with directly editing the configuration, first copy the content for backup. This helps restore normal operation if errors occur.", "EditConfigurationScreen.warning.confirm": "I understand the risks of this operation", From 036b6fab9b0c8bdfb52f51482380b572ecc5a9c1 Mon Sep 17 00:00:00 2001 From: jimmycwc Date: Thu, 4 Jun 2026 15:07:12 +0800 Subject: [PATCH 08/10] [Portal] Replace authgear logo SVG embedded image with vector paths Use inline path elements instead of a base64 PNG for sharper rendering. Co-authored-by: Cursor --- portal/src/images/authgear_logo.svg | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/portal/src/images/authgear_logo.svg b/portal/src/images/authgear_logo.svg index 7430b6d419b..66c3cf45f7d 100644 --- a/portal/src/images/authgear_logo.svg +++ b/portal/src/images/authgear_logo.svg @@ -1,3 +1,9 @@ - + + + + + + + From fff9881d7555482b3b6b9e6404cd3ac1a163045d Mon Sep 17 00:00:00 2001 From: jimmycwc Date: Fri, 5 Jun 2026 10:41:43 +0800 Subject: [PATCH 09/10] [Portal] Migrate Hooks page to Radix UI with tab navigation - Replace Fluent UI components with Radix UI equivalents (Text, Select, TextField, ConfirmationDialog, IconButton, Tooltip) - Introduce tab-based navigation (Blocking Events, Non-blocking Events, Hook Settings, Webhook Signature) using Radix Themes Tabs - Apply two-column section layout (left: label, right: content) inside each tab panel, matching other advanced settings pages - Style Blocking Hooks table to match Admin API keys table pattern (gray-a2 row backgrounds, border-top separators, semibold headers, horizontal scroll wrapper with min-width enforcement) - Switch ScreenContent to list layout for responsive full-width grid - Update Webhook Signature secret key input to use embedded reveal/copy icon buttons in TextField suffix, matching SMS Provider screen style Co-authored-by: Cursor --- .../portal/HookConfigurationScreen.module.css | 136 +++- .../portal/HookConfigurationScreen.tsx | 747 ++++++++++-------- portal/src/locale-data/en.json | 2 +- 3 files changed, 541 insertions(+), 344 deletions(-) diff --git a/portal/src/graphql/portal/HookConfigurationScreen.module.css b/portal/src/graphql/portal/HookConfigurationScreen.module.css index 8016800dfe4..d59a1cfe9fd 100644 --- a/portal/src/graphql/portal/HookConfigurationScreen.module.css +++ b/portal/src/graphql/portal/HookConfigurationScreen.module.css @@ -4,6 +4,98 @@ @apply tablet:col-span-full; } +.pageHeader { + grid-column: 1 / span 9; + @apply tablet:col-span-full; + @apply flex flex-col gap-2; +} + +.pageTitle { + margin: 0; + font-size: 20px; + line-height: 28px; + font-weight: 600; + color: var(--gray-12); +} + +.pageDescription { + color: var(--gray-a11); +} + +.tabsRoot { + grid-column: 1 / span 9; + @apply tablet:col-span-full; + @apply flex min-w-0 flex-col; +} + +.tabsList { + width: 100%; + box-shadow: none; + border-bottom-width: 1px; + border-bottom-style: solid; + border-bottom-color: var(--gray-a6); + margin-bottom: 20px; +} + +.tabContent { + @apply flex min-w-0 flex-col; +} + +.section { + @apply min-w-0 rounded-xl border border-solid bg-white p-6; + border-color: var(--gray-a6); +} + +.sectionInner { + @apply flex min-w-0 flex-row items-start gap-8; + + @apply tablet:flex-col tablet:items-stretch tablet:gap-4; +} + +.sectionHeading { + flex-shrink: 0; + width: 12.5rem; + color: var(--gray-12); +} + +.sectionContent { + @apply flex w-full min-w-0 flex-1 flex-col gap-4; +} + +.sectionDescription { + color: var(--gray-a11); + font-size: 12px; + line-height: 16px; +} + +.hookListLabel { + color: var(--gray-12); +} + +.hookTableWrapper { + width: 100%; + max-width: 100%; + min-width: 0; + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +@media (min-width: 1081px) { + .hookTableWrapper { + scrollbar-width: none; + -ms-overflow-style: none; + } + + .hookTableWrapper::-webkit-scrollbar { + display: none; + } +} + +.hookListHeaderLabel { + @apply flex items-center px-3 py-3 text-sm font-semibold; + color: var(--gray-12); +} + .separator { grid-column: 1 / span 8; @apply tablet:col-span-full; @@ -18,36 +110,30 @@ @apply gap-y-5; } -.columnFull { - grid-column: 1 / span 8; - - @apply tablet:col-span-full; -} - -.secretInput { - grid-column: 1 / span 7; -} - -.secretButton { - grid-column: span 1 / span 1; - justify-self: center; - align-self: end; +.copyIconButton { + @apply shrink-0; + background: none; + border: none; + box-shadow: none; + color: var(--gray-11); } .hookContainer { display: flex; flex-direction: row; - column-gap: 12px; flex: 1 0 0; } +.hookCellContent { + @apply flex items-center px-3 py-2; +} + .hookHeader { display: flex; flex-direction: row; - column-gap: 12px; - padding: 8px 32px 8px 0; - border-bottom-width: 1px; - border-bottom-style: solid; + align-items: center; + min-height: 2.75rem; + min-width: 40rem; } .blockingHookKind { @@ -103,13 +189,15 @@ .hookList { /* stylelint-disable-next-line */ row-gap: 0 !important; + min-width: 40rem; } .hookListItem { - column-gap: 4px; - border-bottom-width: 1px; - border-bottom-style: solid; + border-top: 1px solid var(--gray-a6); + background-color: var(--gray-a2); + min-height: 2.75rem; + min-width: 40rem; + align-items: center; overflow-y: hidden; - overflow-x: auto; - padding: 10px 0; + overflow-x: visible; } diff --git a/portal/src/graphql/portal/HookConfigurationScreen.tsx b/portal/src/graphql/portal/HookConfigurationScreen.tsx index 247e4c07fb0..36faf61a48d 100644 --- a/portal/src/graphql/portal/HookConfigurationScreen.tsx +++ b/portal/src/graphql/portal/HookConfigurationScreen.tsx @@ -1,5 +1,5 @@ import cn from "classnames"; -import React, { useCallback, useContext, useMemo, useState } from "react"; +import React, { useCallback, useContext, useMemo, useRef, useState } from "react"; import { Context, FormattedMessage } from "../../intl"; import { useLocation, useParams, useNavigate } from "react-router-dom"; import { @@ -7,7 +7,6 @@ import { IDropdownOption, Label, FontIcon, - Text, Dialog, useTheme, DialogFooter, @@ -15,11 +14,15 @@ import { import { produce } from "immer"; import ShowError from "../../ShowError"; import ShowLoading from "../../ShowLoading"; +import { CopyIcon, EyeOpenIcon } from "@radix-ui/react-icons"; +import { + IconButton as RadixIconButton, + Tabs, + Text as RadixText, + Tooltip as RadixTooltip, +} from "@radix-ui/themes"; import ScreenContent from "../../ScreenContent"; -import ScreenTitle from "../../ScreenTitle"; -import ScreenDescription from "../../ScreenDescription"; import WidgetTitle from "../../WidgetTitle"; -import Widget from "../../Widget"; import { BlockingHookHandlerConfig, HookFeatureConfig, @@ -41,8 +44,8 @@ import { getDenoScriptPathFromURL, makeDenoScriptSpecifier, } from "../../util/resource"; -import { useCopyFeedback } from "../../hook/useCopyFeedback"; import FieldList, { ListItemProps } from "../../FieldList"; +import { copyToClipboard } from "../../util/clipboard"; import FormContainer from "../../FormContainer"; import FormTextField from "../../FormTextField"; import { clearEmptyObject } from "../../util/misc"; @@ -58,6 +61,7 @@ import { useErrorMessage, useErrorMessageString } from "../../formbinding"; import { useLoading, useIsLoading } from "../../hook/loading"; import { useProvideError } from "../../hook/error"; import TextField from "../../TextField"; +import { TextField as RadixTextField } from "../../components/v2/TextField/TextField"; import ExternalLink from "../../ExternalLink"; import FeatureDisabledMessageBar from "./FeatureDisabledMessageBar"; import PrimaryButton from "../../PrimaryButton"; @@ -67,7 +71,6 @@ import DefaultButton from "../../DefaultButton"; import { useSystemConfig } from "../../context/SystemConfigContext"; import { AppSecretKey } from "./globalTypes.generated"; import { useAppSecretVisitToken } from "./mutations/generateAppSecretVisitTokenMutation"; -import HorizontalDivider from "../../HorizontalDivider"; import { DENO_TYPES_URL } from "../../util/deno"; const CODE_EDITOR_OPTIONS = { @@ -280,6 +283,74 @@ const MASKED_SECRET = "***************"; const WEBHOOK_SIGNATURE_ID = "webhook-signature"; +function CopyIconButton({ + textToCopy, +}: { + textToCopy: string; +}): React.ReactElement { + const { renderToString } = useContext(Context); + const [copied, setCopied] = useState(false); + const timerRef = useRef | null>(null); + + const handleCopy = useCallback(() => { + copyToClipboard(textToCopy); + setCopied(true); + if (timerRef.current != null) { + clearTimeout(timerRef.current); + } + timerRef.current = setTimeout(() => { + setCopied(false); + }, 2000); + }, [textToCopy]); + + return ( + + + + + + ); +} + +function RevealIconButton({ + onClick, +}: { + onClick: () => void; +}): React.ReactElement { + const { renderToString } = useContext(Context); + + return ( + + + + + + ); +} + const EDIT_BUTTON_ICON_PROPS = { iconName: "Edit", }; @@ -540,26 +611,25 @@ const BlockingHandlerItemEdit: React.VFC =
- - +
+ +
+
+ +
{value.kind === "webhook" ? ( -
- +
=
) : null} {value.kind === "denohook" ? ( -
- +
= function HookConfigurationScreenContent(props) { const { appID } = useParams() as { appID: string }; - const { renderToString } = useContext(Context); const { hookFeatureConfig, form: config } = props; - const theme = useTheme(); - const [codeEditorState, setCodeEditorState] = useState(null); @@ -915,20 +979,20 @@ const HookConfigurationScreenContent: React.VFC { + (e: React.ChangeEvent) => { setState((state) => ({ ...state, - timeout: parseIntegerAllowLeadingZeros(value), + timeout: parseIntegerAllowLeadingZeros(e.target.value), })); }, [setState] ); const onTotalTimeoutChange = useCallback( - (_, value?: string) => { + (e: React.ChangeEvent) => { setState((state) => ({ ...state, - totalTimeout: parseIntegerAllowLeadingZeros(value), + totalTimeout: parseIntegerAllowLeadingZeros(e.target.value), })); }, [setState] @@ -1112,31 +1176,24 @@ const HookConfigurationScreenContent: React.VFC) => { - e.preventDefault(); - e.stopPropagation(); + const onRevealSecret = useCallback(() => { + if (state.secret != null) { + setRevealed(true); + return; + } - if (state.secret != null) { - setRevealed(true); - return; - } + const locationState: LocationState = { + isOAuthRedirect: true, + }; - const locationState: LocationState = { - isOAuthRedirect: true, - }; + startReauthentication(navigate, locationState).catch((e) => { + // Normally there should not be any error. + console.error(e); + }); + }, [navigate, state.secret]); - startReauthentication(navigate, locationState).catch((e) => { - // Normally there should not be any error. - console.error(e); - }); - }, - [navigate, state.secret] - ); - - const { copyButtonProps, Feedback } = useCopyFeedback({ - textToCopy: state.secret ?? "", - }); + const isSecretMasked = !revealed || state.secret == null; + const secretKeyValue = isSecretMasked ? MASKED_SECRET : (state.secret ?? ""); const blockingHandlerMax = useMemo(() => { return hookFeatureConfig?.blocking_handler?.maximum ?? 99; @@ -1254,270 +1311,322 @@ const HookConfigurationScreenContent: React.VFC ) : ( <> - - - - - - - - - - - - - ( - - {chunks} - - ), - }} - /> - - {blockingHandlerMax < 99 ? ( - blockingHandlerDisabled ? ( - - ) : ( - - ) - ) : null} - {!hideBlockingHandlerList ? ( - - -
+ + + + + + +
+ + + + + + + + + + + + + + + + + + +
+
+ + + +
+ - - - - - - - ( + + {chunks} + + ), }} - > - - -
- - } - parentJSONPointer="/hook" - fieldName="blocking_handlers" - list={blockingHandlers} - onListItemAdd={onBlockingHandlersChange} - onListItemChange={onBlockingHandlersChangeItemChange} - onListItemDelete={onBlockingHandlersChange} - makeDefaultItem={makeDefaultHandler} - ListItemComponent={BlockingHandlerListItem} - addButtonLabelMessageID="add" - addDisabled={blockingHandlerLimitReached} - /> - ) : null} - - - - - - - - - - ( - - {chunks} - - ), - }} - /> - - {nonBlockingHandlerMax < 99 ? ( - nonBlockingHandlerDisabled ? ( - - ) : ( - - ) - ) : null} - {!hideNonBlockingHandlerList ? ( - - -
+ + {blockingHandlerMax < 99 ? ( + blockingHandlerDisabled ? ( + + ) : ( + + ) + ) : null} + {!hideBlockingHandlerList ? ( +
+ + + + + + + + + + +
+ } + parentJSONPointer="/hook" + fieldName="blocking_handlers" + list={blockingHandlers} + onListItemAdd={onBlockingHandlersChange} + onListItemChange={onBlockingHandlersChangeItemChange} + onListItemDelete={onBlockingHandlersChange} + makeDefaultItem={makeDefaultHandler} + ListItemComponent={BlockingHandlerListItem} + addButtonLabelMessageID="add" + addDisabled={blockingHandlerLimitReached} + /> +
+ ) : null} +
+
+ + + + +
+
+ + + +
+ - - - - ( + + {chunks} + + ), }} - > - - -
- - } - parentJSONPointer="/hook" - fieldName="non_blocking_handlers" - list={nonBlockingHandlers} - onListItemAdd={onNonBlockingHandlersChange} - onListItemChange={onNonBlockingHandlersChange} - onListItemDelete={onNonBlockingHandlersChange} - makeDefaultItem={makeDefaultNonBlockingHandler} - ListItemComponent={NonBlockingHandlerListItem} - addButtonLabelMessageID="add" - addDisabled={nonBlockingHandlerLimitReached} - /> - ) : null} - - - - - - - - - - - - - - - - ( - - {chunks} - - ), - }} - /> - - - - ) : ( - - ) - } - /> - - + /> + + {nonBlockingHandlerMax < 99 ? ( + nonBlockingHandlerDisabled ? ( + + ) : ( + + ) + ) : null} + {!hideNonBlockingHandlerList ? ( + + + + +
+ + + + + + +
+ + } + parentJSONPointer="/hook" + fieldName="non_blocking_handlers" + list={nonBlockingHandlers} + onListItemAdd={onNonBlockingHandlersChange} + onListItemChange={onNonBlockingHandlersChange} + onListItemDelete={onNonBlockingHandlersChange} + makeDefaultItem={makeDefaultNonBlockingHandler} + ListItemComponent={NonBlockingHandlerListItem} + addButtonLabelMessageID="add" + addDisabled={nonBlockingHandlerLimitReached} + /> + ) : null} +
+
+ + + + +
+
+ + + +
+ + } + value={state.totalTimeout?.toFixed(0) ?? ""} + onChange={onTotalTimeoutChange} + /> + + } + value={state.timeout?.toFixed(0) ?? ""} + onChange={onTimeoutChange} + /> +
+
+
+
+ + +
+
+ + + + + +
+ + } + value={secretKeyValue} + readOnly={true} + suffixPlain={true} + suffix={ + isSecretMasked ? ( + + ) : secretKeyValue.length > 0 ? ( + + ) : undefined + } + hint={ + ( + + {chunks} + + ), + }} + /> + } + /> +
+
+
+
+ )} diff --git a/portal/src/locale-data/en.json b/portal/src/locale-data/en.json index 50f492eb9a3..bfea6a7ab8c 100644 --- a/portal/src/locale-data/en.json +++ b/portal/src/locale-data/en.json @@ -1269,7 +1269,7 @@ "HookConfigurationScreen.non-blocking-events-endpoints.label": "Non-blocking Hooks", "HookConfigurationScreen.signature.title": "Webhook Signature", "HookConfigurationScreen.signature.description": "Each webhook event request is signed with a secret key. You must validate the signature to ensure the request originates from Authgear. The signature is calculated as the hex encoded value of HMAC-SHA256 of the request body and is set as the HTTP header x-authgear-body-signature.

Learn more about validating the signature", - "HookConfigurationScreen.signature.secret-key": "Secret Key", + "HookConfigurationScreen.signature.secret-key": "Webhook Signature Secret Key", "AdminAPIConfigurationScreen.title": "Admin API", "AdminAPIConfigurationScreen.description": "The Admin API allows your server to manage your project and users via a GraphQL endpoint.", "AdminAPIConfigurationScreen.keys.title": "API Keys", From 2824b27515e4bdd4de1d85a4fc8ad699701106b2 Mon Sep 17 00:00:00 2001 From: jimmycwc Date: Fri, 5 Jun 2026 17:51:10 +0800 Subject: [PATCH 10/10] [Portal] Remove unused contentWidthAnchor class from multiple configuration screens - Deleted the contentWidthAnchor CSS class from Account Anonymization, Account Deletion, Cookie Lifetime, Edit, Endpoint Direct Access, SAMLCertificate, SMS Provider, and SMTP configuration screens. - Updated corresponding TSX files to remove references to the now-removed class, ensuring cleaner code and improved maintainability. --- .../AccountAnonymizationConfigurationScreen.module.css | 9 --------- .../portal/AccountAnonymizationConfigurationScreen.tsx | 6 ++---- .../portal/AccountDeletionConfigurationScreen.module.css | 9 --------- .../portal/AccountDeletionConfigurationScreen.tsx | 6 ++---- .../portal/CookieLifetimeConfigurationScreen.module.css | 9 --------- .../graphql/portal/CookieLifetimeConfigurationScreen.tsx | 6 ++---- .../graphql/portal/EditConfigurationScreen.module.css | 9 --------- portal/src/graphql/portal/EditConfigurationScreen.tsx | 6 ++---- .../graphql/portal/EndpointDirectAccessScreen.module.css | 9 --------- portal/src/graphql/portal/EndpointDirectAccessScreen.tsx | 6 ++---- .../src/graphql/portal/SAMLCertificateScreen.module.css | 9 --------- portal/src/graphql/portal/SAMLCertificateScreen.tsx | 6 ++---- .../portal/SMSProviderConfigurationScreen.module.css | 9 --------- .../graphql/portal/SMSProviderConfigurationScreen.tsx | 6 ++---- .../graphql/portal/SMTPConfigurationScreen.module.css | 9 --------- portal/src/graphql/portal/SMTPConfigurationScreen.tsx | 6 ++---- 16 files changed, 16 insertions(+), 104 deletions(-) diff --git a/portal/src/graphql/portal/AccountAnonymizationConfigurationScreen.module.css b/portal/src/graphql/portal/AccountAnonymizationConfigurationScreen.module.css index e283b50668b..9be14de1a14 100644 --- a/portal/src/graphql/portal/AccountAnonymizationConfigurationScreen.module.css +++ b/portal/src/graphql/portal/AccountAnonymizationConfigurationScreen.module.css @@ -26,12 +26,3 @@ .settingsCardSaveBarClearance { margin-bottom: 5.5rem; } - -.contentWidthAnchor { - grid-column: 1 / span 9; - height: 0; - overflow: hidden; - pointer-events: none; - - @apply tablet:col-span-full; -} diff --git a/portal/src/graphql/portal/AccountAnonymizationConfigurationScreen.tsx b/portal/src/graphql/portal/AccountAnonymizationConfigurationScreen.tsx index 7ff647c3d36..62dd6677571 100644 --- a/portal/src/graphql/portal/AccountAnonymizationConfigurationScreen.tsx +++ b/portal/src/graphql/portal/AccountAnonymizationConfigurationScreen.tsx @@ -77,10 +77,8 @@ const AccountAnonymizationConfigurationContent: React.VFC
-
+ className={cn(styles.widget, styles.pageHeader)} + > diff --git a/portal/src/graphql/portal/AccountDeletionConfigurationScreen.module.css b/portal/src/graphql/portal/AccountDeletionConfigurationScreen.module.css index e283b50668b..9be14de1a14 100644 --- a/portal/src/graphql/portal/AccountDeletionConfigurationScreen.module.css +++ b/portal/src/graphql/portal/AccountDeletionConfigurationScreen.module.css @@ -26,12 +26,3 @@ .settingsCardSaveBarClearance { margin-bottom: 5.5rem; } - -.contentWidthAnchor { - grid-column: 1 / span 9; - height: 0; - overflow: hidden; - pointer-events: none; - - @apply tablet:col-span-full; -} diff --git a/portal/src/graphql/portal/AccountDeletionConfigurationScreen.tsx b/portal/src/graphql/portal/AccountDeletionConfigurationScreen.tsx index ad976d4c0f6..a2d1c78f018 100644 --- a/portal/src/graphql/portal/AccountDeletionConfigurationScreen.tsx +++ b/portal/src/graphql/portal/AccountDeletionConfigurationScreen.tsx @@ -97,10 +97,8 @@ const AccountDeletionConfigurationContent: React.VFC
-
+ className={cn(styles.widget, styles.pageHeader)} + > diff --git a/portal/src/graphql/portal/CookieLifetimeConfigurationScreen.module.css b/portal/src/graphql/portal/CookieLifetimeConfigurationScreen.module.css index 17de70b06d2..0b6be4faa77 100644 --- a/portal/src/graphql/portal/CookieLifetimeConfigurationScreen.module.css +++ b/portal/src/graphql/portal/CookieLifetimeConfigurationScreen.module.css @@ -27,12 +27,3 @@ .settingsCardSaveBarClearance { margin-bottom: 5.5rem; } - -.contentWidthAnchor { - grid-column: 1 / span 9; - height: 0; - overflow: hidden; - pointer-events: none; - - @apply tablet:col-span-full; -} diff --git a/portal/src/graphql/portal/CookieLifetimeConfigurationScreen.tsx b/portal/src/graphql/portal/CookieLifetimeConfigurationScreen.tsx index b1c3f14b052..15eff279170 100644 --- a/portal/src/graphql/portal/CookieLifetimeConfigurationScreen.tsx +++ b/portal/src/graphql/portal/CookieLifetimeConfigurationScreen.tsx @@ -110,10 +110,8 @@ const CookieLifetimeConfigurationScreenContent: React.VFC
-
+ className={cn(styles.widget, styles.pageHeader)} + > diff --git a/portal/src/graphql/portal/EditConfigurationScreen.module.css b/portal/src/graphql/portal/EditConfigurationScreen.module.css index 8b0593063c6..3ebf5c00e75 100644 --- a/portal/src/graphql/portal/EditConfigurationScreen.module.css +++ b/portal/src/graphql/portal/EditConfigurationScreen.module.css @@ -24,15 +24,6 @@ margin-bottom: 5.5rem; } -.contentWidthAnchor { - grid-column: 1 / span 9; - height: 0; - overflow: hidden; - pointer-events: none; - - @apply tablet:col-span-full; -} - .editorCard { @apply min-w-0 overflow-hidden rounded-xl border border-solid; border-color: var(--gray-a6); diff --git a/portal/src/graphql/portal/EditConfigurationScreen.tsx b/portal/src/graphql/portal/EditConfigurationScreen.tsx index 0295b197e12..a6b5d01f28c 100644 --- a/portal/src/graphql/portal/EditConfigurationScreen.tsx +++ b/portal/src/graphql/portal/EditConfigurationScreen.tsx @@ -97,10 +97,8 @@ const EditConfigurationContent: React.VFC = >
-
+ className={cn(styles.widget, styles.pageHeader)} + >

diff --git a/portal/src/graphql/portal/EndpointDirectAccessScreen.module.css b/portal/src/graphql/portal/EndpointDirectAccessScreen.module.css index 8aa39d595d1..e6fbb36fafe 100644 --- a/portal/src/graphql/portal/EndpointDirectAccessScreen.module.css +++ b/portal/src/graphql/portal/EndpointDirectAccessScreen.module.css @@ -29,15 +29,6 @@ margin-bottom: 5.5rem; } -.contentWidthAnchor { - grid-column: 1 / span 9; - height: 0; - overflow: hidden; - pointer-events: none; - - @apply tablet:col-span-full; -} - .textFieldInOption { @apply ml-6; } diff --git a/portal/src/graphql/portal/EndpointDirectAccessScreen.tsx b/portal/src/graphql/portal/EndpointDirectAccessScreen.tsx index b7f6b11074b..2e83238e75f 100644 --- a/portal/src/graphql/portal/EndpointDirectAccessScreen.tsx +++ b/portal/src/graphql/portal/EndpointDirectAccessScreen.tsx @@ -364,10 +364,8 @@ const EndpointDirectAccessContent: React.VFC =
-
+ className={cn(styles.widget, styles.pageHeader)} + > diff --git a/portal/src/graphql/portal/SAMLCertificateScreen.module.css b/portal/src/graphql/portal/SAMLCertificateScreen.module.css index 19278569006..c82d21bed3b 100644 --- a/portal/src/graphql/portal/SAMLCertificateScreen.module.css +++ b/portal/src/graphql/portal/SAMLCertificateScreen.module.css @@ -24,15 +24,6 @@ padding-bottom: 5.5rem; } -.contentWidthAnchor { - grid-column: 1 / span 9; - height: 0; - overflow: hidden; - pointer-events: none; - - @apply tablet:col-span-full; -} - .settingsCardSaveBarClearance { margin-bottom: 5.5rem; } diff --git a/portal/src/graphql/portal/SAMLCertificateScreen.tsx b/portal/src/graphql/portal/SAMLCertificateScreen.tsx index 9a5d85788b2..0e1a6561a33 100644 --- a/portal/src/graphql/portal/SAMLCertificateScreen.tsx +++ b/portal/src/graphql/portal/SAMLCertificateScreen.tsx @@ -39,10 +39,8 @@ function EditSAMLCertificateContent({
-
+ className={cn(styles.widget, styles.pageHeader)} + > diff --git a/portal/src/graphql/portal/SMSProviderConfigurationScreen.module.css b/portal/src/graphql/portal/SMSProviderConfigurationScreen.module.css index 921e2a9f97f..544b4b8d121 100644 --- a/portal/src/graphql/portal/SMSProviderConfigurationScreen.module.css +++ b/portal/src/graphql/portal/SMSProviderConfigurationScreen.module.css @@ -34,15 +34,6 @@ padding-bottom: 8rem; } -.contentWidthAnchor { - grid-column: 1 / span 9; - height: 0; - overflow: hidden; - pointer-events: none; - - @apply tablet:col-span-full; -} - .senderInfoIcon { cursor: default; color: var(--accent-9); diff --git a/portal/src/graphql/portal/SMSProviderConfigurationScreen.tsx b/portal/src/graphql/portal/SMSProviderConfigurationScreen.tsx index ce132129954..bd9b082886c 100644 --- a/portal/src/graphql/portal/SMSProviderConfigurationScreen.tsx +++ b/portal/src/graphql/portal/SMSProviderConfigurationScreen.tsx @@ -1114,10 +1114,8 @@ function SMSProviderConfigurationContent(props: {
-
+ className={cn(styles.widget, styles.pageHeader)} + >

{isAuthgearOnce ? ( diff --git a/portal/src/graphql/portal/SMTPConfigurationScreen.module.css b/portal/src/graphql/portal/SMTPConfigurationScreen.module.css index 56f1683d614..1d7340a6a9e 100644 --- a/portal/src/graphql/portal/SMTPConfigurationScreen.module.css +++ b/portal/src/graphql/portal/SMTPConfigurationScreen.module.css @@ -56,12 +56,3 @@ .contentWithSaveBar { padding-bottom: 8rem; } - -.contentWidthAnchor { - grid-column: 1 / span 9; - height: 0; - overflow: hidden; - pointer-events: none; - - @apply tablet:col-span-full; -} diff --git a/portal/src/graphql/portal/SMTPConfigurationScreen.tsx b/portal/src/graphql/portal/SMTPConfigurationScreen.tsx index f3fa1e0771d..a7d2fb5ea5f 100644 --- a/portal/src/graphql/portal/SMTPConfigurationScreen.tsx +++ b/portal/src/graphql/portal/SMTPConfigurationScreen.tsx @@ -598,10 +598,8 @@ const SMTPConfigurationScreenContent: React.VFC
-
+ className={cn(styles.widget, styles.pageHeader)} + >

{isAuthgearOnce ? (