Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

492: Keep user logged in after closing tab #581

Merged
merged 2 commits into from
Oct 17, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 50 additions & 15 deletions administration/src/AuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
import { createContext, ReactNode, useMemo, useState } from 'react'
import { Administrator, SignInPayload } from './generated/graphql'

export interface AuthContextData {
export interface AuthData {
token: string
expiry: Date
administrator: Administrator
password: string
}

const noop = () => {}

export const AuthContext = createContext<{
data: AuthContextData | null
signIn: (payload: SignInPayload, password: string) => void
data: AuthData | null
signIn: (payload: SignInPayload) => void
signOut: () => void
}>({ data: null, signIn: noop, signOut: noop })

Expand All @@ -21,26 +20,62 @@ const getExpiryFromToken = (token: string) => {
return new Date(payload.exp * 1000) // exp is in seconds, not milliseconds
}

const convertToAuthContextData: (payload: SignInPayload, password: string) => AuthContextData = (
payload,
password
) => ({
const convertToAuthData: (payload: SignInPayload) => AuthData = payload => ({
token: payload.token,
administrator: payload.user,
expiry: getExpiryFromToken(payload.token),
password,
})

const LOCAL_STORAGE_KEY = 'authdata'

const loadAuthDataFromSessionStorage = (): AuthData | null => {
const authDataRaw = window.localStorage.getItem(LOCAL_STORAGE_KEY)
if (authDataRaw === null) {
return null
}

try {
const partialAuthData: { token: string; administrator: Administrator } = JSON.parse(authDataRaw)
const expiry = getExpiryFromToken(partialAuthData.token)
if (expiry < new Date()) {
window.localStorage.removeItem(LOCAL_STORAGE_KEY)
return null
}
return { ...partialAuthData, expiry: getExpiryFromToken(partialAuthData.token) }
} catch (e) {
window.localStorage.removeItem(LOCAL_STORAGE_KEY)
}
return null
}

const saveAuthDataToSessionStorage = (authData: AuthData) => {
window.localStorage.setItem(
LOCAL_STORAGE_KEY,
JSON.stringify({
token: authData.token,
administrator: authData.administrator,
})
)
}

const removeAuthDataFromSessionStorage = () => window.localStorage.removeItem(LOCAL_STORAGE_KEY)

const AuthProvider = ({ children }: { children: ReactNode }) => {
const [authContextData, setAuthContextData] = useState<AuthContextData | null>(null)
const [authData, setAuthData] = useState<AuthData | null>(loadAuthDataFromSessionStorage())
const contextValue = useMemo(
() => ({
data: authContextData,
signIn: (payload: SignInPayload, password: string) =>
setAuthContextData(convertToAuthContextData(payload, password)),
signOut: () => setAuthContextData(null),
data: authData,
signIn: (payload: SignInPayload) => {
const authData = convertToAuthData(payload)
saveAuthDataToSessionStorage(authData)
setAuthData(authData)
},
signOut: () => {
removeAuthDataFromSessionStorage()
setAuthData(null)
},
}),
[authContextData]
[authData]
)

return <AuthContext.Provider value={contextValue}>{children}</AuthContext.Provider>
Expand Down
84 changes: 48 additions & 36 deletions administration/src/KeepAliveToken.tsx
Original file line number Diff line number Diff line change
@@ -1,65 +1,77 @@
import { Button, Classes, Dialog } from '@blueprintjs/core'
import React, { ReactNode, useContext, useEffect, useState } from 'react'
import { AuthContextData } from './AuthProvider'
import { AuthContext, AuthData } from './AuthProvider'
import { useAppToaster } from './components/AppToaster'
import { SignInPayload, useSignInMutation } from './generated/graphql'
import PasswordInput from './components/PasswordInput'
import { ProjectConfigContext } from './project-configs/ProjectConfigContext'

interface Props {
authData: AuthContextData
authData: AuthData
children: ReactNode
onSignIn: (payload: SignInPayload, password: string) => void
onSignIn: (payload: SignInPayload) => void
onSignOut: () => void
}

const KeepAliveToken = (props: Props) => {
const computeSecondsLeft = (authData: AuthData) => Math.round((authData.expiry.valueOf() - Date.now()) / 1000)

const KeepAliveToken = ({ authData, onSignOut, onSignIn, children }: Props) => {
const projectId = useContext(ProjectConfigContext).projectId
const [timeLeft, setTimeLeft] = useState(Math.round((props.authData.expiry.valueOf() - Date.now()) / 1000))
const email = useContext(AuthContext).data!.administrator.email
const [secondsLeft, setSecondsLeft] = useState(computeSecondsLeft(authData))
useEffect(() => {
setTimeout(() => setTimeLeft(Math.round((props.authData.expiry.valueOf() - Date.now()) / 1000)), 1000)
})
setSecondsLeft(computeSecondsLeft(authData))
const interval = setInterval(() => {
const timeLeft = computeSecondsLeft(authData)
setSecondsLeft(Math.max(timeLeft, 0))
if (timeLeft <= 0) {
onSignOut()
}
}, 1000)
return () => clearInterval(interval)
}, [authData, onSignOut])
const appToaster = useAppToaster()

const [password, setPassword] = useState('')

const [signIn, mutationState] = useSignInMutation({
onCompleted: payload => props.onSignIn(payload.signInPayload, props.authData.password),
onCompleted: payload => {
appToaster?.show({ intent: 'success', message: 'Login-Zeitraum verlängert.' })
onSignIn(payload.signInPayload)
setPassword('')
},
onError: () => appToaster?.show({ intent: 'danger', message: 'Etwas ist schief gelaufen.' }),
})
const extendLogin = () =>
signIn({
variables: {
project: projectId,
authData: {
email: props.authData.administrator.email,
password: props.authData.password,
},
},
})
const extendLogin = () => signIn({ variables: { project: projectId, authData: { email, password } } })

return (
<>
{props.children}
{children}
<Dialog
isOpen={timeLeft <= 30}
isOpen={secondsLeft <= 180}
title={'Ihr Login-Zeitraum läuft ab!'}
icon={'warning-sign'}
isCloseButtonShown={false}>
<div className={Classes.DIALOG_BODY}>
{timeLeft >= 0 ? (
<p>Ihr Login-Zeitraum läuft in {timeLeft} Sekunden ab.</p>
) : (
<p>Ihr Login-Zeitraum ist abgelaufen.</p>
)}
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button onClick={props.onSignOut} loading={mutationState.loading}>
Ausloggen
</Button>
<Button intent={'primary'} onClick={extendLogin} loading={mutationState.loading}>
Login-Zeitraum verlängern
</Button>
<form onSubmit={extendLogin}>
<div className={Classes.DIALOG_BODY}>
<p>Ihr Login-Zeitraum läuft in {secondsLeft} Sekunden ab. Danach werden Sie automatisch ausgeloggt.</p>
<p>Geben Sie Ihr Passwort ein, um den Login-Zeitraum zu verlängern.</p>
<PasswordInput label='' placeholder={'Passwort'} setValue={setPassword} value={password} />
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button onClick={onSignOut} loading={mutationState.loading}>
Ausloggen
</Button>
<Button
intent={'primary'}
type='submit'
loading={mutationState.loading}
text='Login-Zeitraum verlängern'
/>
</div>
</div>
</div>
</form>
</Dialog>
</>
)
Expand Down
8 changes: 2 additions & 6 deletions administration/src/components/auth/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,6 @@ import { Card, H2, H3, H4 } from '@blueprintjs/core'
import { SignInMutation, SignInPayload, useSignInMutation } from '../../generated/graphql'
import { ProjectConfigContext } from '../../project-configs/ProjectConfigContext'

interface Props {
onSignIn: (payload: SignInPayload, password: string) => void
}

const Center = styled('div')`
display: flex;
flex-grow: 1;
Expand All @@ -23,12 +19,12 @@ interface State {
password: string
}

const Login = (props: Props) => {
const Login = (props: { onSignIn: (payload: SignInPayload) => void }) => {
const config = useContext(ProjectConfigContext)
const appToaster = useAppToaster()
const [state, setState] = React.useState<State>({ email: '', password: '' })
const [signIn, mutationState] = useSignInMutation({
onCompleted: (payload: SignInMutation) => props.onSignIn(payload.signInPayload, state.password),
onCompleted: (payload: SignInMutation) => props.onSignIn(payload.signInPayload),
onError: () => appToaster?.show({ intent: 'danger', message: 'Login fehlgeschlagen.' }),
})
const onSubmit = () =>
Expand Down