Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
} from '../../models/application-create-flow';
import type {PlatformApplicationTemplate, TechnologyApplicationTemplate} from '../../models/application-templates';
import getConfigurationTypeFromTemplate from '../../utils/getConfigurationTypeFromTemplate';
import hasInvalidCorsRows from '../../utils/hasInvalidCorsRows';
import isRedirectCapableTemplate from '../../utils/isRedirectCapableTemplate';

/**
Expand Down Expand Up @@ -292,6 +293,7 @@ export default function ConfigureDetails({
relyingPartyName: contextRelyingPartyName,
setRelyingPartyName,
appName,
corsOrigins,
} = useApplicationCreate();
const {
control,
Expand Down Expand Up @@ -327,6 +329,11 @@ export default function ConfigureDetails({
: configurationType;
const effectiveIsRedirectCapable = isEmbeddedApproach ? false : isRedirectCapable;

// Guarded on the same condition that renders the CORS editor, so rows left over from a previously
// selected template can't block a step that no longer shows them.
const showsCorsEditor: boolean = effectiveIsRedirectCapable && Boolean(selectedTemplateConfig?.capabilities?.cors);
const hasInvalidCorsOrigins: boolean = showsCorsEditor && hasInvalidCorsRows(corsOrigins);

const isWallet: boolean = selectedTemplateConfig?.id === 'wallet';
const [walletVendor, setWalletVendor] = useState<string>(CUSTOM_WALLET_VENDOR);
const [customClientId, setCustomClientId] = useState<string>('');
Expand Down Expand Up @@ -455,38 +462,31 @@ export default function ConfigureDetails({
* Determine if step is ready based on validity and configuration type.
*/
useEffect((): void => {
// If Passkey is enabled, we MUST have valid relying party info
if (isPasskeyConfigEnabled) {
if (!relyingPartyId || !relyingPartyName) {
onReadyChange(false);
return;
}
}

if (effectiveConfigurationType === ApplicationCreateFlowConfiguration.NONE) {
// Even if no base config needed, if Passkey is enabled we need those fields valid
// The Passkey check block above handles returning false if invalid.
// If we are here, it means either Passkey is disabled OR Passkey fields are valid.
onReadyChange(true);
return;
}

// For URL-based config, need valid hosting URL
if (effectiveConfigurationType === ApplicationCreateFlowConfiguration.URL) {
let ready: boolean;

if (isPasskeyConfigEnabled && (!relyingPartyId || !relyingPartyName)) {
// If Passkey is enabled, we MUST have valid relying party info
ready = false;
} else if (effectiveConfigurationType === ApplicationCreateFlowConfiguration.NONE) {
// Even if no base config needed, if Passkey is enabled we need those fields valid, which the
// check above has already accounted for.
ready = true;
} else if (effectiveConfigurationType === ApplicationCreateFlowConfiguration.URL) {
// For URL-based config, need valid hosting URL
const hasValidHostingUrl: boolean = !!hostingUrl && !errors.hostingUrl;
const hasValidCallbackUrl: boolean = callbackMode === 'same' || (!!callbackUrl && !errors.callbackUrl);
onReadyChange(!!hasValidHostingUrl && !!hasValidCallbackUrl);
return;
}

// For deeplink config, need valid deeplink. For wallets, also block if the resolved client
// id is already taken by another application (would otherwise fail only on submit).
if (effectiveConfigurationType === ApplicationCreateFlowConfiguration.DEEPLINK) {
onReadyChange(!!deeplink && !errors.deeplink && !isDuplicateWalletClientId);
return;
ready = !!hasValidHostingUrl && !!hasValidCallbackUrl;
} else if (effectiveConfigurationType === ApplicationCreateFlowConfiguration.DEEPLINK) {
// For deeplink config, need valid deeplink. For wallets, also block if the resolved client
// id is already taken by another application (would otherwise fail only on submit).
ready = !!deeplink && !errors.deeplink && !isDuplicateWalletClientId;
} else {
ready = isValid;
}

onReadyChange(isValid);
// A malformed CORS origin used to be dropped on submit without a word, so block here instead.
// This reads the same rows that get merged into the allow-list, so the two cannot disagree.
onReadyChange(ready && !hasInvalidCorsOrigins);
}, [
isValid,
effectiveConfigurationType,
Expand All @@ -501,6 +501,7 @@ export default function ConfigureDetails({
onReadyChange,
selectedTemplateConfig,
isDuplicateWalletClientId,
hasInvalidCorsOrigins,
]);

// For platforms that don't require configuration AND no passkey configuration needed AND the
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright 2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import {isValidOrigin} from '@thunderid/configure-settings';
import {AllowedOriginTypes, createRow, rowKey, type AllowedOriginDraftRow} from '@thunderid/configure-settings';
import {
Alert,
Box,
Expand All @@ -19,6 +19,7 @@ import {Plus, Trash} from '@wso2/oxygen-ui-icons-react';
import type {JSX} from 'react';
import {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import CorsOriginsEditor from './CorsOriginsEditor';
import DevServerLogo from './DevServerLogo';
import useApplicationCreate from '../../contexts/ApplicationCreate/useApplicationCreate';

Expand Down Expand Up @@ -56,12 +57,6 @@ interface UriListEditorProps {
onUrisChange: (uris: string[]) => void;
/** Whether an empty row is an error (redirect URIs are required, post-logout ones are optional). */
required: boolean;
/** Value format validator. Defaults to the permissive URI-format check. */
isValidValue?: (value: string) => boolean;
/** Already-translated "value must not be empty" error message. */
emptyValueMessage?: string;
/** Already-translated "value is not a valid format" error message. */
invalidValueMessage?: string;
}

function UriListEditor({
Expand All @@ -72,9 +67,6 @@ function UriListEditor({
uris,
onUrisChange,
required,
isValidValue = isValidUriFormat,
emptyValueMessage = '',
invalidValueMessage = '',
}: UriListEditorProps): JSX.Element {
const {t} = useTranslation();
const [errors, setErrors] = useState<Record<number, string>>({});
Expand All @@ -84,9 +76,7 @@ function UriListEditor({
if (required) {
setErrors((prev) => ({
...prev,
[index]:
emptyValueMessage ||
t('applications:edit.general.redirectUris.error.empty', 'Invalid Redirect: URI must not be empty.'),
[index]: t('applications:edit.general.redirectUris.error.empty', 'Invalid Redirect: URI must not be empty.'),
}));
return false;
}
Expand All @@ -97,12 +87,13 @@ function UriListEditor({
});
return false;
}
if (!isValidValue(uri)) {
if (!isValidUriFormat(uri)) {
setErrors((prev) => ({
...prev,
[index]:
invalidValueMessage ||
t('applications:edit.general.redirectUris.error.invalid', 'Invalid Redirect: Please enter a valid URL.'),
[index]: t(
'applications:edit.general.redirectUris.error.invalid',
'Invalid Redirect: Please enter a valid URL.',
),
}));
return false;
}
Expand Down Expand Up @@ -198,8 +189,8 @@ interface DevServerBannerProps {
showCors: boolean;
redirectUris: string[];
onRedirectUrisChange: (uris: string[]) => void;
corsOrigins: string[];
onCorsOriginsChange: (origins: string[]) => void;
corsOrigins: AllowedOriginDraftRow[];
onCorsOriginsChange: (origins: AllowedOriginDraftRow[]) => void;
}

/**
Expand All @@ -221,8 +212,10 @@ function DevServerBanner({
if (!redirectUris.includes(devServer.url)) {
onRedirectUrisChange([...redirectUris, devServer.url]);
}
if (showCors && !corsOrigins.includes(devServer.url)) {
onCorsOriginsChange([...corsOrigins, devServer.url]);
// The dev server URL is always an exact origin, never a pattern.
const devServerRow = createRow(AllowedOriginTypes.ORIGIN, devServer.url);
if (showCors && !corsOrigins.some((row) => rowKey(row) === rowKey(devServerRow))) {
onCorsOriginsChange([...corsOrigins, devServerRow]);
}
};

Expand Down Expand Up @@ -347,25 +340,7 @@ export default function ConfigureRedirectUris(): JSX.Element {
/>
)}

{showCors && (
<UriListEditor
title={t('applications:onboarding.configure.details.corsOrigins.title', 'CORS Allowed Origins')}
description={t(
'applications:onboarding.configure.details.corsOrigins.description',
'Origins allowed to make cross-origin requests to the token and userinfo endpoints.',
)}
placeholder={t('applications:onboarding.configure.details.corsOrigins.placeholder', 'https://example.com')}
addLabel={t('applications:onboarding.configure.details.corsOrigins.addOrigin', 'Add Origin')}
uris={corsOrigins}
onUrisChange={setCorsOrigins}
required={false}
isValidValue={isValidOrigin}
invalidValueMessage={t(
'applications:onboarding.configure.details.corsOrigins.error.invalid',
'Enter a valid origin, e.g. https://app.example.com.',
)}
/>
)}
{showCors && <CorsOriginsEditor rows={corsOrigins} onRowsChange={setCorsOrigins} />}
</Stack>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Copyright 2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import {
AllowedOriginRow,
AllowedOriginRowIssueFallbacks,
AllowedOriginTypes,
createRow,
validateAllowedOriginRows,
type AllowedOriginDraftRow,
type AllowedOriginRowError,
type AllowedOriginRowWarning,
} from '@thunderid/configure-settings';
import {Box, Button, FormControl, FormLabel, Stack, Typography} from '@wso2/oxygen-ui';
import {Plus} from '@wso2/oxygen-ui-icons-react';
import type {JSX} from 'react';
import {useMemo, useState} from 'react';
import {useTranslation} from 'react-i18next';

interface CorsOriginsEditorProps {
rows: AllowedOriginDraftRow[];
onRowsChange: (rows: AllowedOriginDraftRow[]) => void;
}

/**
* The Configuration step's CORS Allowed Origins editor. Each row states whether it is an exact
* origin or a regular expression, so nothing is reclassified from its text on the way to the
* deployment's allow-list.
*
* Validation messages are resolved from the `settings` namespace, which the Settings page's CORS
* card uses too, so both surfaces phrase the same rule identically.
*/
export default function CorsOriginsEditor({rows, onRowsChange}: CorsOriginsEditorProps): JSX.Element {
const {t} = useTranslation();
// Messages stay hidden until a row has been blurred, so a half-typed origin doesn't shout.
const [touched, setTouched] = useState<Record<string, boolean>>({});

// An empty list renders as a bare "Add Origin" button with no field to type into, which reads as
// broken rather than "nothing added yet". Show one empty row instead; editing it commits the real
// (currently empty) list upward. The placeholder row is created once, so typing into it doesn't
// remount the field on every keystroke.
const placeholderRow = useMemo(() => createRow(AllowedOriginTypes.ORIGIN), []);
const displayRows = rows.length > 0 ? rows : [placeholderRow];

const issues = validateAllowedOriginRows(displayRows);

/**
* Resolves a row's issue message, or `undefined` while the row has no issue or has not been
* blurred yet.
*
* @param codes - The issue codes for every row, keyed by row id
* @param id - The row to resolve a message for
* @returns The localized message, or `undefined` when the row has nothing to say
*/
const messageFor = (
codes: Record<string, AllowedOriginRowError | AllowedOriginRowWarning>,
id: string,
): string | undefined => {
const code = codes[id];
if (!touched[id] || !code) {
return undefined;
}
return t(`settings:cors.validation.${code}`, AllowedOriginRowIssueFallbacks[code]);
};

/**
* Commits a change to one row upward, leaving every other row as it is. Editing the placeholder row
* is what turns it into the first real entry.
*
* @param id - The row to change
* @param patch - The fields to overwrite on that row
*/
const updateRow = (id: string, patch: Partial<AllowedOriginDraftRow>): void => {
onRowsChange(displayRows.map((row) => (row.id === id ? {...row, ...patch} : row)));
};

return (
<FormControl fullWidth>
<FormLabel>{t('applications:onboarding.configure.details.corsOrigins.title', 'CORS Allowed Origins')}</FormLabel>
<Typography variant="caption" color="text.secondary" sx={{display: 'block', mb: 2}}>
{t(
'applications:onboarding.configure.details.corsOrigins.description',
'Origins allowed to make cross-origin requests to the token and userinfo endpoints. Each entry is either an exact origin or a regular expression.',
)}
</Typography>

<Stack spacing={2}>
{displayRows.map((row) => (
<AllowedOriginRow
key={row.id}
testId="application-cors-origin-row"
type={row.type}
value={row.value}
error={messageFor(issues.errors, row.id)}
warning={messageFor(issues.warnings, row.id)}
originPlaceholder={t(
'applications:onboarding.configure.details.corsOrigins.placeholder',
'https://example.com',
)}
regexPlaceholder={t(
'applications:onboarding.configure.details.corsOrigins.regexPlaceholder',
'^https://[a-z0-9-]+\\.example\\.com$',
)}
typeLabel={t('settings:cors.type.label', 'Entry type')}
originOptionLabel={t('settings:cors.type.origin', 'Origin')}
regexOptionLabel={t('settings:cors.type.regex', 'Regex')}
removeLabel={t('applications:onboarding.configure.details.corsOrigins.removeOrigin', 'Remove Origin')}
onTypeChange={(type) => {
setTouched((prev) => ({...prev, [row.id]: true}));
updateRow(row.id, {type});
}}
onChange={(value) => updateRow(row.id, {value})}
onBlur={() => setTouched((prev) => ({...prev, [row.id]: true}))}
onRemove={() => onRowsChange(displayRows.filter((candidate) => candidate.id !== row.id))}
/>
))}

<Box>
<Button
variant="text"
color="primary"
size="small"
startIcon={<Plus />}
onClick={() => onRowsChange([...displayRows, createRow(AllowedOriginTypes.ORIGIN)])}
>
{t('applications:onboarding.configure.details.corsOrigins.addOrigin', 'Add Origin')}
</Button>
</Box>
</Stack>
</FormControl>
);
}
Loading
Loading