Compare commits

...

2 Commits

Author SHA1 Message Date
Raunak Bhagat
e5f5f35039 fix(web): handle missing user_email in credential description
Conditionally append "by <email>" only when user_email is defined to
avoid rendering "by undefined".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 14:30:11 -08:00
Raunak Bhagat
ef0887e70e refactor(web): modernize credential form components to use opal/refresh patterns
Migrate credential action components (EditCredential, CreateCredential,
CreateStdOAuthCredential) from legacy patterns to the current opal and
refresh-component conventions used in AgentEditorPage.

- Replace refresh-components Button with @opal/components Button
- Replace TextFormField with InputLayouts.Vertical + InputTypeInField
- Replace react-icons with @opal/icons
- Use Modal.Body/Modal.Footer with BasicModalFooter in EditCredential
- Inline CredentialFieldsRenderer into CreateCredential (single usage)
- Inline CreateButton into CreateCredential (single usage)
- Remove dark: modifiers and raw Tailwind color overrides
- Add isOptionalCredentialField helper for optional field indicators
- Use absolute @/ imports throughout actions/ directory

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 12:10:51 -08:00
10 changed files with 514 additions and 522 deletions

View File

@@ -53,7 +53,7 @@ import {
getConnectorOauthRedirectUrl,
useOAuthDetails,
} from "@/lib/connectors/oauth";
import { CreateStdOAuthCredential } from "@/components/credentials/actions/CreateStdOAuthCredential";
import CreateStdOAuthCredential from "@/components/credentials/actions/CreateStdOAuthCredential";
import { Spinner } from "@/components/Spinner";
import Button from "@/refresh-components/buttons/Button";
import { deleteConnector } from "@/lib/connector";

View File

