Skip to content

Commit b8e4356

Browse files
authored
Simple Auth (#25)
1 parent a32503d commit b8e4356

File tree

7 files changed

+208
-34
lines changed

7 files changed

+208
-34
lines changed

src/app/page.tsx

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useState, useMemo } from 'react'
44

55
import { DevToolsUI } from '@/components/DevToolsUi'
6+
import { FetchError } from '@/components/FetchError'
67
import { UploadDropzone } from '@/components/UploadDropzone'
78
import { DiagnosticData } from '@/types/DiagnosticData'
89

@@ -14,6 +15,7 @@ export default function Home({
1415
const [diagnosticData, setDiagnosticData] = useState<DiagnosticData | null>(null)
1516
const [isLoading, setIsLoading] = useState(false)
1617
const [error, setError] = useState<string | null>(null)
18+
const [responseStatus, setResponseStatus] = useState<number | null>(null)
1719

1820
const fileId = searchParams.fileId as string | undefined
1921

@@ -26,11 +28,20 @@ export default function Home({
2628
}
2729

2830
useMemo(() => {
31+
// local storage won't work in SSR
32+
const token = typeof localStorage !== 'undefined' && localStorage.getItem('debugger-token')
2933
if (fileId) {
3034
setIsLoading(true)
3135
setError(null)
32-
fetch(`/api/fetchSlackFile?fileId=${fileId}`)
33-
.then((response) => response.json())
36+
fetch(`/api/fetchSlackFile?fileId=${fileId}`, {
37+
headers: {
38+
authorization: token,
39+
} as any,
40+
})
41+
.then((response) => {
42+
setResponseStatus(response.status)
43+
return response.json()
44+
})
3445
.then((data) => {
3546
if (data.error) {
3647
throw new Error(data.error)
@@ -59,11 +70,7 @@ export default function Home({
5970
</div>
6071
) : !diagnosticData ? (
6172
<div className="flex-grow flex flex-col items-center justify-center">
62-
{error && (
63-
<div className="flex items-center justify-center p-12">
64-
<p className="text-red-500">{error}</p>
65-
</div>
66-
)}
73+
<FetchError error={error} statusCode={responseStatus} />
6774
<UploadDropzone onFileUpload={handleFileUpload} />
6875
</div>
6976
) : (

src/components/FetchError.test.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, it, expect } from 'vitest'
2+
3+
import { FetchError } from './FetchError'
4+
import { render, screen, userEvent, waitFor } from '../test/test-utils'
5+
6+
describe('FetchError', () => {
7+
it('renders fetch error', () => {
8+
render(<FetchError error="oops" statusCode={418} />)
9+
expect(screen.getByText('oops')).toBeInTheDocument()
10+
expect(screen.queryByText('Set auth token')).not.toBeInTheDocument()
11+
})
12+
13+
it('renders fetch error with auth button', () => {
14+
render(<FetchError error="Unauthorized" statusCode={403} />)
15+
expect(screen.getByText('Unauthorized')).toBeInTheDocument()
16+
expect(screen.getByText('Set auth token')).toBeInTheDocument()
17+
})
18+
19+
it('opens auth modal', async () => {
20+
render(<FetchError error="Unauthorized" statusCode={401} />)
21+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
22+
screen.getByText('Set auth token')
23+
userEvent.click(screen.getByText('Set auth token'))
24+
expect(await screen.findByText('Enter your auth token')).toBeInTheDocument()
25+
})
26+
27+
it('saves auth token', async () => {
28+
render(<FetchError error="Unauthorized" statusCode={403} />)
29+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
30+
userEvent.click(screen.getByText('Set auth token'))
31+
expect(await screen.findByText('Enter your auth token')).toBeInTheDocument()
32+
33+
userEvent.click(screen.getByPlaceholderText('Auth token'))
34+
userEvent.paste('abc123')
35+
36+
userEvent.click(screen.getByText('Save'))
37+
38+
waitFor(() => {
39+
expect(localStorage.getItem('debugger-token')).toBe('abc123')
40+
})
41+
})
42+
})

src/components/FetchError.tsx

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { useState } from 'react'
2+
3+
export const FetchError = ({
4+
error,
5+
statusCode,
6+
}: {
7+
error: string | null
8+
statusCode?: number | null
9+
}) => {
10+
if (!error) {
11+
return null
12+
}
13+
14+
const isAuthError = statusCode && statusCode > 400 && statusCode < 404
15+
16+
return (
17+
<div className="flex items-center justify-center p-12">
18+
<p className="text-red-500">{error}</p>
19+
{isAuthError && <AuthButton />}
20+
</div>
21+
)
22+
}
23+
24+
const AuthButton = () => {
25+
const [showModal, setShowModal] = useState(false)
26+
27+
return (
28+
<>
29+
<button
30+
onClick={() => setShowModal(true)}
31+
className="hover:text-blue-800 text-gray-600 font-bold py-1 px-2 rounded mx-3"
32+
>
33+
Set auth token
34+
</button>
35+
{showModal && <AuthModal onClose={() => setShowModal(false)} />}
36+
</>
37+
)
38+
}
39+
40+
const AuthModal = ({ onClose }: { onClose: () => void }) => {
41+
const [value, setValue] = useState('')
42+
const handleSubmit = (e: React.FormEvent) => {
43+
e.preventDefault()
44+
try {
45+
localStorage.setItem('debugger-token', value)
46+
} catch (e) {
47+
console.error('Error saving auth token:', e)
48+
}
49+
window.location.reload()
50+
}
51+
52+
return (
53+
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
54+
<form className="bg-white p-8 rounded-lg w-96" onSubmit={handleSubmit}>
55+
<div>
56+
<h2 className="text-lg mb-4">Enter your auth token</h2>
57+
<input
58+
type="password"
59+
placeholder="Auth token"
60+
name="auth-token"
61+
value={value}
62+
autoFocus
63+
onChange={(e) => setValue(e.target.value)}
64+
className="border border-gray-300 rounded p-2 mb-4 w-full"
65+
/>
66+
</div>
67+
<div className="flex gap-2 justify-end">
68+
<button type="button" onClick={onClose} className="border-2 text-black px-4 py-2 rounded">
69+
Cancel
70+
</button>
71+
<button
72+
type="submit"
73+
className="border-2 border-blue-500 bg-blue-500 text-white px-4 py-2 rounded"
74+
>
75+
Save
76+
</button>
77+
</div>
78+
</form>
79+
</div>
80+
)
81+
}

src/pages/api/auth.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { NextApiRequest, NextApiResponse } from 'next'
2+
import { NextResponse } from 'next/server'
3+
import { vi, describe, it, expect } from 'vitest'
4+
5+
import { isAuthorized } from './auth'
6+
const spy = vi.spyOn(NextResponse, 'next')
7+
8+
describe('isAuthorized', () => {
9+
it('should return 403 if the authorization header is not correct', () => {
10+
const req = { headers: { authorization: 'wrong' } } as NextApiRequest
11+
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() } as unknown as NextApiResponse
12+
13+
isAuthorized(req, res)
14+
15+
expect(res.status).toHaveBeenCalledWith(403)
16+
expect(res.json).toHaveBeenCalledWith({ error: 'Unauthorized' })
17+
})
18+
19+
it('should call .next() if the authorization header is correct', () => {
20+
spy.mockClear()
21+
const req = { headers: { authorization: process.env.AUTH_SECRET } } as NextApiRequest
22+
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() } as unknown as NextApiResponse
23+
24+
isAuthorized(req, res)
25+
26+
expect(res.status).not.toHaveBeenCalled()
27+
expect(res.json).not.toHaveBeenCalled()
28+
expect(NextResponse.next).toHaveBeenCalledOnce()
29+
})
30+
})

src/pages/api/auth.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { NextApiRequest, NextApiResponse } from 'next'
2+
import { NextResponse } from 'next/server'
3+
4+
export const isAuthorized = (req: NextApiRequest, res: NextApiResponse) => {
5+
if (req.headers.authorization !== process.env.AUTH_SECRET) {
6+
console.log('Unauthorized')
7+
return res.status(403).json({ error: 'Unauthorized' })
8+
}
9+
NextResponse.next()
10+
}

src/pages/api/fetchSlackFile.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,16 @@ vi.mock('@/utils/slackClient', () => ({
2828
const mockFetch = vi.fn()
2929
global.fetch = mockFetch
3030

31-
describe.skip('fetchSlackFile API', () => {
31+
describe('fetchSlackFile API', () => {
3232
let mockReq: Partial<NextApiRequest>
3333
let mockRes: Partial<NextApiResponse>
3434

3535
beforeEach(() => {
3636
mockReq = {
3737
query: {},
38+
headers: {
39+
authorization: process.env.AUTH_SECRET,
40+
},
3841
}
3942
mockRes = {
4043
status: vi.fn().mockReturnThis(),

src/pages/api/fetchSlackFile.ts

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,32 @@
11
import { NextApiRequest, NextApiResponse } from 'next'
22

3-
// import { slackClient } from '@/utils/slackClient'
3+
import { slackClient } from '@/utils/slackClient'
44

5-
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
6-
// const { fileId } = req.query
7-
8-
return res.status(403).json({ error: 'Unauthorized' })
9-
10-
// if (!fileId || typeof fileId !== 'string') {
11-
// return res.status(400).json({ error: 'Invalid fileId' })
12-
// }
13-
14-
// try {
15-
// const result = await slackClient.files.info({ file: fileId })
5+
import { isAuthorized } from './auth'
166

17-
// if (!result.file || !result.file.url_private) {
18-
// return res.status(404).json({ error: 'File not found' })
19-
// }
20-
21-
// const fileContent = await fetch(result.file.url_private, {
22-
// headers: { Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}` },
23-
// })
24-
25-
// const jsonData = await fileContent.json()
26-
// res.status(200).json(jsonData)
27-
// } catch (error) {
28-
// console.error('Error fetching Slack file:', error)
29-
// res.status(500).json({ error: 'Error fetching Slack file' })
30-
// }
7+
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
8+
isAuthorized(req, res)
9+
10+
const { fileId } = req.query
11+
if (!fileId || typeof fileId !== 'string') {
12+
return res.status(400).json({ error: 'Invalid fileId' })
13+
}
14+
15+
try {
16+
const result = await slackClient.files.info({ file: fileId })
17+
18+
if (!result.file || !result.file.url_private) {
19+
return res.status(404).json({ error: 'File not found' })
20+
}
21+
22+
const fileContent = await fetch(result.file.url_private, {
23+
headers: { Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}` },
24+
})
25+
26+
const jsonData = await fileContent.json()
27+
res.status(200).json(jsonData)
28+
} catch (error) {
29+
console.error('Error fetching Slack file:', error)
30+
res.status(500).json({ error: 'Error fetching Slack file' })
31+
}
3132
}

0 commit comments

Comments
 (0)