@byline/admin 3.21.0 → 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/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/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
|
@@ -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/
|
|
160
|
-
"@byline/
|
|
161
|
-
"@byline/
|
|
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",
|
|
@@ -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
|
+
})
|