mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-19 01:50:51 -05:00
feat(credentials): reskin the create-credential form with Opal (#15450)
This commit is contained in:
1 parent
6282948694
commit
a1216aba79
63 files changed
+2612
-1443
No files matched your search
@@ -9,6 +9,9 @@ from onyx.connectors.confluence.onyx_confluence import (
|
||||
get_user_email_from_username__server,
|
||||
)
|
||||
from onyx.connectors.credentials_provider import OnyxDBCredentialsProvider
|
||||
from onyx.connectors.cross_connector_utils.miscellaneous_utils import (
|
||||
credential_uses_scoped_token,
|
||||
)
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
from onyx.db.models import ConnectorCredentialPair
|
||||
from onyx.db.users import get_all_users
|
||||
@@ -165,9 +168,10 @@ def confluence_group_sync(
|
||||
tenant_id, cc_pair.connector.source, cc_pair.credential_id
|
||||
)
|
||||
is_cloud = cc_pair.connector.connector_specific_config.get("is_cloud", False)
|
||||
# The credential carries the flag; older connectors set it in their config.
|
||||
scoped_token = cc_pair.connector.connector_specific_config.get(
|
||||
"scoped_token", False
|
||||
)
|
||||
) or credential_uses_scoped_token(provider.get_credentials())
|
||||
wiki_base: str = cc_pair.connector.connector_specific_config["wiki_base"]
|
||||
url = wiki_base.rstrip("/")
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ from jira.exceptions import JIRAError
|
||||
|
||||
from ee.onyx.db.external_perm import ExternalUserGroup
|
||||
from ee.onyx.external_permissions.utils import credential_json
|
||||
from onyx.connectors.cross_connector_utils.miscellaneous_utils import (
|
||||
credential_uses_scoped_token,
|
||||
)
|
||||
from onyx.connectors.jira.utils import build_jira_client
|
||||
from onyx.db.models import ConnectorCredentialPair
|
||||
from onyx.utils.logger import setup_logger
|
||||
@@ -155,15 +158,17 @@ def jira_group_sync(
|
||||
Streams group-by-group rather than accumulating all groups in memory.
|
||||
"""
|
||||
jira_base_url = cc_pair.connector.connector_specific_config.get("jira_base_url", "")
|
||||
credentials = credential_json(cc_pair)
|
||||
# The credential carries the flag; older connectors set it in their config.
|
||||
scoped_token = cc_pair.connector.connector_specific_config.get(
|
||||
"scoped_token", False
|
||||
)
|
||||
) or credential_uses_scoped_token(credentials)
|
||||
|
||||
if not jira_base_url:
|
||||
raise ValueError("No jira_base_url found in connector config")
|
||||
|
||||
jira_client = build_jira_client(
|
||||
credentials=credential_json(cc_pair),
|
||||
credentials=credentials,
|
||||
jira_base=jira_base_url,
|
||||
scoped_token=scoped_token,
|
||||
)
|
||||
|
||||
@@ -36,6 +36,7 @@ from onyx.connectors.confluence.utils import (
|
||||
)
|
||||
from onyx.connectors.credentials_provider import OnyxStaticCredentialsProvider
|
||||
from onyx.connectors.cross_connector_utils.miscellaneous_utils import (
|
||||
credential_uses_scoped_token,
|
||||
is_atlassian_date_error,
|
||||
)
|
||||
from onyx.connectors.exceptions import (
|
||||
@@ -430,6 +431,11 @@ class ConfluenceConnector(
|
||||
self, credentials_provider: CredentialsProviderInterface
|
||||
) -> None:
|
||||
self.credentials_provider = credentials_provider
|
||||
# The credential now carries the flag; older connectors set it in
|
||||
# their own config.
|
||||
self.scoped_token = self.scoped_token or credential_uses_scoped_token(
|
||||
credentials_provider.get_credentials()
|
||||
)
|
||||
|
||||
# raises exception if there's a problem
|
||||
confluence_client = OnyxConfluence(
|
||||
|
||||
@@ -206,6 +206,18 @@ def get_cloudId(base_url: str) -> str:
|
||||
return response.json()["cloudId"]
|
||||
|
||||
|
||||
def credential_uses_scoped_token(credentials: dict[str, Any]) -> bool:
|
||||
"""Whether an Atlassian credential says its API token has scopes.
|
||||
|
||||
The credential form stores a boolean; the edit form can store text, so
|
||||
"true" counts and "false" does not.
|
||||
"""
|
||||
value = credentials.get("scoped_token")
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() == "true"
|
||||
return value is True
|
||||
|
||||
|
||||
def scoped_url(url: str, product: str) -> str:
|
||||
parsed = urlparse(url)
|
||||
base_url = parsed.scheme + "://" + parsed.netloc
|
||||
|
||||
@@ -20,6 +20,7 @@ from onyx.configs.app_configs import (
|
||||
)
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.connectors.cross_connector_utils.miscellaneous_utils import (
|
||||
credential_uses_scoped_token,
|
||||
is_atlassian_date_error,
|
||||
time_str_to_utc,
|
||||
)
|
||||
@@ -704,6 +705,11 @@ class JiraConnector(
|
||||
return project_key
|
||||
|
||||
def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None:
|
||||
# The credential now carries the flag; older connectors set it in
|
||||
# their own config.
|
||||
self.scoped_token = self.scoped_token or credential_uses_scoped_token(
|
||||
credentials
|
||||
)
|
||||
self._jira_client = build_jira_client(
|
||||
credentials=credentials,
|
||||
jira_base=self.jira_base,
|
||||
|
||||
+24
-1
@@ -2,7 +2,10 @@ import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from onyx.connectors.cross_connector_utils.miscellaneous_utils import time_str_to_utc
|
||||
from onyx.connectors.cross_connector_utils.miscellaneous_utils import (
|
||||
credential_uses_scoped_token,
|
||||
time_str_to_utc,
|
||||
)
|
||||
|
||||
|
||||
def test_time_str_to_utc() -> None:
|
||||
@@ -51,3 +54,23 @@ def test_time_str_to_utc_raises_on_impossible_dates() -> None:
|
||||
):
|
||||
with pytest.raises(ValueError):
|
||||
time_str_to_utc(bad)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
(True, True),
|
||||
(False, False),
|
||||
("true", True),
|
||||
("True", True),
|
||||
("false", False),
|
||||
("", False),
|
||||
(None, False),
|
||||
],
|
||||
)
|
||||
def test_credential_uses_scoped_token(value: object, expected: bool) -> None:
|
||||
assert credential_uses_scoped_token({"scoped_token": value}) is expected
|
||||
|
||||
|
||||
def test_credential_uses_scoped_token_without_the_key() -> None:
|
||||
assert credential_uses_scoped_token({}) is False
|
||||
@@ -85,6 +85,7 @@ meanings in one prop. Use `tooltip` for hover text.
|
||||
| `icon` | `IconFunctionComponent` | — | Left icon |
|
||||
| `description` | `string` | — | Description below the title |
|
||||
| `descriptionMaxLines` | `number` | — | Cap the description at N lines and truncate the rest. Unset wraps without a limit — a row showing user-authored text usually wants `1` |
|
||||
| `suffix` | `string` | — | Muted text beside the title, like "(Default)". Shown only by the `heading` variant with a `main-*` `sizePreset`; the default `headline` row drops it |
|
||||
| `sizePreset` | `SizePreset` | `"headline"` | Content size preset |
|
||||
| `variant` | `ContentVariant` | `"heading"` | Content layout variant |
|
||||
| `rightChildren` | `ReactNode` | — | Content after the label (e.g. action button) |
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The `ContentAction` props a row actually uses — eleven of the twenty-two it
|
||||
* The `ContentAction` props a row actually uses — twelve of the twenty-two it
|
||||
* offers. Listed rather than spread, so that everything a caller passes which
|
||||
* is *not* here is DOM, and reaches the row element.
|
||||
*
|
||||
@@ -56,6 +56,12 @@ type RowContentProps = {
|
||||
/** Secondary line under the title. */
|
||||
description?: string | RichStr;
|
||||
|
||||
/**
|
||||
* Muted text beside the title, like "(Default)". Shown only by the
|
||||
* `heading` variant with a `main-*` size preset.
|
||||
*/
|
||||
suffix?: string;
|
||||
|
||||
/**
|
||||
* Cap the description at N lines and truncate the rest. Unset wraps without
|
||||
* a limit, so a row showing user-authored text usually wants `1`.
|
||||
@@ -177,6 +183,7 @@ function LineItemButton({
|
||||
icon,
|
||||
description,
|
||||
descriptionMaxLines,
|
||||
suffix,
|
||||
rightChildren,
|
||||
sizePreset,
|
||||
variant,
|
||||
@@ -261,6 +268,7 @@ function LineItemButton({
|
||||
icon,
|
||||
description,
|
||||
descriptionMaxLines,
|
||||
suffix,
|
||||
rightChildren,
|
||||
sizePreset,
|
||||
variant,
|
||||
|
||||
@@ -70,3 +70,5 @@ import { InputCheckbox } from "@opal/components";
|
||||
// Disabled
|
||||
<InputCheckbox disabled checked />
|
||||
```
|
||||
|
||||
Formik: `InputCheckboxField` from `@opal/form`, bound to a `boolean` field.
|
||||
@@ -41,10 +41,11 @@ export const OptionItem = React.memo(
|
||||
icon={option.icon}
|
||||
title={option.title}
|
||||
description={option.description}
|
||||
suffix={option.suffix}
|
||||
sizePreset="main-ui"
|
||||
// `body` resolves to `ContentSm`, which has no description slot; a
|
||||
// row with one takes the `heading` layout so the line renders.
|
||||
variant={option.description ? "heading" : "body"}
|
||||
// `body` resolves to `ContentSm`, which has no description or suffix
|
||||
// slot; a row with either takes the `heading` layout.
|
||||
variant={option.description || option.suffix ? "heading" : "body"}
|
||||
id={`${fieldId}-option-${sanitizeOptionId(option.value)}`}
|
||||
data-index={index}
|
||||
role="option"
|
||||
|
||||
@@ -21,7 +21,7 @@ default keeps the trigger filled.
|
||||
|
||||
`options` is a list of loose options and dividers in any order, like
|
||||
`<option>`s beside `<optgroup>`s: a divider is `{ title?, options }` and renders
|
||||
a separator line above its rows, carrying `title` when there is one. The trigger shows the chosen option's `icon`, when it has one. A value
|
||||
a separator line above its rows, carrying `title` when there is one. An option's `suffix` shows muted beside its title in the list, like "(Default)". The trigger shows the chosen option's `icon`, when it has one. A value
|
||||
outside the set shows the placeholder with the validation error.
|
||||
|
||||
```tsx
|
||||
|
||||
@@ -121,11 +121,12 @@ function MultiDropdown(props: MultiDropdownProps) {
|
||||
|
||||
// Closed-set doctrine, committed values only: a tag outside the supplied
|
||||
// set (stale seed, options shrank) flags the input chrome's error variant.
|
||||
// Open mode legitimately holds free-form tags, and typing never flags.
|
||||
// Open mode legitimately holds free-form tags, and typing never flags. A
|
||||
// locked tag is fixed by the caller, not picked, so it never flags.
|
||||
const hasInvalidTag = useMemo(() => {
|
||||
if (freeEntry) return false;
|
||||
const optionValues = new Set(flatOptions.map((option) => option.value));
|
||||
return tags.some((tag) => !optionValues.has(tag.id));
|
||||
return tags.some((tag) => !tag.locked && !optionValues.has(tag.id));
|
||||
}, [freeEntry, flatOptions, tags]);
|
||||
|
||||
// The filter is transient UI state, like the single's: closing the
|
||||
|
||||
@@ -2,13 +2,18 @@ import type { RichStr } from "@opal/types";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
import type { InputTypeInTagProps } from "@opal/components/inputs/texts/input-type-in-tag/components";
|
||||
|
||||
/** A clickable row. `title`, `description` and `icon` match `Content`'s props. */
|
||||
/**
|
||||
* A clickable row. `title`, `description`, `suffix` and `icon` match
|
||||
* `Content`'s props.
|
||||
*/
|
||||
export type SelectOption = {
|
||||
value: string;
|
||||
title: string;
|
||||
/** Further text a search matches, such as an identifier the title prettifies. */
|
||||
keywords?: string[];
|
||||
description?: string | RichStr;
|
||||
/** Muted text beside the title in the list, like "(Default)". */
|
||||
suffix?: string;
|
||||
icon?: IconFunctionComponent;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
@@ -31,7 +31,7 @@ Interaction model:
|
||||
|
||||
### `TagItem`
|
||||
|
||||
`TagItem` is `{ id: string; label: string; icon?: IconFunctionComponent; error?: boolean }`. `icon` leads the tag; `error` shows the warning indicator on it.
|
||||
`TagItem` is `{ id: string; label: string; icon?: IconFunctionComponent; error?: boolean; locked?: boolean }`. `icon` leads the tag; `error` shows the warning indicator on it; `locked` removes its remove button, so neither a click nor the keyboard can remove it. In `InputMultiSelect`, a locked tag outside the option set does not flag the error variant.
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
@@ -23,6 +23,12 @@ interface TagItem {
|
||||
|
||||
/** Shows the warning indicator on the tag. */
|
||||
error?: boolean;
|
||||
|
||||
/**
|
||||
* The tag cannot be removed: it has no remove button, and the keyboard
|
||||
* walk skips it.
|
||||
*/
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
/** The props every chips-in-input field exposes to its callers. */
|
||||
@@ -310,10 +316,14 @@ function TagField({
|
||||
error={tag.error}
|
||||
disabled={disabled}
|
||||
removeInTabOrder={false}
|
||||
onRemove={() => {
|
||||
onRemoveTag(tag.id);
|
||||
focusField();
|
||||
}}
|
||||
onRemove={
|
||||
tag.locked
|
||||
? undefined
|
||||
: () => {
|
||||
onRemoveTag(tag.id);
|
||||
focusField();
|
||||
}
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{readOnly ? (
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
}
|
||||
|
||||
.opal-input-type-in-tag-icon {
|
||||
@apply text-text-04;
|
||||
@apply text-text-03;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ export default function InputTypeIn({
|
||||
|
||||
{Icon && (
|
||||
<div className="opal-input-leading-icon">
|
||||
<Icon className="opal-input-leading-icon-svg" />
|
||||
<Icon className="opal-input-leading-icon-svg opal-input-option-icon" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -20,6 +20,12 @@
|
||||
@apply h-4 w-4 text-text-02;
|
||||
}
|
||||
|
||||
/* A select trigger's chosen-option icon reads a step stronger than the
|
||||
search icon. */
|
||||
.opal-input-option-icon {
|
||||
@apply text-text-03;
|
||||
}
|
||||
|
||||
.opal-input[data-variant="primary"] .opal-input-field,
|
||||
.opal-input[data-variant="internal"] .opal-input-field,
|
||||
.opal-input[data-variant="error"] .opal-input-field {
|
||||
|
||||
@@ -117,6 +117,17 @@ When tabs overflow the available width, show navigation arrows:
|
||||
</Tabs>
|
||||
```
|
||||
|
||||
### Space below the tab list
|
||||
|
||||
```tsx
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} gap={4}>
|
||||
…
|
||||
</Tabs>
|
||||
```
|
||||
|
||||
`gap` puts that spacing step (`N / 4` rem) between the list and the panel.
|
||||
Unset, the panel sits flush against the list.
|
||||
|
||||
### Content padding
|
||||
|
||||
```tsx
|
||||
@@ -134,6 +145,7 @@ Forwards all [Radix Tabs.Root](https://www.radix-ui.com/docs/primitives/componen
|
||||
| Prop | Type | Default | Description |
|
||||
| --------------- | -------------------------------------- | ------------- | -------------------------------------- |
|
||||
| `variant` | `"contained" \| "pill" \| "underline"` | `"contained"` | Visual variant for the whole tab group |
|
||||
| `gap` | `Spacing` | — | Space between the tab list and the panel (`N / 4` rem); unset, flush |
|
||||
| `defaultValue` | `string` | — | Initially active tab (uncontrolled) |
|
||||
| `value` | `string` | — | Controlled active tab |
|
||||
| `onValueChange` | `(value: string) => void` | — | Called when active tab changes |
|
||||
|
||||
@@ -39,13 +39,23 @@ interface TabsRootProps extends WithoutStyles<
|
||||
* - `underline`: like pill but without the filled active state.
|
||||
*/
|
||||
variant?: "contained" | "pill" | "underline";
|
||||
|
||||
/**
|
||||
* Space between the tab list and the panel below it, as a {@link Spacing}
|
||||
* step (`N / 4` rem). Unset, the panel sits flush against the list.
|
||||
*/
|
||||
gap?: Spacing;
|
||||
}
|
||||
|
||||
function TabsRoot({ variant = "contained", ...props }: TabsRootProps) {
|
||||
function TabsRoot({ variant = "contained", gap, ...props }: TabsRootProps) {
|
||||
const contextValue = useMemo(() => ({ variant }), [variant]);
|
||||
return (
|
||||
<TabsContext.Provider value={contextValue}>
|
||||
<TabsPrimitive.Root className="w-full" {...props} />
|
||||
<TabsPrimitive.Root
|
||||
{...props}
|
||||
className={cn("w-full", gap !== undefined && "flex flex-col")}
|
||||
style={gap !== undefined ? { gap: spacingToRem(gap) } : undefined}
|
||||
/>
|
||||
</TabsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useField } from "formik";
|
||||
import {
|
||||
InputCheckbox,
|
||||
type InputCheckboxProps,
|
||||
} from "@opal/components/inputs/booleans/input-checkbox/components";
|
||||
|
||||
/** Every `InputCheckbox` prop except the checked state, which Formik owns. */
|
||||
type InputCheckboxFieldProps = Omit<
|
||||
InputCheckboxProps,
|
||||
"checked" | "defaultChecked"
|
||||
> & {
|
||||
/** The Formik field name. */
|
||||
name: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formik-bound `InputCheckbox` over a `boolean` field. A change writes the
|
||||
* new state and marks the field touched; `onCheckedChange` still fires after
|
||||
* the field is updated.
|
||||
*/
|
||||
function InputCheckboxField({
|
||||
name,
|
||||
onCheckedChange,
|
||||
...checkboxProps
|
||||
}: InputCheckboxFieldProps) {
|
||||
const [field, , helpers] = useField<boolean>(name);
|
||||
const handleCheckedChange = useCallback(
|
||||
(checked: boolean) => {
|
||||
// setValue validates with the new value; setTouched must not validate
|
||||
// again with the pre-change values, or that later result would win.
|
||||
void helpers.setValue(checked);
|
||||
void helpers.setTouched(true, false);
|
||||
onCheckedChange?.(checked);
|
||||
},
|
||||
[helpers, onCheckedChange]
|
||||
);
|
||||
return (
|
||||
<InputCheckbox
|
||||
{...checkboxProps}
|
||||
name={name}
|
||||
checked={!!field.value}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { InputCheckboxField, type InputCheckboxFieldProps };
|
||||
@@ -5,6 +5,10 @@ export {
|
||||
type FormFieldState,
|
||||
} from "@opal/form/FieldContext";
|
||||
export { FieldMessage } from "@opal/form/FieldMessage";
|
||||
export {
|
||||
InputCheckboxField,
|
||||
type InputCheckboxFieldProps,
|
||||
} from "@opal/form/InputCheckboxField";
|
||||
export {
|
||||
InputSingleSelectField,
|
||||
type InputSingleSelectFieldProps,
|
||||
|
||||
@@ -276,11 +276,11 @@ function ContentMd({
|
||||
)}
|
||||
|
||||
{suffix && (
|
||||
<span className="opal-content-md-suffix">
|
||||
<Text font={config.optionalFont} color="inherit">
|
||||
<div className="opal-content-md-suffix">
|
||||
<Text as="p" font={config.optionalFont} color="inherit">
|
||||
{suffix === "optional" ? "(Optional)" : suffix}
|
||||
</Text>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{auxIcon &&
|
||||
|
||||
@@ -32,6 +32,11 @@ A two-axis layout component for displaying icon + title + description rows. Rout
|
||||
|
||||
> Icon container height (icon + 2 x padding) always equals the title line-height.
|
||||
|
||||
> In the inline layouts (`body` and `section` variants), the icon slot can hold a small control, such
|
||||
> as a checkbox left of the title; `heading` puts the icon above the title instead. Pass a component
|
||||
> with a stable identity (create it once per field, e.g. with `useMemo`), or React remounts the
|
||||
> control on every render and it loses focus. The icon sits on the title's first line.
|
||||
|
||||
### `variant` — controls structure / layout
|
||||
|
||||
| variant | Description |
|
||||
|
||||
@@ -428,8 +428,8 @@
|
||||
/* min-w-0: as a flex item next to the icon, the row's automatic minimum
|
||||
width would push a clamped title past the container edge, clipping the
|
||||
ellipsis out of view. */
|
||||
@apply flex items-center w-full min-w-0;
|
||||
gap: 0.25rem;
|
||||
/* gap-0: each Text's own 2px inline padding spaces the row. */
|
||||
@apply flex items-center w-full min-w-0 gap-0;
|
||||
}
|
||||
|
||||
.opal-content-md-input-sizer {
|
||||
|
||||
@@ -20,6 +20,18 @@ export function markdown(...lines: string[]): RichStr {
|
||||
return { __brand: "RichStr", raw: lines.join("\n") };
|
||||
}
|
||||
|
||||
/**
|
||||
* Title-cases an identifier in snake, kebab or camel case:
|
||||
* `confluence_username` → "Confluence Username", `apiKey` → "Api Key".
|
||||
*/
|
||||
export function toTitleCase(value: string): string {
|
||||
return value
|
||||
.replace(/[_-]+/g, " ")
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.trim()
|
||||
.replace(/\b\w/g, (character) => character.toUpperCase());
|
||||
}
|
||||
|
||||
export function escapeMarkdown(value: string): string {
|
||||
return value
|
||||
.replace(/[!-/:-@[-`{-~]/g, (character) => `&#${character.charCodeAt(0)};`)
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Text,
|
||||
} from "@opal/components";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
import { toTitleCase } from "@opal/utils";
|
||||
|
||||
interface UserCredentialsModalProps {
|
||||
open: boolean;
|
||||
@@ -26,15 +27,6 @@ interface UserCredentialsModalProps {
|
||||
save: (values: Record<string, string>) => Promise<void>;
|
||||
}
|
||||
|
||||
/** Turn a credential key (`discord_token`, `apiKey`) into a readable label. */
|
||||
function humanizeKey(key: string): string {
|
||||
return key
|
||||
.replace(/[_-]+/g, " ")
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.trim()
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-user credential entry for connections without an OAuth flow (custom
|
||||
* external apps, API-token MCP servers). Renders one field per credential key,
|
||||
@@ -98,7 +90,7 @@ export default function UserCredentialsModal({
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
{credentialKeys.map((key) => (
|
||||
<div key={key} className="flex flex-col gap-1 w-full">
|
||||
<Text font="main-ui-action">{humanizeKey(key)}</Text>
|
||||
<Text font="main-ui-action">{toTitleCase(key)}</Text>
|
||||
<InputPasswordTypeIn
|
||||
value={values[key] ?? ""}
|
||||
onChange={(e) =>
|
||||
|
||||
@@ -41,10 +41,8 @@ import { cn } from "@opal/utils";
|
||||
import FileInput from "@/views/admin/connectors/AddConnectorPage/form/inputs/FileInput";
|
||||
import { RichTextSubtext } from "./RichTextSubtext";
|
||||
import { TypedFile, FILE_TYPE_DEFINITIONS } from "@/lib/connectors/fileTypes";
|
||||
import {
|
||||
createTypedFile,
|
||||
getFileTypeDefinitionForField,
|
||||
} from "@/lib/connectors/utils";
|
||||
import { createTypedFile } from "@/lib/connectors/utils";
|
||||
import { getCredentialFileType } from "@/lib/credentials/utils";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
|
||||
import { SvgEye, SvgEyeClosed, SvgPlusCircle } from "@opal/icons";
|
||||
@@ -451,7 +449,7 @@ export function TypedFileUploadFormField({
|
||||
const [description, setDescription] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const typeDefinitionKey = getFileTypeDefinitionForField(name);
|
||||
const typeDefinitionKey = getCredentialFileType(name);
|
||||
if (typeDefinitionKey) {
|
||||
setDescription(
|
||||
FILE_TYPE_DEFINITIONS[typeDefinitionKey].description || ""
|
||||
@@ -506,7 +504,7 @@ export function TypedFileUploadFormField({
|
||||
return;
|
||||
}
|
||||
|
||||
const typeDefinitionKey = getFileTypeDefinitionForField(name);
|
||||
const typeDefinitionKey = getCredentialFileType(name);
|
||||
|
||||
if (!typeDefinitionKey) {
|
||||
setCustomError(t("fileValidation.noTypeDefinition", { name }));
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useTierAtLeast } from "@/hooks/useTierAtLeast";
|
||||
import { Tier } from "@/lib/settings/types";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import type { Credential } from "@/lib/credentials/types";
|
||||
import { CREDENTIAL_TEMPLATES } from "@/lib/credentials/constants";
|
||||
import { getCredentialSpec } from "@/lib/credentials/utils";
|
||||
import { usePermissionAuthority } from "@/lib/permissions/hooks";
|
||||
import { Permission } from "@/lib/types";
|
||||
import { useSettings } from "@/lib/settings/hooks";
|
||||
@@ -52,10 +52,7 @@ export function AccessTypeForm({
|
||||
|
||||
// If the selected auth method is one that disables sync, return true
|
||||
const isSyncDisabledByAuth = useMemo(() => {
|
||||
const template = (CREDENTIAL_TEMPLATES as any)[connector];
|
||||
const authMethods = template?.authMethods as
|
||||
| { value: string; disablePermSync?: boolean }[]
|
||||
| undefined; // auth methods are returned as an array of objects with a value and disablePermSync property
|
||||
const authMethods = getCredentialSpec(connector)?.methods;
|
||||
if (!authMethods || !selectedAuthMethod) return false;
|
||||
const method = authMethods.find((m) => m.value === selectedAuthMethod);
|
||||
return method?.disablePermSync === true;
|
||||
|
||||
@@ -3218,15 +3218,36 @@
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
"accountEmailField": {
|
||||
"subDescription": "استخدم البريد الإلكتروني للحساب الذي يملك رمز API."
|
||||
},
|
||||
"apiTokenField": {
|
||||
"subDescription": "الصق رمز API للوصول إلى {source}."
|
||||
},
|
||||
"createButton": {
|
||||
"label": "إنشاء"
|
||||
},
|
||||
"created": {
|
||||
"toast": "تم إنشاء بيانات اعتماد جديدة!"
|
||||
},
|
||||
"name": {
|
||||
"label": "الاسم:",
|
||||
"placeholder": "(اختياري) اسم بيانات الاعتماد..."
|
||||
"emailField": {
|
||||
"placeholder": "email@company.com"
|
||||
},
|
||||
"share": {
|
||||
"admins": {
|
||||
"label": "المسؤولون",
|
||||
"description": "يمكن لجميع المسؤولين إعادة استخدام هذا الحساب لإنشاء الموصلات.",
|
||||
"suffix": "(افتراضي)"
|
||||
},
|
||||
"adminsAndGroups": {
|
||||
"description": "يمكن للمسؤولين والمجموعات التي تحددها إعادة استخدام هذا الحساب.",
|
||||
"label": "المسؤولون والمجموعات"
|
||||
},
|
||||
"onlyMe": {
|
||||
"label": "أنا فقط"
|
||||
},
|
||||
"description": "اسمح للمجموعات بإعادة استخدام هذا الحساب لإنشاء الموصلات.",
|
||||
"title": "مشاركة هذا الحساب"
|
||||
},
|
||||
"submitError": {
|
||||
"toast": "حدث خطأ أثناء إرسال بيانات الاعتماد"
|
||||
@@ -3261,6 +3282,82 @@
|
||||
"toast": "حدث خطأ أثناء تحديث بيانات الاعتماد"
|
||||
}
|
||||
},
|
||||
"displayNames": {
|
||||
"accessKeyId": "معرّف مفتاح الوصول إلى {source}",
|
||||
"secretAccessKey": "مفتاح الوصول السري إلى {source}",
|
||||
"accountId": "معرّف حساب {source}",
|
||||
"apiToken": "رمز API",
|
||||
"clientId": "معرّف عميل {source}",
|
||||
"clientSecret": "سر عميل {source}",
|
||||
"clientToken": "رمز عميل {source}",
|
||||
"directoryId": "معرّف الدليل في {source}",
|
||||
"certificatePassword": "كلمة مرور شهادة {source}",
|
||||
"privateKey": "المفتاح الخاص لـ {source}",
|
||||
"certificate": "شهادة {source}",
|
||||
"baseUrl": "عنوان URL الأساسي لـ {source}",
|
||||
"apiBaseUrl": "عنوان URL الأساسي لـ {source} API",
|
||||
"url": "عنوان URL لـ {source}",
|
||||
"enterpriseServerUrl": "عنوان URL لخادم {source} Enterprise",
|
||||
"botToken": "رمز بوت {source}",
|
||||
"username": "اسم مستخدم {source}",
|
||||
"apiUsername": "اسم مستخدم API لـ {source}",
|
||||
"userEmail": "بريد مستخدم {source} الإلكتروني",
|
||||
"accountEmail": "بريد حساب {source} الإلكتروني",
|
||||
"password": "كلمة مرور {source}",
|
||||
"subdomain": "النطاق الفرعي لـ {source}",
|
||||
"domain": "نطاق {source}",
|
||||
"enterpriseId": "معرّف مؤسسة {source}",
|
||||
"teamId": "معرّف فريق {source}",
|
||||
"applicationId": "معرّف تطبيق {source}",
|
||||
"apiTokenId": "معرّف رمز API لـ {source}",
|
||||
"apiTokenSecret": "السر الخاص برمز API لـ {source}",
|
||||
"securityToken": "رمز الأمان لـ {source}",
|
||||
"integrationToken": "رمز تكامل {source}",
|
||||
"accessKey": "مفتاح الوصول إلى {source}",
|
||||
"accessKeySecret": "سر مفتاح الوصول إلى {source}",
|
||||
"key": "مفتاح {source}",
|
||||
"secret": "سر {source}",
|
||||
"roleArn": "ARN لدور {source}",
|
||||
"namespace": "مساحة أسماء {source}",
|
||||
"region": "منطقة {source}",
|
||||
"portalId": "معرّف بوابة {source}",
|
||||
"oauthTokens": "رموز {source} OAuth",
|
||||
"isSandbox": "بيئة تجريبية (Sandbox)",
|
||||
"serviceUserEmail": "بريد مستخدم الخدمة الإلكتروني",
|
||||
"zuliprcContent": "محتوى Zuliprc",
|
||||
"authenticationMethod": "طريقة المصادقة",
|
||||
"scopedToken": "هذا رمز API ذو نطاقات"
|
||||
},
|
||||
"hints": {
|
||||
"boxUserEmail": "اختياري. مستخدم Box الذي يتم انتحال هويته.",
|
||||
"githubBaseUrl": "اختياري. حدد مضيف الخادم لديك، مثل https://github.example.com. اتركه فارغًا لاستخدام github.com.",
|
||||
"gongBaseUrl": "اختياري. للبيانات خارج الولايات المتحدة، حدد مضيف منطقتك، مثل https://[region].api.gong.io.",
|
||||
"jiraUserEmail": "مطلوب لـ Jira Cloud.",
|
||||
"lumappsServiceUser": "المستخدم الذي تتم الفهرسة نيابةً عنه.",
|
||||
"outlineBaseUrl": "مثل https://app.getoutline.com، أو عنوان URL الخاص بخادمك المستضاف ذاتيًا.",
|
||||
"pfxFile": "ملف PFX.",
|
||||
"testrailBaseUrl": "مثل https://yourcompany.testrail.io.",
|
||||
"scopedToken": "يجب توجيه الرموز ذات النطاقات عبر بوابة API الخاصة بـ Atlassian. اتركه معطّلًا للرموز الكلاسيكية."
|
||||
},
|
||||
"methods": {
|
||||
"labels": {
|
||||
"clientSecret": "سر العميل",
|
||||
"certificateAuthentication": "المصادقة بالشهادة",
|
||||
"certificate": "الشهادة",
|
||||
"accessKeyAndSecret": "مفتاح الوصول والسر",
|
||||
"iamRole": "دور IAM",
|
||||
"assumeRole": "تولّي الدور"
|
||||
},
|
||||
"descriptions": {
|
||||
"sharepointClientSecret": "إذا اخترت هذا الوضع، فسيستخدم موصل SharePoint سر العميل للمصادقة. ستحتاج إلى تقديم معرّف العميل وسر العميل.",
|
||||
"sharepointCertificate": "إذا اخترت هذا الوضع، فسيستخدم موصل SharePoint شهادة للمصادقة. ستحتاج إلى تقديم معرّف العميل ومعرّف الدليل وكلمة مرور الشهادة وبيانات PFX.",
|
||||
"teamsClientSecret": "يسجّل الموصل الدخول باستخدام سر العميل الخاص بتسجيل التطبيق. قدّم معرّف العميل ومعرّف الدليل والسر. رسائل القنوات وأعضاؤها فقط: يرفض SharePoint السر، لذا يتطلب خيار تضمين المرفقات استخدام الشهادة.",
|
||||
"teamsCertificate": "يسجّل الموصل الدخول باستخدام شهادة مرفوعة إلى تسجيل التطبيق. قدّم معرّف العميل ومعرّف الدليل وحزمة PFX وكلمة مرورها. مطلوب لخيار تضمين المرفقات، الذي يقرأ ملفات القنوات وقرّاءها من SharePoint.",
|
||||
"outlookClientSecret": "يسجّل الموصل الدخول باستخدام سر العميل الخاص بتسجيل التطبيق. قدّم معرّف العميل ومعرّف الدليل والسر.",
|
||||
"outlookCertificate": "يسجّل الموصل الدخول باستخدام شهادة مرفوعة إلى تسجيل التطبيق. قدّم معرّف العميل ومعرّف الدليل وحزمة PFX وكلمة مرورها.",
|
||||
"s3AssumeRole": "إذا اخترت هذا الوضع، فسيتولّى مثيل Amazon EC2 دوره الحالي للوصول إلى S3. لا حاجة إلى بيانات اعتماد إضافية."
|
||||
}
|
||||
},
|
||||
"modal": {
|
||||
"createTitle": "إنشاء بيانات اعتماد {source}",
|
||||
"editTitle": "تعديل بيانات الاعتماد",
|
||||
@@ -3290,6 +3387,10 @@
|
||||
"retryButton": {
|
||||
"label": "إعادة المحاولة"
|
||||
},
|
||||
"signIn": {
|
||||
"description": "يتطلب {appName} نطاقات للقراءة فقط.",
|
||||
"title": "تسجيل الدخول باستخدام {source}"
|
||||
},
|
||||
"startError": {
|
||||
"message": "تعذر بدء OAuth"
|
||||
}
|
||||
@@ -3341,6 +3442,13 @@
|
||||
"success": {
|
||||
"toast": "تم تحديث بيانات الاعتماد"
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
"authMethodRequired": "يُرجى اختيار طريقة المصادقة",
|
||||
"empty": "لا يمكن ترك {field} فارغًا",
|
||||
"fileRequired": "يُرجى اختيار ملف {field}",
|
||||
"invalidEmail": "يُرجى إدخال بريد إلكتروني صالح في {field}",
|
||||
"required": "يُرجى إدخال {field}"
|
||||
}
|
||||
},
|
||||
"customAnalytics": {
|
||||
|
||||
@@ -3218,15 +3218,36 @@
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
"accountEmailField": {
|
||||
"subDescription": "Verwenden Sie die E-Mail-Adresse des Kontos, dem das API-Token gehört."
|
||||
},
|
||||
"apiTokenField": {
|
||||
"subDescription": "Fügen Sie Ihr API-Token ein, um auf {source} zuzugreifen."
|
||||
},
|
||||
"createButton": {
|
||||
"label": "Erstellen"
|
||||
},
|
||||
"created": {
|
||||
"toast": "Neue Anmeldedaten erstellt!"
|
||||
},
|
||||
"name": {
|
||||
"label": "Name:",
|
||||
"placeholder": "(Optional) Name der Anmeldedaten..."
|
||||
"emailField": {
|
||||
"placeholder": "email@company.com"
|
||||
},
|
||||
"share": {
|
||||
"admins": {
|
||||
"label": "Admins",
|
||||
"description": "Alle Admins können dieses Konto wiederverwenden, um Connectoren zu erstellen.",
|
||||
"suffix": "(Standard)"
|
||||
},
|
||||
"adminsAndGroups": {
|
||||
"description": "Admins und von Ihnen benannte Gruppen können dieses Konto wiederverwenden.",
|
||||
"label": "Admins & Gruppen"
|
||||
},
|
||||
"onlyMe": {
|
||||
"label": "Nur ich"
|
||||
},
|
||||
"description": "Erlaube Gruppen, dieses Konto zum Erstellen von Konnektoren wiederzuverwenden.",
|
||||
"title": "Dieses Konto teilen"
|
||||
},
|
||||
"submitError": {
|
||||
"toast": "Fehler beim Übermitteln der Anmeldedaten"
|
||||
@@ -3261,6 +3282,82 @@
|
||||
"toast": "Fehler beim Aktualisieren der Anmeldedaten"
|
||||
}
|
||||
},
|
||||
"displayNames": {
|
||||
"accessKeyId": "{source}-Zugriffsschlüssel-ID",
|
||||
"secretAccessKey": "Geheimer {source}-Zugriffsschlüssel",
|
||||
"accountId": "{source}-Konto-ID",
|
||||
"apiToken": "API-Token",
|
||||
"clientId": "{source}-Client-ID",
|
||||
"clientSecret": "{source}-Client-Secret",
|
||||
"clientToken": "{source}-Client-Token",
|
||||
"directoryId": "{source}-Verzeichnis-ID",
|
||||
"certificatePassword": "{source}-Zertifikatspasswort",
|
||||
"privateKey": "Privater Schlüssel für {source}",
|
||||
"certificate": "{source}-Zertifikat",
|
||||
"baseUrl": "{source}-Basis-URL",
|
||||
"apiBaseUrl": "{source}-API-Basis-URL",
|
||||
"url": "{source}-URL",
|
||||
"enterpriseServerUrl": "{source}-Enterprise-Server-URL",
|
||||
"botToken": "{source}-Bot-Token",
|
||||
"username": "{source}-Benutzername",
|
||||
"apiUsername": "{source}-API-Benutzername",
|
||||
"userEmail": "{source}-Benutzer-E-Mail",
|
||||
"accountEmail": "E-Mail-Adresse des {source}-Kontos",
|
||||
"password": "{source}-Passwort",
|
||||
"subdomain": "{source}-Subdomain",
|
||||
"domain": "{source}-Domain",
|
||||
"enterpriseId": "{source}-Enterprise-ID",
|
||||
"teamId": "{source}-Team-ID",
|
||||
"applicationId": "{source}-Anwendungs-ID",
|
||||
"apiTokenId": "{source}-API-Token-ID",
|
||||
"apiTokenSecret": "{source}-API-Token-Geheimnis",
|
||||
"securityToken": "{source}-Sicherheitstoken",
|
||||
"integrationToken": "{source}-Integrationstoken",
|
||||
"accessKey": "{source}-Zugriffsschlüssel",
|
||||
"accessKeySecret": "{source}-Zugriffsschlüssel-Geheimnis",
|
||||
"key": "{source}-Schlüssel",
|
||||
"secret": "{source}-Geheimnis",
|
||||
"roleArn": "{source}-Rollen-ARN",
|
||||
"namespace": "{source}-Namespace",
|
||||
"region": "{source}-Region",
|
||||
"portalId": "{source}-Portal-ID",
|
||||
"oauthTokens": "{source}-OAuth-Tokens",
|
||||
"isSandbox": "Sandbox-Umgebung",
|
||||
"serviceUserEmail": "E-Mail-Adresse des Dienstbenutzers",
|
||||
"zuliprcContent": "Zuliprc-Inhalt",
|
||||
"authenticationMethod": "Authentifizierungsmethode",
|
||||
"scopedToken": "Dies ist ein API-Token mit Bereichen"
|
||||
},
|
||||
"hints": {
|
||||
"boxUserEmail": "Optional. Der Box-Benutzer, dessen Identität übernommen wird.",
|
||||
"githubBaseUrl": "Optional. Gib den Host deines Servers an, z. B. https://github.example.com. Für github.com leer lassen.",
|
||||
"gongBaseUrl": "Optional. Für Daten außerhalb der USA gib den Host deiner Region an, z. B. https://[region].api.gong.io.",
|
||||
"jiraUserEmail": "Erforderlich für Jira Cloud.",
|
||||
"lumappsServiceUser": "Der Benutzer, in dessen Namen indiziert wird.",
|
||||
"outlineBaseUrl": "Zum Beispiel https://app.getoutline.com oder deine selbst gehostete URL.",
|
||||
"pfxFile": "Eine PFX-Datei.",
|
||||
"testrailBaseUrl": "Zum Beispiel https://yourcompany.testrail.io.",
|
||||
"scopedToken": "Tokens mit Bereichen müssen über das API-Gateway von Atlassian geleitet werden. Lassen Sie diese Option für klassische Tokens deaktiviert."
|
||||
},
|
||||
"methods": {
|
||||
"labels": {
|
||||
"clientSecret": "Client-Secret",
|
||||
"certificateAuthentication": "Zertifikatsauthentifizierung",
|
||||
"certificate": "Zertifikat",
|
||||
"accessKeyAndSecret": "Zugriffsschlüssel und Secret",
|
||||
"iamRole": "IAM-Rolle",
|
||||
"assumeRole": "Rolle übernehmen"
|
||||
},
|
||||
"descriptions": {
|
||||
"sharepointClientSecret": "In diesem Modus authentifiziert sich der SharePoint-Connector mit einem Client-Secret. Geben Sie die Client-ID und das Client-Secret an.",
|
||||
"sharepointCertificate": "In diesem Modus authentifiziert sich der SharePoint-Connector mit einem Zertifikat. Geben Sie die Client-ID, die Verzeichnis-ID, das Zertifikatspasswort und die PFX-Daten an.",
|
||||
"teamsClientSecret": "Der Connector meldet sich mit einem Client-Secret der App-Registrierung an. Geben Sie die Client-ID, die Verzeichnis-ID und das Secret an. Nur Kanalnachrichten und Mitglieder: SharePoint lehnt ein Secret ab, daher benötigt „Anhänge einbeziehen“ die Zertifikatsoption.",
|
||||
"teamsCertificate": "Der Connector meldet sich mit einem Zertifikat an, das in die App-Registrierung hochgeladen wurde. Geben Sie die Client-ID, die Verzeichnis-ID, das PFX-Paket und sein Passwort an. Erforderlich für „Anhänge einbeziehen“, das Kanaldateien und ihre Leser aus SharePoint liest.",
|
||||
"outlookClientSecret": "Der Connector meldet sich mit einem Client-Secret der App-Registrierung an. Geben Sie die Client-ID, die Verzeichnis-ID und das Secret an.",
|
||||
"outlookCertificate": "Der Connector meldet sich mit einem Zertifikat an, das in die App-Registrierung hochgeladen wurde. Geben Sie die Client-ID, die Verzeichnis-ID, das PFX-Paket und sein Passwort an.",
|
||||
"s3AssumeRole": "In diesem Modus übernimmt die Amazon-EC2-Instanz ihre vorhandene Rolle, um auf S3 zuzugreifen. Es sind keine weiteren Anmeldedaten erforderlich."
|
||||
}
|
||||
},
|
||||
"modal": {
|
||||
"createTitle": "{source}-Anmeldedaten erstellen",
|
||||
"editTitle": "Anmeldedaten bearbeiten",
|
||||
@@ -3290,6 +3387,10 @@
|
||||
"retryButton": {
|
||||
"label": "Erneut versuchen"
|
||||
},
|
||||
"signIn": {
|
||||
"description": "{appName} benötigt Lesezugriff.",
|
||||
"title": "Mit {source} anmelden"
|
||||
},
|
||||
"startError": {
|
||||
"message": "OAuth konnte nicht gestartet werden"
|
||||
}
|
||||
@@ -3341,6 +3442,13 @@
|
||||
"success": {
|
||||
"toast": "Anmeldedaten aktualisiert"
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
"authMethodRequired": "Bitte wählen Sie eine Authentifizierungsmethode aus",
|
||||
"empty": "{field} darf nicht leer sein",
|
||||
"fileRequired": "Bitte wählen Sie eine Datei für {field} aus",
|
||||
"invalidEmail": "Bitte geben Sie für {field} eine gültige E-Mail-Adresse ein",
|
||||
"required": "Bitte geben Sie {field} ein"
|
||||
}
|
||||
},
|
||||
"customAnalytics": {
|
||||
|
||||
@@ -3218,15 +3218,36 @@
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
"accountEmailField": {
|
||||
"subDescription": "Use the account email that owns the API token."
|
||||
},
|
||||
"apiTokenField": {
|
||||
"subDescription": "Paste your API token to access {source}."
|
||||
},
|
||||
"createButton": {
|
||||
"label": "Create"
|
||||
},
|
||||
"created": {
|
||||
"toast": "Created new credential!"
|
||||
},
|
||||
"name": {
|
||||
"label": "Name:",
|
||||
"placeholder": "(Optional) credential name.."
|
||||
"emailField": {
|
||||
"placeholder": "email@company.com"
|
||||
},
|
||||
"share": {
|
||||
"admins": {
|
||||
"label": "Admins",
|
||||
"description": "All admins can reuse this account to create connectors.",
|
||||
"suffix": "(Default)"
|
||||
},
|
||||
"adminsAndGroups": {
|
||||
"description": "Admins and groups named by you can reuse this account.",
|
||||
"label": "Admins & Groups"
|
||||
},
|
||||
"onlyMe": {
|
||||
"label": "Only Me"
|
||||
},
|
||||
"description": "Allow groups to reuse this account to create connectors.",
|
||||
"title": "Share This Account"
|
||||
},
|
||||
"submitError": {
|
||||
"toast": "Error submitting credential"
|
||||
@@ -3261,6 +3282,82 @@
|
||||
"toast": "Error updating credential"
|
||||
}
|
||||
},
|
||||
"displayNames": {
|
||||
"accessKeyId": "{source} Access Key ID",
|
||||
"secretAccessKey": "{source} Secret Access Key",
|
||||
"accountId": "{source} Account ID",
|
||||
"apiToken": "API Token",
|
||||
"clientId": "{source} Client ID",
|
||||
"clientSecret": "{source} Client Secret",
|
||||
"clientToken": "{source} Client Token",
|
||||
"directoryId": "{source} Directory ID",
|
||||
"certificatePassword": "{source} Certificate Password",
|
||||
"privateKey": "{source} Private Key",
|
||||
"certificate": "{source} Certificate",
|
||||
"baseUrl": "{source} Base URL",
|
||||
"apiBaseUrl": "{source} API Base URL",
|
||||
"url": "{source} URL",
|
||||
"enterpriseServerUrl": "{source} Enterprise Server URL",
|
||||
"botToken": "{source} Bot Token",
|
||||
"username": "{source} Username",
|
||||
"apiUsername": "{source} API Username",
|
||||
"userEmail": "{source} User Email",
|
||||
"accountEmail": "{source} Account Email",
|
||||
"password": "{source} Password",
|
||||
"subdomain": "{source} Subdomain",
|
||||
"domain": "{source} Domain",
|
||||
"enterpriseId": "{source} Enterprise ID",
|
||||
"teamId": "{source} Team ID",
|
||||
"applicationId": "{source} Application ID",
|
||||
"apiTokenId": "{source} API Token ID",
|
||||
"apiTokenSecret": "{source} API Token Secret",
|
||||
"securityToken": "{source} Security Token",
|
||||
"integrationToken": "{source} Integration Token",
|
||||
"accessKey": "{source} Access Key",
|
||||
"accessKeySecret": "{source} Access Key Secret",
|
||||
"key": "{source} Key",
|
||||
"secret": "{source} Secret",
|
||||
"roleArn": "{source} Role ARN",
|
||||
"namespace": "{source} Namespace",
|
||||
"region": "{source} Region",
|
||||
"portalId": "{source} Portal ID",
|
||||
"oauthTokens": "{source} Oauth Tokens",
|
||||
"isSandbox": "Is Sandbox Environment",
|
||||
"serviceUserEmail": "Service User Email",
|
||||
"zuliprcContent": "Zuliprc Content",
|
||||
"authenticationMethod": "Authentication Method",
|
||||
"scopedToken": "This is an API token with scopes"
|
||||
},
|
||||
"hints": {
|
||||
"boxUserEmail": "Optional. The Box user to impersonate.",
|
||||
"githubBaseUrl": "Optional. Set your server host, like https://github.example.com. Leave blank for github.com.",
|
||||
"gongBaseUrl": "Optional. For data outside the US, set your region's host, like https://[region].api.gong.io.",
|
||||
"jiraUserEmail": "Required for Jira Cloud.",
|
||||
"lumappsServiceUser": "The user to index on behalf of.",
|
||||
"outlineBaseUrl": "For example https://app.getoutline.com, or your self-hosted URL.",
|
||||
"pfxFile": "A PFX file.",
|
||||
"testrailBaseUrl": "For example https://yourcompany.testrail.io.",
|
||||
"scopedToken": "Scoped tokens require routing through Atlassian's API gateway. Leave off for classic tokens."
|
||||
},
|
||||
"methods": {
|
||||
"labels": {
|
||||
"clientSecret": "Client Secret",
|
||||
"certificateAuthentication": "Certificate Authentication",
|
||||
"certificate": "Certificate",
|
||||
"accessKeyAndSecret": "Access Key and Secret",
|
||||
"iamRole": "IAM Role",
|
||||
"assumeRole": "Assume Role"
|
||||
},
|
||||
"descriptions": {
|
||||
"sharepointClientSecret": "If you select this mode, the SharePoint connector will use a client secret to authenticate. You will need to provide the client ID and client secret.",
|
||||
"sharepointCertificate": "If you select this mode, the SharePoint connector will use a certificate to authenticate. You will need to provide the client ID, directory ID, certificate password, and PFX data.",
|
||||
"teamsClientSecret": "The connector signs in with a client secret of the app registration. Provide the client ID, directory ID and secret. Channel messages and members only: SharePoint refuses a secret, so Include Attachments needs the certificate option.",
|
||||
"teamsCertificate": "The connector signs in with a certificate uploaded to the app registration. Provide the client ID, directory ID, the PFX bundle and its password. Required for Include Attachments, which reads channel files and their readers from SharePoint.",
|
||||
"outlookClientSecret": "The connector signs in with a client secret of the app registration. Provide the client ID, directory ID and secret.",
|
||||
"outlookCertificate": "The connector signs in with a certificate uploaded to the app registration. Provide the client ID, directory ID, the PFX bundle and its password.",
|
||||
"s3AssumeRole": "If you select this mode, the Amazon EC2 instance will assume its existing role to access S3. No additional credentials are required."
|
||||
}
|
||||
},
|
||||
"modal": {
|
||||
"createTitle": "Create {source} Credential",
|
||||
"editTitle": "Edit Credential",
|
||||
@@ -3290,6 +3387,10 @@
|
||||
"retryButton": {
|
||||
"label": "Retry"
|
||||
},
|
||||
"signIn": {
|
||||
"description": "{appName} requires read-only scopes.",
|
||||
"title": "Sign in with {source}"
|
||||
},
|
||||
"startError": {
|
||||
"message": "Unable to start OAuth"
|
||||
}
|
||||
@@ -3341,6 +3442,13 @@
|
||||
"success": {
|
||||
"toast": "Updated credential"
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
"authMethodRequired": "Please select an authentication method",
|
||||
"empty": "{field} cannot be empty",
|
||||
"fileRequired": "Please select a {field} file",
|
||||
"invalidEmail": "Please enter a valid {field}",
|
||||
"required": "Please enter your {field}"
|
||||
}
|
||||
},
|
||||
"customAnalytics": {
|
||||
|
||||
@@ -3218,15 +3218,36 @@
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
"accountEmailField": {
|
||||
"subDescription": "Usa el correo de la cuenta a la que pertenece el token de API."
|
||||
},
|
||||
"apiTokenField": {
|
||||
"subDescription": "Pega tu token de API para acceder a {source}."
|
||||
},
|
||||
"createButton": {
|
||||
"label": "Crear"
|
||||
},
|
||||
"created": {
|
||||
"toast": "¡Nueva credencial creada!"
|
||||
},
|
||||
"name": {
|
||||
"label": "Nombre:",
|
||||
"placeholder": "(Opcional) nombre de la credencial..."
|
||||
"emailField": {
|
||||
"placeholder": "email@company.com"
|
||||
},
|
||||
"share": {
|
||||
"admins": {
|
||||
"label": "Administradores",
|
||||
"description": "Todos los administradores pueden reutilizar esta cuenta para crear conectores.",
|
||||
"suffix": "(Predeterminado)"
|
||||
},
|
||||
"adminsAndGroups": {
|
||||
"description": "Los administradores y los grupos que indiques pueden reutilizar esta cuenta.",
|
||||
"label": "Administradores y grupos"
|
||||
},
|
||||
"onlyMe": {
|
||||
"label": "Solo yo"
|
||||
},
|
||||
"description": "Permite que los grupos reutilicen esta cuenta para crear conectores.",
|
||||
"title": "Compartir esta cuenta"
|
||||
},
|
||||
"submitError": {
|
||||
"toast": "Error al enviar la credencial"
|
||||
@@ -3261,6 +3282,82 @@
|
||||
"toast": "Error al actualizar la credencial"
|
||||
}
|
||||
},
|
||||
"displayNames": {
|
||||
"accessKeyId": "ID de clave de acceso de {source}",
|
||||
"secretAccessKey": "Clave de acceso secreta de {source}",
|
||||
"accountId": "ID de cuenta de {source}",
|
||||
"apiToken": "Token de API",
|
||||
"clientId": "ID de cliente de {source}",
|
||||
"clientSecret": "Secreto de cliente de {source}",
|
||||
"clientToken": "Token de cliente de {source}",
|
||||
"directoryId": "ID de directorio de {source}",
|
||||
"certificatePassword": "Contraseña del certificado de {source}",
|
||||
"privateKey": "Clave privada de {source}",
|
||||
"certificate": "Certificado de {source}",
|
||||
"baseUrl": "URL base de {source}",
|
||||
"apiBaseUrl": "URL base de API de {source}",
|
||||
"url": "URL de {source}",
|
||||
"enterpriseServerUrl": "URL del servidor de {source} Enterprise",
|
||||
"botToken": "Token del bot de {source}",
|
||||
"username": "Nombre de usuario de {source}",
|
||||
"apiUsername": "Nombre de usuario de API de {source}",
|
||||
"userEmail": "Correo del usuario de {source}",
|
||||
"accountEmail": "Correo de la cuenta de {source}",
|
||||
"password": "Contraseña de {source}",
|
||||
"subdomain": "Subdominio de {source}",
|
||||
"domain": "Dominio de {source}",
|
||||
"enterpriseId": "ID de empresa de {source}",
|
||||
"teamId": "ID de equipo de {source}",
|
||||
"applicationId": "ID de aplicación de {source}",
|
||||
"apiTokenId": "ID del token de API de {source}",
|
||||
"apiTokenSecret": "Secreto del token de API de {source}",
|
||||
"securityToken": "Token de seguridad de {source}",
|
||||
"integrationToken": "Token de integración de {source}",
|
||||
"accessKey": "Clave de acceso de {source}",
|
||||
"accessKeySecret": "Secreto de la clave de acceso de {source}",
|
||||
"key": "Clave de {source}",
|
||||
"secret": "Secreto de {source}",
|
||||
"roleArn": "ARN del rol de {source}",
|
||||
"namespace": "Namespace de {source}",
|
||||
"region": "Región de {source}",
|
||||
"portalId": "ID de portal de {source}",
|
||||
"oauthTokens": "Tokens de OAuth de {source}",
|
||||
"isSandbox": "Es un entorno de sandbox",
|
||||
"serviceUserEmail": "Correo del usuario de servicio",
|
||||
"zuliprcContent": "Contenido de zuliprc",
|
||||
"authenticationMethod": "Método de autenticación",
|
||||
"scopedToken": "Es un token de API con ámbitos"
|
||||
},
|
||||
"hints": {
|
||||
"boxUserEmail": "Opcional. El usuario de Box al que suplantar.",
|
||||
"githubBaseUrl": "Opcional. Indica el host de tu servidor, como https://github.example.com. Déjalo en blanco para github.com.",
|
||||
"gongBaseUrl": "Opcional. Para datos fuera de EE. UU., indica el host de tu región, como https://[region].api.gong.io.",
|
||||
"jiraUserEmail": "Obligatorio para Jira Cloud.",
|
||||
"lumappsServiceUser": "El usuario en cuyo nombre se indexa.",
|
||||
"outlineBaseUrl": "Por ejemplo, https://app.getoutline.com, o tu URL autoalojada.",
|
||||
"pfxFile": "Un archivo PFX.",
|
||||
"testrailBaseUrl": "Por ejemplo, https://yourcompany.testrail.io.",
|
||||
"scopedToken": "Los tokens con ámbitos deben enrutarse a través de la puerta de enlace de API de Atlassian. Déjalo desactivado para los tokens clásicos."
|
||||
},
|
||||
"methods": {
|
||||
"labels": {
|
||||
"clientSecret": "Secreto de cliente",
|
||||
"certificateAuthentication": "Autenticación con certificado",
|
||||
"certificate": "Certificado",
|
||||
"accessKeyAndSecret": "Clave de acceso y secreto",
|
||||
"iamRole": "Rol de IAM",
|
||||
"assumeRole": "Asumir rol"
|
||||
},
|
||||
"descriptions": {
|
||||
"sharepointClientSecret": "Si seleccionas este modo, el conector de SharePoint usará un secreto de cliente para autenticarse. Deberás proporcionar el ID de cliente y el secreto de cliente.",
|
||||
"sharepointCertificate": "Si seleccionas este modo, el conector de SharePoint usará un certificado para autenticarse. Deberás proporcionar el ID de cliente, el ID de directorio, la contraseña del certificado y los datos PFX.",
|
||||
"teamsClientSecret": "El conector inicia sesión con un secreto de cliente del registro de la aplicación. Proporciona el ID de cliente, el ID de directorio y el secreto. Solo mensajes y miembros de canales: SharePoint rechaza un secreto, así que Incluir archivos adjuntos necesita la opción de certificado.",
|
||||
"teamsCertificate": "El conector inicia sesión con un certificado cargado en el registro de la aplicación. Proporciona el ID de cliente, el ID de directorio, el paquete PFX y su contraseña. Es obligatorio para Incluir archivos adjuntos, que lee los archivos de los canales y sus lectores desde SharePoint.",
|
||||
"outlookClientSecret": "El conector inicia sesión con un secreto de cliente del registro de la aplicación. Proporciona el ID de cliente, el ID de directorio y el secreto.",
|
||||
"outlookCertificate": "El conector inicia sesión con un certificado cargado en el registro de la aplicación. Proporciona el ID de cliente, el ID de directorio, el paquete PFX y su contraseña.",
|
||||
"s3AssumeRole": "Si seleccionas este modo, la instancia de Amazon EC2 asumirá su rol existente para acceder a S3. No se necesitan más credenciales."
|
||||
}
|
||||
},
|
||||
"modal": {
|
||||
"createTitle": "Crear credencial de {source}",
|
||||
"editTitle": "Editar credencial",
|
||||
@@ -3290,6 +3387,10 @@
|
||||
"retryButton": {
|
||||
"label": "Reintentar"
|
||||
},
|
||||
"signIn": {
|
||||
"description": "{appName} requiere permisos de solo lectura.",
|
||||
"title": "Iniciar sesión con {source}"
|
||||
},
|
||||
"startError": {
|
||||
"message": "No se pudo iniciar OAuth"
|
||||
}
|
||||
@@ -3341,6 +3442,13 @@
|
||||
"success": {
|
||||
"toast": "Credencial actualizada"
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
"authMethodRequired": "Selecciona un método de autenticación",
|
||||
"empty": "El campo {field} no puede estar vacío",
|
||||
"fileRequired": "Selecciona un archivo para {field}",
|
||||
"invalidEmail": "Introduce un correo electrónico válido en {field}",
|
||||
"required": "Introduce el valor de {field}"
|
||||
}
|
||||
},
|
||||
"customAnalytics": {
|
||||
|
||||
@@ -3218,15 +3218,36 @@
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
"accountEmailField": {
|
||||
"subDescription": "Utilisez l'adresse e-mail du compte propriétaire du jeton d'API."
|
||||
},
|
||||
"apiTokenField": {
|
||||
"subDescription": "Collez votre jeton d'API pour accéder à {source}."
|
||||
},
|
||||
"createButton": {
|
||||
"label": "Créer"
|
||||
},
|
||||
"created": {
|
||||
"toast": "Nouvel identifiant créé !"
|
||||
},
|
||||
"name": {
|
||||
"label": "Nom :",
|
||||
"placeholder": "(Facultatif) nom de l'identifiant..."
|
||||
"emailField": {
|
||||
"placeholder": "email@company.com"
|
||||
},
|
||||
"share": {
|
||||
"admins": {
|
||||
"label": "Administrateurs",
|
||||
"description": "Tous les administrateurs peuvent réutiliser ce compte pour créer des connecteurs.",
|
||||
"suffix": "(Par défaut)"
|
||||
},
|
||||
"adminsAndGroups": {
|
||||
"description": "Les administrateurs et les groupes que vous désignez peuvent réutiliser ce compte.",
|
||||
"label": "Administrateurs et groupes"
|
||||
},
|
||||
"onlyMe": {
|
||||
"label": "Moi uniquement"
|
||||
},
|
||||
"description": "Autorisez les groupes à réutiliser ce compte pour créer des connecteurs.",
|
||||
"title": "Partager ce compte"
|
||||
},
|
||||
"submitError": {
|
||||
"toast": "Erreur lors de l'envoi de l'identifiant"
|
||||
@@ -3261,6 +3282,82 @@
|
||||
"toast": "Erreur lors de la mise à jour de l'identifiant"
|
||||
}
|
||||
},
|
||||
"displayNames": {
|
||||
"accessKeyId": "ID de clé d'accès {source}",
|
||||
"secretAccessKey": "Clé d'accès secrète {source}",
|
||||
"accountId": "ID de compte {source}",
|
||||
"apiToken": "Jeton d'API",
|
||||
"clientId": "ID client {source}",
|
||||
"clientSecret": "Secret client {source}",
|
||||
"clientToken": "Jeton client {source}",
|
||||
"directoryId": "ID de répertoire {source}",
|
||||
"certificatePassword": "Mot de passe du certificat {source}",
|
||||
"privateKey": "Clé privée {source}",
|
||||
"certificate": "Certificat {source}",
|
||||
"baseUrl": "URL de base {source}",
|
||||
"apiBaseUrl": "URL de base de l'API {source}",
|
||||
"url": "URL {source}",
|
||||
"enterpriseServerUrl": "URL du serveur {source} Enterprise",
|
||||
"botToken": "Jeton du bot {source}",
|
||||
"username": "Nom d'utilisateur {source}",
|
||||
"apiUsername": "Nom d'utilisateur de l'API {source}",
|
||||
"userEmail": "E-mail de l'utilisateur {source}",
|
||||
"accountEmail": "E-mail du compte {source}",
|
||||
"password": "Mot de passe {source}",
|
||||
"subdomain": "Sous-domaine {source}",
|
||||
"domain": "Domaine {source}",
|
||||
"enterpriseId": "ID d'entreprise {source}",
|
||||
"teamId": "ID d'équipe {source}",
|
||||
"applicationId": "ID d'application {source}",
|
||||
"apiTokenId": "ID du jeton d'API {source}",
|
||||
"apiTokenSecret": "Secret du jeton d'API {source}",
|
||||
"securityToken": "Jeton de sécurité {source}",
|
||||
"integrationToken": "Jeton d'intégration {source}",
|
||||
"accessKey": "Clé d'accès {source}",
|
||||
"accessKeySecret": "Secret de la clé d'accès {source}",
|
||||
"key": "Clé {source}",
|
||||
"secret": "Secret {source}",
|
||||
"roleArn": "ARN du rôle {source}",
|
||||
"namespace": "Espace de noms {source}",
|
||||
"region": "Région {source}",
|
||||
"portalId": "ID de portail {source}",
|
||||
"oauthTokens": "Jetons OAuth {source}",
|
||||
"isSandbox": "Environnement sandbox",
|
||||
"serviceUserEmail": "E-mail de l'utilisateur de service",
|
||||
"zuliprcContent": "Contenu de zuliprc",
|
||||
"authenticationMethod": "Méthode d'authentification",
|
||||
"scopedToken": "Ceci est un jeton d'API avec des portées"
|
||||
},
|
||||
"hints": {
|
||||
"boxUserEmail": "Facultatif. L'utilisateur Box à usurper.",
|
||||
"githubBaseUrl": "Facultatif. Indiquez l'hôte de votre serveur, par exemple https://github.example.com. Laissez vide pour github.com.",
|
||||
"gongBaseUrl": "Facultatif. Pour les données hors des États-Unis, indiquez l'hôte de votre région, par exemple https://[region].api.gong.io.",
|
||||
"jiraUserEmail": "Obligatoire pour Jira Cloud.",
|
||||
"lumappsServiceUser": "L'utilisateur au nom duquel indexer.",
|
||||
"outlineBaseUrl": "Par exemple https://app.getoutline.com, ou l'URL de votre instance auto-hébergée.",
|
||||
"pfxFile": "Un fichier PFX.",
|
||||
"testrailBaseUrl": "Par exemple https://yourcompany.testrail.io.",
|
||||
"scopedToken": "Les jetons avec portées doivent passer par la passerelle d'API d'Atlassian. Laissez cette option désactivée pour les jetons classiques."
|
||||
},
|
||||
"methods": {
|
||||
"labels": {
|
||||
"clientSecret": "Secret client",
|
||||
"certificateAuthentication": "Authentification par certificat",
|
||||
"certificate": "Certificat",
|
||||
"accessKeyAndSecret": "Clé d'accès et secret",
|
||||
"iamRole": "Rôle IAM",
|
||||
"assumeRole": "Endosser un rôle"
|
||||
},
|
||||
"descriptions": {
|
||||
"sharepointClientSecret": "Dans ce mode, le connecteur SharePoint s'authentifie avec un secret client. Vous devez fournir l'ID client et le secret client.",
|
||||
"sharepointCertificate": "Dans ce mode, le connecteur SharePoint s'authentifie avec un certificat. Vous devez fournir l'ID client, l'ID d'annuaire, le mot de passe du certificat et les données PFX.",
|
||||
"teamsClientSecret": "Le connecteur se connecte avec un secret client de l'inscription d'application. Fournissez l'ID client, l'ID d'annuaire et le secret. Messages et membres des canaux uniquement : SharePoint refuse un secret, l'option Inclure les pièces jointes nécessite donc le certificat.",
|
||||
"teamsCertificate": "Le connecteur se connecte avec un certificat téléversé dans l'inscription d'application. Fournissez l'ID client, l'ID d'annuaire, le paquet PFX et son mot de passe. Obligatoire pour Inclure les pièces jointes, qui lit les fichiers des canaux et leurs lecteurs depuis SharePoint.",
|
||||
"outlookClientSecret": "Le connecteur se connecte avec un secret client de l'inscription d'application. Fournissez l'ID client, l'ID d'annuaire et le secret.",
|
||||
"outlookCertificate": "Le connecteur se connecte avec un certificat téléversé dans l'inscription d'application. Fournissez l'ID client, l'ID d'annuaire, le paquet PFX et son mot de passe.",
|
||||
"s3AssumeRole": "Dans ce mode, l'instance Amazon EC2 endosse son rôle existant pour accéder à S3. Aucun autre identifiant n'est nécessaire."
|
||||
}
|
||||
},
|
||||
"modal": {
|
||||
"createTitle": "Créer un identifiant {source}",
|
||||
"editTitle": "Modifier l'identifiant",
|
||||
@@ -3290,6 +3387,10 @@
|
||||
"retryButton": {
|
||||
"label": "Réessayer"
|
||||
},
|
||||
"signIn": {
|
||||
"description": "{appName} nécessite des autorisations en lecture seule.",
|
||||
"title": "Se connecter avec {source}"
|
||||
},
|
||||
"startError": {
|
||||
"message": "Impossible de démarrer OAuth"
|
||||
}
|
||||
@@ -3341,6 +3442,13 @@
|
||||
"success": {
|
||||
"toast": "Identifiant mis à jour"
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
"authMethodRequired": "Veuillez sélectionner une méthode d'authentification",
|
||||
"empty": "Le champ {field} ne peut pas être vide",
|
||||
"fileRequired": "Veuillez sélectionner un fichier pour {field}",
|
||||
"invalidEmail": "Veuillez saisir une adresse e-mail valide dans {field}",
|
||||
"required": "Veuillez renseigner le champ {field}"
|
||||
}
|
||||
},
|
||||
"customAnalytics": {
|
||||
|
||||
@@ -3218,15 +3218,36 @@
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
"accountEmailField": {
|
||||
"subDescription": "API トークンを所有するアカウントのメールアドレスを使用してください。"
|
||||
},
|
||||
"apiTokenField": {
|
||||
"subDescription": "{source} にアクセスするための API トークンを貼り付けてください。"
|
||||
},
|
||||
"createButton": {
|
||||
"label": "作成"
|
||||
},
|
||||
"created": {
|
||||
"toast": "新しい認証情報を作成しました!"
|
||||
},
|
||||
"name": {
|
||||
"label": "名前:",
|
||||
"placeholder": "(任意) 認証情報の名前..."
|
||||
"emailField": {
|
||||
"placeholder": "email@company.com"
|
||||
},
|
||||
"share": {
|
||||
"admins": {
|
||||
"label": "管理者",
|
||||
"description": "すべての管理者がこのアカウントを再利用してコネクタを作成できます。",
|
||||
"suffix": "(デフォルト)"
|
||||
},
|
||||
"adminsAndGroups": {
|
||||
"description": "管理者と、あなたが指定したグループがこのアカウントを再利用できます。",
|
||||
"label": "管理者とグループ"
|
||||
},
|
||||
"onlyMe": {
|
||||
"label": "自分のみ"
|
||||
},
|
||||
"description": "グループがこのアカウントを再利用してコネクターを作成できるようにします。",
|
||||
"title": "このアカウントを共有"
|
||||
},
|
||||
"submitError": {
|
||||
"toast": "認証情報の送信中にエラーが発生しました"
|
||||
@@ -3261,6 +3282,82 @@
|
||||
"toast": "認証情報の更新中にエラーが発生しました"
|
||||
}
|
||||
},
|
||||
"displayNames": {
|
||||
"accessKeyId": "{source} アクセスキー ID",
|
||||
"secretAccessKey": "{source} シークレットアクセスキー",
|
||||
"accountId": "{source} アカウント ID",
|
||||
"apiToken": "API トークン",
|
||||
"clientId": "{source} クライアント ID",
|
||||
"clientSecret": "{source} クライアントシークレット",
|
||||
"clientToken": "{source} クライアントトークン",
|
||||
"directoryId": "{source} ディレクトリ ID",
|
||||
"certificatePassword": "{source} 証明書のパスワード",
|
||||
"privateKey": "{source} 秘密鍵",
|
||||
"certificate": "{source} 証明書",
|
||||
"baseUrl": "{source} ベース URL",
|
||||
"apiBaseUrl": "{source} API ベース URL",
|
||||
"url": "{source} URL",
|
||||
"enterpriseServerUrl": "{source} Enterprise Server URL",
|
||||
"botToken": "{source} ボットトークン",
|
||||
"username": "{source} ユーザー名",
|
||||
"apiUsername": "{source} API ユーザー名",
|
||||
"userEmail": "{source} ユーザーのメールアドレス",
|
||||
"accountEmail": "{source} アカウントのメールアドレス",
|
||||
"password": "{source} パスワード",
|
||||
"subdomain": "{source} サブドメイン",
|
||||
"domain": "{source} ドメイン",
|
||||
"enterpriseId": "{source} エンタープライズ ID",
|
||||
"teamId": "{source} チーム ID",
|
||||
"applicationId": "{source} アプリケーション ID",
|
||||
"apiTokenId": "{source} API トークン ID",
|
||||
"apiTokenSecret": "{source} API トークンシークレット",
|
||||
"securityToken": "{source} セキュリティトークン",
|
||||
"integrationToken": "{source} インテグレーショントークン",
|
||||
"accessKey": "{source} アクセスキー",
|
||||
"accessKeySecret": "{source} アクセスキーシークレット",
|
||||
"key": "{source} キー",
|
||||
"secret": "{source} シークレット",
|
||||
"roleArn": "{source} ロール ARN",
|
||||
"namespace": "{source} ネームスペース",
|
||||
"region": "{source} リージョン",
|
||||
"portalId": "{source} ポータル ID",
|
||||
"oauthTokens": "{source} OAuth トークン",
|
||||
"isSandbox": "サンドボックス環境かどうか",
|
||||
"serviceUserEmail": "サービスユーザーのメールアドレス",
|
||||
"zuliprcContent": "Zuliprc の内容",
|
||||
"authenticationMethod": "認証方法",
|
||||
"scopedToken": "スコープ付きの API トークンです"
|
||||
},
|
||||
"hints": {
|
||||
"boxUserEmail": "任意。なりすます Box ユーザーを指定します。",
|
||||
"githubBaseUrl": "任意。https://github.example.com のようにサーバーのホストを指定します。github.com を使う場合は空欄のままにします。",
|
||||
"gongBaseUrl": "任意。米国外のデータを使う場合は、https://[region].api.gong.io のようにリージョンのホストを指定します。",
|
||||
"jiraUserEmail": "Jira Cloud では必須です。",
|
||||
"lumappsServiceUser": "代理でインデックスを作成するユーザーです。",
|
||||
"outlineBaseUrl": "例: https://app.getoutline.com、またはセルフホストの URL。",
|
||||
"pfxFile": "PFX ファイルです。",
|
||||
"testrailBaseUrl": "例: https://yourcompany.testrail.io。",
|
||||
"scopedToken": "スコープ付きトークンは Atlassian の API ゲートウェイを経由する必要があります。クラシックトークンの場合はオフのままにしてください。"
|
||||
},
|
||||
"methods": {
|
||||
"labels": {
|
||||
"clientSecret": "クライアントシークレット",
|
||||
"certificateAuthentication": "証明書認証",
|
||||
"certificate": "証明書",
|
||||
"accessKeyAndSecret": "アクセスキーとシークレット",
|
||||
"iamRole": "IAM ロール",
|
||||
"assumeRole": "ロールの引き受け"
|
||||
},
|
||||
"descriptions": {
|
||||
"sharepointClientSecret": "このモードでは、SharePoint コネクタはクライアントシークレットで認証します。クライアント ID とクライアントシークレットを入力してください。",
|
||||
"sharepointCertificate": "このモードでは、SharePoint コネクタは証明書で認証します。クライアント ID、ディレクトリ ID、証明書のパスワード、PFX データを入力してください。",
|
||||
"teamsClientSecret": "コネクタはアプリ登録のクライアントシークレットでサインインします。クライアント ID、ディレクトリ ID、シークレットを入力してください。チャネルのメッセージとメンバーのみ対象です。SharePoint はシークレットを受け付けないため、「添付ファイルを含める」には証明書オプションが必要です。",
|
||||
"teamsCertificate": "コネクタはアプリ登録にアップロードされた証明書でサインインします。クライアント ID、ディレクトリ ID、PFX バンドルとそのパスワードを入力してください。「添付ファイルを含める」に必要です。この機能は SharePoint からチャネルのファイルとその閲覧者を読み取ります。",
|
||||
"outlookClientSecret": "コネクタはアプリ登録のクライアントシークレットでサインインします。クライアント ID、ディレクトリ ID、シークレットを入力してください。",
|
||||
"outlookCertificate": "コネクタはアプリ登録にアップロードされた証明書でサインインします。クライアント ID、ディレクトリ ID、PFX バンドルとそのパスワードを入力してください。",
|
||||
"s3AssumeRole": "このモードでは、Amazon EC2 インスタンスが既存のロールを引き受けて S3 にアクセスします。追加の認証情報は不要です。"
|
||||
}
|
||||
},
|
||||
"modal": {
|
||||
"createTitle": "{source} の認証情報を作成",
|
||||
"editTitle": "認証情報を編集",
|
||||
@@ -3290,6 +3387,10 @@
|
||||
"retryButton": {
|
||||
"label": "再試行"
|
||||
},
|
||||
"signIn": {
|
||||
"description": "{appName} には読み取り専用のスコープが必要です。",
|
||||
"title": "{source} でサインイン"
|
||||
},
|
||||
"startError": {
|
||||
"message": "OAuth を開始できません"
|
||||
}
|
||||
@@ -3341,6 +3442,13 @@
|
||||
"success": {
|
||||
"toast": "認証情報を更新しました"
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
"authMethodRequired": "認証方式を選択してください",
|
||||
"empty": "{field}は空にできません",
|
||||
"fileRequired": "{field}のファイルを選択してください",
|
||||
"invalidEmail": "{field}に有効なメールアドレスを入力してください",
|
||||
"required": "{field}を入力してください"
|
||||
}
|
||||
},
|
||||
"customAnalytics": {
|
||||
|
||||
@@ -3218,15 +3218,36 @@
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
"accountEmailField": {
|
||||
"subDescription": "API 토큰을 소유한 계정의 이메일을 사용하세요."
|
||||
},
|
||||
"apiTokenField": {
|
||||
"subDescription": "{source}에 액세스하려면 API 토큰을 붙여 넣으세요."
|
||||
},
|
||||
"createButton": {
|
||||
"label": "생성"
|
||||
},
|
||||
"created": {
|
||||
"toast": "새 자격 증명을 생성했습니다!"
|
||||
},
|
||||
"name": {
|
||||
"label": "이름:",
|
||||
"placeholder": "(선택 사항) 자격 증명 이름..."
|
||||
"emailField": {
|
||||
"placeholder": "email@company.com"
|
||||
},
|
||||
"share": {
|
||||
"admins": {
|
||||
"label": "관리자",
|
||||
"description": "모든 관리자가 이 계정을 재사용하여 커넥터를 만들 수 있습니다.",
|
||||
"suffix": "(기본값)"
|
||||
},
|
||||
"adminsAndGroups": {
|
||||
"description": "관리자와 지정한 그룹이 이 계정을 재사용할 수 있습니다.",
|
||||
"label": "관리자 및 그룹"
|
||||
},
|
||||
"onlyMe": {
|
||||
"label": "나만"
|
||||
},
|
||||
"description": "그룹이 이 계정을 재사용하여 커넥터를 만들 수 있도록 허용합니다.",
|
||||
"title": "이 계정 공유"
|
||||
},
|
||||
"submitError": {
|
||||
"toast": "자격 증명 제출 중 오류가 발생했습니다"
|
||||
@@ -3261,6 +3282,82 @@
|
||||
"toast": "자격 증명 업데이트 중 오류가 발생했습니다"
|
||||
}
|
||||
},
|
||||
"displayNames": {
|
||||
"accessKeyId": "{source} 액세스 키 ID",
|
||||
"secretAccessKey": "{source} 시크릿 액세스 키",
|
||||
"accountId": "{source} 계정 ID",
|
||||
"apiToken": "API 토큰",
|
||||
"clientId": "{source} 클라이언트 ID",
|
||||
"clientSecret": "{source} 클라이언트 시크릿",
|
||||
"clientToken": "{source} 클라이언트 토큰",
|
||||
"directoryId": "{source} 디렉터리 ID",
|
||||
"certificatePassword": "{source} 인증서 비밀번호",
|
||||
"privateKey": "{source} 프라이빗 키",
|
||||
"certificate": "{source} 인증서",
|
||||
"baseUrl": "{source} 기본 URL",
|
||||
"apiBaseUrl": "{source} API 기본 URL",
|
||||
"url": "{source} URL",
|
||||
"enterpriseServerUrl": "{source} Enterprise Server URL",
|
||||
"botToken": "{source} 봇 토큰",
|
||||
"username": "{source} 사용자 이름",
|
||||
"apiUsername": "{source} API 사용자 이름",
|
||||
"userEmail": "{source} 사용자 이메일",
|
||||
"accountEmail": "{source} 계정 이메일",
|
||||
"password": "{source} 비밀번호",
|
||||
"subdomain": "{source} 서브도메인",
|
||||
"domain": "{source} 도메인",
|
||||
"enterpriseId": "{source} 엔터프라이즈 ID",
|
||||
"teamId": "{source} 팀 ID",
|
||||
"applicationId": "{source} 애플리케이션 ID",
|
||||
"apiTokenId": "{source} API 토큰 ID",
|
||||
"apiTokenSecret": "{source} API 토큰 시크릿",
|
||||
"securityToken": "{source} 보안 토큰",
|
||||
"integrationToken": "{source} 통합 토큰",
|
||||
"accessKey": "{source} 액세스 키",
|
||||
"accessKeySecret": "{source} 액세스 키 시크릿",
|
||||
"key": "{source} 키",
|
||||
"secret": "{source} 시크릿",
|
||||
"roleArn": "{source} 역할 ARN",
|
||||
"namespace": "{source} 네임스페이스",
|
||||
"region": "{source} 리전",
|
||||
"portalId": "{source} 포털 ID",
|
||||
"oauthTokens": "{source} OAuth 토큰",
|
||||
"isSandbox": "샌드박스 환경 여부",
|
||||
"serviceUserEmail": "서비스 사용자 이메일",
|
||||
"zuliprcContent": "Zuliprc 내용",
|
||||
"authenticationMethod": "인증 방법",
|
||||
"scopedToken": "범위가 지정된 API 토큰입니다"
|
||||
},
|
||||
"hints": {
|
||||
"boxUserEmail": "선택 사항입니다. 대신 접근할 Box 사용자입니다.",
|
||||
"githubBaseUrl": "선택 사항입니다. 서버 호스트를 입력하세요(예: https://github.example.com). github.com을 사용하려면 비워 두세요.",
|
||||
"gongBaseUrl": "선택 사항입니다. 미국 외 지역의 데이터는 해당 리전의 호스트를 입력하세요(예: https://[region].api.gong.io).",
|
||||
"jiraUserEmail": "Jira Cloud에 필요합니다.",
|
||||
"lumappsServiceUser": "대신 인덱싱할 사용자입니다.",
|
||||
"outlineBaseUrl": "예: https://app.getoutline.com 또는 자체 호스팅 URL.",
|
||||
"pfxFile": "PFX 파일입니다.",
|
||||
"testrailBaseUrl": "예: https://yourcompany.testrail.io.",
|
||||
"scopedToken": "범위가 지정된 토큰은 Atlassian의 API 게이트웨이를 거쳐야 합니다. 클래식 토큰이면 끈 상태로 두세요."
|
||||
},
|
||||
"methods": {
|
||||
"labels": {
|
||||
"clientSecret": "클라이언트 시크릿",
|
||||
"certificateAuthentication": "인증서 인증",
|
||||
"certificate": "인증서",
|
||||
"accessKeyAndSecret": "액세스 키 및 시크릿",
|
||||
"iamRole": "IAM 역할",
|
||||
"assumeRole": "역할 수임"
|
||||
},
|
||||
"descriptions": {
|
||||
"sharepointClientSecret": "이 모드를 선택하면 SharePoint 커넥터가 클라이언트 시크릿으로 인증합니다. 클라이언트 ID와 클라이언트 시크릿을 입력해야 합니다.",
|
||||
"sharepointCertificate": "이 모드를 선택하면 SharePoint 커넥터가 인증서로 인증합니다. 클라이언트 ID, 디렉터리 ID, 인증서 비밀번호, PFX 데이터를 입력해야 합니다.",
|
||||
"teamsClientSecret": "커넥터가 앱 등록의 클라이언트 시크릿으로 로그인합니다. 클라이언트 ID, 디렉터리 ID, 시크릿을 입력하세요. 채널 메시지와 멤버만 해당됩니다. SharePoint는 시크릿을 거부하므로 첨부 파일 포함에는 인증서 옵션이 필요합니다.",
|
||||
"teamsCertificate": "커넥터가 앱 등록에 업로드된 인증서로 로그인합니다. 클라이언트 ID, 디렉터리 ID, PFX 번들과 그 비밀번호를 입력하세요. 첨부 파일 포함에 필요하며, 이 기능은 SharePoint에서 채널 파일과 해당 열람자를 읽습니다.",
|
||||
"outlookClientSecret": "커넥터가 앱 등록의 클라이언트 시크릿으로 로그인합니다. 클라이언트 ID, 디렉터리 ID, 시크릿을 입력하세요.",
|
||||
"outlookCertificate": "커넥터가 앱 등록에 업로드된 인증서로 로그인합니다. 클라이언트 ID, 디렉터리 ID, PFX 번들과 그 비밀번호를 입력하세요.",
|
||||
"s3AssumeRole": "이 모드를 선택하면 Amazon EC2 인스턴스가 기존 역할을 수임하여 S3에 액세스합니다. 추가 자격 증명은 필요하지 않습니다."
|
||||
}
|
||||
},
|
||||
"modal": {
|
||||
"createTitle": "{source} 자격 증명 생성",
|
||||
"editTitle": "자격 증명 편집",
|
||||
@@ -3290,6 +3387,10 @@
|
||||
"retryButton": {
|
||||
"label": "다시 시도"
|
||||
},
|
||||
"signIn": {
|
||||
"description": "{appName}에는 읽기 전용 범위가 필요합니다.",
|
||||
"title": "{source}(으)로 로그인"
|
||||
},
|
||||
"startError": {
|
||||
"message": "OAuth를 시작할 수 없습니다"
|
||||
}
|
||||
@@ -3341,6 +3442,13 @@
|
||||
"success": {
|
||||
"toast": "자격 증명을 업데이트했습니다"
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
"authMethodRequired": "인증 방식을 선택하세요",
|
||||
"empty": "{field}은(는) 비워 둘 수 없습니다",
|
||||
"fileRequired": "{field} 파일을 선택하세요",
|
||||
"invalidEmail": "{field}에 올바른 이메일 주소를 입력하세요",
|
||||
"required": "{field}을(를) 입력하세요"
|
||||
}
|
||||
},
|
||||
"customAnalytics": {
|
||||
|
||||
@@ -3218,15 +3218,36 @@
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
"accountEmailField": {
|
||||
"subDescription": "Use o e-mail da conta dona do token de API."
|
||||
},
|
||||
"apiTokenField": {
|
||||
"subDescription": "Cole seu token de API para acessar {source}."
|
||||
},
|
||||
"createButton": {
|
||||
"label": "Criar"
|
||||
},
|
||||
"created": {
|
||||
"toast": "Nova credencial criada!"
|
||||
},
|
||||
"name": {
|
||||
"label": "Nome:",
|
||||
"placeholder": "(Opcional) nome da credencial..."
|
||||
"emailField": {
|
||||
"placeholder": "email@company.com"
|
||||
},
|
||||
"share": {
|
||||
"admins": {
|
||||
"label": "Administradores",
|
||||
"description": "Todos os administradores podem reutilizar esta conta para criar conectores.",
|
||||
"suffix": "(Padrão)"
|
||||
},
|
||||
"adminsAndGroups": {
|
||||
"description": "Administradores e os grupos que você indicar podem reutilizar esta conta.",
|
||||
"label": "Administradores e grupos"
|
||||
},
|
||||
"onlyMe": {
|
||||
"label": "Somente eu"
|
||||
},
|
||||
"description": "Permita que grupos reutilizem esta conta para criar conectores.",
|
||||
"title": "Compartilhar esta conta"
|
||||
},
|
||||
"submitError": {
|
||||
"toast": "Erro ao enviar a credencial"
|
||||
@@ -3261,6 +3282,82 @@
|
||||
"toast": "Erro ao atualizar a credencial"
|
||||
}
|
||||
},
|
||||
"displayNames": {
|
||||
"accessKeyId": "ID da chave de acesso de {source}",
|
||||
"secretAccessKey": "Chave de acesso secreta de {source}",
|
||||
"accountId": "ID da conta do {source}",
|
||||
"apiToken": "Token de API",
|
||||
"clientId": "ID do cliente do {source}",
|
||||
"clientSecret": "Segredo do cliente do {source}",
|
||||
"clientToken": "Token do cliente do {source}",
|
||||
"directoryId": "ID do diretório do {source}",
|
||||
"certificatePassword": "Senha do certificado do {source}",
|
||||
"privateKey": "Chave privada do {source}",
|
||||
"certificate": "Certificado do {source}",
|
||||
"baseUrl": "URL base do {source}",
|
||||
"apiBaseUrl": "URL base da API do {source}",
|
||||
"url": "URL do {source}",
|
||||
"enterpriseServerUrl": "URL do {source} Enterprise Server",
|
||||
"botToken": "Token do bot do {source}",
|
||||
"username": "Nome de usuário do {source}",
|
||||
"apiUsername": "Nome de usuário da API do {source}",
|
||||
"userEmail": "E-mail do usuário do {source}",
|
||||
"accountEmail": "E-mail da conta do {source}",
|
||||
"password": "Senha do {source}",
|
||||
"subdomain": "Subdomínio do {source}",
|
||||
"domain": "Domínio do {source}",
|
||||
"enterpriseId": "ID da empresa do {source}",
|
||||
"teamId": "ID da equipe do {source}",
|
||||
"applicationId": "ID do aplicativo do {source}",
|
||||
"apiTokenId": "ID do token de API do {source}",
|
||||
"apiTokenSecret": "Segredo do token de API do {source}",
|
||||
"securityToken": "Token de segurança do {source}",
|
||||
"integrationToken": "Token de integração do {source}",
|
||||
"accessKey": "Chave de acesso do {source}",
|
||||
"accessKeySecret": "Segredo da chave de acesso do {source}",
|
||||
"key": "Chave do {source}",
|
||||
"secret": "Segredo do {source}",
|
||||
"roleArn": "ARN da função da {source}",
|
||||
"namespace": "Namespace do {source}",
|
||||
"region": "Região do {source}",
|
||||
"portalId": "ID do portal do {source}",
|
||||
"oauthTokens": "Tokens OAuth do {source}",
|
||||
"isSandbox": "Ambiente de sandbox",
|
||||
"serviceUserEmail": "E-mail do usuário de serviço",
|
||||
"zuliprcContent": "Conteúdo do Zuliprc",
|
||||
"authenticationMethod": "Método de autenticação",
|
||||
"scopedToken": "Este é um token de API com escopos"
|
||||
},
|
||||
"hints": {
|
||||
"boxUserEmail": "Opcional. O usuário do Box a ser representado.",
|
||||
"githubBaseUrl": "Opcional. Defina o host do seu servidor, como https://github.example.com. Deixe em branco para github.com.",
|
||||
"gongBaseUrl": "Opcional. Para dados fora dos EUA, defina o host da sua região, como https://[region].api.gong.io.",
|
||||
"jiraUserEmail": "Obrigatório para o Jira Cloud.",
|
||||
"lumappsServiceUser": "O usuário em nome do qual indexar.",
|
||||
"outlineBaseUrl": "Por exemplo, https://app.getoutline.com ou a URL da sua instância própria.",
|
||||
"pfxFile": "Um arquivo PFX.",
|
||||
"testrailBaseUrl": "Por exemplo, https://yourcompany.testrail.io.",
|
||||
"scopedToken": "Tokens com escopos precisam passar pelo gateway de API da Atlassian. Deixe desativado para tokens clássicos."
|
||||
},
|
||||
"methods": {
|
||||
"labels": {
|
||||
"clientSecret": "Segredo do cliente",
|
||||
"certificateAuthentication": "Autenticação por certificado",
|
||||
"certificate": "Certificado",
|
||||
"accessKeyAndSecret": "Chave de acesso e segredo",
|
||||
"iamRole": "Função do IAM",
|
||||
"assumeRole": "Assumir função"
|
||||
},
|
||||
"descriptions": {
|
||||
"sharepointClientSecret": "Neste modo, o conector do SharePoint usa um segredo do cliente para se autenticar. Você precisa informar o ID do cliente e o segredo do cliente.",
|
||||
"sharepointCertificate": "Neste modo, o conector do SharePoint usa um certificado para se autenticar. Você precisa informar o ID do cliente, o ID do diretório, a senha do certificado e os dados PFX.",
|
||||
"teamsClientSecret": "O conector entra com um segredo do cliente do registro do aplicativo. Informe o ID do cliente, o ID do diretório e o segredo. Somente mensagens e membros de canais: o SharePoint recusa um segredo, então Incluir anexos precisa da opção de certificado.",
|
||||
"teamsCertificate": "O conector entra com um certificado enviado ao registro do aplicativo. Informe o ID do cliente, o ID do diretório, o pacote PFX e a senha dele. Obrigatório para Incluir anexos, que lê os arquivos dos canais e seus leitores no SharePoint.",
|
||||
"outlookClientSecret": "O conector entra com um segredo do cliente do registro do aplicativo. Informe o ID do cliente, o ID do diretório e o segredo.",
|
||||
"outlookCertificate": "O conector entra com um certificado enviado ao registro do aplicativo. Informe o ID do cliente, o ID do diretório, o pacote PFX e a senha dele.",
|
||||
"s3AssumeRole": "Neste modo, a instância do Amazon EC2 assume a função que já tem para acessar o S3. Nenhuma outra credencial é necessária."
|
||||
}
|
||||
},
|
||||
"modal": {
|
||||
"createTitle": "Criar credencial de {source}",
|
||||
"editTitle": "Editar credencial",
|
||||
@@ -3290,6 +3387,10 @@
|
||||
"retryButton": {
|
||||
"label": "Tentar novamente"
|
||||
},
|
||||
"signIn": {
|
||||
"description": "{appName} requer escopos somente leitura.",
|
||||
"title": "Entrar com {source}"
|
||||
},
|
||||
"startError": {
|
||||
"message": "Não foi possível iniciar o OAuth"
|
||||
}
|
||||
@@ -3341,6 +3442,13 @@
|
||||
"success": {
|
||||
"toast": "Credencial atualizada"
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
"authMethodRequired": "Selecione um método de autenticação",
|
||||
"empty": "O campo {field} não pode ficar vazio",
|
||||
"fileRequired": "Selecione um arquivo para {field}",
|
||||
"invalidEmail": "Informe um e-mail válido em {field}",
|
||||
"required": "Preencha o campo {field}"
|
||||
}
|
||||
},
|
||||
"customAnalytics": {
|
||||
|
||||
@@ -3218,15 +3218,36 @@
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
"accountEmailField": {
|
||||
"subDescription": "请使用拥有该 API 令牌的账户邮箱。"
|
||||
},
|
||||
"apiTokenField": {
|
||||
"subDescription": "粘贴你的 API 令牌以访问 {source}。"
|
||||
},
|
||||
"createButton": {
|
||||
"label": "创建"
|
||||
},
|
||||
"created": {
|
||||
"toast": "已创建新凭据!"
|
||||
},
|
||||
"name": {
|
||||
"label": "名称:",
|
||||
"placeholder": "(可选) 凭据名称..."
|
||||
"emailField": {
|
||||
"placeholder": "email@company.com"
|
||||
},
|
||||
"share": {
|
||||
"admins": {
|
||||
"label": "管理员",
|
||||
"description": "所有管理员都可以重复使用此账户来创建连接器。",
|
||||
"suffix": "(默认)"
|
||||
},
|
||||
"adminsAndGroups": {
|
||||
"description": "管理员和你指定的群组可以重复使用此账户。",
|
||||
"label": "管理员和群组"
|
||||
},
|
||||
"onlyMe": {
|
||||
"label": "仅限我"
|
||||
},
|
||||
"description": "允许群组复用此账号来创建连接器。",
|
||||
"title": "共享此账号"
|
||||
},
|
||||
"submitError": {
|
||||
"toast": "提交凭据时出错"
|
||||
@@ -3261,6 +3282,82 @@
|
||||
"toast": "更新凭据时出错"
|
||||
}
|
||||
},
|
||||
"displayNames": {
|
||||
"accessKeyId": "{source} 访问密钥 ID",
|
||||
"secretAccessKey": "{source} 秘密访问密钥",
|
||||
"accountId": "{source} 账户 ID",
|
||||
"apiToken": "API 令牌",
|
||||
"clientId": "{source} 客户端 ID",
|
||||
"clientSecret": "{source} 客户端密钥",
|
||||
"clientToken": "{source} 客户端令牌",
|
||||
"directoryId": "{source} 目录 ID",
|
||||
"certificatePassword": "{source} 证书密码",
|
||||
"privateKey": "{source} 私钥",
|
||||
"certificate": "{source} 证书",
|
||||
"baseUrl": "{source} 基础 URL",
|
||||
"apiBaseUrl": "{source} API 基础 URL",
|
||||
"url": "{source} URL",
|
||||
"enterpriseServerUrl": "{source} Enterprise Server URL",
|
||||
"botToken": "{source} 机器人令牌",
|
||||
"username": "{source} 用户名",
|
||||
"apiUsername": "{source} API 用户名",
|
||||
"userEmail": "{source} 用户邮箱",
|
||||
"accountEmail": "{source} 账号邮箱",
|
||||
"password": "{source} 密码",
|
||||
"subdomain": "{source} 子域名",
|
||||
"domain": "{source} 域名",
|
||||
"enterpriseId": "{source} 企业 ID",
|
||||
"teamId": "{source} 团队 ID",
|
||||
"applicationId": "{source} 应用 ID",
|
||||
"apiTokenId": "{source} API 令牌 ID",
|
||||
"apiTokenSecret": "{source} API 令牌密钥",
|
||||
"securityToken": "{source} 安全令牌",
|
||||
"integrationToken": "{source} 集成令牌",
|
||||
"accessKey": "{source} 访问密钥",
|
||||
"accessKeySecret": "{source} 访问密钥 Secret",
|
||||
"key": "{source} 密钥",
|
||||
"secret": "{source} Secret",
|
||||
"roleArn": "{source} 角色 ARN",
|
||||
"namespace": "{source} 命名空间",
|
||||
"region": "{source} 区域",
|
||||
"portalId": "{source} 门户 ID",
|
||||
"oauthTokens": "{source} OAuth 令牌",
|
||||
"isSandbox": "是否为沙盒环境",
|
||||
"serviceUserEmail": "服务用户邮箱",
|
||||
"zuliprcContent": "Zuliprc 内容",
|
||||
"authenticationMethod": "身份验证方式",
|
||||
"scopedToken": "这是带作用域的 API 令牌"
|
||||
},
|
||||
"hints": {
|
||||
"boxUserEmail": "可选。要模拟的 Box 用户。",
|
||||
"githubBaseUrl": "可选。设置你的服务器主机,例如 https://github.example.com。使用 github.com 时请留空。",
|
||||
"gongBaseUrl": "可选。美国以外的数据请设置你所在区域的主机,例如 https://[region].api.gong.io。",
|
||||
"jiraUserEmail": "Jira Cloud 必填。",
|
||||
"lumappsServiceUser": "要代表其建立索引的用户。",
|
||||
"outlineBaseUrl": "例如 https://app.getoutline.com,或你的自托管 URL。",
|
||||
"pfxFile": "PFX 文件。",
|
||||
"testrailBaseUrl": "例如 https://yourcompany.testrail.io。",
|
||||
"scopedToken": "带作用域的令牌需要通过 Atlassian 的 API 网关进行路由。经典令牌请保持关闭。"
|
||||
},
|
||||
"methods": {
|
||||
"labels": {
|
||||
"clientSecret": "客户端密钥",
|
||||
"certificateAuthentication": "证书身份验证",
|
||||
"certificate": "证书",
|
||||
"accessKeyAndSecret": "访问密钥和 Secret",
|
||||
"iamRole": "IAM 角色",
|
||||
"assumeRole": "代入角色"
|
||||
},
|
||||
"descriptions": {
|
||||
"sharepointClientSecret": "选择此模式后,SharePoint 连接器将使用客户端密钥进行身份验证。你需要提供客户端 ID 和客户端密钥。",
|
||||
"sharepointCertificate": "选择此模式后,SharePoint 连接器将使用证书进行身份验证。你需要提供客户端 ID、目录 ID、证书密码和 PFX 数据。",
|
||||
"teamsClientSecret": "连接器使用应用注册的客户端密钥登录。请提供客户端 ID、目录 ID 和密钥。仅限频道消息和成员:SharePoint 不接受密钥,因此“包含附件”需要使用证书选项。",
|
||||
"teamsCertificate": "连接器使用上传到应用注册的证书登录。请提供客户端 ID、目录 ID、PFX 文件包及其密码。“包含附件”需要此选项,该功能会从 SharePoint 读取频道文件及其读者。",
|
||||
"outlookClientSecret": "连接器使用应用注册的客户端密钥登录。请提供客户端 ID、目录 ID 和密钥。",
|
||||
"outlookCertificate": "连接器使用上传到应用注册的证书登录。请提供客户端 ID、目录 ID、PFX 文件包及其密码。",
|
||||
"s3AssumeRole": "选择此模式后,Amazon EC2 实例将代入其现有角色来访问 S3。无需其他凭据。"
|
||||
}
|
||||
},
|
||||
"modal": {
|
||||
"createTitle": "创建 {source} 凭据",
|
||||
"editTitle": "编辑凭据",
|
||||
@@ -3290,6 +3387,10 @@
|
||||
"retryButton": {
|
||||
"label": "重试"
|
||||
},
|
||||
"signIn": {
|
||||
"description": "{appName} 需要只读权限范围。",
|
||||
"title": "使用 {source} 登录"
|
||||
},
|
||||
"startError": {
|
||||
"message": "无法启动 OAuth"
|
||||
}
|
||||
@@ -3341,6 +3442,13 @@
|
||||
"success": {
|
||||
"toast": "已更新凭据"
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
"authMethodRequired": "请选择身份验证方式",
|
||||
"empty": "{field}不能为空",
|
||||
"fileRequired": "请选择{field}文件",
|
||||
"invalidEmail": "请在{field}中输入有效的邮箱地址",
|
||||
"required": "请输入{field}"
|
||||
}
|
||||
},
|
||||
"customAnalytics": {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { connectorConfigs } from "@/lib/connectors/connectors";
|
||||
import { CREDENTIAL_TEMPLATES } from "@/lib/credentials/constants";
|
||||
import { CREDENTIAL_SPECS } from "@/lib/credentials/constants";
|
||||
import {
|
||||
createConnectorInitialValues,
|
||||
createConnectorValidationSchema,
|
||||
getFileTypeDefinitionForField,
|
||||
} from "@/lib/connectors/utils";
|
||||
import { getCredentialFileType } from "@/lib/credentials/utils";
|
||||
import { FileTypeCategory, OneDriveScope } from "@/lib/connectors/types";
|
||||
import { getSourceDocLink, getSourceMetadata } from "@/lib/sources";
|
||||
import {
|
||||
@@ -16,28 +16,25 @@ const ONE_DRIVE_USERS_REQUIRED = "Add at least one user for Specific scope";
|
||||
|
||||
describe("OneDrive connector metadata", () => {
|
||||
it("defines both app-only credential methods", () => {
|
||||
expect(CREDENTIAL_TEMPLATES[ValidSources.OneDrive]).toMatchObject({
|
||||
authentication_method: "client_secret",
|
||||
authMethods: [
|
||||
{
|
||||
value: "client_secret",
|
||||
fields: {
|
||||
onedrive_client_id: "",
|
||||
onedrive_directory_id: "",
|
||||
onedrive_client_secret: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
value: "certificate",
|
||||
fields: {
|
||||
onedrive_client_id: "",
|
||||
onedrive_directory_id: "",
|
||||
onedrive_certificate_password: "",
|
||||
onedrive_private_key: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(CREDENTIAL_SPECS[ValidSources.OneDrive].methods).toMatchObject([
|
||||
{
|
||||
value: "client_secret",
|
||||
fields: [
|
||||
"onedrive_client_id",
|
||||
"onedrive_directory_id",
|
||||
"onedrive_client_secret",
|
||||
],
|
||||
},
|
||||
{
|
||||
value: "certificate",
|
||||
fields: [
|
||||
"onedrive_client_id",
|
||||
"onedrive_directory_id",
|
||||
"onedrive_certificate_password",
|
||||
"onedrive_private_key",
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the OneDrive logo, PKCS12 upload type, and sync controls", () => {
|
||||
@@ -47,7 +44,7 @@ describe("OneDrive connector metadata", () => {
|
||||
expect(getSourceDocLink(ValidSources.OneDrive)).toBe(
|
||||
"https://docs.onyx.app/admins/connectors/official/onedrive"
|
||||
);
|
||||
expect(getFileTypeDefinitionForField("onedrive_private_key")).toBe(
|
||||
expect(getCredentialFileType("onedrive_private_key")).toBe(
|
||||
FileTypeCategory.ONEDRIVE_PFX_FILE
|
||||
);
|
||||
expect(validAutoSyncSources).toContain(ValidSources.OneDrive);
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function triggerIndexing(
|
||||
export const submitFiles = async (
|
||||
selectedFiles: File[],
|
||||
name: string,
|
||||
access_type: string,
|
||||
access_type: AccessType,
|
||||
groups?: number[]
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
@@ -114,7 +114,7 @@ export const submitFiles = async (
|
||||
connector.id,
|
||||
credentialId,
|
||||
name,
|
||||
access_type as AccessType,
|
||||
access_type,
|
||||
groups
|
||||
);
|
||||
if (!credentialResponse.ok) {
|
||||
|
||||
@@ -171,32 +171,6 @@ export function createTypedFile(
|
||||
return new TypedFile(file, typeDefinition, fieldKey);
|
||||
}
|
||||
|
||||
export function isTypedFileField(fieldKey: string): boolean {
|
||||
// Define which fields should be typed files
|
||||
const typedFileFields = new Set([
|
||||
"sp_private_key",
|
||||
"onedrive_private_key",
|
||||
"outlook_private_key",
|
||||
"teams_private_key",
|
||||
]);
|
||||
return typedFileFields.has(fieldKey);
|
||||
}
|
||||
|
||||
// Get the appropriate file type definition for a field
|
||||
export function getFileTypeDefinitionForField(
|
||||
fieldKey: string
|
||||
): FileTypeCategory | null {
|
||||
const fieldToTypeMap: Record<string, FileTypeCategory> = {
|
||||
sp_private_key: FileTypeCategory.SHAREPOINT_PFX_FILE,
|
||||
onedrive_private_key: FileTypeCategory.ONEDRIVE_PFX_FILE,
|
||||
// The same PFX bundle rules apply to every Microsoft app registration.
|
||||
outlook_private_key: FileTypeCategory.SHAREPOINT_PFX_FILE,
|
||||
teams_private_key: FileTypeCategory.SHAREPOINT_PFX_FILE,
|
||||
};
|
||||
|
||||
return fieldToTypeMap[fieldKey] || null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connector-credential pairs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,65 +1,55 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button as OpalButton } from "@opal/components";
|
||||
import { Button, Divider } from "@opal/components";
|
||||
import { AccessType } from "@/lib/types";
|
||||
import { ValidSources } from "@/lib/connectors/types/source";
|
||||
import { submitCredential } from "@/lib/credentials/svc";
|
||||
import { TextFormField } from "@/components/Field";
|
||||
import { Form, Formik, FormikHelpers } from "formik";
|
||||
import { toast } from "@opal/layouts";
|
||||
import { Section, toast } from "@opal/layouts";
|
||||
import GDriveMain from "@/views/admin/connectors/AddConnectorPage/form/gdrive/GoogleDrivePage";
|
||||
import type { Connector } from "@/lib/connectors/types";
|
||||
import { CREDENTIAL_TEMPLATES } from "@/lib/credentials/constants";
|
||||
import type {
|
||||
Credential,
|
||||
CredentialTemplateWithAuth,
|
||||
} from "@/lib/credentials/types";
|
||||
import type { Credential } from "@/lib/credentials/types";
|
||||
import { GmailMain } from "@/views/admin/connectors/AddConnectorPage/form/gmail/GmailPage";
|
||||
import type {
|
||||
CredentialActionType,
|
||||
CredentialFieldValues,
|
||||
} from "@/lib/credentials/types";
|
||||
import { createValidationSchema } from "@/lib/credentials/utils";
|
||||
import type { CredentialActionType } from "@/lib/credentials/types";
|
||||
import {
|
||||
createValidationSchema,
|
||||
getCredentialSpec,
|
||||
initialCredentialValues,
|
||||
} from "@/lib/credentials/utils";
|
||||
import { useTierAtLeast } from "@/hooks/useTierAtLeast";
|
||||
import { Tier } from "@/lib/settings/types";
|
||||
import { AdvancedOptionsToggle } from "@/components/AdvancedOptionsToggle";
|
||||
import {
|
||||
IsPublicGroupSelectorFormType,
|
||||
IsPublicGroupSelector,
|
||||
} from "@/components/IsPublicGroupSelector";
|
||||
import CardSection from "@/components/admin/CardSection";
|
||||
DEFAULT_SHARE_AUDIENCE,
|
||||
ShareAccountField,
|
||||
shareAccountPayload,
|
||||
type ShareAccountFormValues,
|
||||
} from "@/lib/credentials/components/ShareAccountField";
|
||||
import { useCredentialFieldCopy } from "@/lib/credentials/hooks";
|
||||
import { CredentialFieldsRenderer } from "@/lib/credentials/components/CredentialFieldsRenderer";
|
||||
import { TypedFile } from "@/lib/connectors/fileTypes";
|
||||
import { usePermissionAuthority } from "@/lib/permissions/hooks";
|
||||
import { Permission } from "@/lib/types";
|
||||
import { SvgPlusCircle } from "@opal/icons";
|
||||
const CreateButton = ({
|
||||
onClick,
|
||||
isSubmitting,
|
||||
requiresGroup,
|
||||
groups,
|
||||
}: {
|
||||
|
||||
interface CreateButtonProps {
|
||||
onClick: () => void;
|
||||
isSubmitting: boolean;
|
||||
// Only a scoped manager must land the credential in a group — GATE 2 requires
|
||||
// it of them and of nobody else.
|
||||
requiresGroup: boolean;
|
||||
groups: number[];
|
||||
}) => {
|
||||
/** False while any required field is empty or malformed. */
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
function CreateButton({ onClick, isSubmitting, isValid }: CreateButtonProps) {
|
||||
const t = useTranslations("admin");
|
||||
return (
|
||||
<OpalButton
|
||||
disabled={isSubmitting || (requiresGroup && groups.length === 0)}
|
||||
<Button
|
||||
disabled={isSubmitting || !isValid}
|
||||
onClick={onClick}
|
||||
icon={SvgPlusCircle}
|
||||
>
|
||||
{t("credentials.create.createButton.label")}
|
||||
</OpalButton>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
type CreateCredentialFormValues = IsPublicGroupSelectorFormType & {
|
||||
name: string;
|
||||
type CreateCredentialFormValues = ShareAccountFormValues & {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
@@ -98,14 +88,11 @@ export default function CreateCredential({
|
||||
refresh?: () => void;
|
||||
}) {
|
||||
const t = useTranslations("admin");
|
||||
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
|
||||
const tValidation = useTranslations("admin.credentials.validation");
|
||||
const fieldCopy = useCredentialFieldCopy(sourceType);
|
||||
const [authMethod, setAuthMethod] = useState<string>();
|
||||
const businessTier = useTierAtLeast(Tier.BUSINESS);
|
||||
|
||||
const { isGlobalHolder, isScopedManager } = usePermissionAuthority(
|
||||
Permission.MANAGE_CONNECTORS
|
||||
);
|
||||
|
||||
const handleSubmit = async (
|
||||
values: CreateCredentialFormValues,
|
||||
formikHelpers: FormikHelpers<CreateCredentialFormValues>,
|
||||
@@ -122,7 +109,7 @@ export default function CreateCredential({
|
||||
setSubmitting(true);
|
||||
formikHelpers.setSubmitting(true);
|
||||
|
||||
const { name, is_public, groups, ...credentialValues } = values;
|
||||
const { share, groups, ...credentialValues } = values;
|
||||
|
||||
let privateKey: TypedFile | null = null;
|
||||
const filteredCredentialValues = Object.fromEntries(
|
||||
@@ -138,10 +125,8 @@ export default function CreateCredential({
|
||||
try {
|
||||
const response = await submitCredential({
|
||||
credential_json: filteredCredentialValues,
|
||||
admin_public: true,
|
||||
curator_public: is_public,
|
||||
groups: groups,
|
||||
name: name,
|
||||
...shareAccountPayload({ share, groups }),
|
||||
// No name: the credential list shows its "Untitled" fallback.
|
||||
source: sourceType,
|
||||
private_key: privateKey || undefined,
|
||||
});
|
||||
@@ -191,27 +176,35 @@ export default function CreateCredential({
|
||||
return <GDriveMain />;
|
||||
}
|
||||
|
||||
const credentialTemplate: CredentialFieldValues =
|
||||
CREDENTIAL_TEMPLATES[sourceType];
|
||||
const validationSchema = createValidationSchema(credentialTemplate);
|
||||
const spec = getCredentialSpec(sourceType);
|
||||
if (!spec) {
|
||||
return null;
|
||||
}
|
||||
const validationSchema = createValidationSchema(spec, {
|
||||
fieldTitle: (key) => fieldCopy(key).title,
|
||||
required: (field) => tValidation("required", { field }),
|
||||
empty: (field) => tValidation("empty", { field }),
|
||||
invalidEmail: (field) => tValidation("invalidEmail", { field }),
|
||||
fileRequired: (field) => tValidation("fileRequired", { field }),
|
||||
authMethodRequired: tValidation("authMethodRequired"),
|
||||
});
|
||||
|
||||
// Set initial auth method for templates with multiple auth methods
|
||||
const templateWithAuth =
|
||||
credentialTemplate as CredentialTemplateWithAuth<CredentialFieldValues>;
|
||||
const initialAuthMethod =
|
||||
templateWithAuth?.authMethods?.[0]?.value || undefined;
|
||||
// A spec with auth methods starts on its first one.
|
||||
const initialAuthMethod = spec.methods?.[0]?.value;
|
||||
|
||||
return (
|
||||
<Formik<CreateCredentialFormValues>
|
||||
initialValues={{
|
||||
name: "",
|
||||
is_public: isGlobalHolder || !businessTier,
|
||||
...initialCredentialValues(spec),
|
||||
share: DEFAULT_SHARE_AUDIENCE,
|
||||
groups: [],
|
||||
...(initialAuthMethod && {
|
||||
authentication_method: initialAuthMethod,
|
||||
}),
|
||||
}}
|
||||
validationSchema={validationSchema}
|
||||
// Validate the empty form too, so Create starts disabled.
|
||||
validateOnMount
|
||||
onSubmit={() => {}} // This will be overridden by our custom submit handlers
|
||||
>
|
||||
{(formikProps) => {
|
||||
@@ -224,40 +217,26 @@ export default function CreateCredential({
|
||||
}
|
||||
|
||||
return (
|
||||
<Form className="w-full flex items-stretch">
|
||||
<CardSection className="w-full items-start dark:bg-neutral-900 mt-4 flex flex-col gap-y-6">
|
||||
<TextFormField
|
||||
name="name"
|
||||
placeholder={t("credentials.create.name.placeholder")}
|
||||
label={t("credentials.create.name.label")}
|
||||
/>
|
||||
|
||||
// No card of its own: the form sits directly in its host (the
|
||||
// credential step's create card, or a modal).
|
||||
<Form className="w-full">
|
||||
<Section alignItems="stretch" gap={4}>
|
||||
<CredentialFieldsRenderer
|
||||
credentialTemplate={credentialTemplate}
|
||||
source={sourceType}
|
||||
spec={spec}
|
||||
authMethod={authMethod || initialAuthMethod}
|
||||
setAuthMethod={setAuthMethod}
|
||||
/>
|
||||
|
||||
<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">
|
||||
{businessTier && (
|
||||
<div className="flex flex-col items-start">
|
||||
{isGlobalHolder && (
|
||||
<AdvancedOptionsToggle
|
||||
showAdvancedOptions={showAdvancedOptions}
|
||||
setShowAdvancedOptions={setShowAdvancedOptions}
|
||||
/>
|
||||
)}
|
||||
{(showAdvancedOptions || !isGlobalHolder) && (
|
||||
<IsPublicGroupSelector
|
||||
formikProps={formikProps}
|
||||
objectName="credential"
|
||||
isGlobalHolder={isGlobalHolder}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Above: the fields the source needs. Below: optional sharing
|
||||
and the Create button. */}
|
||||
<Divider paddingParallel={0} paddingPerpendicular={0} />
|
||||
|
||||
{businessTier && (
|
||||
<ShareAccountField disabled={!formikProps.isValid} />
|
||||
)}
|
||||
|
||||
<Section flexDirection="row" justifyContent="end">
|
||||
<CreateButton
|
||||
onClick={() =>
|
||||
handleSubmit(
|
||||
@@ -267,11 +246,10 @@ export default function CreateCredential({
|
||||
)
|
||||
}
|
||||
isSubmitting={formikProps.isSubmitting}
|
||||
requiresGroup={isScopedManager}
|
||||
groups={formikProps.values.groups}
|
||||
isValid={formikProps.isValid}
|
||||
/>
|
||||
</div>
|
||||
</CardSection>
|
||||
</Section>
|
||||
</Section>
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as Yup from "yup";
|
||||
import { Button, InputTypeIn, MessageCard } from "@opal/components";
|
||||
import { InputTypeIn, MessageCard } from "@opal/components";
|
||||
import { InputVertical, Section } from "@opal/layouts";
|
||||
import { Form, Formik, FormikHelpers } from "formik";
|
||||
import { useState } from "react";
|
||||
@@ -8,6 +8,8 @@ import type { OAuthAdditionalKwargDescription } from "@/lib/credentials/types";
|
||||
import { getConnectorOauthRedirectUrl } from "@/lib/connectors/svc";
|
||||
import { ValidSources } from "@/lib/connectors/types/source";
|
||||
import { FormikField } from "@/refresh-components/form/FormikField";
|
||||
import { OAuthSignInRow } from "@/lib/credentials/components/OAuthSignInRow";
|
||||
import { getSourceDisplayName } from "@/lib/sources";
|
||||
|
||||
type OAuthFormValues = Record<string, string>;
|
||||
|
||||
@@ -78,6 +80,7 @@ export function CreateStdOAuthCredential({
|
||||
render={(formikField, _helper, _meta, status) => (
|
||||
<InputTypeIn
|
||||
{...formikField}
|
||||
id={field.name}
|
||||
variant={status === "error" ? "error" : "primary"}
|
||||
/>
|
||||
)}
|
||||
@@ -91,11 +94,10 @@ export function CreateStdOAuthCredential({
|
||||
description={errorMessage}
|
||||
/>
|
||||
)}
|
||||
<Section flexDirection="row" justifyContent="start">
|
||||
<Button disabled={isSubmitting} type="submit">
|
||||
{t("credentials.oauth.connectButton.label")}
|
||||
</Button>
|
||||
</Section>
|
||||
<OAuthSignInRow
|
||||
source={getSourceDisplayName(sourceType) || sourceType}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</Section>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
@@ -1,172 +1,240 @@
|
||||
"use client";
|
||||
|
||||
import { Tabs } from "@opal/components";
|
||||
import { useFormikContext } from "formik";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
BooleanFormField,
|
||||
TextFormField,
|
||||
TypedFileUploadFormField,
|
||||
} from "@/components/Field";
|
||||
import { getDisplayNameForCredentialKey } from "@/lib/credentials/utils";
|
||||
import type { CredentialTemplateWithAuth } from "@/lib/credentials/types";
|
||||
import { isTypedFileField } from "@/lib/connectors/utils";
|
||||
import type { CredentialFieldValues } from "@/lib/credentials/types";
|
||||
InputPasswordTypeIn,
|
||||
InputSwitch,
|
||||
InputTypeIn,
|
||||
MessageCard,
|
||||
Tabs,
|
||||
} from "@opal/components";
|
||||
import {
|
||||
Content,
|
||||
InputHorizontal,
|
||||
InputVertical,
|
||||
Label,
|
||||
Section,
|
||||
} from "@opal/layouts";
|
||||
import { InputCheckboxField } from "@opal/form";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
import { useFormikContext } from "formik";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { TypedFileUploadFormField } from "@/components/Field";
|
||||
import { FormikField } from "@/refresh-components/form/FormikField";
|
||||
import { useCredentialFieldCopy } from "@/lib/credentials/hooks";
|
||||
import { getCredentialSpec, methodFields } from "@/lib/credentials/utils";
|
||||
import type {
|
||||
CredentialFieldValues,
|
||||
CredentialSpec,
|
||||
CredentialSpecField,
|
||||
} from "@/lib/credentials/types";
|
||||
import type { ValidSources } from "@/lib/connectors/types/source";
|
||||
|
||||
interface CredentialFieldProps {
|
||||
source: ValidSources;
|
||||
fieldKey: string;
|
||||
field: CredentialSpecField;
|
||||
}
|
||||
|
||||
/**
|
||||
* A checkbox bound to one Formik field, shaped as a `Content` icon so it sits
|
||||
* where an icon would. It depends only on the field name, so it keeps its
|
||||
* identity across renders and React never remounts the checkbox (which would
|
||||
* drop its focus on every toggle). The icon's class and size are not passed
|
||||
* on: the checkbox keeps its own colours and is already the icon's 1rem.
|
||||
*/
|
||||
function useCheckboxIcon(name: string, label: string): IconFunctionComponent {
|
||||
return useMemo(() => {
|
||||
// The label names the visible checkbox; the wrapping <label> only names
|
||||
// its hidden native input.
|
||||
function CheckboxIcon() {
|
||||
return <InputCheckboxField name={name} aria-label={label} />;
|
||||
}
|
||||
return CheckboxIcon;
|
||||
}, [name, label]);
|
||||
}
|
||||
|
||||
/** One field of a credential spec, drawn with the Opal input for its kind. */
|
||||
function CredentialField({ source, fieldKey, field }: CredentialFieldProps) {
|
||||
const t = useTranslations("admin");
|
||||
const copy = useCredentialFieldCopy(source)(fieldKey);
|
||||
const label = copy.title;
|
||||
const checkboxIcon = useCheckboxIcon(fieldKey, label);
|
||||
|
||||
// A file such as a .pfx key is binary; Opal's InputFile reads text, so
|
||||
// this field keeps the typed upload until Opal can hand back a File.
|
||||
if (field.kind === "file") {
|
||||
return <TypedFileUploadFormField name={fieldKey} label={label} />;
|
||||
}
|
||||
|
||||
// The checkbox stands in for the icon, left of the title. The label around
|
||||
// it hands a click on the title or description to the checkbox.
|
||||
if (field.kind === "checkbox") {
|
||||
return (
|
||||
<Label>
|
||||
<Content
|
||||
icon={checkboxIcon}
|
||||
title={label}
|
||||
description={copy.description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
/>
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.kind === "toggle") {
|
||||
return (
|
||||
<InputHorizontal withLabel title={label}>
|
||||
<FormikField<boolean>
|
||||
name={fieldKey}
|
||||
render={(formikField, helper) => (
|
||||
<InputSwitch
|
||||
checked={!!formikField.value}
|
||||
onCheckedChange={(checked) => helper.setValue(checked)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
);
|
||||
}
|
||||
|
||||
const placeholder =
|
||||
field.kind === "email"
|
||||
? t("credentials.create.emailField.placeholder")
|
||||
: undefined;
|
||||
// The account email pairs with an API token, so it says which account,
|
||||
// under the input.
|
||||
const subDescription =
|
||||
field.displayName === "accountEmail"
|
||||
? t("credentials.create.accountEmailField.subDescription")
|
||||
: field.displayName === "apiToken"
|
||||
? t("credentials.create.apiTokenField.subDescription", {
|
||||
source: getCredentialSpec(source)?.brandName ?? source,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
// The field name ties the label to the input's id and shows the field's
|
||||
// Formik error under it.
|
||||
<InputVertical
|
||||
withLabel={fieldKey}
|
||||
title={label}
|
||||
subDescription={subDescription}
|
||||
>
|
||||
<FormikField<string>
|
||||
name={fieldKey}
|
||||
render={(formikField, _helper, _meta, status) =>
|
||||
field.kind === "secret" ? (
|
||||
<InputPasswordTypeIn
|
||||
{...formikField}
|
||||
id={fieldKey}
|
||||
data-testid={fieldKey}
|
||||
value={formikField.value ?? ""}
|
||||
placeholder={placeholder}
|
||||
error={status === "error"}
|
||||
/>
|
||||
) : (
|
||||
<InputTypeIn
|
||||
{...formikField}
|
||||
id={fieldKey}
|
||||
data-testid={fieldKey}
|
||||
value={formikField.value ?? ""}
|
||||
placeholder={placeholder}
|
||||
variant={status === "error" ? "error" : "primary"}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</InputVertical>
|
||||
);
|
||||
}
|
||||
|
||||
interface CredentialFieldsRendererProps {
|
||||
credentialTemplate: CredentialFieldValues;
|
||||
source: ValidSources;
|
||||
spec: CredentialSpec;
|
||||
authMethod?: string;
|
||||
setAuthMethod?: (method: string) => void;
|
||||
}
|
||||
|
||||
export function CredentialFieldsRenderer({
|
||||
credentialTemplate,
|
||||
source,
|
||||
spec,
|
||||
authMethod,
|
||||
setAuthMethod,
|
||||
}: CredentialFieldsRendererProps) {
|
||||
const templateWithAuth =
|
||||
credentialTemplate as CredentialTemplateWithAuth<any>;
|
||||
const { values, setValues } = useFormikContext<any>();
|
||||
const t = useTranslations("admin");
|
||||
const { values, setValues } = useFormikContext<CredentialFieldValues>();
|
||||
const methods = spec.methods;
|
||||
|
||||
// remove other auth‐method 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];
|
||||
});
|
||||
}
|
||||
// Switching auth method drops the fields only the other methods use.
|
||||
function handleAuthMethodChange(newMethod: string) {
|
||||
const kept = new Set(
|
||||
methods?.find((method) => method.value === newMethod)?.fields
|
||||
);
|
||||
const cleaned: CredentialFieldValues = {
|
||||
...values,
|
||||
authentication_method: newMethod,
|
||||
};
|
||||
Object.keys(spec.fields).forEach((fieldKey) => {
|
||||
if (!kept.has(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) {
|
||||
if (methods && methods.length > 1) {
|
||||
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}>
|
||||
{/* 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={typeof val === "string" ? val : undefined}
|
||||
label={getDisplayNameForCredentialKey(key)}
|
||||
type={
|
||||
key.toLowerCase().includes("token") ||
|
||||
key.toLowerCase().includes("password") ||
|
||||
key.toLowerCase().includes("secret")
|
||||
? "password"
|
||||
: "text"
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Tabs.Content>
|
||||
<Tabs
|
||||
gap={4}
|
||||
value={authMethod || methods[0]?.value || ""}
|
||||
onValueChange={handleAuthMethodChange}
|
||||
>
|
||||
<Tabs.List>
|
||||
{methods.map((method) => (
|
||||
<Tabs.Trigger key={method.value} value={method.value}>
|
||||
{t(`credentials.methods.labels.${method.label}`)}
|
||||
</Tabs.Trigger>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
|
||||
{methods.map((method) => (
|
||||
<Tabs.Content key={method.value} value={method.value}>
|
||||
<Section alignItems="stretch" gap={4}>
|
||||
{/* A method with nothing to fill in explains itself instead. */}
|
||||
{method.fields.length === 0 && method.description && (
|
||||
<MessageCard
|
||||
variant="info"
|
||||
title={t(
|
||||
`credentials.methods.descriptions.${method.description}`
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{methodFields(spec, method).map(([key, field]) => (
|
||||
<CredentialField
|
||||
key={key}
|
||||
source={source}
|
||||
fieldKey={key}
|
||||
field={field}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
</Tabs.Content>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
// 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={typeof val === "string" ? val : undefined}
|
||||
label={getDisplayNameForCredentialKey(key)}
|
||||
type={
|
||||
key.toLowerCase().includes("token") ||
|
||||
key.toLowerCase().includes("password") ||
|
||||
key.toLowerCase().includes("secret")
|
||||
? "password"
|
||||
: "text"
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{Object.entries(spec.fields).map(([key, field]) => (
|
||||
<CredentialField
|
||||
key={key}
|
||||
source={source}
|
||||
fieldKey={key}
|
||||
field={field}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -17,15 +17,11 @@ import type { CCPairFullInfo } from "@/lib/connectors/types";
|
||||
import { Button, Card, Modal, Text } from "@opal/components";
|
||||
import { buildCCPairInfoUrl } from "@/lib/connectors/utils";
|
||||
import { getSourceDisplayName } from "@/lib/sources";
|
||||
import type {
|
||||
ConfluenceCredentialJson,
|
||||
Credential,
|
||||
} from "@/lib/credentials/types";
|
||||
import type { Credential } from "@/lib/credentials/types";
|
||||
import { getConnectorOauthRedirectUrl } from "@/lib/connectors/svc";
|
||||
import { useCredentialSetup } from "@/lib/credentials/hooks";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import { TypedFile } from "@/lib/connectors/fileTypes";
|
||||
import { isTypedFileField } from "@/lib/connectors/utils";
|
||||
import { SvgEdit, SvgKey } from "@opal/icons";
|
||||
import CreateCredential from "@/lib/credentials/components/CreateCredential";
|
||||
import { CreateStdOAuthCredential } from "@/lib/credentials/components/CreateStdOAuthCredential";
|
||||
@@ -33,6 +29,7 @@ import type { CredentialFieldValues } from "@/lib/credentials/types";
|
||||
import {
|
||||
getCredentialCreationActionLabel,
|
||||
getCredentialCreationMethods,
|
||||
getCredentialFileType,
|
||||
shouldRedirectToOAuth,
|
||||
} from "@/lib/credentials/utils";
|
||||
import { CredentialCreationMethod } from "@/lib/credentials/types";
|
||||
@@ -137,7 +134,7 @@ export default function CredentialSection({
|
||||
) => {
|
||||
let privateKey: TypedFile | null = null;
|
||||
Object.entries(details).forEach(([key, value]) => {
|
||||
if (isTypedFileField(key)) {
|
||||
if (getCredentialFileType(key) !== null) {
|
||||
privateKey = value as TypedFile;
|
||||
delete details[key];
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { Credential } from "@/lib/credentials/types";
|
||||
import { useCredentialSetup } from "@/lib/credentials/hooks";
|
||||
import { useSettings } from "@/lib/settings/hooks";
|
||||
import CreateCredential from "@/lib/credentials/components/CreateCredential";
|
||||
import { OAuthSignInRow } from "@/lib/credentials/components/OAuthSignInRow";
|
||||
import { CreateStdOAuthCredential } from "@/lib/credentials/components/CreateStdOAuthCredential";
|
||||
import ModifyCredential from "@/lib/credentials/components/ModifyCredential";
|
||||
import { shouldRedirectToOAuth } from "@/lib/credentials/utils";
|
||||
@@ -84,11 +85,7 @@ export function CredentialsConfigurer({
|
||||
function renderCredentialForm(method: CredentialCreationMethod) {
|
||||
if (method === CredentialCreationMethod.OAuth && oauthDetails) {
|
||||
return shouldRedirectToOAuth(oauthDetails) ? (
|
||||
<Section alignItems="start">
|
||||
<Button onClick={attemptOauthRedirect}>
|
||||
{t("add.authorizeButton.label", { source: displayName })}
|
||||
</Button>
|
||||
</Section>
|
||||
<OAuthSignInRow source={displayName} onConnect={attemptOauthRedirect} />
|
||||
) : (
|
||||
<CreateStdOAuthCredential
|
||||
sourceType={connector}
|
||||
@@ -237,6 +234,7 @@ export function CredentialsConfigurer({
|
||||
<div className="p-4" data-testid="credential-form">
|
||||
{namesMethods ? (
|
||||
<Tabs
|
||||
gap={4}
|
||||
value={openMethod ?? defaultMethod}
|
||||
onValueChange={(value) => {
|
||||
// Matched against the real methods rather than cast:
|
||||
|
||||
@@ -5,14 +5,15 @@ import { useTranslations } from "next-intl";
|
||||
import { TextFormField, TypedFileUploadFormField } from "@/components/Field";
|
||||
import { Form, Formik, FormikHelpers } from "formik";
|
||||
import { toast } from "@opal/layouts";
|
||||
import { getDisplayNameForCredentialKey } from "@/lib/credentials/utils";
|
||||
import { useCredentialFieldCopy } from "@/lib/credentials/hooks";
|
||||
import type { Credential } from "@/lib/credentials/types";
|
||||
import {
|
||||
createEditingValidationSchema,
|
||||
createInitialValues,
|
||||
getCredentialFileType,
|
||||
getCredentialSpec,
|
||||
getEditableCredentialFields,
|
||||
} from "@/lib/credentials/utils";
|
||||
import { isTypedFileField } from "@/lib/connectors/utils";
|
||||
import { SvgCheckSquare, SvgTrash } from "@opal/icons";
|
||||
import type {
|
||||
CredentialFieldValues,
|
||||
@@ -38,6 +39,21 @@ export default function EditCredential({
|
||||
onUpdate,
|
||||
}: EditCredentialProps) {
|
||||
const t = useTranslations("admin");
|
||||
const fieldCopy = useCredentialFieldCopy(sourceType);
|
||||
const spec = getCredentialSpec(sourceType);
|
||||
|
||||
// The spec says which fields are secret. A stored key it does not list
|
||||
// keeps the old guess from its name.
|
||||
function isSecretField(key: string): boolean {
|
||||
const field = spec?.fields[key];
|
||||
if (field) return field.kind === "secret";
|
||||
const lower = key.toLowerCase();
|
||||
return (
|
||||
lower.includes("token") ||
|
||||
lower.includes("password") ||
|
||||
lower.includes("secret")
|
||||
);
|
||||
}
|
||||
const editableCredentialFields = getEditableCredentialFields(
|
||||
credential,
|
||||
sourceType
|
||||
@@ -84,11 +100,11 @@ export default function EditCredential({
|
||||
/>
|
||||
|
||||
{Object.entries(editableCredentialFields).map(([key, value]) =>
|
||||
isTypedFileField(key) ? (
|
||||
getCredentialFileType(key) !== null ? (
|
||||
<TypedFileUploadFormField
|
||||
key={key}
|
||||
name={key}
|
||||
label={getDisplayNameForCredentialKey(key)}
|
||||
label={fieldCopy(key).title}
|
||||
/>
|
||||
) : (
|
||||
<TextFormField
|
||||
@@ -96,14 +112,8 @@ export default function EditCredential({
|
||||
key={key}
|
||||
name={key}
|
||||
placeholder={value == null ? undefined : String(value)}
|
||||
label={getDisplayNameForCredentialKey(key)}
|
||||
type={
|
||||
key.toLowerCase().includes("token") ||
|
||||
key.toLowerCase().includes("password") ||
|
||||
key.toLowerCase().includes("secret")
|
||||
? "password"
|
||||
: "text"
|
||||
}
|
||||
label={fieldCopy(key).title}
|
||||
type={isSecretField(key) ? "password" : "text"}
|
||||
disabled={key === "authentication_method"}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -7,10 +7,7 @@ import Text from "@/refresh-components/texts/Text";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { AccessType } from "@/lib/types";
|
||||
import { SvgEdit } from "@opal/icons";
|
||||
import type {
|
||||
ConfluenceCredentialJson,
|
||||
Credential,
|
||||
} from "@/lib/credentials/types";
|
||||
import type { AnyCredential, Credential } from "@/lib/credentials/types";
|
||||
import type { Connector } from "@/lib/connectors/types";
|
||||
import {
|
||||
SvgArrowExchange,
|
||||
@@ -164,7 +161,7 @@ export interface ModifyCredentialProps {
|
||||
accessType: AccessType
|
||||
) => void;
|
||||
onSwitch?: (newCredential: Credential<any>) => void;
|
||||
onEditCredential?: (credential: Credential<ConfluenceCredentialJson>) => void;
|
||||
onEditCredential?: (credential: AnyCredential) => void;
|
||||
onDeleteCredential: (credential: Credential<any | null>) => void;
|
||||
onCreateNew?: () => void;
|
||||
}
|
||||
@@ -234,8 +231,7 @@ export default function ModifyCredential({
|
||||
}}
|
||||
onEditCredential={
|
||||
onEditCredential
|
||||
? (credential: Credential<ConfluenceCredentialJson>) =>
|
||||
onEditCredential(credential)
|
||||
? (credential: AnyCredential) => onEditCredential(credential)
|
||||
: undefined
|
||||
}
|
||||
currentCredentialId={
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@opal/components";
|
||||
import { InputHorizontal } from "@opal/layouts";
|
||||
import { SvgArrowExchange } from "@opal/icons";
|
||||
import { useSettings } from "@/lib/settings/hooks";
|
||||
|
||||
interface OAuthSignInRowProps {
|
||||
/** The source's display name, e.g. "Confluence". */
|
||||
source: string;
|
||||
/** Leaves for the provider. Omit when the button submits its form. */
|
||||
onConnect?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The OAuth route's action row: what signing in grants on the left, the
|
||||
* Connect button on the right. Inside a form, the button submits it.
|
||||
*/
|
||||
export function OAuthSignInRow({
|
||||
source,
|
||||
onConnect,
|
||||
disabled,
|
||||
}: OAuthSignInRowProps) {
|
||||
const t = useTranslations("admin.credentials.oauth");
|
||||
const { appName } = useSettings();
|
||||
return (
|
||||
<InputHorizontal
|
||||
title={t("signIn.title", { source })}
|
||||
description={t("signIn.description", { appName })}
|
||||
center
|
||||
>
|
||||
<Button
|
||||
icon={SvgArrowExchange}
|
||||
disabled={disabled}
|
||||
type={onConnect ? "button" : "submit"}
|
||||
onClick={onConnect}
|
||||
>
|
||||
{t("connectButton.label")}
|
||||
</Button>
|
||||
</InputHorizontal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useFormikContext } from "formik";
|
||||
import {
|
||||
InputMultiSelect,
|
||||
InputSingleSelect,
|
||||
type TagItem,
|
||||
} from "@opal/components";
|
||||
import { InputHorizontal, Section } from "@opal/layouts";
|
||||
import { Disabled } from "@opal/core";
|
||||
import { SvgLock, SvgUserManage, SvgUsers } from "@opal/icons";
|
||||
import { useUserGroups } from "@/lib/hooks";
|
||||
|
||||
/** Who may reuse a new credential. */
|
||||
export type ShareAudience = "admins" | "adminsAndGroups" | "onlyMe";
|
||||
|
||||
export const DEFAULT_SHARE_AUDIENCE: ShareAudience = "admins";
|
||||
|
||||
/** The form values the field reads and writes. */
|
||||
export interface ShareAccountFormValues {
|
||||
share: ShareAudience;
|
||||
/** User groups that may reuse the credential, with `adminsAndGroups`. */
|
||||
groups: number[];
|
||||
}
|
||||
|
||||
function isShareAudience(value: string): value is ShareAudience {
|
||||
return (
|
||||
value === "admins" || value === "adminsAndGroups" || value === "onlyMe"
|
||||
);
|
||||
}
|
||||
|
||||
/** The credential payload's sharing fields for the chosen audience. */
|
||||
export function shareAccountPayload({ share, groups }: ShareAccountFormValues) {
|
||||
return {
|
||||
admin_public: share !== "onlyMe",
|
||||
groups: share === "adminsAndGroups" ? groups : [],
|
||||
};
|
||||
}
|
||||
|
||||
// The tag id of the admins chip; group tags use their numeric ids.
|
||||
const ADMINS_TAG_ID = "admins";
|
||||
|
||||
interface ShareAccountFieldProps {
|
||||
/** Dims and locks the field, as while the account's fields are invalid. */
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who may reuse the new credential: admins (the default), admins and named
|
||||
* user groups, or only its creator. With groups, a picker below lists them
|
||||
* after a fixed admins chip, since admins keep access either way.
|
||||
*/
|
||||
export function ShareAccountField({ disabled }: ShareAccountFieldProps) {
|
||||
const t = useTranslations("admin.credentials.create.share");
|
||||
const tGroups = useTranslations("common.groupsMultiSelect");
|
||||
const { values, setFieldValue } = useFormikContext<ShareAccountFormValues>();
|
||||
const { data: userGroups } = useUserGroups();
|
||||
|
||||
const groupNames = new Map(
|
||||
(userGroups ?? []).map((group) => [group.id, group.name])
|
||||
);
|
||||
const groupTags: TagItem[] = values.groups.flatMap((id) => {
|
||||
const name = groupNames.get(id);
|
||||
return name === undefined ? [] : [{ id: String(id), label: name }];
|
||||
});
|
||||
const adminsTag: TagItem = {
|
||||
id: ADMINS_TAG_ID,
|
||||
label: t("admins.label"),
|
||||
icon: SvgUserManage,
|
||||
locked: true,
|
||||
};
|
||||
|
||||
return (
|
||||
<Disabled disabled={disabled}>
|
||||
<Section alignItems="stretch" gap={4}>
|
||||
<InputHorizontal
|
||||
// A bare label (no htmlFor) hands a click on the title to the select
|
||||
// inside it, which opens the list.
|
||||
withLabel
|
||||
title={t("title")}
|
||||
description={t("description")}
|
||||
center
|
||||
>
|
||||
<InputSingleSelect
|
||||
value={values.share}
|
||||
onValueChange={(next) => {
|
||||
if (isShareAudience(next)) setFieldValue("share", next);
|
||||
}}
|
||||
defaultOption={DEFAULT_SHARE_AUDIENCE}
|
||||
placeholder={t("title")}
|
||||
disabled={disabled}
|
||||
options={[
|
||||
{
|
||||
value: "admins",
|
||||
title: t("admins.label"),
|
||||
suffix: t("admins.suffix"),
|
||||
description: t("admins.description"),
|
||||
icon: SvgUserManage,
|
||||
},
|
||||
{
|
||||
value: "adminsAndGroups",
|
||||
title: t("adminsAndGroups.label"),
|
||||
description: t("adminsAndGroups.description"),
|
||||
icon: SvgUsers,
|
||||
},
|
||||
{
|
||||
options: [
|
||||
{ value: "onlyMe", title: t("onlyMe.label"), icon: SvgLock },
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
|
||||
{values.share === "adminsAndGroups" && (
|
||||
<InputMultiSelect
|
||||
tags={[adminsTag, ...groupTags]}
|
||||
options={(userGroups ?? []).map((group) => ({
|
||||
value: String(group.id),
|
||||
title: group.name,
|
||||
icon: SvgUsers,
|
||||
}))}
|
||||
onSelectOption={(option) =>
|
||||
setFieldValue("groups", [...values.groups, Number(option.value)])
|
||||
}
|
||||
onRemoveTag={(id) =>
|
||||
setFieldValue(
|
||||
"groups",
|
||||
values.groups.filter((group) => String(group) !== id)
|
||||
)
|
||||
}
|
||||
placeholder={tGroups("userGroups.label")}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</Disabled>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,14 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import useSWR, { mutate, useSWRConfig } from "swr";
|
||||
import { CREDENTIAL_TEMPLATES } from "@/lib/credentials/constants";
|
||||
import { getConnectorOauthRedirectUrl } from "@/lib/connectors/svc";
|
||||
import { adminDeleteCredential, deleteCredential } from "@/lib/credentials/svc";
|
||||
import { usePermissionAuthority } from "@/lib/permissions/hooks";
|
||||
import { Permission } from "@/lib/types";
|
||||
import {
|
||||
getCredentialCreationMethods,
|
||||
getCredentialSpec,
|
||||
shouldRedirectToOAuth,
|
||||
} from "@/lib/credentials/utils";
|
||||
import { CredentialCreationMethod } from "@/lib/credentials/types";
|
||||
@@ -26,7 +27,6 @@ import {
|
||||
import type {
|
||||
AnyCredential,
|
||||
Credential,
|
||||
CredentialFieldValues,
|
||||
CredentialSetup,
|
||||
GmailCredentialJson,
|
||||
GmailServiceAccountCredentialJson,
|
||||
@@ -37,6 +37,44 @@ import type {
|
||||
} from "@/lib/credentials/types";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { SWR_KEYS } from "@/lib/swr-keys";
|
||||
import { toTitleCase } from "@opal/utils";
|
||||
|
||||
/** A credential field's title, and its hint when it has one. */
|
||||
export interface CredentialFieldCopy {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up a credential field's title and hint from the source's spec. A key
|
||||
* the spec does not list (a stored key of an older credential) falls back to
|
||||
* a title read from the key itself, so no field renders blank.
|
||||
*/
|
||||
export function useCredentialFieldCopy(
|
||||
source: ValidSources
|
||||
): (key: string) => CredentialFieldCopy {
|
||||
const t = useTranslations("admin.credentials");
|
||||
const spec = getCredentialSpec(source);
|
||||
return (key) => {
|
||||
const field = spec?.fields[key];
|
||||
if (!spec || !field) {
|
||||
return {
|
||||
title:
|
||||
key === "authentication_method"
|
||||
? t("displayNames.authenticationMethod")
|
||||
: toTitleCase(key),
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: t(`displayNames.${field.displayName}`, {
|
||||
source: spec.brandName,
|
||||
}),
|
||||
description: field.hint
|
||||
? t(`hints.${field.hint.key}`, field.hint.values)
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every credential the admin can see, across all sources. Pass
|
||||
@@ -156,9 +194,7 @@ export function useCredentialSetup(sourceType: ValidSources): CredentialSetup {
|
||||
|
||||
const displayName = getSourceDisplayName(sourceType) || sourceType;
|
||||
const methods = getCredentialCreationMethods(oauthDetails);
|
||||
const template = CREDENTIAL_TEMPLATES[sourceType] as
|
||||
| CredentialFieldValues
|
||||
| undefined;
|
||||
const spec = getCredentialSpec(sourceType);
|
||||
|
||||
// Two gates used to be kept apart and could disagree: the source list and
|
||||
// the source's own metadata flag. A source has to pass both.
|
||||
@@ -263,7 +299,7 @@ export function useCredentialSetup(sourceType: ValidSources): CredentialSetup {
|
||||
isLoading,
|
||||
methods,
|
||||
namesMethods: methods.length > 1,
|
||||
template,
|
||||
spec,
|
||||
canAuthorize,
|
||||
openMethod,
|
||||
open,
|
||||
|
||||
@@ -39,7 +39,9 @@ export async function createCredentialWithPrivateKey(
|
||||
formData.append("groups", String(group));
|
||||
});
|
||||
}
|
||||
formData.append(CREDENTIAL_NAME, credential.name || "");
|
||||
if (credential.name) {
|
||||
formData.append(CREDENTIAL_NAME, credential.name);
|
||||
}
|
||||
formData.append(CREDENTIAL_SOURCE, credential.source);
|
||||
if (credential.private_key) {
|
||||
formData.append(CREDENTIAL_UPLOADED_FILE, credential.private_key.file);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SWRResponse } from "swr";
|
||||
import type { CredentialFieldValues } from "@/lib/credentials/types/fields";
|
||||
import type { CredentialSpec } from "@/lib/credentials/types/spec";
|
||||
import type { ValidSources } from "@/lib/connectors/types/source";
|
||||
import type { TypedFile } from "@/lib/connectors/fileTypes";
|
||||
|
||||
@@ -21,26 +21,6 @@ export interface OAuthDetails {
|
||||
additional_kwargs: OAuthAdditionalKwargDescription[];
|
||||
}
|
||||
|
||||
export interface AuthMethodOption<
|
||||
TFields,
|
||||
TAuthMethod extends string = string,
|
||||
> {
|
||||
value: TAuthMethod;
|
||||
label: string;
|
||||
fields: TFields;
|
||||
description?: string;
|
||||
// UI-only: if true, hide/disable the "Auto Sync Permissions" access type when this auth is used
|
||||
disablePermSync?: boolean;
|
||||
}
|
||||
|
||||
export interface CredentialTemplateWithAuth<
|
||||
TFields,
|
||||
TAuthMethod extends string = string,
|
||||
> {
|
||||
authentication_method?: TAuthMethod;
|
||||
authMethods?: AuthMethodOption<Partial<TFields>, TAuthMethod>[];
|
||||
}
|
||||
|
||||
export interface CredentialBase<T> {
|
||||
credential_json: T;
|
||||
admin_public: boolean;
|
||||
@@ -107,8 +87,8 @@ export interface CredentialSetup {
|
||||
methods: CredentialCreationMethod[];
|
||||
/** True when there is more than one way in, so each needs naming. */
|
||||
namesMethods: boolean;
|
||||
/** The source's credential field template, absent for a source with none. */
|
||||
template: CredentialFieldValues | undefined;
|
||||
/** The source's credential spec, null for a source with none. */
|
||||
spec: CredentialSpec | null;
|
||||
/** Whether this deployment offers the hosted Authorize flow for the source. */
|
||||
canAuthorize: boolean;
|
||||
/** The method whose creation form is showing, if any. */
|
||||
|
||||
@@ -1,63 +1,8 @@
|
||||
/** The `credential_json` shape of each source. */
|
||||
import type { TypedFile } from "@/lib/connectors/fileTypes";
|
||||
|
||||
export interface GithubCredentialJson {
|
||||
github_access_token: string;
|
||||
github_base_url: string | null;
|
||||
}
|
||||
|
||||
export interface GitbookCredentialJson {
|
||||
gitbook_api_key: string;
|
||||
}
|
||||
|
||||
export interface GitlabCredentialJson {
|
||||
gitlab_url: string;
|
||||
gitlab_access_token: string;
|
||||
}
|
||||
|
||||
export interface LumAppsCredentialJson {
|
||||
lumapps_application_id: string;
|
||||
lumapps_api_key: string;
|
||||
lumapps_service_user: string;
|
||||
}
|
||||
|
||||
export interface BitbucketCredentialJson {
|
||||
bitbucket_email: string;
|
||||
bitbucket_api_token: string;
|
||||
}
|
||||
|
||||
export interface BookstackCredentialJson {
|
||||
bookstack_base_url: string;
|
||||
bookstack_api_token_id: string;
|
||||
bookstack_api_token_secret: string;
|
||||
}
|
||||
|
||||
export interface OutlineCredentialJson {
|
||||
outline_base_url: string;
|
||||
outline_api_token: string;
|
||||
}
|
||||
|
||||
export interface ConfluenceCredentialJson {
|
||||
confluence_username: string;
|
||||
confluence_access_token: string;
|
||||
}
|
||||
|
||||
export interface JiraCredentialJson {
|
||||
jira_user_email: string | null;
|
||||
jira_api_token: string;
|
||||
}
|
||||
|
||||
export interface JiraServerCredentialJson {
|
||||
jira_api_token: string;
|
||||
}
|
||||
|
||||
export interface ProductboardCredentialJson {
|
||||
productboard_access_token: string;
|
||||
}
|
||||
|
||||
export interface SlackCredentialJson {
|
||||
slack_bot_token: string;
|
||||
}
|
||||
/**
|
||||
* The `credential_json` of the Google sources, which set up credentials on
|
||||
* their own pages. Every other source's shape comes from its spec: see
|
||||
* `SourceCredentialJson` in `@/lib/credentials/constants`.
|
||||
*/
|
||||
|
||||
export interface GmailCredentialJson {
|
||||
google_tokens: string;
|
||||
@@ -80,223 +25,3 @@ export interface GoogleDriveServiceAccountCredentialJson {
|
||||
google_primary_admin: string;
|
||||
authentication_method?: string;
|
||||
}
|
||||
|
||||
export interface SlabCredentialJson {
|
||||
slab_bot_token: string;
|
||||
}
|
||||
|
||||
export interface CodaCredentialJson {
|
||||
coda_bearer_token: string;
|
||||
}
|
||||
|
||||
export interface NotionCredentialJson {
|
||||
notion_integration_token: string;
|
||||
}
|
||||
|
||||
export interface ZulipCredentialJson {
|
||||
zuliprc_content: string;
|
||||
}
|
||||
|
||||
export interface GuruCredentialJson {
|
||||
guru_user: string;
|
||||
guru_user_token: string;
|
||||
}
|
||||
|
||||
export interface GongCredentialJson {
|
||||
gong_access_key: string;
|
||||
gong_access_key_secret: string;
|
||||
gong_base_url: string | null;
|
||||
}
|
||||
|
||||
export interface LoopioCredentialJson {
|
||||
loopio_subdomain: string;
|
||||
loopio_client_id: string;
|
||||
loopio_client_token: string;
|
||||
}
|
||||
|
||||
export interface LinearCredentialJson {
|
||||
linear_api_key: string;
|
||||
}
|
||||
|
||||
export interface HubSpotCredentialJson {
|
||||
hubspot_access_token: string;
|
||||
}
|
||||
|
||||
export interface Document360CredentialJson {
|
||||
portal_id: string;
|
||||
document360_api_token: string;
|
||||
}
|
||||
|
||||
export interface ClickupCredentialJson {
|
||||
clickup_api_token: string;
|
||||
clickup_team_id: string;
|
||||
}
|
||||
|
||||
export interface ZendeskCredentialJson {
|
||||
zendesk_subdomain: string;
|
||||
zendesk_email: string;
|
||||
zendesk_token: string;
|
||||
}
|
||||
|
||||
export interface BoxCredentialJson {
|
||||
box_client_id: string;
|
||||
box_client_secret: string;
|
||||
box_enterprise_id: string;
|
||||
box_user_email: string | null;
|
||||
}
|
||||
|
||||
export interface DropboxCredentialJson {
|
||||
dropbox_access_token: string;
|
||||
}
|
||||
|
||||
export interface R2CredentialJson {
|
||||
account_id: string;
|
||||
r2_access_key_id: string;
|
||||
r2_secret_access_key: string;
|
||||
}
|
||||
|
||||
export interface S3CredentialJson {
|
||||
aws_access_key_id?: string;
|
||||
aws_secret_access_key?: string;
|
||||
aws_role_arn?: string;
|
||||
}
|
||||
|
||||
export interface GCSCredentialJson {
|
||||
access_key_id: string;
|
||||
secret_access_key: string;
|
||||
}
|
||||
|
||||
export interface OCICredentialJson {
|
||||
namespace: string;
|
||||
region: string;
|
||||
access_key_id: string;
|
||||
secret_access_key: string;
|
||||
}
|
||||
export interface SalesforceLegacyCredentialJson {
|
||||
authentication_method?: "password";
|
||||
sf_username: string;
|
||||
sf_password: string;
|
||||
sf_security_token: string;
|
||||
is_sandbox: boolean;
|
||||
}
|
||||
|
||||
export interface SalesforceOAuthCredentialJson {
|
||||
authentication_method: "oauth";
|
||||
sf_access_token: string;
|
||||
sf_refresh_token: string;
|
||||
sf_instance_url: string;
|
||||
sf_login_url: string;
|
||||
}
|
||||
|
||||
export type SalesforceCredentialJson =
|
||||
| SalesforceLegacyCredentialJson
|
||||
| SalesforceOAuthCredentialJson;
|
||||
|
||||
export interface SharepointCredentialJson {
|
||||
sp_client_id: string;
|
||||
sp_client_secret?: string;
|
||||
sp_directory_id: string;
|
||||
sp_certificate_password?: string;
|
||||
sp_private_key?: TypedFile;
|
||||
}
|
||||
|
||||
export type OneDriveAuthenticationMethod = "client_secret" | "certificate";
|
||||
|
||||
export interface OneDriveCredentialJson {
|
||||
authentication_method: OneDriveAuthenticationMethod;
|
||||
onedrive_client_id: string;
|
||||
onedrive_directory_id: string;
|
||||
onedrive_client_secret?: string;
|
||||
onedrive_certificate_password?: string;
|
||||
onedrive_private_key?: TypedFile | null;
|
||||
}
|
||||
|
||||
export interface AsanaCredentialJson {
|
||||
asana_api_token_secret: string;
|
||||
}
|
||||
|
||||
export interface TeamsCredentialJson {
|
||||
teams_client_id: string;
|
||||
teams_client_secret?: string;
|
||||
teams_directory_id: string;
|
||||
teams_certificate_password?: string;
|
||||
teams_private_key?: TypedFile;
|
||||
}
|
||||
|
||||
export interface OutlookCredentialJson {
|
||||
outlook_client_id: string;
|
||||
outlook_client_secret?: string;
|
||||
outlook_directory_id: string;
|
||||
outlook_certificate_password?: string;
|
||||
outlook_private_key?: TypedFile;
|
||||
}
|
||||
|
||||
export interface DiscourseCredentialJson {
|
||||
discourse_api_key: string;
|
||||
discourse_api_username: string;
|
||||
}
|
||||
|
||||
export interface AxeroCredentialJson {
|
||||
base_url: string;
|
||||
axero_api_token: string;
|
||||
}
|
||||
|
||||
export interface DiscordCredentialJson {
|
||||
discord_bot_token: string;
|
||||
}
|
||||
|
||||
export interface FreshdeskCredentialJson {
|
||||
freshdesk_domain: string;
|
||||
freshdesk_api_key: string;
|
||||
}
|
||||
|
||||
export interface FirefliesCredentialJson {
|
||||
fireflies_api_key: string;
|
||||
}
|
||||
|
||||
export interface ZoomCredentialJson {
|
||||
zoom_account_id: string;
|
||||
zoom_client_id: string;
|
||||
zoom_client_secret: string;
|
||||
}
|
||||
|
||||
export interface BraintrustCredentialJson {
|
||||
braintrust_api_key: string;
|
||||
}
|
||||
|
||||
export interface CanvasCredentialJson {
|
||||
canvas_access_token: string;
|
||||
}
|
||||
|
||||
export interface MediaWikiCredentialJson {}
|
||||
export interface WikipediaCredentialJson extends MediaWikiCredentialJson {}
|
||||
|
||||
export interface EgnyteCredentialJson {
|
||||
domain: string;
|
||||
access_token: string;
|
||||
}
|
||||
|
||||
export interface AirtableCredentialJson {
|
||||
airtable_access_token: string;
|
||||
}
|
||||
|
||||
export interface HighspotCredentialJson {
|
||||
highspot_url: string;
|
||||
highspot_key: string;
|
||||
highspot_secret: string;
|
||||
}
|
||||
|
||||
export interface DrupalWikiCredentialJson {
|
||||
drupal_wiki_api_token: string;
|
||||
}
|
||||
|
||||
export interface ImapCredentialJson {
|
||||
imap_username: string;
|
||||
imap_password: string;
|
||||
}
|
||||
|
||||
export interface TestRailCredentialJson {
|
||||
testrail_base_url: string;
|
||||
testrail_username: string;
|
||||
testrail_api_key: string;
|
||||
}
|
||||
@@ -14,3 +14,16 @@ export interface CredentialFormValues extends CredentialFieldValues {
|
||||
}
|
||||
|
||||
export type CredentialActionType = "create" | "createAndSwap";
|
||||
|
||||
/**
|
||||
* What a credential form's validation says, already translated. `fieldTitle`
|
||||
* names a field by its key; the rest wrap that title in a message.
|
||||
*/
|
||||
export interface CredentialValidationMessages {
|
||||
fieldTitle: (key: string) => string;
|
||||
required: (field: string) => string;
|
||||
empty: (field: string) => string;
|
||||
invalidEmail: (field: string) => string;
|
||||
fileRequired: (field: string) => string;
|
||||
authMethodRequired: string;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "@/lib/credentials/types/credential";
|
||||
export * from "@/lib/credentials/types/credentialJson";
|
||||
export * from "@/lib/credentials/types/fields";
|
||||
export * from "@/lib/credentials/types/spec";
|
||||
@@ -0,0 +1,107 @@
|
||||
/** The shape of a source's credential spec. The specs live in `@/lib/credentials/constants`. */
|
||||
import type en from "@/i18n/messages/en.json";
|
||||
import type { FileTypeCategory } from "@/lib/connectors/types/fileTypes";
|
||||
|
||||
type CredentialMessages = (typeof en)["admin"]["credentials"];
|
||||
|
||||
/** A field title in `admin.credentials.displayNames`. May use `{source}`. */
|
||||
export type CredentialDisplayName = keyof CredentialMessages["displayNames"];
|
||||
|
||||
/** A field hint in `admin.credentials.hints`. */
|
||||
export type CredentialHintKey = keyof CredentialMessages["hints"];
|
||||
|
||||
/** An auth-method tab label in `admin.credentials.methods.labels`. */
|
||||
export type CredentialMethodLabel =
|
||||
keyof CredentialMessages["methods"]["labels"];
|
||||
|
||||
/** An auth-method description in `admin.credentials.methods.descriptions`. */
|
||||
export type CredentialMethodDescription =
|
||||
keyof CredentialMessages["methods"]["descriptions"];
|
||||
|
||||
export interface CredentialHint {
|
||||
key: CredentialHintKey;
|
||||
values?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CredentialFieldOptions {
|
||||
/** The backend accepts the credential without this field. */
|
||||
optional?: boolean;
|
||||
hint?: CredentialHint;
|
||||
}
|
||||
|
||||
interface CredentialFieldBase extends CredentialFieldOptions {
|
||||
displayName: CredentialDisplayName;
|
||||
}
|
||||
|
||||
export type CredentialTextKind = "text" | "email" | "url" | "secret";
|
||||
|
||||
/** One field of a credential spec. `kind` picks how it renders. */
|
||||
export type CredentialSpecField =
|
||||
| (CredentialFieldBase & { kind: CredentialTextKind })
|
||||
| (CredentialFieldBase & { kind: "toggle" | "checkbox" })
|
||||
| (CredentialFieldBase & { kind: "file"; fileType: FileTypeCategory });
|
||||
|
||||
export type CredentialSpecFields = Record<string, CredentialSpecField>;
|
||||
|
||||
/** One way to authenticate. It names the spec fields it asks for. */
|
||||
export interface CredentialSpecMethod<TKey extends string = string> {
|
||||
/** The `authentication_method` the backend reads. */
|
||||
value: string;
|
||||
label: CredentialMethodLabel;
|
||||
description?: CredentialMethodDescription;
|
||||
fields: readonly TKey[];
|
||||
/** Hides the "Auto Sync Permissions" access type for this method. */
|
||||
disablePermSync?: boolean;
|
||||
}
|
||||
|
||||
/** A source's credential: its fields and, optionally, its auth methods. */
|
||||
export interface CredentialSpec {
|
||||
/** The name field titles use for `{source}`, like "Microsoft Teams". */
|
||||
brandName: string;
|
||||
/** Every field, declared once. Keys are what the backend reads. */
|
||||
fields: CredentialSpecFields;
|
||||
/** Two or more ways to authenticate. The first is the default. */
|
||||
methods?: readonly CredentialSpecMethod[];
|
||||
}
|
||||
|
||||
export type MethodsOf<TFields> = readonly CredentialSpecMethod<
|
||||
Extract<keyof TFields, string>
|
||||
>[];
|
||||
|
||||
/** A spec that keeps its literal field keys and whether it has methods. */
|
||||
export interface DefinedCredentialSpec<
|
||||
TFields extends CredentialSpecFields,
|
||||
TMethods extends MethodsOf<TFields> | undefined,
|
||||
> {
|
||||
brandName: string;
|
||||
fields: TFields;
|
||||
methods: TMethods;
|
||||
}
|
||||
|
||||
// A file field is uploaded on its own; the stored JSON holds its content as a
|
||||
// string.
|
||||
type FieldValueOf<TField> = TField extends { kind: "toggle" | "checkbox" }
|
||||
? boolean
|
||||
: string;
|
||||
|
||||
type RequiredKeys<TFields> = {
|
||||
[K in keyof TFields]: TFields[K] extends { optional: true } ? never : K;
|
||||
}[keyof TFields];
|
||||
|
||||
/**
|
||||
* The `credential_json` a spec produces. With auth methods, each field is
|
||||
* present only under its own method, so every field is optional.
|
||||
*/
|
||||
export type CredentialJsonOf<TSpec> = TSpec extends {
|
||||
fields: infer TFields;
|
||||
}
|
||||
? TSpec extends { methods: readonly unknown[] }
|
||||
? { authentication_method?: string } & {
|
||||
[K in keyof TFields]?: FieldValueOf<TFields[K]>;
|
||||
}
|
||||
: { [K in RequiredKeys<TFields>]: FieldValueOf<TFields[K]> } & {
|
||||
[K in Exclude<keyof TFields, RequiredKeys<TFields>>]?: FieldValueOf<
|
||||
TFields[K]
|
||||
> | null;
|
||||
}
|
||||
: never;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CREDENTIAL_TEMPLATES } from "@/lib/credentials/constants";
|
||||
import { CREDENTIAL_SPECS } from "@/lib/credentials/constants";
|
||||
import type { Credential } from "@/lib/credentials/types";
|
||||
import type { CredentialFieldValues } from "@/lib/credentials/types";
|
||||
import { ValidSources } from "@/lib/connectors/types/source";
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@/lib/credentials/utils";
|
||||
import {
|
||||
CredentialCreationMethod,
|
||||
type CredentialValidationMessages,
|
||||
type OAuthDetails,
|
||||
} from "@/lib/credentials/types";
|
||||
|
||||
@@ -118,9 +119,19 @@ describe("credential edit helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const MESSAGES: CredentialValidationMessages = {
|
||||
fieldTitle: (key) => key,
|
||||
required: (field) => `required: ${field}`,
|
||||
empty: (field) => `empty: ${field}`,
|
||||
invalidEmail: (field) => `invalid email: ${field}`,
|
||||
fileRequired: (field) => `file required: ${field}`,
|
||||
authMethodRequired: "auth method required",
|
||||
};
|
||||
|
||||
describe("createValidationSchema", () => {
|
||||
const schema = createValidationSchema(
|
||||
CREDENTIAL_TEMPLATES[ValidSources.Outlook]
|
||||
CREDENTIAL_SPECS[ValidSources.Outlook],
|
||||
MESSAGES
|
||||
);
|
||||
const ids = {
|
||||
outlook_client_id: "client-id",
|
||||
@@ -163,7 +174,8 @@ describe("createValidationSchema", () => {
|
||||
|
||||
it("requires the SharePoint app ids under both of its methods", () => {
|
||||
const sharepointSchema = createValidationSchema(
|
||||
CREDENTIAL_TEMPLATES[ValidSources.Sharepoint]
|
||||
CREDENTIAL_SPECS[ValidSources.Sharepoint],
|
||||
MESSAGES
|
||||
);
|
||||
const sharepointIds = {
|
||||
sp_client_id: "client-id",
|
||||
|
||||
+139
-108
@@ -1,119 +1,160 @@
|
||||
import * as Yup from "yup";
|
||||
|
||||
import { CREDENTIAL_TEMPLATES } from "@/lib/credentials/constants";
|
||||
import type {
|
||||
Credential,
|
||||
CredentialTemplateWithAuth,
|
||||
} from "@/lib/credentials/types";
|
||||
import { isTypedFileField } from "@/lib/connectors/utils";
|
||||
import type { Credential } from "@/lib/credentials/types";
|
||||
import type {
|
||||
CredentialFieldValues,
|
||||
CredentialFormValues,
|
||||
} from "@/lib/credentials/types";
|
||||
import { ValidSources } from "@/lib/connectors/types/source";
|
||||
import { CREDENTIAL_DISPLAY_NAMES } from "@/lib/credentials/constants";
|
||||
import { toast } from "@opal/layouts";
|
||||
import {
|
||||
CredentialCreationMethod,
|
||||
type OAuthDetails,
|
||||
} from "@/lib/credentials/types";
|
||||
import type {
|
||||
CredentialSpec,
|
||||
CredentialSpecField,
|
||||
CredentialSpecMethod,
|
||||
CredentialValidationMessages,
|
||||
} from "@/lib/credentials/types";
|
||||
import type { FileTypeCategory } from "@/lib/connectors/types/fileTypes";
|
||||
import { CREDENTIAL_SPECS } from "@/lib/credentials/constants";
|
||||
|
||||
// What a credential template seeds a field with: "" for a required text
|
||||
// field, null for an optional one or a file, a boolean for a checkbox.
|
||||
type CredentialFieldSeed = string | boolean | null;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Credential specs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A source's spec, or null when it has no credential form. */
|
||||
export function getCredentialSpec(source: ValidSources): CredentialSpec | null {
|
||||
return CREDENTIAL_SPECS[source];
|
||||
}
|
||||
|
||||
const FILE_FIELD_TYPES: ReadonlyMap<string, FileTypeCategory> = new Map(
|
||||
Object.values<CredentialSpec | null>(CREDENTIAL_SPECS).flatMap((spec) =>
|
||||
Object.entries(spec?.fields ?? {}).flatMap(([key, field]) => {
|
||||
if (field.kind !== "file") return [];
|
||||
const entry: [string, FileTypeCategory] = [key, field.fileType];
|
||||
return [entry];
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
/** The file type a credential field takes, or null when it takes text. */
|
||||
export function getCredentialFileType(key: string): FileTypeCategory | null {
|
||||
return FILE_FIELD_TYPES.get(key) ?? null;
|
||||
}
|
||||
|
||||
/** The value a form starts a field at. */
|
||||
export function initialFieldValue(
|
||||
field: CredentialSpecField
|
||||
): string | boolean | null {
|
||||
if (field.kind === "toggle" || field.kind === "checkbox") return false;
|
||||
if (field.kind === "file" || field.optional) return null;
|
||||
return "";
|
||||
}
|
||||
|
||||
/** A method's fields, in the order the method lists them. */
|
||||
export function methodFields(
|
||||
spec: CredentialSpec,
|
||||
method: CredentialSpecMethod
|
||||
): [string, CredentialSpecField][] {
|
||||
return method.fields.flatMap((key) => {
|
||||
const field = spec.fields[key];
|
||||
if (!field) return [];
|
||||
const entry: [string, CredentialSpecField] = [key, field];
|
||||
return [entry];
|
||||
});
|
||||
}
|
||||
|
||||
/** The value of each field a form starts with. */
|
||||
export function initialCredentialValues(
|
||||
spec: CredentialSpec
|
||||
): CredentialFieldValues {
|
||||
return Object.fromEntries(
|
||||
Object.entries(spec.fields).map(([key, field]) => [
|
||||
key,
|
||||
initialFieldValue(field),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation and edit forms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AUTHENTICATION_METHOD_KEY = "authentication_method";
|
||||
const ONEDRIVE_LEGACY_AUTHENTICATION_METHOD_KEY =
|
||||
"onedrive_authentication_method";
|
||||
|
||||
interface FieldMethods {
|
||||
def: CredentialFieldSeed;
|
||||
methods: string[];
|
||||
}
|
||||
|
||||
// The rules for one credential field. With `selected` the required rules
|
||||
// apply only while one of the field's auth methods is chosen.
|
||||
function fieldSchema(
|
||||
key: string,
|
||||
def: CredentialFieldSeed,
|
||||
field: CredentialSpecField,
|
||||
messages: CredentialValidationMessages,
|
||||
selected?: (method: string) => boolean
|
||||
): Yup.AnySchema {
|
||||
const displayName = getDisplayNameForCredentialKey(key);
|
||||
if (typeof def === "boolean") {
|
||||
const title = messages.fieldTitle(key);
|
||||
if (field.kind === "toggle" || field.kind === "checkbox") {
|
||||
return Yup.boolean()
|
||||
.nullable()
|
||||
.default(false)
|
||||
.transform((v, o) => (o === undefined ? false : v));
|
||||
}
|
||||
if (isTypedFileField(key)) {
|
||||
if (field.kind === "file") {
|
||||
// TypedFile fields use mixed schema instead of string.
|
||||
const required = Yup.mixed().required(
|
||||
`Please select a ${displayName} file`
|
||||
);
|
||||
const required = Yup.mixed().required(messages.fileRequired(title));
|
||||
if (!selected) return required;
|
||||
return Yup.mixed().when("authentication_method", {
|
||||
return Yup.mixed().when(AUTHENTICATION_METHOD_KEY, {
|
||||
is: selected,
|
||||
then: () => required,
|
||||
otherwise: () => Yup.mixed().notRequired(),
|
||||
});
|
||||
}
|
||||
if (def === null) {
|
||||
return Yup.string()
|
||||
.trim()
|
||||
const base =
|
||||
field.kind === "email"
|
||||
? Yup.string().trim().email(messages.invalidEmail(title))
|
||||
: Yup.string().trim();
|
||||
if (field.optional) {
|
||||
return base
|
||||
.transform((v) => (v === "" ? null : v))
|
||||
.nullable()
|
||||
.notRequired();
|
||||
}
|
||||
const required = (s: Yup.StringSchema) =>
|
||||
s
|
||||
.min(1, `${displayName} cannot be empty`)
|
||||
.required(`Please enter your ${displayName}`);
|
||||
if (!selected) return required(Yup.string().trim());
|
||||
return Yup.string()
|
||||
.trim()
|
||||
.when("authentication_method", {
|
||||
is: selected,
|
||||
then: required,
|
||||
otherwise: (s) => s.notRequired(),
|
||||
});
|
||||
s.min(1, messages.empty(title)).required(messages.required(title));
|
||||
if (!selected) return required(base);
|
||||
return base.when(AUTHENTICATION_METHOD_KEY, {
|
||||
is: selected,
|
||||
then: required,
|
||||
otherwise: (s) => s.notRequired(),
|
||||
});
|
||||
}
|
||||
|
||||
export function createValidationSchema(jsonValues: Record<string, any>) {
|
||||
export function createValidationSchema(
|
||||
spec: CredentialSpec,
|
||||
messages: CredentialValidationMessages
|
||||
) {
|
||||
const schemaFields: Record<string, Yup.AnySchema> = {};
|
||||
const template = jsonValues as CredentialTemplateWithAuth<any>;
|
||||
// multi-auth templates
|
||||
if (template.authMethods && template.authMethods.length > 1) {
|
||||
// auth method selector
|
||||
schemaFields["authentication_method"] = Yup.string().required(
|
||||
"Please select an authentication method"
|
||||
const methods = spec.methods;
|
||||
if (methods) {
|
||||
schemaFields[AUTHENTICATION_METHOD_KEY] = Yup.string().required(
|
||||
messages.authMethodRequired
|
||||
);
|
||||
// A field several methods share (the app ids of SharePoint and Outlook)
|
||||
// is required under every method that lists it, so collect them first.
|
||||
const methodsByField = new Map<string, FieldMethods>();
|
||||
template.authMethods.forEach((method) => {
|
||||
Object.entries(method.fields).forEach(([key, def]) => {
|
||||
const entry: FieldMethods = methodsByField.get(key) ?? {
|
||||
def,
|
||||
methods: [],
|
||||
};
|
||||
entry.methods.push(method.value);
|
||||
methodsByField.set(key, entry);
|
||||
});
|
||||
});
|
||||
methodsByField.forEach(({ def, methods }, key) => {
|
||||
schemaFields[key] = fieldSchema(key, def, (method) =>
|
||||
methods.includes(method)
|
||||
);
|
||||
});
|
||||
}
|
||||
// single-auth templates and other fields
|
||||
for (const key in jsonValues) {
|
||||
if (!Object.prototype.hasOwnProperty.call(jsonValues, key)) continue;
|
||||
if (key === "authentication_method" || key === "authMethods") continue;
|
||||
schemaFields[key] = fieldSchema(key, jsonValues[key]);
|
||||
for (const [key, field] of Object.entries(spec.fields)) {
|
||||
// A field several methods share (the Microsoft app ids) is required
|
||||
// under every method that lists it.
|
||||
const fieldMethods = methods
|
||||
?.filter((method) => method.fields.includes(key))
|
||||
.map((method) => method.value);
|
||||
schemaFields[key] = fieldSchema(
|
||||
key,
|
||||
field,
|
||||
messages,
|
||||
fieldMethods && ((method) => fieldMethods.includes(method))
|
||||
);
|
||||
}
|
||||
|
||||
schemaFields["name"] = Yup.string().optional();
|
||||
return Yup.object().shape(schemaFields);
|
||||
}
|
||||
|
||||
@@ -124,7 +165,7 @@ export function createEditingValidationSchema(
|
||||
|
||||
for (const key in jsonValues) {
|
||||
if (Object.prototype.hasOwnProperty.call(jsonValues, key)) {
|
||||
if (isTypedFileField(key)) {
|
||||
if (getCredentialFileType(key) !== null) {
|
||||
// TypedFile fields use mixed schema for optional file uploads during editing.
|
||||
schemaFields[key] = Yup.mixed().optional();
|
||||
} else {
|
||||
@@ -139,24 +180,24 @@ export function createEditingValidationSchema(
|
||||
|
||||
function getAuthMethodFieldsForCredential(
|
||||
credentialJson: CredentialFieldValues,
|
||||
credentialTemplate: CredentialTemplateWithAuth<CredentialFieldValues>,
|
||||
spec: CredentialSpec,
|
||||
methods: readonly CredentialSpecMethod[],
|
||||
storedAuthMethod: string | undefined
|
||||
): CredentialFieldValues {
|
||||
const authMethods = credentialTemplate.authMethods ?? [];
|
||||
const selectedAuthMethod =
|
||||
authMethods.find((method) => method.value === storedAuthMethod) ??
|
||||
authMethods.find((method) =>
|
||||
Object.keys(method.fields).some((fieldKey) => fieldKey in credentialJson)
|
||||
methods.find((method) => method.value === storedAuthMethod) ??
|
||||
methods.find((method) =>
|
||||
method.fields.some((fieldKey) => fieldKey in credentialJson)
|
||||
) ??
|
||||
authMethods[0];
|
||||
methods[0];
|
||||
|
||||
return {
|
||||
authentication_method:
|
||||
storedAuthMethod ??
|
||||
selectedAuthMethod?.value ??
|
||||
credentialTemplate.authentication_method ??
|
||||
"",
|
||||
...selectedAuthMethod?.fields,
|
||||
authentication_method: storedAuthMethod ?? selectedAuthMethod?.value ?? "",
|
||||
...Object.fromEntries(
|
||||
(selectedAuthMethod ? methodFields(spec, selectedAuthMethod) : []).map(
|
||||
([key, field]) => [key, initialFieldValue(field)]
|
||||
)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -203,29 +244,24 @@ export function getEditableCredentialFields(
|
||||
return {};
|
||||
}
|
||||
|
||||
const credentialTemplate = CREDENTIAL_TEMPLATES[sourceType] as
|
||||
| CredentialFieldValues
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
if (!credentialTemplate) {
|
||||
const spec = getCredentialSpec(sourceType);
|
||||
if (!spec) {
|
||||
return credentialJson;
|
||||
}
|
||||
|
||||
const templateWithAuth =
|
||||
credentialTemplate as CredentialTemplateWithAuth<CredentialFieldValues>;
|
||||
const templateFields =
|
||||
templateWithAuth.authMethods && templateWithAuth.authMethods.length > 1
|
||||
? getAuthMethodFieldsForCredential(
|
||||
credentialJson,
|
||||
templateWithAuth,
|
||||
getStoredAuthMethod(credentialJson, sourceType)
|
||||
)
|
||||
: Object.fromEntries(
|
||||
Object.entries(credentialTemplate).filter(
|
||||
([key]) => key !== "authMethods"
|
||||
)
|
||||
);
|
||||
const templateFields: CredentialFieldValues = spec.methods
|
||||
? getAuthMethodFieldsForCredential(
|
||||
credentialJson,
|
||||
spec,
|
||||
spec.methods,
|
||||
getStoredAuthMethod(credentialJson, sourceType)
|
||||
)
|
||||
: Object.fromEntries(
|
||||
Object.entries(spec.fields).map(([key, field]) => [
|
||||
key,
|
||||
initialFieldValue(field),
|
||||
])
|
||||
);
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(templateFields).map(([key, templateValue]) => [
|
||||
@@ -254,7 +290,7 @@ export function createInitialValues(
|
||||
|
||||
for (const key in credentialFields) {
|
||||
// Initialize TypedFile fields as null, other fields as empty strings
|
||||
if (isTypedFileField(key)) {
|
||||
if (getCredentialFileType(key) !== null) {
|
||||
initialValues[key] = null;
|
||||
} else {
|
||||
initialValues[key] = "";
|
||||
@@ -264,11 +300,6 @@ export function createInitialValues(
|
||||
return initialValues;
|
||||
}
|
||||
|
||||
/** The label for a credential field key, falling back to the key itself. */
|
||||
export function getDisplayNameForCredentialKey(key: string): string {
|
||||
return CREDENTIAL_DISPLAY_NAMES[key] || key;
|
||||
}
|
||||
|
||||
// Parse an uploaded OAuth app JSON; toasts and returns null when invalid.
|
||||
export const parseOauthAppCredentialJson = (
|
||||
value: string
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
ConfigurableSources,
|
||||
ValidSources,
|
||||
} from "@/lib/connectors/types/source";
|
||||
import { CREDENTIAL_TEMPLATES } from "@/lib/credentials/constants";
|
||||
import { getCredentialSpec } from "@/lib/credentials/utils";
|
||||
import type { Credential } from "@/lib/credentials/types";
|
||||
import {
|
||||
defaultRefreshFreqMinutes,
|
||||
@@ -154,8 +154,8 @@ export default function AddConnector({
|
||||
Permission.MANAGE_CONNECTORS
|
||||
);
|
||||
|
||||
// Get credential template and configuration
|
||||
const credentialTemplate = CREDENTIAL_TEMPLATES[connector];
|
||||
// Get credential spec and configuration
|
||||
const credentialSpec = getCredentialSpec(connector);
|
||||
const configuration: ConnectionConfiguration =
|
||||
useConnectorConfiguration(connector);
|
||||
const formControlFieldNames = new Set(
|
||||
@@ -189,8 +189,8 @@ export default function AddConnector({
|
||||
(connector === "gmail" && liveGmailCredential) ||
|
||||
currentCredential;
|
||||
|
||||
// Sources without a credential template skip the credential section.
|
||||
const noCredentials = credentialTemplate == null;
|
||||
// Sources without a credential spec skip the credential section.
|
||||
const noCredentials = credentialSpec == null;
|
||||
const canCreate = noCredentials || credentialActivated != null;
|
||||
|
||||
// The page body waits for the source's saved credentials: no connector
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
updateConnectorCredentialPairName,
|
||||
updateConnectorCredentialPairProperty,
|
||||
} from "@/lib/connector";
|
||||
import { CREDENTIAL_TEMPLATES } from "@/lib/credentials/constants";
|
||||
import { getCredentialSpec } from "@/lib/credentials/utils";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import Title from "@/components/ui/title";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -705,7 +705,7 @@ function Main({ ccPairId }: { ccPairId: number }) {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{CREDENTIAL_TEMPLATES[ccPair.connector.source] && can(ccPair, "edit") && (
|
||||
{getCredentialSpec(ccPair.connector.source) && can(ccPair, "edit") && (
|
||||
<>
|
||||
<Title size="md" className="mt-10 mb-2">
|
||||
{t("sections.credential.title")}
|
||||
|
||||
@@ -217,11 +217,6 @@ export class OneDriveConnectorSetupPage {
|
||||
}
|
||||
|
||||
private async fillCredentialIdentity() {
|
||||
// Scoped to the credential form: the connector's own name field is on
|
||||
// the same page.
|
||||
await this.credentialForm
|
||||
.getByTestId("name")
|
||||
.fill("OneDrive test credential");
|
||||
await this.page.getByTestId("onedrive_client_id").fill("client-id");
|
||||
await this.page.getByTestId("onedrive_directory_id").fill("directory-id");
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user