@@ -19,7 +19,7 @@ import {
import { deleteCredential } from "@/lib/credential";
import ModifyCredential from "@/components/credentials/actions/ModifyCredential";
import CreateCredential from "@/components/credentials/actions/CreateCredential";
import { CreateStdOAuthCredential } from "@/components/credentials/actions/CreateStdOAuthCredential";
import CreateStdOAuthCredential from "@/components/credentials/actions/CreateStdOAuthCredential";
import { GmailMain } from "@/app/admin/connectors/[connector]/pages/gmail/GmailPage";
import CardSection from "@/components/admin/CardSection";
import { Spinner } from "@/components/Spinner";

View File

@@ -3,9 +3,7 @@
import { AccessType, ValidSources } from "@/lib/types";
import useSWR, { mutate } from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { FaKey } from "react-icons/fa";
import { useState } from "react";
import { FiEdit2 } from "react-icons/fi";
import {
deleteCredential,
swapCredential,
@@ -16,7 +14,6 @@ import { toast } from "@/hooks/useToast";
import CreateCredential from "./actions/CreateCredential";
import { CCPairFullInfo } from "@/app/admin/connector/[ccPairId]/types";
import ModifyCredential from "./actions/ModifyCredential";
import Text from "@/components/ui/text";
import {
buildCCPairInfoUrl,
buildSimilarCredentialInfoURL,
@@ -33,10 +30,12 @@ import {
useOAuthDetails,
} from "@/lib/connectors/oauth";
import { Spinner } from "@/components/Spinner";
import { CreateStdOAuthCredential } from "@/components/credentials/actions/CreateStdOAuthCredential";
import { Card } from "../ui/card";
import CreateStdOAuthCredential from "@/components/credentials/actions/CreateStdOAuthCredential";
import Card from "@/refresh-components/cards/Card";
import { isTypedFileField, TypedFile } from "@/lib/connectors/fileTypes";
import { SvgEdit, SvgKey } from "@opal/icons";
import { LineItemLayout } from "@/layouts/general-layouts";
import { Button } from "@opal/components";
export interface CredentialSectionProps {
ccPair: CCPairFullInfo;
@@ -170,88 +169,30 @@ export default function CredentialSection({
}
return (
<div
className="flex
flex-col
gap-y-4
rounded-lg
bg-background"
>
<Card className="p-6">
<div className="flex items-center">
<div className="flex-shrink-0 mr-3">
<FaKey className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-grow flex flex-col justify-center">
<div className="flex items-center justify-between">
<div>
<Text className="font-medium">
{ccPair.credential.name ||
`Credential #${ccPair.credential.id}`}
</Text>
<div className="text-xs text-muted-foreground/70">
Created{" "}
<i>
{new Date(
ccPair.credential.time_created
).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</i>
{ccPair.credential.user_email && (
<>
{" "}
by <i>{ccPair.credential.user_email}</i>
</>
)}
</div>
</div>
<button
onClick={() => setShowModifyCredential(true)}
className="inline-flex
items-center
justify-center
p-2
rounded-md
text-muted-foreground
hover:bg-accent
hover:text-accent-foreground
transition-colors"
>
<FiEdit2 className="h-4 w-4" />
<span className="sr-only">Update Credentials</span>
</button>
</div>
</div>
</div>
</Card>
<>
{showModifyCredential && (
<Modal open onOpenChange={closeModifyCredential}>
<Modal.Content>
<Modal.Header
icon={SvgEdit}
title="Update Credentials"
description="Select a credential as needed! Ensure that you have selected a credential with the proper permissions for this connector!"
onClose={closeModifyCredential}
/>
<Modal.Body>
<ModifyCredential
close={closeModifyCredential}
accessType={ccPair.access_type}
attachedConnector={ccPair.connector}
defaultedCredential={defaultedCredential}
credentials={credentials}
editableCredentials={editableCredentials}
onDeleteCredential={onDeleteCredential}
onEditCredential={(credential: Credential<any>) =>
onEditCredential(credential)
}
onSwap={onSwap}
onCreateNew={() => makeShowCreateCredential()}
/>
</Modal.Body>
<ModifyCredential
close={closeModifyCredential}
accessType={ccPair.access_type}
attachedConnector={ccPair.connector}
defaultedCredential={defaultedCredential}
credentials={credentials}
editableCredentials={editableCredentials}
onDeleteCredential={onDeleteCredential}
onEditCredential={(credential: Credential<any>) =>
onEditCredential(credential)
}
onSwap={onSwap}
onCreateNew={() => makeShowCreateCredential()}
/>
</Modal.Content>
</Modal>
)}
@@ -262,15 +203,14 @@ export default function CredentialSection({
<Modal.Header
icon={SvgEdit}
title="Edit Credential"
description="Ensure that you update to a credential with the proper permissions!"
onClose={closeEditingCredential}
/>
<EditCredential
onUpdate={onUpdateCredential}
credential={editingCredential}
onClose={closeEditingCredential}
/>
<Modal.Body>
<EditCredential
onUpdate={onUpdateCredential}
credential={editingCredential}
onClose={closeEditingCredential}
/>
</Modal.Body>
</Modal.Content>
</Modal>
)}
@@ -308,6 +248,34 @@ export default function CredentialSection({
</Modal.Content>
</Modal>
)}
</div>
<Card padding={0.5}>
<LineItemLayout
icon={SvgKey}
title={
ccPair.credential.name || `Credential #${ccPair.credential.id}`
}
description={`Created ${new Date(
ccPair.credential.time_created
).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}${
ccPair.credential.user_email
? ` by ${ccPair.credential.user_email}`
: ""
}`}
rightChildren={
<Button
icon={SvgEdit}
prominence="tertiary"
onClick={() => setShowModifyCredential(true)}
/>
}
reducedPadding
/>
</Card>
</>
);
}

View File

@@ -1,17 +1,24 @@
"use client";
import { useState } from "react";
import Button from "@/refresh-components/buttons/Button";
import { ValidSources, AccessType } from "@/lib/types";
import { FaAccusoft } from "react-icons/fa";
import { submitCredential } from "@/components/admin/connectors/CredentialForm";
import { TextFormField } from "@/components/Field";
import { Form, Formik, FormikHelpers } from "formik";
import { toast } from "@/hooks/useToast";
import GDriveMain from "@/app/admin/connectors/[connector]/pages/gdrive/GoogleDrivePage";
import { Connector } from "@/lib/connectors/connectors";
import { Credential, credentialTemplates } from "@/lib/connectors/credentials";
import {
Credential,
credentialTemplates,
getDisplayNameForCredentialKey,
CredentialTemplateWithAuth,
} from "@/lib/connectors/credentials";
import { GmailMain } from "@/app/admin/connectors/[connector]/pages/gmail/GmailPage";
import { ActionType, dictionaryType } from "../types";
import { createValidationSchema } from "../lib";
import { ActionType, dictionaryType } from "@/components/credentials/types";
import {
createValidationSchema,
isOptionalCredentialField,
} from "@/components/credentials/lib";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import { AdvancedOptionsToggle } from "@/components/AdvancedOptionsToggle";
import {
@@ -19,49 +26,23 @@ import {
IsPublicGroupSelector,
} from "@/components/IsPublicGroupSelector";
import { useUser } from "@/providers/UserProvider";
import CardSection from "@/components/admin/CardSection";
import { CredentialFieldsRenderer } from "./CredentialFieldsRenderer";
import { TypedFile } from "@/lib/connectors/fileTypes";
import { isTypedFileField, TypedFile } from "@/lib/connectors/fileTypes";
import { BooleanFormField, TypedFileUploadFormField } from "@/components/Field";
import ConnectorDocsLink from "@/components/admin/connectors/ConnectorDocsLink";
import Tabs from "@/refresh-components/Tabs";
import Text from "@/refresh-components/texts/Text";
import { SvgPlusCircle } from "@opal/icons";
const CreateButton = ({
onClick,
isSubmitting,
isAdmin,
groups,
}: {
onClick: () => void;
isSubmitting: boolean;
isAdmin: boolean;
groups: number[];
}) => (
<Button
onClick={onClick}
disabled={isSubmitting || (!isAdmin && groups.length === 0)}
leftIcon={SvgPlusCircle}
>
Create
</Button>
);
import { Button } from "@opal/components";
import * as InputLayouts from "@/layouts/input-layouts";
import InputTypeInField from "@/refresh-components/form/InputTypeInField";
type formType = IsPublicGroupSelectorFormType & {
name: string;
[key: string]: any; // For additional credential fields
};
export default function CreateCredential({
hideSource,
sourceType,
accessType,
close,
onClose = () => null,
onSwitch,
onSwap = async () => null,
swapConnector,
refresh = () => null,
}: {
export interface CreateCredentialProps {
// Source information
hideSource?: boolean; // hides docs link
sourceType: ValidSources;
accessType: AccessType;
@@ -84,7 +65,18 @@ export default function CreateCredential({
// Mutating parent state
refresh?: () => void;
}) {
}
export default function CreateCredential({
sourceType,
accessType,
close,
onClose = () => null,
onSwitch,
onSwap = async () => null,
swapConnector,
refresh = () => null,
}: CreateCredentialProps) {
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const [authMethod, setAuthMethod] = useState<string>();
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
@@ -208,64 +200,222 @@ export default function CreateCredential({
formikProps.setFieldValue("authentication_method", authMethod);
}
const templateWithAuth =
credentialTemplate as CredentialTemplateWithAuth<any>;
const hasMultipleAuthMethods =
templateWithAuth.authMethods &&
templateWithAuth.authMethods.length > 1;
const handleAuthMethodChange = (newMethod: string) => {
const cleaned: Record<string, any> = {
...formikProps.values,
authentication_method: newMethod,
};
templateWithAuth.authMethods?.forEach((m) => {
if (m.value !== newMethod) {
Object.keys(m.fields).forEach((fieldKey) => {
delete cleaned[fieldKey];
});
}
});
formikProps.setValues(cleaned as typeof formikProps.values);
setAuthMethod(newMethod);
};
const currentAuthMethod = authMethod || initialAuthMethod;
return (
<Form className="w-full flex items-stretch">
{!hideSource && <ConnectorDocsLink sourceType={sourceType} />}
<CardSection className="w-full items-start dark:bg-neutral-900 mt-4 flex flex-col gap-y-6">
<TextFormField
<Form className="w-full flex flex-col gap-4">
<ConnectorDocsLink sourceType={sourceType} />
<InputLayouts.Vertical name="name" title="Name" optional>
<InputTypeInField
name="name"
placeholder="(Optional) credential name.."
label="Name:"
placeholder="Name your credential"
/>
</InputLayouts.Vertical>
<CredentialFieldsRenderer
credentialTemplate={credentialTemplate}
authMethod={authMethod || initialAuthMethod}
setAuthMethod={setAuthMethod}
/>
{hasMultipleAuthMethods && templateWithAuth.authMethods ? (
<div className="w-full space-y-4">
<input
type="hidden"
name="authentication_method"
value={
currentAuthMethod ||
(templateWithAuth.authMethods?.[0]?.value ?? "")
}
/>
{!swapConnector && (
<div className="mt-4 flex w-full flex-col sm:flex-row justify-between items-end">
<div className="w-full sm:w-3/4 mb-4 sm:mb-0">
{isPaidEnterpriseFeaturesEnabled && (
<div className="flex flex-col items-start">
{isAdmin && (
<AdvancedOptionsToggle
showAdvancedOptions={showAdvancedOptions}
setShowAdvancedOptions={setShowAdvancedOptions}
/>
<Tabs
value={
currentAuthMethod ||
templateWithAuth.authMethods?.[0]?.value ||
""
}
onValueChange={handleAuthMethodChange}
>
<Tabs.List>
{templateWithAuth.authMethods.map((method) => (
<Tabs.Trigger key={method.value} value={method.value}>
{method.label}
</Tabs.Trigger>
))}
</Tabs.List>
{templateWithAuth.authMethods.map((method) => (
<Tabs.Content
key={method.value}
value={method.value}
alignItems="stretch"
>
{Object.keys(method.fields).length === 0 &&
method.description && (
<div className="p-4 bg-background-tint-02 border border-border-02 rounded-md">
<Text secondaryBody text03>
{method.description}
</Text>
</div>
)}
{(showAdvancedOptions || !isAdmin) && (
<IsPublicGroupSelector
formikProps={formikProps}
objectName="credential"
publicToWhom="Curators"
/>
)}
</div>
)}
</div>
<CreateButton
onClick={() =>
handleSubmit(formikProps.values, formikProps, "create")
}
isSubmitting={formikProps.isSubmitting}
isAdmin={isAdmin}
groups={formikProps.values.groups}
/>
{Object.entries(method.fields).map(([key, val]) => {
if (isTypedFileField(key)) {
return (
<TypedFileUploadFormField
key={key}
name={key}
label={getDisplayNameForCredentialKey(key)}
/>
);
}
if (typeof val === "boolean") {
return (
<BooleanFormField
key={key}
name={key}
label={getDisplayNameForCredentialKey(key)}
/>
);
}
const inputType =
key.toLowerCase().includes("token") ||
key.toLowerCase().includes("password") ||
key.toLowerCase().includes("secret")
? "password"
: "text";
return (
<InputLayouts.Vertical
key={key}
name={key}
title={getDisplayNameForCredentialKey(key)}
optional={isOptionalCredentialField(val)}
>
<InputTypeInField
name={key}
placeholder={val}
type={inputType}
/>
</InputLayouts.Vertical>
);
})}
</Tabs.Content>
))}
</Tabs>
</div>
) : (
Object.entries(credentialTemplate).map(([key, val]) => {
if (key === "authentication_method" || key === "authMethods") {
return null;
}
if (isTypedFileField(key)) {
return (
<TypedFileUploadFormField
key={key}
name={key}
label={getDisplayNameForCredentialKey(key)}
/>
);
}
if (typeof val === "boolean") {
return (
<BooleanFormField
key={key}
name={key}
label={getDisplayNameForCredentialKey(key)}
/>
);
}
const inputType =
key.toLowerCase().includes("token") ||
key.toLowerCase().includes("password") ||
key.toLowerCase().includes("secret")
? "password"
: "text";
return (
<InputLayouts.Vertical
key={key}
name={key}
title={getDisplayNameForCredentialKey(key)}
optional={isOptionalCredentialField(val)}
>
<InputTypeInField
name={key}
placeholder={val as string}
type={inputType}
/>
</InputLayouts.Vertical>
);
})
)}
{!swapConnector && (
<div className="mt-4 flex w-full flex-col sm:flex-row justify-between items-end">
<div className="w-full sm:w-3/4 mb-4 sm:mb-0">
{isPaidEnterpriseFeaturesEnabled && (
<div className="flex flex-col items-start">
{isAdmin && (
<AdvancedOptionsToggle
showAdvancedOptions={showAdvancedOptions}
setShowAdvancedOptions={setShowAdvancedOptions}
/>
)}
{(showAdvancedOptions || !isAdmin) && (
<IsPublicGroupSelector
formikProps={formikProps}
objectName="credential"
publicToWhom="Curators"
/>
)}
</div>
)}
</div>
)}
</CardSection>
<Button
onClick={() =>
handleSubmit(formikProps.values, formikProps, "create")
}
disabled={
formikProps.isSubmitting ||
(!isAdmin && formikProps.values.groups.length === 0)
}
icon={SvgPlusCircle}
>
Create
</Button>
</div>
)}
{swapConnector && (
<Button
className="bg-rose-500 hover:bg-rose-400"
onClick={() =>
handleSubmit(formikProps.values, formikProps, "createAndSwap")
}
disabled={formikProps.isSubmitting}
leftIcon={() => (
<FaAccusoft className="fill-text-inverted-05" />
)}
icon={SvgPlusCircle}
>
Create
</Button>

View File

@@ -1,30 +1,33 @@
import * as Yup from "yup";
"use client";
import Button from "@/refresh-components/buttons/Button";
import * as Yup from "yup";
import { ValidSources } from "@/lib/types";
import { TextFormField } from "@/components/Field";
import { Form, Formik, FormikHelpers } from "formik";
import CardSection from "@/components/admin/CardSection";
import { getConnectorOauthRedirectUrl } from "@/lib/connectors/oauth";
import { OAuthAdditionalKwargDescription } from "@/lib/connectors/credentials";
import { Button } from "@opal/components";
import { SvgPlusCircle } from "@opal/icons";
import * as InputLayouts from "@/layouts/input-layouts";
import * as GeneralLayouts from "@/layouts/general-layouts";
import InputTypeInField from "@/refresh-components/form/InputTypeInField";
type formType = {
[key: string]: any; // For additional credential fields
[key: string]: any;
};
export function CreateStdOAuthCredential({
export interface CreateStdOAuthCredentialProps {
sourceType: ValidSources;
additionalFields: OAuthAdditionalKwargDescription[];
}
export default function CreateStdOAuthCredential({
sourceType,
additionalFields,
}: {
// Source information
sourceType: ValidSources;
additionalFields: OAuthAdditionalKwargDescription[];
}) {
const handleSubmit = async (
}: CreateStdOAuthCredentialProps) {
async function handleSubmit(
values: formType,
formikHelpers: FormikHelpers<formType>
) => {
) {
const { setSubmitting, validateForm } = formikHelpers;
const errors = await validateForm(values);
@@ -34,7 +37,6 @@ export function CreateStdOAuthCredential({
}
setSubmitting(true);
formikHelpers.setSubmitting(true);
const redirectUrl = await getConnectorOauthRedirectUrl(sourceType, values);
@@ -43,7 +45,7 @@ export function CreateStdOAuthCredential({
}
window.location.href = redirectUrl;
};
}
return (
<Formik
@@ -57,27 +59,26 @@ export function CreateStdOAuthCredential({
additionalFields.map((field) => [field.name, Yup.string().required()])
),
})}
onSubmit={(values, formikHelpers) => {
handleSubmit(values, formikHelpers);
}}
onSubmit={handleSubmit}
>
{() => (
<Form className="w-full flex items-stretch">
<CardSection className="w-full !border-0 mt-4 flex flex-col gap-y-6">
<Form>
<GeneralLayouts.Section>
{additionalFields.map((field) => (
<TextFormField
<InputLayouts.Vertical
key={field.name}
name={field.name}
label={field.display_name}
subtext={field.description}
type="text"
/>
title={field.display_name}
description={field.description}
>
<InputTypeInField name={field.name} />
</InputLayouts.Vertical>
))}
<div className="flex w-full">
<Button type="submit">Create</Button>
</div>
</CardSection>
<Button type="submit" icon={SvgPlusCircle}>
Create
</Button>
</GeneralLayouts.Section>
</Form>
)}
</Formik>

View File

@@ -1,178 +0,0 @@
"use client";
import Tabs from "@/refresh-components/Tabs";
import { useFormikContext } from "formik";
import {
BooleanFormField,
TextFormField,
TypedFileUploadFormField,
} from "@/components/Field";
import {
getDisplayNameForCredentialKey,
CredentialTemplateWithAuth,
} from "@/lib/connectors/credentials";
import { dictionaryType } from "../types";
import { isTypedFileField } from "@/lib/connectors/fileTypes";
interface CredentialFieldsRendererProps {
credentialTemplate: dictionaryType;
authMethod?: string;
setAuthMethod?: (method: string) => void;
}
export function CredentialFieldsRenderer({
credentialTemplate,
authMethod,
setAuthMethod,
}: CredentialFieldsRendererProps) {
const templateWithAuth =
credentialTemplate as CredentialTemplateWithAuth<any>;
const { values, setValues } = useFormikContext<any>();
// remove other authmethod fields when switching
const handleAuthMethodChange = (newMethod: string) => {
// start from current form values
const cleaned = { ...values, authentication_method: newMethod };
// delete every field not in the selected auth method
templateWithAuth.authMethods?.forEach((m) => {
if (m.value !== newMethod) {
Object.keys(m.fields).forEach((fieldKey) => {
delete cleaned[fieldKey];
});
}
});
setValues(cleaned);
setAuthMethod?.(newMethod);
};
// Check if this credential template has multiple auth methods
const hasMultipleAuthMethods =
templateWithAuth.authMethods && templateWithAuth.authMethods.length > 1;
if (hasMultipleAuthMethods && templateWithAuth.authMethods) {
return (
<div className="w-full space-y-4">
{/* Render authentication_method as a hidden field */}
<input
type="hidden"
name="authentication_method"
value={authMethod || (templateWithAuth.authMethods?.[0]?.value ?? "")}
/>
<Tabs
value={authMethod || templateWithAuth.authMethods?.[0]?.value || ""}
onValueChange={handleAuthMethodChange}
>
<Tabs.List>
{templateWithAuth.authMethods.map((method) => (
<Tabs.Trigger key={method.value} value={method.value}>
{method.label}
</Tabs.Trigger>
))}
</Tabs.List>
{templateWithAuth.authMethods.map((method) => (
<Tabs.Content
key={method.value}
value={method.value}
alignItems="stretch"
>
{/* Show description if method has no fields but has a description */}
{Object.keys(method.fields).length === 0 &&
method.description && (
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-md">
<p className="text-sm text-blue-800 dark:text-blue-200">
{method.description}
</p>
</div>
)}
{Object.entries(method.fields).map(([key, val]) => {
if (isTypedFileField(key)) {
return (
<TypedFileUploadFormField
key={key}
name={key}
label={getDisplayNameForCredentialKey(key)}
/>
);
}
if (typeof val === "boolean") {
return (
<BooleanFormField
key={key}
name={key}
label={getDisplayNameForCredentialKey(key)}
/>
);
}
return (
<TextFormField
key={key}
name={key}
placeholder={val}
label={getDisplayNameForCredentialKey(key)}
type={
key.toLowerCase().includes("token") ||
key.toLowerCase().includes("password") ||
key.toLowerCase().includes("secret")
? "password"
: "text"
}
/>
);
})}
</Tabs.Content>
))}
</Tabs>
</div>
);
}
// Render single auth method fields (existing behavior)
return (
<>
{Object.entries(credentialTemplate).map(([key, val]) => {
// Skip auth method metadata fields
if (key === "authentication_method" || key === "authMethods") {
return null;
}
if (isTypedFileField(key)) {
return (
<TypedFileUploadFormField
key={key}
name={key}
label={getDisplayNameForCredentialKey(key)}
/>
);
}
if (typeof val === "boolean") {
return (
<BooleanFormField
key={key}
name={key}
label={getDisplayNameForCredentialKey(key)}
/>
);
}
return (
<TextFormField
key={key}
name={key}
placeholder={val as string}
label={getDisplayNameForCredentialKey(key)}
type={
key.toLowerCase().includes("token") ||
key.toLowerCase().includes("password") ||
key.toLowerCase().includes("secret")
? "password"
: "text"
}
/>
);
})}
</>
);
}

View File

@@ -1,18 +1,25 @@
import Button from "@/refresh-components/buttons/Button";
import Text from "@/components/ui/text";
"use client";
import { FaNewspaper, FaTrash } from "react-icons/fa";
import { TextFormField, TypedFileUploadFormField } from "@/components/Field";
import Modal, { BasicModalFooter } from "@/refresh-components/Modal";
import { TypedFileUploadFormField } from "@/components/Field";
import { Form, Formik, FormikHelpers } from "formik";
import { toast } from "@/hooks/useToast";
import {
Credential,
getDisplayNameForCredentialKey,
} from "@/lib/connectors/credentials";
import { createEditingValidationSchema, createInitialValues } from "../lib";
import { dictionaryType, formType } from "../types";
import {
createEditingValidationSchema,
createInitialValues,
} from "@/components/credentials/lib";
import { dictionaryType, formType } from "@/components/credentials/types";
import { isTypedFileField } from "@/lib/connectors/fileTypes";
import { SvgTrash } from "@opal/icons";
import { SvgCheck, SvgTrash } from "@opal/icons";
import { Button } from "@opal/components";
import * as InputLayouts from "@/layouts/input-layouts";
import * as GeneralLayouts from "@/layouts/general-layouts";
import InputTypeInField from "@/refresh-components/form/InputTypeInField";
export interface EditCredentialProps {
credential: Credential<dictionaryType>;
onClose: () => void;
@@ -33,10 +40,10 @@ export default function EditCredential({
);
const initialValues = createInitialValues(credential);
const handleSubmit = async (
async function handleSubmit(
values: formType,
formikHelpers: FormikHelpers<formType>
) => {
) {
formikHelpers.setSubmitting(true);
try {
await onUpdate(credential, values, onClose);
@@ -46,68 +53,88 @@ export default function EditCredential({
} finally {
formikHelpers.setSubmitting(false);
}
};
}
return (
<div className="flex flex-col gap-y-6">
<Text>
Ensure that you update to a credential with the proper permissions!
</Text>
<Formik
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={handleSubmit}
>
{({ isSubmitting, resetForm }) => (
<Form>
<TextFormField
includeRevert
name="name"
placeholder={credential.name || ""}
label="Name (optional):"
/>
{Object.entries(credential.credential_json).map(([key, value]) =>
isTypedFileField(key) ? (
<TypedFileUploadFormField
key={key}
name={key}
label={getDisplayNameForCredentialKey(key)}
<Formik
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={handleSubmit}
>
{({ isSubmitting, resetForm }) => (
<Form>
<Modal.Body>
<GeneralLayouts.Section>
<InputLayouts.Vertical name="name" title="Name" optional>
<InputTypeInField
name="name"
placeholder={initialValues.name}
/>
) : (
<TextFormField
includeRevert
key={key}
name={key}
placeholder={value as string}
label={getDisplayNameForCredentialKey(key)}
type={
</InputLayouts.Vertical>
{Object.entries(credential.credential_json).map(
([key, value]) => {
if (isTypedFileField(key))
return (
<TypedFileUploadFormField
key={key}
name={key}
label={getDisplayNameForCredentialKey(key)}
/>
);
const inputType =
key.toLowerCase().includes("token") ||
key.toLowerCase().includes("password")
? "password"
: "text"
}
disabled={key === "authentication_method"}
/>
)
)}
<div className="flex justify-between w-full">
<Button onClick={() => resetForm()} leftIcon={SvgTrash}>
Reset Changes
</Button>
<Button
type="submit"
disabled={isSubmitting}
className="bg-indigo-500 hover:bg-indigo-400"
leftIcon={FaNewspaper}
>
Update
</Button>
</div>
</Form>
)}
</Formik>
</div>
: "text";
return (
<InputLayouts.Vertical
key={key}
name={key}
title={getDisplayNameForCredentialKey(key)}
>
<InputTypeInField
name={key}
placeholder={
// # Note (@raunakab):
// Will always be a string since the `if (isTypedFileField)` check above will filter out `TypeFile` values.
value as string
}
type={inputType}
variant={
key === "authentication_method"
? "disabled"
: undefined
}
/>
</InputLayouts.Vertical>
);
}
)}
</GeneralLayouts.Section>
</Modal.Body>
<Modal.Footer>
<BasicModalFooter
cancel={
<Button
onClick={() => resetForm()}
icon={SvgTrash}
prominence="secondary"
>
Reset Changes
</Button>
}
submit={
<Button type="submit" disabled={isSubmitting} icon={SvgCheck}>
Update
</Button>
}
/>
</Modal.Footer>
</Form>
)}
</Formik>
);
}

View File

@@ -1,17 +1,24 @@
"use client";
import React, { useState } from "react";
import Modal from "@/refresh-components/Modal";
import Button from "@/refresh-components/buttons/Button";
import Modal, { BasicModalFooter } from "@/refresh-components/Modal";
import { Button } from "@opal/components";
import Text from "@/refresh-components/texts/Text";
import { Badge } from "@/components/ui/badge";
import { AccessType } from "@/lib/types";
import { EditIcon, NewChatIcon, SwapIcon } from "@/components/icons/icons";
import {
ConfluenceCredentialJson,
Credential,
} from "@/lib/connectors/credentials";
import { Connector } from "@/lib/connectors/connectors";
import { SvgAlertTriangle, SvgTrash } from "@opal/icons";
import { Button as OpalButton } from "@opal/components";
import {
SvgAlertTriangle,
SvgBubbleText,
SvgCheck,
SvgEdit,
SvgTrash,
} from "@opal/icons";
interface CredentialSelectionTableProps {
credentials: Credential<any>[];
editableCredentials: Credential<any>[];
@@ -115,21 +122,21 @@ function CredentialSelectionTable({
{new Date(credential.time_updated).toLocaleString()}
</td>
<td className="p-2 flex gap-x-2 content-center mt-auto">
<OpalButton
<Button
onClick={async () => {
onDeleteCredential(credential);
}}
disabled={selected || !editable}
icon={SvgTrash}
prominence="tertiary"
/>
{onEditCredential && (
<button
<Button
disabled={!editable}
onClick={() => onEditCredential(credential)}
className="cursor-pointer my-auto"
>
<EditIcon />
</button>
icon={SvgEdit}
prominence="tertiary"
/>
)}
</td>
</tr>
@@ -203,31 +210,32 @@ export default function ModifyCredential({
</Text>
</Modal.Body>
<Modal.Footer>
<Button
onClick={async () => {
onDeleteCredential(confirmDeletionCredential);
setConfirmDeletionCredential(null);
}}
>
Confirm
</Button>
<Button
secondary
onClick={() => setConfirmDeletionCredential(null)}
>
Cancel
</Button>
<BasicModalFooter
cancel={
<Button
prominence="secondary"
onClick={() => setConfirmDeletionCredential(null)}
>
Cancel
</Button>
}
submit={
<Button
onClick={async () => {
onDeleteCredential(confirmDeletionCredential);
setConfirmDeletionCredential(null);
}}
>
Confirm
</Button>
}
/>
</Modal.Footer>
</Modal.Content>
</Modal>
)}
<div className="mb-0">
<Text as="p" className="mb-4">
Select a credential as needed! Ensure that you have selected a
credential with the proper permissions for this connector!
</Text>
<Modal.Body>
<CredentialSelectionTable
onDeleteCredential={async (credential: Credential<any | null>) => {
setConfirmDeletionCredential(credential);
@@ -251,52 +259,48 @@ export default function ModifyCredential({
}
}}
/>
</Modal.Body>
{!showIfEmpty && (
<div className="flex mt-8 justify-between">
{onCreateNew ? (
{!showIfEmpty && (
<Modal.Footer>
<BasicModalFooter
cancel={
onCreateNew ? (
<Button
onClick={onCreateNew}
icon={SvgBubbleText}
prominence="secondary"
>
Create
</Button>
) : undefined
}
submit={
<Button
disabled={selectedCredential == null}
onClick={() => {
onCreateNew();
}}
className="bg-background-500 disabled:border-transparent
transition-colors duration-150 ease-in disabled:bg-background-300
disabled:hover:bg-background-300 hover:bg-background-600 cursor-pointer"
>
<div className="flex gap-x-2 items-center w-full border-none">
<NewChatIcon className="text-white" />
<p>Create</p>
</div>
</Button>
) : (
<div />
)}
<Button
disabled={selectedCredential == null}
onClick={() => {
if (onSwap && attachedConnector) {
onSwap(selectedCredential!, attachedConnector.id, accessType);
if (close) {
close();
if (onSwap && attachedConnector) {
onSwap(
selectedCredential!,
attachedConnector.id,
accessType
);
if (close) {
close();
}
}
}
if (onSwitch) {
onSwitch(selectedCredential!);
}
}}
className="bg-indigo-500 disabled:border-transparent
transition-colors duration-150 ease-in disabled:bg-indigo-300
disabled:hover:bg-indigo-300 hover:bg-indigo-600 cursor-pointer"
>
<div className="flex gap-x-2 items-center w-full border-none">
<SwapIcon className="text-white" />
<p>Select</p>
</div>
</Button>
</div>
)}
</div>
if (onSwitch) {
onSwitch(selectedCredential!);
}
}}
icon={SvgCheck}
>
Select
</Button>
}
/>
</Modal.Footer>
)}
</>
);
}

View File

@@ -107,6 +107,24 @@ export function createEditingValidationSchema(json_values: dictionaryType) {
return Yup.object().shape(schemaFields);
}
/**
* Returns true when the credential template marks a field as optional.
*
* A field is optional when:
* - it is a boolean toggle (`typeof def === "boolean"`)
* - its template default is `null` (nullable / not-required)
*
* In the editing flow every field is optional, so callers can pass
* `allOptional = true` to short-circuit.
*/
export function isOptionalCredentialField(
def: unknown,
allOptional = false
): boolean {
if (allOptional) return true;
return def === null || typeof def === "boolean";
}
export function createInitialValues(credential: Credential<any>): formType {
const initialValues: formType = {
name: credential.name || "",

View File

@@ -1,3 +1,5 @@
"use client";
import { useEffect, useSyncExternalStore } from "react";
import { useRouter } from "next/navigation";
import type { Route } from "next";