Skip to content

Commit dec1fd3

Browse files
feat: add customer authentication (#1205)
* feat: draft for auth service * feat: draft for requests * fix: update identity management * feat: update redirect urls * feat: restructure route protection and environment variable loading * fix: fix lint * fix: fix 404 building without error --------- Co-authored-by: Felix Evers <[email protected]>
1 parent ff94a49 commit dec1fd3

File tree

21 files changed

+270
-102
lines changed

21 files changed

+270
-102
lines changed

customer/.env.development

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1-
NEXT_PUBLIC_API_URL=https://staging.api.helpwave.de
2-
NEXT_PUBLIC_OAUTH_REDIRECT_URI=http://localhost:3000/auth/callback
3-
NEXT_PUBLIC_FAKE_TOKEN_ENABLE=true
1+
NEXT_PUBLIC_API_URL=https://localhost:8000
2+
NEXT_PUBLIC_OIDC_PROVIDER=http://localhost:8080/realms/myrealm
3+
NEXT_PUBLIC_CLIENT_ID=myclient
4+
NEXT_PUBLIC_REDIRECT_URI=http://localhost:3000/auth/callback
5+
NEXT_PUBLIC_POST_LOGOUT_REDIRECT_URI=http://localhost:3000/

customer/.env.production

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
NODE_ENV=production
2-
NEXT_PUBLIC_API_URL=https://staging.api.helpwave.de
3-
NEXT_PUBLIC_OAUTH_REDIRECT_URI=https://staging-tasks.helpwave.de/auth/callback
4-
NEXT_PUBLIC_FAKE_TOKEN_ENABLE=true
1+
NEXT_PUBLIC_API_URL=https://customer.api.helpwave.de
2+
NEXT_PUBLIC_OIDC_PROVIDER=https://id.helpwave.de/realms/main/
3+
NEXT_PUBLIC_CLIENT_ID=customer-api
4+
NEXT_PUBLIC_REDIRECT_URI=https://customer.helpwave.de
5+
NEXT_PUBLIC_POST_LOGOUT_REDIRECT_URI=https://customer.helpwave.de/

customer/api/auth/authService.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
'use client'
2+
3+
import { CLIENT_ID, OIDC_PROVIDER, POST_LOGOUT_REDIRECT_URI, REDIRECT_URI } from '@/api/auth/config'
4+
import type { User } from 'oidc-client-ts'
5+
import { UserManager } from 'oidc-client-ts'
6+
7+
const userManager = new UserManager({
8+
authority: OIDC_PROVIDER,
9+
client_id: CLIENT_ID,
10+
redirect_uri: REDIRECT_URI,
11+
response_type: 'code',
12+
scope: 'openid profile email',
13+
post_logout_redirect_uri: POST_LOGOUT_REDIRECT_URI,
14+
// userStore: userStore, // TODO Consider persisting user data across sessions
15+
})
16+
17+
export const signUp = () => {
18+
return userManager.signinRedirect()
19+
}
20+
21+
export const login = (redirectURI?: string) => {
22+
return userManager.signinRedirect({ redirect_uri: redirectURI })
23+
}
24+
25+
export const handleCallback = async () => {
26+
return await userManager.signinRedirectCallback()
27+
}
28+
29+
export const logout = () => {
30+
return userManager.signoutRedirect()
31+
}
32+
33+
export const getUser = async (): Promise<User | null> => {
34+
return await userManager.getUser()
35+
}
36+
37+
export const renewToken = async () => {
38+
return await userManager.signinSilent()
39+
}
40+
41+
export const restoreSession = async (): Promise<User | undefined> => {
42+
if (typeof window === 'undefined') return // Prevent SSR access
43+
const user = await userManager.getUser()
44+
if (!user) return
45+
46+
// If access token is expired, refresh it
47+
if (user.expired) {
48+
try {
49+
console.log('Access token expired, refreshing...')
50+
const refreshedUser = await renewToken()
51+
return refreshedUser ?? undefined
52+
} catch (error) {
53+
console.error('Silent token renewal failed', error)
54+
return
55+
}
56+
}
57+
58+
return user
59+
}
60+
61+
userManager.events.addAccessTokenExpiring(async () => {
62+
console.log('Token expiring, refreshing...')
63+
await renewToken()
64+
})

customer/api/auth/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export const OIDC_PROVIDER = process.env.NEXT_PUBLIC_OIDC_PROVIDER
2+
export const CLIENT_ID = process.env.NEXT_PUBLIC_CLIENT_ID
3+
export const REDIRECT_URI = process.env.NEXT_PUBLIC_REDIRECT_URI
4+
export const POST_LOGOUT_REDIRECT_URI = process.env.NEXT_PUBLIC_POST_LOGOUT_REDIRECT_URI

customer/api/mutations/customer_mutations.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
22
import { QueryKeys } from '@/api/mutations/query_keys'
33
import type { Customer } from '@/api/dataclasses/customer'
44
import { CustomerAPI } from '@/api/services/customer'
5+
import { useAuth } from '@/hooks/useAuth'
6+
import { apiURL } from '@/config'
57

68

79

@@ -16,8 +18,28 @@ export const useCustomerMyselfQuery = () => {
1618

1719
export const useCustomerCreateMutation = () => {
1820
const queryClient = useQueryClient()
21+
const { authHeader } = useAuth()
1922
return useMutation({
2023
mutationFn: async (customer: Customer) => {
24+
const data = await fetch(`${apiURL}/customer`, {
25+
method: 'PUT',
26+
mode: 'no-cors',
27+
headers: { ...authHeader },
28+
body: JSON.stringify({
29+
name: customer.name,
30+
email: customer.email,
31+
website_url: customer.websiteURL,
32+
address: customer.address.street,
33+
house_number: customer.address.houseNumber,
34+
care_of: customer.address.houseNumberAdditional,
35+
postal_code: customer.address.postalCode,
36+
city: customer.address.city,
37+
country: customer.address.country,
38+
})
39+
})
40+
const json = await data.json()
41+
console.log(json)
42+
// TODO parse json as customer
2143
return await CustomerAPI.create(customer)
2244
},
2345
onSuccess: () => {

customer/components/layout/NavigationSidebar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export const NavigationSidebar = ({ items, className }: NavSidebarProps) => {
8787
<div className={tw('flex flex-col p-4 gap-y-4 bg-gray-50')}>
8888
<div className={tw('flex flex-row gap-x-2 items-center')}>
8989
<Avatar avatarUrl="https://helpwave.de/favicon.ico" alt="" size="small"/>
90-
{identity?.name}
90+
{identity?.profile?.name}
9191
</div>
9292
<Button onClick={logout} color="hw-negative">{translation.logout}</Button>
9393
</div>

customer/components/pages/login.tsx

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@ import { useTranslation, type PropsForTranslation } from '@helpwave/common/hooks
33
import { Page } from '@/components/layout/Page'
44
import titleWrapper from '@/utils/titleWrapper'
55
import { tw } from '@twind/core'
6-
import { Input } from '@helpwave/common/components/user-input/Input'
7-
import { useState } from 'react'
86
import { Button } from '@helpwave/common/components/Button'
97

108
type LoginTranslation = {
@@ -20,14 +18,14 @@ const defaultLoginTranslations: Record<Languages, LoginTranslation> = {
2018
login: 'Login',
2119
email: 'Email',
2220
password: 'Password',
23-
signIn: 'Sign In',
21+
signIn: 'helpwave Sign In',
2422
register: 'Register',
2523
},
2624
de: {
2725
login: 'Login',
2826
email: 'Email',
2927
password: 'Passwort',
30-
signIn: 'Einloggen',
28+
signIn: 'helpwave Login',
3129
register: 'Registrieren',
3230
}
3331
}
@@ -38,12 +36,11 @@ export type LoginData = {
3836
}
3937

4038
type LoginPageProps = {
41-
login: (data: LoginData) => Promise<boolean>,
39+
login: () => Promise<boolean>,
4240
}
4341

4442
export const LoginPage = ({ login, overwriteTranslation }: PropsForTranslation<LoginTranslation, LoginPageProps>) => {
4543
const translation = useTranslation(defaultLoginTranslations, overwriteTranslation)
46-
const [loginData, setLoginData] = useState<LoginData>({ email: '', password: '' })
4744

4845
return (
4946
<Page
@@ -54,24 +51,11 @@ export const LoginPage = ({ login, overwriteTranslation }: PropsForTranslation<L
5451
>
5552
<div className={tw('flex flex-col bg-gray-100 max-w-[300px] p-8 gap-y-2 rounded-lg shadow-lg')}>
5653
<h2 className={tw('font-bold font-inter text-2xl')}>{translation.login}</h2>
57-
<Input
58-
label={{ name: translation.email }}
59-
value={loginData.email}
60-
type="email"
61-
onChange={email => setLoginData({ ...loginData, email })}
62-
/>
63-
<Input
64-
label={{ name: translation.password }}
65-
value={loginData.password}
66-
type="password"
67-
onChange={password => setLoginData({ ...loginData, password })}
68-
/>
69-
<Button onClick={() => {
70-
login(loginData)
71-
}}>{translation.signIn}</Button>
54+
<Button onClick={login}>{translation.signIn}</Button>
7255

73-
<Button onClick={() => {
74-
}} className={tw('mt-6')}>{translation.register}</Button>
56+
{/*
57+
<Button onClick={() => {}} className={tw('mt-6')}>{translation.register}</Button>
58+
*/}
7559
</div>
7660
</Page>
7761
)

customer/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const apiURL = 'https://api.customer.helpwave.de'

customer/environment.d.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,9 @@
44
declare namespace NodeJS {
55
interface ProcessEnv {
66
NODE_ENV?: string,
7-
NEXT_PUBLIC_SHOW_STAGING_DISCLAIMER_MODAL?: string,
8-
NEXT_PUBLIC_PLAYSTORE_LINK?: string,
9-
NEXT_PUBLIC_APPSTORE_LINK?: string,
10-
NEXT_PUBLIC_FEEDBACK_FORM_URL?: string,
11-
NEXT_PUBLIC_FEATURES_FEED_URL?: string,
12-
NEXT_PUBLIC_IMPRINT_URL?: string,
13-
NEXT_PUBLIC_PRIVACY_URL?: string,
7+
NEXT_PUBLIC_OIDC_PROVIDER: string,
8+
NEXT_PUBLIC_CLIENT_ID: string,
9+
NEXT_PUBLIC_REDIRECT_URI: string,
10+
NEXT_PUBLIC_POST_LOGOUT_REDIRECT_URI: string,
1411
}
1512
}

customer/hooks/useAuth.tsx

Lines changed: 37 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,39 @@
11
'use client' // Ensures this runs only on the client-side
22

3-
import type { PropsWithChildren } from 'react'
4-
import { createContext, useContext, useEffect, useState } from 'react'
5-
import Cookies from 'js-cookie'
3+
import type { ComponentType, PropsWithChildren } from 'react'
4+
import { useEffect } from 'react'
5+
import { createContext, useContext, useState } from 'react'
66
import { tw } from '@twind/core'
77
import { LoadingAnimation } from '@helpwave/common/components/LoadingAnimation'
8-
import type { LoginData } from '@/components/pages/login'
98
import { LoginPage } from '@/components/pages/login'
10-
11-
type Identity = {
12-
token: string,
13-
name: string,
14-
}
9+
import { login, logout, restoreSession } from '@/api/auth/authService'
10+
import type { User } from 'oidc-client-ts'
11+
import { REDIRECT_URI } from '@/api/auth/config'
1512

1613
type AuthContextType = {
17-
identity: Identity,
14+
identity: User,
1815
logout: () => void,
1916
}
2017

2118
const AuthContext = createContext<AuthContextType | undefined>(undefined)
2219

2320
type AuthState = {
24-
identity?: Identity,
21+
identity?: User,
2522
isLoading: boolean,
2623
}
2724

28-
const cookieName = 'authToken'
29-
3025
export const AuthProvider = ({ children }: PropsWithChildren) => {
3126
const [{ isLoading, identity }, setAuthState] = useState<AuthState>({ isLoading: true })
3227

33-
const checkIdentity = () => {
34-
setAuthState({ isLoading: true })
35-
const token = Cookies.get(cookieName)
36-
const newAuthState = !!token
37-
if (newAuthState) {
38-
const identity: Identity = { token: 'test-token', name: 'Max Mustermann' }
39-
setAuthState({ identity, isLoading: false })
40-
} else {
41-
setAuthState({ isLoading: false })
42-
}
43-
}
44-
45-
// Check authentication state on first load
4628
useEffect(() => {
47-
checkIdentity()
29+
restoreSession().then(identity => {
30+
setAuthState({
31+
identity,
32+
isLoading: false,
33+
})
34+
})
4835
}, [])
4936

50-
const login = async (_: LoginData) => {
51-
// TODO do real login
52-
Cookies.set(cookieName, 'testdata', { expires: 1 })
53-
checkIdentity()
54-
return true
55-
}
56-
57-
const logout = () => {
58-
Cookies.remove(cookieName)
59-
checkIdentity()
60-
}
61-
6237
if (!identity && isLoading) {
6338
return (
6439
<div className={tw('flex flex-col items-center justify-center w-screen h-screen')}>
@@ -68,7 +43,12 @@ export const AuthProvider = ({ children }: PropsWithChildren) => {
6843
}
6944

7045
if (!identity) {
71-
return (<LoginPage login={login} />)
46+
return (
47+
<LoginPage login={async () => {
48+
await login(REDIRECT_URI + `?redirect_uri=${encodeURIComponent(window.location.href)}`)
49+
return true
50+
}}/>
51+
)
7252
}
7353

7454
return (
@@ -78,11 +58,27 @@ export const AuthProvider = ({ children }: PropsWithChildren) => {
7858
)
7959
}
8060

61+
export const withAuth = <P extends object>(Component: ComponentType<P>) => {
62+
const WrappedComponent = (props: P) => (
63+
<AuthProvider>
64+
<Component {...props} />
65+
</AuthProvider>
66+
)
67+
WrappedComponent.displayName = `withAuth(${Component.displayName || Component.name || 'Component'})`
68+
69+
return WrappedComponent
70+
}
71+
72+
8173
// Custom hook for using AuthContext
8274
export const useAuth = () => {
8375
const context = useContext(AuthContext)
8476
if (!context) {
8577
throw new Error('useAuth must be used within an AuthProvider')
8678
}
87-
return context
79+
const authHeader = {
80+
'Content-Type': 'application/json',
81+
'Authorization': `Bearer ${context.identity.access_token}`,
82+
}
83+
return { ...context, authHeader }
8884
}

0 commit comments

Comments
 (0)