@sanity/sdk-react 0.0.0-alpha.1

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.
Files changed (60) hide show
  1. package/dist/_chunks-es/useLogOut.js +36 -0
  2. package/dist/_chunks-es/useLogOut.js.map +1 -0
  3. package/dist/components.d.ts +235 -0
  4. package/dist/components.js +250 -0
  5. package/dist/components.js.map +1 -0
  6. package/dist/hooks.d.ts +145 -0
  7. package/dist/hooks.js +27 -0
  8. package/dist/hooks.js.map +1 -0
  9. package/dist/index.d.ts +7 -0
  10. package/dist/index.js +6 -0
  11. package/dist/index.js.map +1 -0
  12. package/package.json +113 -0
  13. package/src/_exports/components.ts +12 -0
  14. package/src/_exports/hooks.ts +7 -0
  15. package/src/_exports/index.ts +10 -0
  16. package/src/components/DocumentGridLayout/DocumentGridLayout.stories.tsx +95 -0
  17. package/src/components/DocumentGridLayout/DocumentGridLayout.test.tsx +42 -0
  18. package/src/components/DocumentGridLayout/DocumentGridLayout.tsx +23 -0
  19. package/src/components/DocumentListLayout/DocumentListLayout.stories.tsx +95 -0
  20. package/src/components/DocumentListLayout/DocumentListLayout.test.tsx +42 -0
  21. package/src/components/DocumentListLayout/DocumentListLayout.tsx +15 -0
  22. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.md +49 -0
  23. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.stories.tsx +34 -0
  24. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.test.tsx +30 -0
  25. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.tsx +115 -0
  26. package/src/components/Login/LoginLinks.test.tsx +100 -0
  27. package/src/components/Login/LoginLinks.tsx +73 -0
  28. package/src/components/auth/AuthBoundary.test.tsx +103 -0
  29. package/src/components/auth/AuthBoundary.tsx +101 -0
  30. package/src/components/auth/AuthError.test.ts +36 -0
  31. package/src/components/auth/AuthError.ts +27 -0
  32. package/src/components/auth/Login.test.tsx +41 -0
  33. package/src/components/auth/Login.tsx +58 -0
  34. package/src/components/auth/LoginCallback.test.tsx +86 -0
  35. package/src/components/auth/LoginCallback.tsx +41 -0
  36. package/src/components/auth/LoginError.test.tsx +56 -0
  37. package/src/components/auth/LoginError.tsx +54 -0
  38. package/src/components/auth/LoginFooter.test.tsx +29 -0
  39. package/src/components/auth/LoginFooter.tsx +67 -0
  40. package/src/components/auth/LoginLayout.test.tsx +33 -0
  41. package/src/components/auth/LoginLayout.tsx +99 -0
  42. package/src/components/context/SanityProvider.test.tsx +25 -0
  43. package/src/components/context/SanityProvider.tsx +42 -0
  44. package/src/hooks/Documents/.keep +0 -0
  45. package/src/hooks/auth/useAuthState.test.tsx +106 -0
  46. package/src/hooks/auth/useAuthState.tsx +33 -0
  47. package/src/hooks/auth/useAuthToken.test.tsx +94 -0
  48. package/src/hooks/auth/useAuthToken.tsx +16 -0
  49. package/src/hooks/auth/useCurrentUser.test.tsx +50 -0
  50. package/src/hooks/auth/useCurrentUser.tsx +27 -0
  51. package/src/hooks/auth/useHandleCallback.test.tsx +25 -0
  52. package/src/hooks/auth/useHandleCallback.tsx +50 -0
  53. package/src/hooks/auth/useLogOut.test.tsx +67 -0
  54. package/src/hooks/auth/useLogOut.tsx +15 -0
  55. package/src/hooks/auth/useLoginUrls.test.tsx +61 -0
  56. package/src/hooks/auth/useLoginUrls.tsx +51 -0
  57. package/src/hooks/client/useClient.test.tsx +130 -0
  58. package/src/hooks/client/useClient.ts +56 -0
  59. package/src/hooks/context/useSanityInstance.test.tsx +31 -0
  60. package/src/hooks/context/useSanityInstance.ts +23 -0
