@byline/admin 3.20.4 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/fields/field-renderer.js +2 -0
- package/dist/fields/numerical/numerical-field.d.ts +3 -1
- package/dist/fields/numerical/numerical-field.js +42 -5
- package/dist/modules/auth/components/sign-in-form.d.ts +5 -4
- package/dist/modules/auth/components/sign-in-form.js +6 -3
- package/dist/modules/auth/safe-redirect.d.ts +4 -0
- package/dist/modules/auth/safe-redirect.js +20 -0
- package/dist/modules/auth/safe-redirect.test.node.d.ts +1 -0
- package/dist/modules/auth/sign-in-form-props.test.node.d.ts +1 -0
- package/package.json +5 -5
- package/src/fields/field-renderer.tsx +2 -0
- package/src/fields/numerical/numerical-field.tsx +53 -8
- package/src/modules/auth/components/sign-in-form.tsx +20 -9
- package/src/modules/auth/safe-redirect.test.node.ts +52 -0
- package/src/modules/auth/safe-redirect.ts +33 -0
- package/src/modules/auth/sign-in-form-props.test.node.ts +14 -0
|
@@ -6,13 +6,15 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
import type { CounterField, DecimalField, FieldComponentSlots, FloatField, IntegerField } from '@byline/core';
|
|
9
|
+
type NumericalValue = string | number | null;
|
|
9
10
|
export declare const NumericalField: ({ field, value, defaultValue, onChange, id, path, components, }: {
|
|
10
11
|
field: IntegerField | FloatField | DecimalField | CounterField;
|
|
11
12
|
value?: string | number | null;
|
|
12
13
|
defaultValue?: string | number | null;
|
|
13
|
-
onChange?: (value:
|
|
14
|
+
onChange?: (value: NumericalValue) => void;
|
|
14
15
|
id?: string;
|
|
15
16
|
path?: string;
|
|
16
17
|
/** Optional UI component slot overrides from the admin config. */
|
|
17
18
|
components?: FieldComponentSlots;
|
|
18
19
|
}) => import("react").JSX.Element;
|
|
20
|
+
export {};
|
|
@@ -1,13 +1,39 @@
|
|
|
1
1
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { normalizeNumericValue } from "@byline/core";
|
|
2
4
|
import { Input } from "@byline/ui/react";
|
|
3
5
|
import { useFieldError, useFieldValue } from "../../forms/form-context.js";
|
|
6
|
+
const COMPLETE_NUMERIC_RE = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
|
|
4
7
|
const NumericalField = ({ field, value, defaultValue, onChange, id, path, components })=>{
|
|
5
8
|
const fieldPath = path ?? field.name;
|
|
6
9
|
const fieldError = useFieldError(fieldPath);
|
|
7
10
|
const fieldValue = useFieldValue(fieldPath);
|
|
8
|
-
const incomingValue = value
|
|
11
|
+
const incomingValue = void 0 !== value ? value : void 0 !== fieldValue ? fieldValue : defaultValue ?? null;
|
|
9
12
|
const htmlId = id ?? fieldPath;
|
|
10
|
-
const
|
|
13
|
+
const canonicalDisplay = null == incomingValue ? '' : String(incomingValue);
|
|
14
|
+
const [displayValue, setDisplayValue] = useState(canonicalDisplay);
|
|
15
|
+
const [isEditing, setIsEditing] = useState(false);
|
|
16
|
+
useEffect(()=>{
|
|
17
|
+
if (!isEditing) setDisplayValue(canonicalDisplay);
|
|
18
|
+
}, [
|
|
19
|
+
canonicalDisplay,
|
|
20
|
+
isEditing
|
|
21
|
+
]);
|
|
22
|
+
const canonicalize = (nextValue)=>{
|
|
23
|
+
if (null == nextValue || 'string' == typeof nextValue && '' === nextValue.trim()) return null;
|
|
24
|
+
if ('counter' === field.type) return;
|
|
25
|
+
if ('string' == typeof nextValue && !COMPLETE_NUMERIC_RE.test(nextValue.trim())) return;
|
|
26
|
+
try {
|
|
27
|
+
return normalizeNumericValue(field.type, nextValue, fieldPath);
|
|
28
|
+
} catch {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const commit = (nextValue)=>{
|
|
33
|
+
const canonical = canonicalize(nextValue);
|
|
34
|
+
if (void 0 !== canonical) onChange?.(canonical);
|
|
35
|
+
return canonical;
|
|
36
|
+
};
|
|
11
37
|
const slots = components;
|
|
12
38
|
const CustomLabel = slots?.Label;
|
|
13
39
|
const CustomHelpText = slots?.HelpText;
|
|
@@ -35,12 +61,14 @@ const NumericalField = ({ field, value, defaultValue, onChange, id, path, compon
|
|
|
35
61
|
const renderInput = ()=>{
|
|
36
62
|
if (CustomField) return /*#__PURE__*/ jsx(CustomField, {
|
|
37
63
|
...slotBaseProps,
|
|
38
|
-
onChange: (
|
|
64
|
+
onChange: (nextValue)=>commit(nextValue),
|
|
39
65
|
defaultValue: defaultValue,
|
|
40
66
|
placeholder: field.placeholder
|
|
41
67
|
});
|
|
42
68
|
return /*#__PURE__*/ jsx(Input, {
|
|
43
|
-
type: "
|
|
69
|
+
type: "text",
|
|
70
|
+
inputMode: 'integer' === field.type || 'counter' === field.type ? 'numeric' : 'decimal',
|
|
71
|
+
step: 'integer' === field.type || 'counter' === field.type ? 1 : 'any',
|
|
44
72
|
id: htmlId,
|
|
45
73
|
name: field.name,
|
|
46
74
|
label: suppressInputLabel ? void 0 : field.label,
|
|
@@ -48,7 +76,16 @@ const NumericalField = ({ field, value, defaultValue, onChange, id, path, compon
|
|
|
48
76
|
readOnly: field.readOnly,
|
|
49
77
|
helpText: suppressInputHelpText ? void 0 : field.helpText,
|
|
50
78
|
value: displayValue,
|
|
51
|
-
|
|
79
|
+
onFocus: ()=>setIsEditing(true),
|
|
80
|
+
onChange: (e)=>{
|
|
81
|
+
setDisplayValue(e.target.value);
|
|
82
|
+
commit(e.target.value);
|
|
83
|
+
},
|
|
84
|
+
onBlur: ()=>{
|
|
85
|
+
setIsEditing(false);
|
|
86
|
+
const canonical = commit(displayValue);
|
|
87
|
+
setDisplayValue(void 0 === canonical ? canonicalDisplay : canonical?.toString() ?? '');
|
|
88
|
+
},
|
|
52
89
|
error: null != fieldError,
|
|
53
90
|
errorText: fieldError
|
|
54
91
|
});
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
interface SignInFormProps {
|
|
2
|
-
/**
|
|
1
|
+
export interface SignInFormProps {
|
|
2
|
+
/** Host-validated root-relative destination after successful sign-in. */
|
|
3
|
+
redirectTo?: string;
|
|
4
|
+
/** @deprecated Use `redirectTo`. */
|
|
3
5
|
callbackUrl?: string;
|
|
4
6
|
/**
|
|
5
7
|
* Optional plain "Home" link rendered on the left of the action row.
|
|
@@ -8,5 +10,4 @@ interface SignInFormProps {
|
|
|
8
10
|
*/
|
|
9
11
|
homeUrl?: string;
|
|
10
12
|
}
|
|
11
|
-
export declare function SignInForm({ callbackUrl, homeUrl }: SignInFormProps): import("react").JSX.Element;
|
|
12
|
-
export {};
|
|
13
|
+
export declare function SignInForm({ redirectTo, callbackUrl, homeUrl }: SignInFormProps): import("react").JSX.Element;
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useState } from "react";
|
|
4
|
+
import { getClientConfig } from "@byline/core";
|
|
4
5
|
import { useTranslation } from "@byline/i18n/react";
|
|
5
6
|
import { Alert, Button, Card, Input, LoaderEllipsis } from "@byline/ui/react";
|
|
6
7
|
import classnames from "classnames";
|
|
7
8
|
import { useBylineAdminServices } from "../../../services/admin-services-context.js";
|
|
9
|
+
import { normalizeRootRelativeRedirect, resolveSignInFormRedirect } from "../safe-redirect.js";
|
|
8
10
|
import sign_in_form_module from "./sign-in-form.module.js";
|
|
9
|
-
function SignInForm({ callbackUrl, homeUrl }) {
|
|
11
|
+
function SignInForm({ redirectTo, callbackUrl, homeUrl }) {
|
|
10
12
|
const { adminSignIn } = useBylineAdminServices();
|
|
11
13
|
const { t } = useTranslation('byline-admin');
|
|
12
14
|
const [email, setEmail] = useState('');
|
|
@@ -17,6 +19,7 @@ function SignInForm({ callbackUrl, homeUrl }) {
|
|
|
17
19
|
event.preventDefault();
|
|
18
20
|
if (pending) return;
|
|
19
21
|
if (0 === email.trim().length || 0 === password.length) return void setError(t('auth.signIn.errors.empty'));
|
|
22
|
+
const destination = resolveSignInFormRedirect(redirectTo, callbackUrl, getClientConfig().routes.admin);
|
|
20
23
|
setPending(true);
|
|
21
24
|
setError(null);
|
|
22
25
|
try {
|
|
@@ -26,13 +29,13 @@ function SignInForm({ callbackUrl, homeUrl }) {
|
|
|
26
29
|
password
|
|
27
30
|
}
|
|
28
31
|
});
|
|
29
|
-
const target = callbackUrl && callbackUrl.length > 0 ? callbackUrl : '/admin';
|
|
30
|
-
window.location.assign(target);
|
|
31
32
|
} catch (err) {
|
|
32
33
|
console.warn('sign-in failed', err);
|
|
33
34
|
setError(t('auth.signIn.errors.invalidCredentials'));
|
|
34
35
|
setPending(false);
|
|
36
|
+
return;
|
|
35
37
|
}
|
|
38
|
+
window.location.assign(normalizeRootRelativeRedirect(destination) ?? '/');
|
|
36
39
|
}
|
|
37
40
|
return /*#__PURE__*/ jsxs(Card, {
|
|
38
41
|
className: classnames('byline-sign-in-card', sign_in_form_module.card),
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Accept only canonical, unencoded, root-relative same-origin redirect paths. */
|
|
2
|
+
export declare function normalizeRootRelativeRedirect(value: string): string | undefined;
|
|
3
|
+
/** Resolve the new prop first, then the deprecated prop, then a trusted fallback. */
|
|
4
|
+
export declare function resolveSignInFormRedirect(redirectTo: string | undefined, callbackUrl: string | undefined, fallback: string | (() => string)): string;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { normalizeRootRelativeRedirect } from "@byline/core";
|
|
2
|
+
function safe_redirect_normalizeRootRelativeRedirect(value) {
|
|
3
|
+
const normalized = normalizeRootRelativeRedirect(value);
|
|
4
|
+
if (!normalized) return;
|
|
5
|
+
let url;
|
|
6
|
+
try {
|
|
7
|
+
url = new URL(normalized, 'https://byline.invalid');
|
|
8
|
+
} catch {
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
if ('https://byline.invalid' !== url.origin) return;
|
|
12
|
+
return `${url.pathname}${url.search}${url.hash}`;
|
|
13
|
+
}
|
|
14
|
+
function resolveSignInFormRedirect(redirectTo, callbackUrl, fallback) {
|
|
15
|
+
const requested = (redirectTo ? safe_redirect_normalizeRootRelativeRedirect(redirectTo) : void 0) ?? (callbackUrl ? safe_redirect_normalizeRootRelativeRedirect(callbackUrl) : void 0);
|
|
16
|
+
if (requested) return requested;
|
|
17
|
+
const defaultPath = 'function' == typeof fallback ? fallback() : fallback;
|
|
18
|
+
return safe_redirect_normalizeRootRelativeRedirect(defaultPath) ?? '/';
|
|
19
|
+
}
|
|
20
|
+
export { resolveSignInFormRedirect, safe_redirect_normalizeRootRelativeRedirect as normalizeRootRelativeRedirect };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@byline/admin",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "
|
|
5
|
+
"version": "4.0.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -155,10 +155,10 @@
|
|
|
155
155
|
"react-diff-viewer-continued": "^4.2.2",
|
|
156
156
|
"uuid": "^14.0.1",
|
|
157
157
|
"zod": "^4.4.3",
|
|
158
|
-
"@byline/auth": "
|
|
159
|
-
"@byline/core": "
|
|
160
|
-
"@byline/ui": "
|
|
161
|
-
"@byline/i18n": "
|
|
158
|
+
"@byline/auth": "4.0.0",
|
|
159
|
+
"@byline/core": "4.0.0",
|
|
160
|
+
"@byline/ui": "4.0.0",
|
|
161
|
+
"@byline/i18n": "4.0.0"
|
|
162
162
|
},
|
|
163
163
|
"peerDependencies": {
|
|
164
164
|
"react": "^19.0.0",
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { useEffect, useState } from 'react'
|
|
10
|
+
|
|
9
11
|
import type {
|
|
10
12
|
CounterField,
|
|
11
13
|
DecimalField,
|
|
@@ -14,10 +16,15 @@ import type {
|
|
|
14
16
|
FloatField,
|
|
15
17
|
IntegerField,
|
|
16
18
|
} from '@byline/core'
|
|
19
|
+
import { normalizeNumericValue } from '@byline/core'
|
|
17
20
|
import { Input } from '@byline/ui/react'
|
|
18
21
|
|
|
19
22
|
import { useFieldError, useFieldValue } from '../../forms/form-context'
|
|
20
23
|
|
|
24
|
+
type NumericalValue = string | number | null
|
|
25
|
+
|
|
26
|
+
const COMPLETE_NUMERIC_RE = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/
|
|
27
|
+
|
|
21
28
|
export const NumericalField = ({
|
|
22
29
|
field,
|
|
23
30
|
value,
|
|
@@ -30,7 +37,7 @@ export const NumericalField = ({
|
|
|
30
37
|
field: IntegerField | FloatField | DecimalField | CounterField
|
|
31
38
|
value?: string | number | null
|
|
32
39
|
defaultValue?: string | number | null
|
|
33
|
-
onChange?: (value:
|
|
40
|
+
onChange?: (value: NumericalValue) => void
|
|
34
41
|
id?: string
|
|
35
42
|
path?: string
|
|
36
43
|
/** Optional UI component slot overrides from the admin config. */
|
|
@@ -38,11 +45,36 @@ export const NumericalField = ({
|
|
|
38
45
|
}) => {
|
|
39
46
|
const fieldPath = path ?? field.name
|
|
40
47
|
const fieldError = useFieldError(fieldPath)
|
|
41
|
-
const fieldValue = useFieldValue<
|
|
42
|
-
const incomingValue =
|
|
48
|
+
const fieldValue = useFieldValue<NumericalValue | undefined>(fieldPath)
|
|
49
|
+
const incomingValue =
|
|
50
|
+
value !== undefined ? value : fieldValue !== undefined ? fieldValue : (defaultValue ?? null)
|
|
43
51
|
const htmlId = id ?? fieldPath
|
|
44
|
-
const
|
|
45
|
-
|
|
52
|
+
const canonicalDisplay = incomingValue == null ? '' : String(incomingValue)
|
|
53
|
+
const [displayValue, setDisplayValue] = useState(canonicalDisplay)
|
|
54
|
+
const [isEditing, setIsEditing] = useState(false)
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!isEditing) setDisplayValue(canonicalDisplay)
|
|
58
|
+
}, [canonicalDisplay, isEditing])
|
|
59
|
+
|
|
60
|
+
const canonicalize = (nextValue: NumericalValue): NumericalValue | undefined => {
|
|
61
|
+
if (nextValue == null || (typeof nextValue === 'string' && nextValue.trim() === '')) return null
|
|
62
|
+
if (field.type === 'counter') return undefined
|
|
63
|
+
if (typeof nextValue === 'string' && !COMPLETE_NUMERIC_RE.test(nextValue.trim())) {
|
|
64
|
+
return undefined
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
return normalizeNumericValue(field.type, nextValue, fieldPath)
|
|
68
|
+
} catch {
|
|
69
|
+
return undefined
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const commit = (nextValue: NumericalValue) => {
|
|
74
|
+
const canonical = canonicalize(nextValue)
|
|
75
|
+
if (canonical !== undefined) onChange?.(canonical)
|
|
76
|
+
return canonical
|
|
77
|
+
}
|
|
46
78
|
|
|
47
79
|
// Custom component slots (from admin config)
|
|
48
80
|
const slots = components
|
|
@@ -79,7 +111,7 @@ export const NumericalField = ({
|
|
|
79
111
|
return (
|
|
80
112
|
<CustomField
|
|
81
113
|
{...slotBaseProps}
|
|
82
|
-
onChange={(
|
|
114
|
+
onChange={(nextValue: NumericalValue) => commit(nextValue)}
|
|
83
115
|
defaultValue={defaultValue}
|
|
84
116
|
placeholder={field.placeholder}
|
|
85
117
|
/>
|
|
@@ -87,7 +119,9 @@ export const NumericalField = ({
|
|
|
87
119
|
}
|
|
88
120
|
return (
|
|
89
121
|
<Input
|
|
90
|
-
type="
|
|
122
|
+
type="text"
|
|
123
|
+
inputMode={field.type === 'integer' || field.type === 'counter' ? 'numeric' : 'decimal'}
|
|
124
|
+
step={field.type === 'integer' || field.type === 'counter' ? 1 : 'any'}
|
|
91
125
|
id={htmlId}
|
|
92
126
|
name={field.name}
|
|
93
127
|
label={suppressInputLabel ? undefined : field.label}
|
|
@@ -95,7 +129,18 @@ export const NumericalField = ({
|
|
|
95
129
|
readOnly={field.readOnly}
|
|
96
130
|
helpText={suppressInputHelpText ? undefined : field.helpText}
|
|
97
131
|
value={displayValue}
|
|
98
|
-
|
|
132
|
+
onFocus={() => setIsEditing(true)}
|
|
133
|
+
onChange={(e) => {
|
|
134
|
+
setDisplayValue(e.target.value)
|
|
135
|
+
commit(e.target.value)
|
|
136
|
+
}}
|
|
137
|
+
onBlur={() => {
|
|
138
|
+
setIsEditing(false)
|
|
139
|
+
const canonical = commit(displayValue)
|
|
140
|
+
setDisplayValue(
|
|
141
|
+
canonical === undefined ? canonicalDisplay : (canonical?.toString() ?? '')
|
|
142
|
+
)
|
|
143
|
+
}}
|
|
99
144
|
error={fieldError != null}
|
|
100
145
|
errorText={fieldError}
|
|
101
146
|
/>
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
* Admin sign-in form.
|
|
13
13
|
*
|
|
14
14
|
* Client component — collects email + password, calls the `adminSignIn`
|
|
15
|
-
* server fn, and on success navigates to
|
|
16
|
-
*
|
|
15
|
+
* server fn, and on success navigates to a safe caller-supplied destination.
|
|
16
|
+
* On failure renders a generic "Invalid
|
|
17
17
|
* credentials" alert; the provider equalises timing between
|
|
18
18
|
* unknown-email and wrong-password so the UI doesn't distinguish the two.
|
|
19
19
|
*
|
|
@@ -25,15 +25,19 @@
|
|
|
25
25
|
|
|
26
26
|
import { type FormEvent, useState } from 'react'
|
|
27
27
|
|
|
28
|
+
import { getClientConfig } from '@byline/core'
|
|
28
29
|
import { useTranslation } from '@byline/i18n/react'
|
|
29
30
|
import { Alert, Button, Card, Input, LoaderEllipsis } from '@byline/ui/react'
|
|
30
31
|
import cx from 'classnames'
|
|
31
32
|
|
|
32
33
|
import { useBylineAdminServices } from '../../../services/admin-services-context.js'
|
|
34
|
+
import { normalizeRootRelativeRedirect, resolveSignInFormRedirect } from '../safe-redirect.js'
|
|
33
35
|
import styles from './sign-in-form.module.css'
|
|
34
36
|
|
|
35
|
-
interface SignInFormProps {
|
|
36
|
-
/**
|
|
37
|
+
export interface SignInFormProps {
|
|
38
|
+
/** Host-validated root-relative destination after successful sign-in. */
|
|
39
|
+
redirectTo?: string
|
|
40
|
+
/** @deprecated Use `redirectTo`. */
|
|
37
41
|
callbackUrl?: string
|
|
38
42
|
/**
|
|
39
43
|
* Optional plain "Home" link rendered on the left of the action row.
|
|
@@ -43,7 +47,7 @@ interface SignInFormProps {
|
|
|
43
47
|
homeUrl?: string
|
|
44
48
|
}
|
|
45
49
|
|
|
46
|
-
export function SignInForm({ callbackUrl, homeUrl }: SignInFormProps) {
|
|
50
|
+
export function SignInForm({ redirectTo, callbackUrl, homeUrl }: SignInFormProps) {
|
|
47
51
|
const { adminSignIn } = useBylineAdminServices()
|
|
48
52
|
const { t } = useTranslation('byline-admin')
|
|
49
53
|
const [email, setEmail] = useState('')
|
|
@@ -59,19 +63,26 @@ export function SignInForm({ callbackUrl, homeUrl }: SignInFormProps) {
|
|
|
59
63
|
return
|
|
60
64
|
}
|
|
61
65
|
|
|
66
|
+
const destination = resolveSignInFormRedirect(
|
|
67
|
+
redirectTo,
|
|
68
|
+
callbackUrl,
|
|
69
|
+
getClientConfig().routes.admin
|
|
70
|
+
)
|
|
71
|
+
|
|
62
72
|
setPending(true)
|
|
63
73
|
setError(null)
|
|
64
74
|
try {
|
|
65
75
|
await adminSignIn({ data: { email: email.trim(), password } })
|
|
66
|
-
const target = callbackUrl && callbackUrl.length > 0 ? callbackUrl : '/admin'
|
|
67
|
-
// Full-page navigation — the admin layout needs to re-run its
|
|
68
|
-
// `beforeLoad` guard against the freshly-set session cookies.
|
|
69
|
-
window.location.assign(target)
|
|
70
76
|
} catch (err) {
|
|
71
77
|
console.warn('sign-in failed', err)
|
|
72
78
|
setError(t('auth.signIn.errors.invalidCredentials'))
|
|
73
79
|
setPending(false)
|
|
80
|
+
return
|
|
74
81
|
}
|
|
82
|
+
|
|
83
|
+
// Keep navigation outside the credential error boundary. A browser-level
|
|
84
|
+
// navigation failure must not relabel a successful sign-in as bad credentials.
|
|
85
|
+
window.location.assign(normalizeRootRelativeRedirect(destination) ?? '/')
|
|
75
86
|
}
|
|
76
87
|
|
|
77
88
|
return (
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { normalizeRootRelativeRedirect, resolveSignInFormRedirect } from './safe-redirect.js'
|
|
4
|
+
|
|
5
|
+
describe('normalizeRootRelativeRedirect', () => {
|
|
6
|
+
it.each([
|
|
7
|
+
['/cms', '/cms'],
|
|
8
|
+
['/cms/account?tab=profile#name', '/cms/account?tab=profile#name'],
|
|
9
|
+
])('accepts %j', (value, expected) => {
|
|
10
|
+
expect(normalizeRootRelativeRedirect(value)).toBe(expected)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it.each([
|
|
14
|
+
'',
|
|
15
|
+
' /cms',
|
|
16
|
+
'https://evil.test',
|
|
17
|
+
'//evil.test',
|
|
18
|
+
'/\\evil.test',
|
|
19
|
+
'/cms\\account',
|
|
20
|
+
'/cms\naccount',
|
|
21
|
+
'%2Fcms',
|
|
22
|
+
'/%2F%2Fevil.test',
|
|
23
|
+
'/cms/%5cevil.test',
|
|
24
|
+
'/cms/%2e/account',
|
|
25
|
+
'/cms/../account',
|
|
26
|
+
'/cms/./account',
|
|
27
|
+
'/cms\u0085account',
|
|
28
|
+
])('rejects %j', (value) => {
|
|
29
|
+
expect(normalizeRootRelativeRedirect(value)).toBeUndefined()
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('resolveSignInFormRedirect', () => {
|
|
34
|
+
it('prefers a safe redirectTo over the deprecated callbackUrl', () => {
|
|
35
|
+
expect(resolveSignInFormRedirect('/cms/account', '/cms/users', '/cms')).toBe('/cms/account')
|
|
36
|
+
expect(
|
|
37
|
+
resolveSignInFormRedirect('/cms/account', undefined, () => {
|
|
38
|
+
throw new Error('fallback must be lazy')
|
|
39
|
+
})
|
|
40
|
+
).toBe('/cms/account')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('supports a safe deprecated callbackUrl when redirectTo is absent or unsafe', () => {
|
|
44
|
+
expect(resolveSignInFormRedirect(undefined, '/cms/users', '/cms')).toBe('/cms/users')
|
|
45
|
+
expect(resolveSignInFormRedirect('https://evil.test', '/cms/users', '/cms')).toBe('/cms/users')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('uses the safe configured fallback without permitting an open redirect', () => {
|
|
49
|
+
expect(resolveSignInFormRedirect('//evil.test', 'https://evil.test', '/cms')).toBe('/cms')
|
|
50
|
+
expect(resolveSignInFormRedirect(undefined, undefined, 'https://evil.test')).toBe('/')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { normalizeRootRelativeRedirect as normalizeCoreRedirect } from '@byline/core'
|
|
2
|
+
|
|
3
|
+
/** Accept only canonical, unencoded, root-relative same-origin redirect paths. */
|
|
4
|
+
export function normalizeRootRelativeRedirect(value: string): string | undefined {
|
|
5
|
+
const normalized = normalizeCoreRedirect(value)
|
|
6
|
+
if (!normalized) return undefined
|
|
7
|
+
|
|
8
|
+
// Keep an origin check next to the navigation sink even if core validation changes.
|
|
9
|
+
let url: URL
|
|
10
|
+
try {
|
|
11
|
+
url = new URL(normalized, 'https://byline.invalid')
|
|
12
|
+
} catch {
|
|
13
|
+
return undefined
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (url.origin !== 'https://byline.invalid') return undefined
|
|
17
|
+
return `${url.pathname}${url.search}${url.hash}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Resolve the new prop first, then the deprecated prop, then a trusted fallback. */
|
|
21
|
+
export function resolveSignInFormRedirect(
|
|
22
|
+
redirectTo: string | undefined,
|
|
23
|
+
callbackUrl: string | undefined,
|
|
24
|
+
fallback: string | (() => string)
|
|
25
|
+
): string {
|
|
26
|
+
const requested =
|
|
27
|
+
(redirectTo ? normalizeRootRelativeRedirect(redirectTo) : undefined) ??
|
|
28
|
+
(callbackUrl ? normalizeRootRelativeRedirect(callbackUrl) : undefined)
|
|
29
|
+
if (requested) return requested
|
|
30
|
+
|
|
31
|
+
const defaultPath = typeof fallback === 'function' ? fallback() : fallback
|
|
32
|
+
return normalizeRootRelativeRedirect(defaultPath) ?? '/'
|
|
33
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, expectTypeOf, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { SignInFormProps } from './components/sign-in-form.js'
|
|
4
|
+
|
|
5
|
+
describe('SignInFormProps', () => {
|
|
6
|
+
it('keeps legacy callbackUrl-only callers type-compatible', () => {
|
|
7
|
+
expectTypeOf<{ callbackUrl: string }>().toMatchTypeOf<SignInFormProps>()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('accepts the preferred redirectTo prop and no destination prop', () => {
|
|
11
|
+
expectTypeOf<{ redirectTo: string }>().toMatchTypeOf<SignInFormProps>()
|
|
12
|
+
expectTypeOf<Record<string, never>>().toMatchTypeOf<SignInFormProps>()
|
|
13
|
+
})
|
|
14
|
+
})
|