Skip to content

Commit 6e6bb85

Browse files
authored
MIM-582: property-tree: revise login flow (#79)
* wip * no errors now when strict mode is off * usequery for user * some clean up + use typed callback request * auth provider not needed * tree: revise auth callback * tree: move auth stuff * tree: userUser -> useUser * tree: track last known path for redirect on re-auth
1 parent 145788b commit 6e6bb85

File tree

9 files changed

+229
-258
lines changed

9 files changed

+229
-258
lines changed

packages/property-tree/src/App.tsx

Lines changed: 21 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,7 @@ import {
77
import { CommandPalette } from './components/CommandPalette'
88
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
99
import { CommandPaletteProvider } from './components/hooks/useCommandPalette'
10-
import {
11-
AuthProvider,
12-
ProtectedRoute,
13-
useAuth,
14-
} from './components/auth/useAuth'
15-
import { AuthCallback } from './components/auth/AuthCallback'
10+
import { AuthCallback } from './auth/AuthCallback'
1611

1712
import { CompanyView } from './components/views/CompanyView'
1813
import { PropertyView } from './components/views/PropertyView'
@@ -35,6 +30,9 @@ import {
3530
BreadcrumbItem,
3631
BreadcrumbLink,
3732
} from './components/ui/Breadcrumb'
33+
import { ProtectedRoute } from './auth/ProtectedRoute'
34+
import { useAuth } from './auth/useAuth'
35+
import { useUser } from './auth/useUser'
3836

3937
const queryClient = new QueryClient({
4038
defaultOptions: {
@@ -46,7 +44,8 @@ const queryClient = new QueryClient({
4644
})
4745

4846
function AppContent() {
49-
const { user, logout } = useAuth()
47+
const { logout } = useAuth()
48+
const userState = useUser()
5049

5150
return (
5251
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-900">
@@ -77,9 +76,9 @@ function AppContent() {
7776
</BreadcrumbItem>
7877
</BreadcrumbList>
7978
</Breadcrumb>
80-
{user && (
79+
{userState.tag === 'success' && (
8180
<div className="flex items-center gap-4">
82-
<span className="text-sm">{user.name}</span>
81+
<span className="text-sm">{userState.user.name}</span>
8382
<button
8483
onClick={logout}
8584
className="text-sm text-gray-500 hover:text-gray-700"
@@ -123,36 +122,23 @@ function AppContent() {
123122
}
124123

125124
export default function App() {
126-
const authConfig = {
127-
keycloakUrl:
128-
import.meta.env.VITE_KEYCLOAK_URL ||
129-
'https://auth-test.mimer.nu/realms/onecore-test',
130-
clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID || 'onecore-test',
131-
apiUrl: import.meta.env.VITE_CORE_API_URL || 'http://localhost:5010',
132-
redirectUri:
133-
import.meta.env.VITE_KEYCLOAK_REDIRECT_URI ||
134-
'http://localhost:3000/callback',
135-
}
136-
137125
return (
138126
<QueryClientProvider client={queryClient}>
139-
<AuthProvider config={authConfig}>
127+
<CommandPaletteProvider>
140128
<Router>
141-
<CommandPaletteProvider>
142-
<Routes>
143-
<Route path="/callback" element={<AuthCallback />} />
144-
<Route
145-
path="/*"
146-
element={
147-
<ProtectedRoute>
148-
<AppContent />
149-
</ProtectedRoute>
150-
}
151-
/>
152-
</Routes>
153-
</CommandPaletteProvider>
129+
<Routes>
130+
<Route path="/callback" element={<AuthCallback />} />
131+
<Route
132+
path="/*"
133+
element={
134+
<ProtectedRoute>
135+
<AppContent />
136+
</ProtectedRoute>
137+
}
138+
/>
139+
</Routes>
154140
</Router>
155-
</AuthProvider>
141+
</CommandPaletteProvider>
156142
</QueryClientProvider>
157143
)
158144
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export const authConfig = {
2+
keycloakUrl:
3+
import.meta.env.VITE_KEYCLOAK_URL ||
4+
'https://auth-test.mimer.nu/realms/onecore-test',
5+
clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID || 'onecore-test',
6+
apiUrl: import.meta.env.VITE_CORE_API_URL || 'http://localhost:5010',
7+
redirectUri:
8+
import.meta.env.VITE_KEYCLOAK_REDIRECT_URI ||
9+
'http://localhost:3000/callback',
10+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import React from 'react'
2+
import { Navigate, useNavigate, useSearchParams } from 'react-router-dom'
3+
import { match, P } from 'ts-pattern'
4+
5+
import { authConfig } from '@/auth-config'
6+
import { POST } from '@/services/api/core/base-api'
7+
8+
type AuthCallbackState =
9+
| { tag: 'loading' }
10+
| { tag: 'success' }
11+
| { tag: 'error'; error: string }
12+
13+
export function AuthCallback() {
14+
const navigate = useNavigate()
15+
const [searchParams] = useSearchParams()
16+
const [state, setState] = React.useState<AuthCallbackState>({
17+
tag: 'loading',
18+
})
19+
20+
const code = searchParams.get('code')
21+
22+
const lastKnownClientPath = match(searchParams.get('state'))
23+
.with(P.string, decodeURIComponent)
24+
.otherwise(() => '/')
25+
26+
const requested = React.useRef(false)
27+
28+
React.useEffect(() => {
29+
if (!code) {
30+
return setState({
31+
tag: 'error',
32+
error: 'Ingen autentiseringskod hittades i URL:en.',
33+
})
34+
} else {
35+
if (!requested.current) {
36+
// Prevent duplicate requests caused by <StrictMode/> in development ¯\_(ツ)_/¯
37+
requested.current = true
38+
POST('/auth/callback', {
39+
credentials: 'include',
40+
body: {
41+
code,
42+
redirectUri: authConfig.redirectUri,
43+
},
44+
})
45+
.then(() => setState({ tag: 'success' }))
46+
.catch((err) => {
47+
console.error(err)
48+
setState({
49+
tag: 'error',
50+
error: 'Ett fel uppstod vid autentisering.',
51+
})
52+
})
53+
}
54+
}
55+
}, [code, navigate])
56+
57+
return match(state)
58+
.with({ tag: 'loading' }, () => (
59+
<div className="flex items-center justify-center h-screen">
60+
Autentiserar...
61+
</div>
62+
))
63+
.with({ tag: 'error' }, (e) => (
64+
<div className="flex flex-col items-center justify-center h-screen">
65+
<div className="text-red-500 mb-4">{e.error}</div>
66+
<button
67+
onClick={() => (window.location.href = '/')}
68+
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
69+
>
70+
Gå tillbaka
71+
</button>
72+
</div>
73+
))
74+
.with({ tag: 'success' }, () => (
75+
<div className="flex items-center justify-center h-screen">
76+
<Navigate to={lastKnownClientPath} />
77+
</div>
78+
))
79+
.exhaustive()
80+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import React from 'react'
2+
import { match } from 'ts-pattern'
3+
4+
import { useAuth } from './useAuth'
5+
import { useUser } from './useUser'
6+
7+
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
8+
const { login } = useAuth()
9+
const user = useUser()
10+
11+
React.useEffect(() => {
12+
if (user.tag === 'error' && user.error === 'unauthenticated') {
13+
login(location.pathname)
14+
}
15+
}, [login, user])
16+
17+
return match(user)
18+
.with({ tag: 'loading' }, () => (
19+
<div className="flex items-center justify-center h-screen">Laddar...</div>
20+
))
21+
.with({ tag: 'error' }, (e) => (
22+
<div className="flex items-center justify-center h-screen">
23+
<div className="text-red-500">Okänt fel, kontakta support.</div>
24+
</div>
25+
))
26+
.with({ tag: 'success' }, () => <>{children}</>)
27+
.exhaustive()
28+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { authConfig } from '@/auth-config'
2+
3+
export function useAuth() {
4+
const login = (currentClientPath?: string) => {
5+
const authUrl = new URL(
6+
`${authConfig.keycloakUrl}/protocol/openid-connect/auth`
7+
)
8+
authUrl.searchParams.append('client_id', authConfig.clientId)
9+
authUrl.searchParams.append('redirect_uri', authConfig.redirectUri)
10+
authUrl.searchParams.append('response_type', 'code')
11+
authUrl.searchParams.append('scope', 'openid profile email')
12+
13+
if (currentClientPath) {
14+
authUrl.searchParams.append(
15+
'state',
16+
encodeURIComponent(currentClientPath)
17+
)
18+
}
19+
20+
window.location.href = authUrl.toString()
21+
}
22+
23+
const logout = () => {
24+
const logoutUrl = new URL(`${authConfig.apiUrl}/auth/logout`)
25+
window.location.href = logoutUrl.toString()
26+
}
27+
28+
return {
29+
login,
30+
logout,
31+
}
32+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { useQuery } from '@tanstack/react-query'
2+
import { match, P } from 'ts-pattern'
3+
4+
import { authConfig } from '@/auth-config'
5+
6+
export type User = {
7+
id: string
8+
name: string
9+
email: string
10+
roles: string[]
11+
}
12+
13+
type UserState =
14+
| { tag: 'loading' }
15+
| { tag: 'error'; error: 'unauthenticated' | 'unknown' }
16+
| { tag: 'success'; user: User }
17+
18+
export function useUser() {
19+
const q = useQuery<User, 'unauthenticated' | 'unknown'>({
20+
queryKey: ['auth', 'user'],
21+
retry: false,
22+
refetchInterval: 5000,
23+
queryFn: () =>
24+
fetch(`${authConfig.apiUrl}/auth/profile`, {
25+
credentials: 'include',
26+
}).then((res) => {
27+
if (!res.ok) {
28+
if (res.status === 401) {
29+
throw 'unauthenticated'
30+
} else {
31+
throw 'unknown'
32+
}
33+
}
34+
35+
return res.json()
36+
}),
37+
})
38+
39+
return match(q)
40+
.returnType<UserState>()
41+
.with({ isLoading: true }, () => ({ tag: 'loading' }))
42+
.with(
43+
{ data: P.not(P.nullish), isLoading: false, isError: false },
44+
(v) => ({
45+
tag: 'success',
46+
user: v.data,
47+
})
48+
)
49+
.with({ error: 'unauthenticated', isLoading: false }, () => ({
50+
tag: 'error',
51+
error: 'unauthenticated',
52+
}))
53+
.with({ error: 'unknown', isLoading: false }, () => ({
54+
tag: 'error',
55+
error: 'unknown',
56+
}))
57+
.otherwise(() => ({ tag: 'loading' }))
58+
}

packages/property-tree/src/components/auth/AuthCallback.tsx

Lines changed: 0 additions & 44 deletions
This file was deleted.

0 commit comments

Comments
 (0)