@@ -0,0 +1,49 @@
1
+ # DocumentPreviewLayout
2
+
3
+ The DocumentPreviewLayout component is used to render a compact representation of a [document](#). These previews are often rendered for each document within a [DocumentListUI component](#), but can also be rendered as standalone components.
4
+
5
+ Document previews surface the following information about each document:
6
+
7
+ - The document's title
8
+ - A subtitle (optional)
9
+ - A piece of media, such as an icon or image (optional)
10
+ - The document type (optional)
11
+ - The document’s state, such as draft or published (optional)
12
+
13
+ Additionally, each document preview can take a `url` prop to enable navigation changes when selecting the document, and a `selected` prop to indicate that a given document has been selected.
14
+
15
+ ![An image of the stock preview component]()
16
+
17
+ ```jsx
18
+ <DocumentPreviewLayout
19
+ title={doc.title}
20
+ subtitle={doc.subtitle}
21
+ media={doc.media}
22
+ docType={doc.type}
23
+ docState={doc.published ? 'published' : 'draft'}
24
+ url={doc.url}
25
+ selected={true}
26
+ />
27
+ ```
28
+
29
+ ## Installation
30
+
31
+ ```shell
32
+ npm install @sanity/sdk
33
+ ```
34
+
35
+ ```javascript
36
+ import DocumentPreviewLayout from `@sanity/sdk/react/DocumentPreviewLayout`
37
+ ```
38
+
39
+ ## Props
40
+
41
+ | Name | Type | Description |
42
+ | ---------- | -------------------- | ----------------------------------------------------------- |
43
+ | `title` | `string` | The title to display for the document |
44
+ | `subtitle` | `string` (optional) | The subtitle to display for the document |
45
+ | `media` | `node` (optional) | The image, icon, or other node to display with the document |
46
+ | `docType` | `string` (optional) | The document type |
47
+ | `docState` | `string` (optional) | The state of the document, such as 'published' or 'draft' |
48
+ | `url` | `string` (optional) | The URL to navigate to when selecting the document |
49
+ | `selected` | `boolean` (optional) | The `selected` state of the document; defaults to `false` |
@@ -0,0 +1,34 @@
1
+ import {type Meta, type StoryObj} from '@storybook/react'
2
+
3
+ import {DocumentPreviewLayout} from './DocumentPreviewLayout'
4
+
5
+ const meta: Meta<typeof DocumentPreviewLayout> = {
6
+ title: 'DocumentPreviewLayout',
7
+ component: DocumentPreviewLayout,
8
+ }
9
+
10
+ export default meta
11
+ type Story = StoryObj<typeof meta>
12
+
13
+ export const Basic: Story = {
14
+ args: {
15
+ title: 'Hello World',
16
+ url: '#',
17
+ },
18
+ render: (props) => {
19
+ return <DocumentPreviewLayout {...props} />
20
+ },
21
+ }
22
+
23
+ export const AllProps: Story = {
24
+ args: {
25
+ title: 'Hello World',
26
+ subtitle: 'It’s nice to meet you',
27
+ url: '#',
28
+ docType: 'article',
29
+ status: 'published',
30
+ },
31
+ render: (props) => {
32
+ return <DocumentPreviewLayout {...props} />
33
+ },
34
+ }
@@ -0,0 +1,30 @@
1
+ import {render, screen} from '../../../test/test-utils.tsx'
2
+ import {DocumentPreviewLayout} from './DocumentPreviewLayout'
3
+
4
+ describe('DocumentPreviewLayout', () => {
5
+ it('renders the data it receives via props', () => {
6
+ render(<DocumentPreviewLayout title="Test Preview" subtitle="It works" />)
7
+ expect(screen.getByText('Test Preview')).toBeVisible()
8
+ expect(screen.getByText('It works')).toBeVisible()
9
+ })
10
+
11
+ it('renders empty when no title is provided (todo)', () => {
12
+ const {container} = render(<DocumentPreviewLayout title="" />)
13
+ expect(container).toBeEmptyDOMElement()
14
+ })
15
+
16
+ it('renders the doctype when one is provided', () => {
17
+ render(<DocumentPreviewLayout title="Test Preview" docType="article" />)
18
+ expect(screen.getByText('article')).toBeVisible()
19
+ })
20
+
21
+ it('renders the published status when provided', () => {
22
+ render(<DocumentPreviewLayout title="Test Preview" status="published" />)
23
+ expect(screen.getByText('published')).toBeVisible()
24
+ })
25
+
26
+ it('renders the draft status when provided', () => {
27
+ render(<DocumentPreviewLayout title="Test Preview" status="draft" />)
28
+ expect(screen.getByText('draft')).toBeVisible()
29
+ })
30
+ })
@@ -0,0 +1,115 @@
1
+ import {Badge, Button, Stack, Text} from '@sanity/ui'
2
+ import styled from 'styled-components'
3
+
4
+ /**
5
+ * @public
6
+ */
7
+ export interface DocumentPreviewLayoutProps {
8
+ docType?: string
9
+ media?: React.ReactNode // Todo: determine how media data will be passed to this component; need to represent either an image or an icon
10
+ selected?: boolean
11
+ status?: string
12
+ subtitle?: string
13
+ title: string
14
+ url?: string
15
+ }
16
+
17
+ // Todo: replace with actual media (either image or icon)
18
+ const TempMedia = styled.div`
19
+ aspect-ratio: 1 / 1;
20
+ inline-size: 33px;
21
+ border: 1px solid #ccc;
22
+ `
23
+
24
+ // Set a containment context for the Preview
25
+ const Container = styled.div`
26
+ container-type: inline-size;
27
+ display: flex;
28
+ align-items: center;
29
+ gap: 0.75em;
30
+ `
31
+
32
+ // Status labels are visually hidden when a narrow document list is rendered;
33
+ // text remains accessible to screen readers
34
+ const StatusLabel = styled.span`
35
+ @container (width < 52ch) {
36
+ clip: rect(0 0 0 0);
37
+ clip-path: inset(50%);
38
+ height: 1px;
39
+ overflow: hidden;
40
+ position: absolute;
41
+ white-space: nowrap;
42
+ width: 1px;
43
+ }
44
+ `
45
+
46
+ /**
47
+ * This is a component that renders a document preview.
48
+ *
49
+ * @public
50
+ *
51
+ * @param props - The props for the DocumentPreviewLayout component.
52
+ * @returns - The DocumentPreviewLayout component.
53
+ */
54
+ export const DocumentPreviewLayout = ({
55
+ docType,
56
+ selected = false,
57
+ status = '',
58
+ subtitle = '',
59
+ title,
60
+ url = '',
61
+ }: DocumentPreviewLayoutProps): JSX.Element => {
62
+ // Todo: empty state
63
+ if (!title) {
64
+ return <></>
65
+ }
66
+
67
+ return (
68
+ <Button
69
+ as="a"
70
+ href={url}
71
+ mode="bleed"
72
+ width="fill"
73
+ padding={3}
74
+ selected={selected}
75
+ data-ui="DocumentPreviewLayout"
76
+ >
77
+ <Container>
78
+ <TempMedia />
79
+
80
+ <Stack flex={1} space={2}>
81
+ <Text size={1} weight="medium" textOverflow="ellipsis">
82
+ {title}
83
+ </Text>
84
+ {subtitle && (
85
+ <Text muted size={1} textOverflow="ellipsis">
86
+ {subtitle}
87
+ </Text>
88
+ )}
89
+ </Stack>
90
+
91
+ {docType && (
92
+ <Badge padding={2} fontSize={0}>
93
+ {docType}
94
+ </Badge>
95
+ )}
96
+
97
+ {/* Todo: finalize UI for this */}
98
+ {status === 'published' && (
99
+ <Badge padding={2} fontSize={0} tone="positive">
100
+ ✔︎ <StatusLabel>published</StatusLabel>
101
+ </Badge>
102
+ )}
103
+
104
+ {/* Todo: finalize UI for this, determine if we need to show 'draft' or just 'published' */}
105
+ {status === 'draft' && (
106
+ <Badge padding={2} fontSize={0} tone="caution">
107
+ ⛑︎ <StatusLabel>draft</StatusLabel>
108
+ </Badge>
109
+ )}
110
+ </Container>
111
+ </Button>
112
+ )
113
+ }
114
+
115
+ DocumentPreviewLayout.displayName = 'DocumentPreviewLayout'
@@ -0,0 +1,100 @@
1
+ import {createSanityInstance} from '@sanity/sdk'
2
+ import {ThemeProvider} from '@sanity/ui'
3
+ import {buildTheme} from '@sanity/ui/theme'
4
+ import {render, screen} from '@testing-library/react'
5
+ import React from 'react'
6
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
7
+
8
+ import {useAuthState} from '../../hooks/auth/useAuthState'
9
+ import {useLoginUrls} from '../../hooks/auth/useLoginUrls'
10
+ import {SanityProvider} from '../context/SanityProvider'
11
+ import {LoginLinks} from './LoginLinks'
12
+
13
+ // Mock the hooks and SDK functions
14
+ vi.mock('../../hooks/auth/useLoginUrls', () => ({
15
+ useLoginUrls: vi.fn(() => [
16
+ {
17
+ name: 'google',
18
+ title: 'Google',
19
+ url: 'https://google.com/auth',
20
+ },
21
+ {
22
+ name: 'github',
23
+ title: 'GitHub',
24
+ url: 'https://github.com/auth',
25
+ },
26
+ ]),
27
+ }))
28
+ vi.mock('@sanity/sdk', async () => {
29
+ const actual = await vi.importActual('@sanity/sdk')
30
+
31
+ return {
32
+ ...actual,
33
+ tradeTokenForSession: vi.fn(),
34
+ getSidUrlHash: vi.fn().mockReturnValue(null),
35
+ getSidUrlSearch: vi.fn(),
36
+ }
37
+ })
38
+
39
+ vi.mock('../../hooks/auth/useAuthState', () => ({
40
+ useAuthState: vi.fn(() => 'logged-out'),
41
+ }))
42
+
43
+ vi.mock('../../hooks/auth/useHandleCallback', () => ({
44
+ useHandleCallback: vi.fn(),
45
+ }))
46
+
47
+ const theme = buildTheme({})
48
+
49
+ describe('LoginLinks', () => {
50
+ beforeEach(() => {
51
+ vi.clearAllMocks()
52
+ })
53
+
54
+ const sanityInstance = createSanityInstance({projectId: 'test-project-id', dataset: 'production'})
55
+ const renderWithWrappers = (ui: React.ReactElement) => {
56
+ return render(
57
+ <ThemeProvider theme={theme}>
58
+ <SanityProvider sanityInstance={sanityInstance}>{ui}</SanityProvider>
59
+ </ThemeProvider>,
60
+ )
61
+ }
62
+
63
+ it('renders auth provider links correctly when not authenticated', () => {
64
+ vi.mocked(useAuthState).mockReturnValue({
65
+ type: 'logged-out',
66
+ isDestroyingSession: false,
67
+ })
68
+ renderWithWrappers(<LoginLinks />)
69
+
70
+ expect(screen.getByText('Choose login provider')).toBeInTheDocument()
71
+
72
+ const authProviders = useLoginUrls()
73
+ authProviders.forEach((provider) => {
74
+ const button = screen.getByRole('link', {name: provider.title})
75
+ expect(button).toBeInTheDocument()
76
+ expect(button).toHaveAttribute('href', provider.url)
77
+ })
78
+ })
79
+
80
+ it('shows loading state while logging in', () => {
81
+ vi.mocked(useAuthState).mockReturnValue({
82
+ type: 'logging-in',
83
+ isExchangingToken: false,
84
+ })
85
+ renderWithWrappers(<LoginLinks />)
86
+
87
+ expect(screen.getByText('Logging in...')).toBeInTheDocument()
88
+ })
89
+
90
+ it('shows success message when logged in', () => {
91
+ vi.mocked(useAuthState).mockReturnValue({
92
+ type: 'logged-in',
93
+ token: 'test-token',
94
+ currentUser: null,
95
+ })
96
+ renderWithWrappers(<LoginLinks />)
97
+
98
+ expect(screen.getByText('You are logged in')).toBeInTheDocument()
99
+ })
100
+ })
@@ -0,0 +1,73 @@
1
+ import {Button, Card, Container, Flex, Heading, Stack} from '@sanity/ui'
2
+ import {type ReactElement} from 'react'
3
+
4
+ import {useAuthState} from '../../hooks/auth/useAuthState'
5
+ import {useHandleCallback} from '../../hooks/auth/useHandleCallback'
6
+ import {useLoginUrls} from '../../hooks/auth/useLoginUrls'
7
+
8
+ /**
9
+ * Component that handles Sanity authentication flow and renders login provider options
10
+ *
11
+ * @public
12
+ *
13
+ * @returns Rendered component
14
+ *
15
+ * @remarks
16
+ * The component handles three states:
17
+ * 1. Loading state during token exchange
18
+ * 2. Success state after successful authentication
19
+ * 3. Provider selection UI when not authenticated
20
+ *
21
+ * @example
22
+ * ```tsx
23
+ * const config = { projectId: 'your-project-id', dataset: 'production' }
24
+ * return <LoginLinks sanityInstance={config} />
25
+ * ```
26
+ */
27
+ export const LoginLinks = (): ReactElement => {
28
+ const loginUrls = useLoginUrls()
29
+ const authState = useAuthState()
30
+ useHandleCallback()
31
+
32
+ if (authState.type === 'logging-in') {
33
+ return <div>Logging in...</div>
34
+ }
35
+
36
+ // Show success state after authentication
37
+ if (authState.type === 'logged-in') {
38
+ return <div>You are logged in</div>
39
+ }
40
+
41
+ /**
42
+ * Render provider selection UI
43
+ * Uses Sanity UI components for consistent styling
44
+ */
45
+ return (
46
+ <Card height="fill" overflow="auto" paddingX={4}>
47
+ <Flex height="fill" direction="column" align="center" justify="center" paddingTop={4}>
48
+ <Container width={0}>
49
+ <Stack space={4}>
50
+ <Heading align="center" size={1}>
51
+ Choose login provider
52
+ </Heading>
53
+
54
+ <Stack space={2}>
55
+ {loginUrls.map((provider, index) => (
56
+ <Button
57
+ key={`${provider.url}_${index}`}
58
+ as="a"
59
+ href={provider.url}
60
+ mode="ghost"
61
+ tone="default"
62
+ space={3}
63
+ padding={3}
64
+ text={provider.title}
65
+ />
66
+ ))}
67
+ </Stack>
68
+ </Stack>
69
+ </Container>
70
+ </Flex>
71
+ </Card>
72
+ )
73
+ }
@@ -0,0 +1,103 @@
1
+ import {createSanityInstance} from '@sanity/sdk'
2
+ import {ThemeProvider} from '@sanity/ui'
3
+ import {buildTheme} from '@sanity/ui/theme'
4
+ import {render, screen, waitFor} from '@testing-library/react'
5
+ import React from 'react'
6
+ import {beforeEach, describe, expect, it, type MockInstance, vi} from 'vitest'
7
+
8
+ import {useAuthState} from '../../hooks/auth/useAuthState'
9
+ import {SanityProvider} from '../context/SanityProvider'
10
+ import {AuthBoundary} from './AuthBoundary'
11
+
12
+ // Mock hooks
13
+ vi.mock('../../hooks/auth/useAuthState', () => ({
14
+ useAuthState: vi.fn(() => 'logged-out'),
15
+ }))
16
+ vi.mock('../../hooks/auth/useLoginUrls', () => ({
17
+ useLoginUrls: vi.fn(() => [{title: 'Provider A', url: 'https://provider-a.com/auth'}]),
18
+ }))
19
+ vi.mock('../../hooks/auth/useHandleCallback', () => ({
20
+ useHandleCallback: vi.fn(() => async () => {}),
21
+ }))
22
+ vi.mock('../../hooks/auth/useLogOut', () => ({
23
+ useLogOut: vi.fn(() => async () => {}),
24
+ }))
25
+
26
+ // Mock AuthError throwing scenario
27
+ vi.mock('./AuthError', async (importOriginal) => {
28
+ const actual = await importOriginal<typeof import('./AuthError')>()
29
+ return {
30
+ ...actual,
31
+ AuthError: class MockAuthError extends Error {
32
+ constructor(error: Error) {
33
+ super(error.message)
34
+ this.name = 'AuthError'
35
+ this.cause = error
36
+ }
37
+ },
38
+ }
39
+ })
40
+
41
+ const theme = buildTheme({})
42
+ const sanityInstance = createSanityInstance({projectId: 'test-project-id', dataset: 'production'})
43
+ const renderWithWrappers = (ui: React.ReactElement) => {
44
+ return render(
45
+ <ThemeProvider theme={theme}>
46
+ <SanityProvider sanityInstance={sanityInstance}>{ui}</SanityProvider>
47
+ </ThemeProvider>,
48
+ )
49
+ }
50
+
51
+ describe('AuthBoundary', () => {
52
+ let consoleErrorSpy: MockInstance
53
+ beforeEach(() => {
54
+ vi.clearAllMocks()
55
+ consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
56
+ })
57
+
58
+ afterEach(() => {
59
+ consoleErrorSpy?.mockRestore()
60
+ })
61
+
62
+ it('renders the Login component when authState="logged-out"', () => {
63
+ vi.mocked(useAuthState).mockReturnValue({type: 'logged-out', isDestroyingSession: false})
64
+ renderWithWrappers(<AuthBoundary>Protected Content</AuthBoundary>)
65
+
66
+ // The login screen should show "Choose login provider" by default
67
+ expect(screen.getByText('Choose login provider')).toBeInTheDocument()
68
+ expect(screen.queryByText('Protected Content')).not.toBeInTheDocument()
69
+ })
70
+
71
+ it('renders the LoginCallback component when authState="logging-in"', () => {
72
+ vi.mocked(useAuthState).mockReturnValue({type: 'logging-in', isExchangingToken: false})
73
+ renderWithWrappers(<AuthBoundary>Protected Content</AuthBoundary>)
74
+
75
+ // The callback screen shows "Logging you in…"
76
+ expect(screen.getByText('Logging you in…')).toBeInTheDocument()
77
+ })
78
+
79
+ it('renders children when authState="logged-in"', () => {
80
+ vi.mocked(useAuthState).mockReturnValue({
81
+ type: 'logged-in',
82
+ currentUser: null,
83
+ token: 'exampleToken',
84
+ })
85
+ renderWithWrappers(<AuthBoundary>Protected Content</AuthBoundary>)
86
+
87
+ expect(screen.getByText('Protected Content')).toBeInTheDocument()
88
+ })
89
+
90
+ it('shows the LoginError (via ErrorBoundary) when authState="error"', async () => {
91
+ vi.mocked(useAuthState).mockReturnValue({type: 'error', error: new Error('test error')})
92
+ renderWithWrappers(<AuthBoundary>Protected Content</AuthBoundary>)
93
+
94
+ // The AuthBoundary should throw an AuthError internally
95
+ // and then display the LoginError component as the fallback.
96
+ await waitFor(() => {
97
+ expect(screen.getByText('Authentication Error')).toBeInTheDocument()
98
+ expect(
99
+ screen.getByText('Please try again or contact support if the problem persists.'),
100
+ ).toBeInTheDocument()
101
+ })
102
+ })
103
+ })
@@ -0,0 +1,101 @@
1
+ import {useMemo} from 'react'
2
+ import {ErrorBoundary, type FallbackProps} from 'react-error-boundary'
3
+
4
+ import {useAuthState} from '../../hooks/auth/useAuthState'
5
+ import {AuthError} from './AuthError'
6
+ import {Login} from './Login'
7
+ import {LoginCallback} from './LoginCallback'
8
+ import {LoginError, type LoginErrorProps} from './LoginError'
9
+ import type {LoginLayoutProps} from './LoginLayout'
10
+
11
+ /**
12
+ * @alpha
13
+ */
14
+ export interface AuthBoundaryProps extends LoginLayoutProps {
15
+ /**
16
+ * Custom component to render the login screen.
17
+ * Receives all login layout props. Defaults to {@link Login}.
18
+ */
19
+ LoginComponent?: React.ComponentType<LoginLayoutProps>
20
+
21
+ /**
22
+ * Custom component to render during OAuth callback processing.
23
+ * Receives all login layout props. Defaults to {@link LoginCallback}.
24
+ */
25
+ CallbackComponent?: React.ComponentType<LoginLayoutProps>
26
+
27
+ /**
28
+ * Custom component to render when authentication errors occur.
29
+ * Receives login layout props and error boundary props. Defaults to
30
+ * {@link LoginError}
31
+ */
32
+ LoginErrorComponent?: React.ComponentType<LoginErrorProps>
33
+ }
34
+
35
+ /**
36
+ * A component that handles authentication flow and error boundaries for a
37
+ * protected section of the application.
38
+ *
39
+ * @remarks
40
+ * This component manages different authentication states and renders the
41
+ * appropriate components based on that state.
42
+ *
43
+ * @example
44
+ * ```tsx
45
+ * function App() {
46
+ * return (
47
+ * <AuthBoundary header={<MyLogo />}>
48
+ * <ProtectedContent />
49
+ * </AuthBoundary>
50
+ * )
51
+ * }
52
+ * ```
53
+ *
54
+ * @alpha
55
+ */
56
+ export function AuthBoundary({
57
+ LoginErrorComponent = LoginError,
58
+ ...props
59
+ }: AuthBoundaryProps): React.ReactNode {
60
+ const {header, footer} = props
61
+ const FallbackComponent = useMemo(() => {
62
+ return function LoginComponentWithLayoutProps(fallbackProps: FallbackProps) {
63
+ return <LoginErrorComponent {...fallbackProps} header={header} footer={footer} />
64
+ }
65
+ }, [header, footer, LoginErrorComponent])
66
+
67
+ return (
68
+ <ErrorBoundary FallbackComponent={FallbackComponent}>
69
+ <AuthSwitch {...props} />
70
+ </ErrorBoundary>
71
+ )
72
+ }
73
+
74
+ interface AuthSwitchProps extends LoginLayoutProps {
75
+ LoginComponent?: React.ComponentType<LoginLayoutProps>
76
+ CallbackComponent?: React.ComponentType<LoginLayoutProps>
77
+ }
78
+
79
+ function AuthSwitch({
80
+ LoginComponent = Login,
81
+ CallbackComponent = LoginCallback,
82
+ children,
83
+ ...props
84
+ }: AuthSwitchProps) {
85
+ const authState = useAuthState()
86
+
87
+ switch (authState.type) {
88
+ case 'error': {
89
+ throw new AuthError(authState.error)
90
+ }
91
+ case 'logging-in': {
92
+ return <CallbackComponent {...props} />
93
+ }
94
+ case 'logged-in': {
95
+ return children
96
+ }
97
+ default: {
98
+ return <LoginComponent {...props} />
99
+ }
100
+ }
101
+ }
@@ -0,0 +1,36 @@
1
+ import {describe, expect, it} from 'vitest'
2
+
3
+ import {AuthError} from './AuthError'
4
+
5
+ describe('AuthError', () => {
6
+ it('should use error message if provided', () => {
7
+ const originalError = new Error('Authentication failed')
8
+ const authError = new AuthError(originalError)
9
+
10
+ expect(authError.message).toBe('Authentication failed')
11
+ expect(authError.cause).toBe(originalError)
12
+ })
13
+
14
+ it('should handle non-error objects with message property', () => {
15
+ const customError = {message: 'Custom error message'}
16
+ const authError = new AuthError(customError)
17
+
18
+ expect(authError.message).toBe('Custom error message')
19
+ expect(authError.cause).toBe(customError)
20
+ })
21
+
22
+ it('should handle errors without message property', () => {
23
+ const nonError = {foo: 'bar'}
24
+ const authError = new AuthError(nonError)
25
+
26
+ expect(authError.message).toBe('')
27
+ expect(authError.cause).toBe(nonError)
28
+ })
29
+
30
+ it('should handle primitive error values', () => {
31
+ const authError = new AuthError('string error')
32
+
33
+ expect(authError.message).toBe('')
34
+ expect(authError.cause).toBe('string error')
35
+ })
36
+ })
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Error class for authentication-related errors. Wraps errors thrown during the
3
+ * authentication flow.
4
+ *
5
+ * @remarks
6
+ * This class provides a consistent error type for authentication failures while
7
+ * preserving the original error as the cause. If the original error has a
8
+ * message property, it will be used as the error message.
9
+ *
10
+ * @alpha
11
+ */
12
+ export class AuthError extends Error {
13
+ constructor(error: unknown) {
14
+ if (
15
+ typeof error === 'object' &&
16
+ !!error &&
17
+ 'message' in error &&
18
+ typeof error.message === 'string'
19
+ ) {
20
+ super(error.message)
21
+ } else {
22
+ super()
23
+ }
24
+
25
+ this.cause = error
26
+ }
27
+ }