@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.
- package/dist/_chunks-es/useLogOut.js +36 -0
- package/dist/_chunks-es/useLogOut.js.map +1 -0
- package/dist/components.d.ts +235 -0
- package/dist/components.js +250 -0
- package/dist/components.js.map +1 -0
- package/dist/hooks.d.ts +145 -0
- package/dist/hooks.js +27 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/package.json +113 -0
- package/src/_exports/components.ts +12 -0
- package/src/_exports/hooks.ts +7 -0
- package/src/_exports/index.ts +10 -0
- package/src/components/DocumentGridLayout/DocumentGridLayout.stories.tsx +95 -0
- package/src/components/DocumentGridLayout/DocumentGridLayout.test.tsx +42 -0
- package/src/components/DocumentGridLayout/DocumentGridLayout.tsx +23 -0
- package/src/components/DocumentListLayout/DocumentListLayout.stories.tsx +95 -0
- package/src/components/DocumentListLayout/DocumentListLayout.test.tsx +42 -0
- package/src/components/DocumentListLayout/DocumentListLayout.tsx +15 -0
- package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.md +49 -0
- package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.stories.tsx +34 -0
- package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.test.tsx +30 -0
- package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.tsx +115 -0
- package/src/components/Login/LoginLinks.test.tsx +100 -0
- package/src/components/Login/LoginLinks.tsx +73 -0
- package/src/components/auth/AuthBoundary.test.tsx +103 -0
- package/src/components/auth/AuthBoundary.tsx +101 -0
- package/src/components/auth/AuthError.test.ts +36 -0
- package/src/components/auth/AuthError.ts +27 -0
- package/src/components/auth/Login.test.tsx +41 -0
- package/src/components/auth/Login.tsx +58 -0
- package/src/components/auth/LoginCallback.test.tsx +86 -0
- package/src/components/auth/LoginCallback.tsx +41 -0
- package/src/components/auth/LoginError.test.tsx +56 -0
- package/src/components/auth/LoginError.tsx +54 -0
- package/src/components/auth/LoginFooter.test.tsx +29 -0
- package/src/components/auth/LoginFooter.tsx +67 -0
- package/src/components/auth/LoginLayout.test.tsx +33 -0
- package/src/components/auth/LoginLayout.tsx +99 -0
- package/src/components/context/SanityProvider.test.tsx +25 -0
- package/src/components/context/SanityProvider.tsx +42 -0
- package/src/hooks/Documents/.keep +0 -0
- package/src/hooks/auth/useAuthState.test.tsx +106 -0
- package/src/hooks/auth/useAuthState.tsx +33 -0
- package/src/hooks/auth/useAuthToken.test.tsx +94 -0
- package/src/hooks/auth/useAuthToken.tsx +16 -0
- package/src/hooks/auth/useCurrentUser.test.tsx +50 -0
- package/src/hooks/auth/useCurrentUser.tsx +27 -0
- package/src/hooks/auth/useHandleCallback.test.tsx +25 -0
- package/src/hooks/auth/useHandleCallback.tsx +50 -0
- package/src/hooks/auth/useLogOut.test.tsx +67 -0
- package/src/hooks/auth/useLogOut.tsx +15 -0
- package/src/hooks/auth/useLoginUrls.test.tsx +61 -0
- package/src/hooks/auth/useLoginUrls.tsx +51 -0
- package/src/hooks/client/useClient.test.tsx +130 -0
- package/src/hooks/client/useClient.ts +56 -0
- package/src/hooks/context/useSanityInstance.test.tsx +31 -0
- 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
|
+
}
|