@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.
@@ -1,5 +1,7 @@
1
- interface SignInFormProps {
2
- /** Destination after successful sign-in. Defaults to `/admin`. */
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 {};
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": "3.21.0",
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": "3.21.0",
159
- "@byline/ui": "3.21.0",
160
- "@byline/i18n": "3.21.0",
161
- "@byline/core": "3.21.0"
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 the caller-supplied
16
- * `callbackUrl` (or `/admin`). On failure renders a generic "Invalid
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
- /** Destination after successful sign-in. Defaults to `/admin`. */
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
+ })