@stack-spot/auth-react 1.2.3 → 1.3.0
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/out/index.d.ts +36 -3
- package/out/index.js +235 -31
- package/out/index.js.map +1 -1
- package/out/index.mjs +234 -32
- package/out/index.mjs.map +1 -1
- package/package.json +10 -2
- package/src/Authenticated.tsx +58 -0
- package/src/Login.tsx +163 -0
- package/src/SessionManager.ts +8 -11
- package/src/index.ts +3 -0
- package/src/utils/cookies.ts +2 -2
- package/src/utils/hooks/use-effect-once.tsx +43 -0
- package/src/utils/redirect.ts +11 -0
- package/src/utils/regex.ts +1 -0
- package/tsconfig.json +5 -1
package/src/Login.tsx
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/* eslint-disable max-len */
|
|
2
|
+
import { Button, IconBox, Input, Text } from '@citric/core'
|
|
3
|
+
import { Github } from '@citric/icons'
|
|
4
|
+
import { LoadingCircular } from '@citric/ui'
|
|
5
|
+
import { BannerWarning } from '@stack-spot/portal-components'
|
|
6
|
+
import { MiniLogo } from '@stack-spot/portal-components/dist/components/MiniLogo'
|
|
7
|
+
import { theme } from '@stack-spot/portal-theme'
|
|
8
|
+
import { Dictionary, useTranslate } from '@stack-spot/portal-translate'
|
|
9
|
+
import { useState } from 'react'
|
|
10
|
+
import { styled } from 'styled-components'
|
|
11
|
+
|
|
12
|
+
export type LoginType = 'sso' | 'idp'
|
|
13
|
+
|
|
14
|
+
interface BaseData {
|
|
15
|
+
type: LoginType,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface SSOData extends BaseData {
|
|
19
|
+
type: 'sso',
|
|
20
|
+
email: string,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface IDPData extends BaseData {
|
|
24
|
+
type: 'idp',
|
|
25
|
+
provider: 'external-idp:github',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type LoginData = SSOData | IDPData
|
|
29
|
+
|
|
30
|
+
export type LoginProps = {
|
|
31
|
+
initialValue?: string,
|
|
32
|
+
onSubmit: (data: LoginData) => Promise<void>,
|
|
33
|
+
welcomeText?: string,
|
|
34
|
+
removeLoadingOnSuccess?: boolean,
|
|
35
|
+
className?: string,
|
|
36
|
+
style?: React.CSSProperties,
|
|
37
|
+
banner?: React.ReactNode,
|
|
38
|
+
loginTypes?: LoginType[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const LoginBox = styled.form`
|
|
42
|
+
display: flex;
|
|
43
|
+
flex-direction: column;
|
|
44
|
+
justify-content: center;
|
|
45
|
+
gap: 24px;
|
|
46
|
+
|
|
47
|
+
header {
|
|
48
|
+
display: flex;
|
|
49
|
+
flex-direction: column;
|
|
50
|
+
align-items: center;
|
|
51
|
+
gap: 24px;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.title {
|
|
55
|
+
font-size: 1rem;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.separator {
|
|
59
|
+
padding: 0 8px;
|
|
60
|
+
background-color: ${theme.color.light['400']};
|
|
61
|
+
color: ${theme.color.light['700']};
|
|
62
|
+
display: flex;
|
|
63
|
+
flex-direction: row;
|
|
64
|
+
align-items: center;
|
|
65
|
+
justify-content: center;
|
|
66
|
+
gap: 20px;
|
|
67
|
+
margin: 0;
|
|
68
|
+
|
|
69
|
+
&:before, &:after {
|
|
70
|
+
content: '';
|
|
71
|
+
height: 1px;
|
|
72
|
+
flex: 1;
|
|
73
|
+
background-color: ${theme.color.light['600']};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.error {
|
|
78
|
+
color: ${theme.color.danger['500']};
|
|
79
|
+
line-height: 1.5rem;
|
|
80
|
+
}
|
|
81
|
+
`
|
|
82
|
+
|
|
83
|
+
export const Login = ({ onSubmit, initialValue = '', welcomeText, removeLoadingOnSuccess, className, style, banner, loginTypes = ['idp', 'sso'] }: LoginProps) => {
|
|
84
|
+
const t = useTranslate(dictionary)
|
|
85
|
+
const searchParams = new URLSearchParams(location.search)
|
|
86
|
+
const [error, setError] = useState(searchParams.get('error_description') || searchParams.get('error') || '')
|
|
87
|
+
const [loading, setLoading] = useState(false)
|
|
88
|
+
const [email, setEmail] = useState(initialValue)
|
|
89
|
+
const disabled = !email.match(/\w+@\w+/)
|
|
90
|
+
const idpLoginEnabled = loginTypes.includes('idp')
|
|
91
|
+
const ssoLoginEnabled = loginTypes.includes('sso')
|
|
92
|
+
|
|
93
|
+
async function login(type: LoginType) {
|
|
94
|
+
setError('')
|
|
95
|
+
setLoading(true)
|
|
96
|
+
try {
|
|
97
|
+
const data: LoginData = type === 'sso' ? { type: 'sso', email } : { type: 'idp', provider: 'external-idp:github' }
|
|
98
|
+
await onSubmit(data)
|
|
99
|
+
if (removeLoadingOnSuccess) setLoading(false)
|
|
100
|
+
} catch (error: any) {
|
|
101
|
+
setLoading(false)
|
|
102
|
+
setError(error.message || error.toString())
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function submitForm(e: React.FormEvent<HTMLFormElement>) {
|
|
107
|
+
e.preventDefault()
|
|
108
|
+
if (disabled) return
|
|
109
|
+
login('sso')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<>
|
|
114
|
+
<LoginBox onSubmit={submitForm} className={className} style={style}>
|
|
115
|
+
<header>
|
|
116
|
+
<MiniLogo />
|
|
117
|
+
<Text className="title">{welcomeText || t.welcome}</Text>
|
|
118
|
+
</header>
|
|
119
|
+
{ssoLoginEnabled && <>
|
|
120
|
+
<Input name="email" value={email} onChange={e => setEmail(e.target.value)} placeholder={t.placeholder} />
|
|
121
|
+
<Button colorScheme="primary" disabled={disabled || loading}>
|
|
122
|
+
{loading ? <LoadingCircular /> : <Text>{t.continue}</Text>}
|
|
123
|
+
</Button>
|
|
124
|
+
</>}
|
|
125
|
+
{ssoLoginEnabled && idpLoginEnabled && <p className="separator">{t.or}</p>}
|
|
126
|
+
{idpLoginEnabled &&
|
|
127
|
+
<Button colorScheme="light" type="button" onClick={() => login('idp')} disabled={loading}>
|
|
128
|
+
{loading ? <LoadingCircular /> : (
|
|
129
|
+
<>
|
|
130
|
+
<IconBox>
|
|
131
|
+
<Github />
|
|
132
|
+
</IconBox>
|
|
133
|
+
<Text>{t.loginWithGithub}</Text>
|
|
134
|
+
</>
|
|
135
|
+
)}
|
|
136
|
+
</Button>}
|
|
137
|
+
{error && <Text className="error">{t.error}: {error}</Text>}
|
|
138
|
+
</LoginBox>
|
|
139
|
+
{banner ? <BannerWarning>
|
|
140
|
+
{banner}
|
|
141
|
+
</BannerWarning> : null}
|
|
142
|
+
</>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const dictionary = {
|
|
147
|
+
en: {
|
|
148
|
+
welcome: 'Welcome to StackSpot',
|
|
149
|
+
placeholder: 'your@email.com',
|
|
150
|
+
continue: 'Continue',
|
|
151
|
+
or: 'or',
|
|
152
|
+
loginWithGithub: 'Login with Github',
|
|
153
|
+
error: 'Error while attempting to login',
|
|
154
|
+
},
|
|
155
|
+
pt: {
|
|
156
|
+
welcome: 'Bem vindo à StackSpot',
|
|
157
|
+
placeholder: 'nome@email.com',
|
|
158
|
+
continue: 'Continuar',
|
|
159
|
+
or: 'ou',
|
|
160
|
+
loginWithGithub: 'Logar com o GitHub',
|
|
161
|
+
error: 'Erro ao fazer login',
|
|
162
|
+
},
|
|
163
|
+
} satisfies Dictionary
|
package/src/SessionManager.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AuthConfig, AuthManager, Session, ThirdPartyAuthType, ThirdPartyLoginParams } from '@stack-spot/auth'
|
|
2
2
|
import { sessionCookie } from './utils/cookies'
|
|
3
|
+
import { redirect } from './utils/redirect'
|
|
3
4
|
|
|
4
5
|
const sessionKey = 'session'
|
|
5
6
|
|
|
@@ -76,7 +77,7 @@ export class SessionManager {
|
|
|
76
77
|
const isSharedSessionTypeBlocked = this.config.blockedAuthTypes?.includes(sharedSessionCookie.type)
|
|
77
78
|
if (isSharedSessionTypeBlocked) return false
|
|
78
79
|
else if (isDifferentSessionActive || !session) {
|
|
79
|
-
this.startThirdPartyLogin(sharedSessionCookie)
|
|
80
|
+
await this.startThirdPartyLogin(sharedSessionCookie)
|
|
80
81
|
return false
|
|
81
82
|
}
|
|
82
83
|
return true
|
|
@@ -94,11 +95,11 @@ export class SessionManager {
|
|
|
94
95
|
return this.current!
|
|
95
96
|
}
|
|
96
97
|
|
|
97
|
-
endSession(redirectToLogin = true) {
|
|
98
|
-
this.
|
|
98
|
+
async endSession(redirectToLogin = true) {
|
|
99
|
+
this.current = undefined
|
|
99
100
|
localStorage.removeItem(sessionKey)
|
|
100
101
|
sessionCookie.delete()
|
|
101
|
-
if (redirectToLogin) this.
|
|
102
|
+
if (redirectToLogin) await redirect(this.config.loginUrl)
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
async logout() {
|
|
@@ -108,7 +109,7 @@ export class SessionManager {
|
|
|
108
109
|
// eslint-disable-next-line no-console
|
|
109
110
|
console.error(`Could not logout from IDM.\n${error}`)
|
|
110
111
|
}
|
|
111
|
-
this.endSession()
|
|
112
|
+
await this.endSession()
|
|
112
113
|
}
|
|
113
114
|
|
|
114
115
|
async startThirdPartyLogin(data: ThirdPartyLoginParams) {
|
|
@@ -117,7 +118,7 @@ export class SessionManager {
|
|
|
117
118
|
from: location.href,
|
|
118
119
|
finalRedirect: params.get('finalRedirect'),
|
|
119
120
|
})
|
|
120
|
-
|
|
121
|
+
await redirect(authUrl)
|
|
121
122
|
}
|
|
122
123
|
|
|
123
124
|
urlHasThirdPartyLoginData() {
|
|
@@ -132,9 +133,9 @@ export class SessionManager {
|
|
|
132
133
|
}
|
|
133
134
|
const { session, data: { from, finalRedirect } } = await this.auth.completeThirdPartyLogin(location.search)
|
|
134
135
|
this.setSession(session)
|
|
135
|
-
if (finalRedirect) location.href = finalRedirect
|
|
136
136
|
history.replaceState(null, '', from || location.toString().replace(/\?.*$/, ''))
|
|
137
137
|
this.sendLoginEventRd(this.current?.getTokenData().email, this.current?.getTokenData().name)
|
|
138
|
+
if (finalRedirect) await redirect(finalRedirect)
|
|
138
139
|
}
|
|
139
140
|
|
|
140
141
|
getEmailForLogin() {
|
|
@@ -155,10 +156,6 @@ export class SessionManager {
|
|
|
155
156
|
}
|
|
156
157
|
}
|
|
157
158
|
|
|
158
|
-
private redirectToLoginUrl() {
|
|
159
|
-
window.location.href = this.config.loginUrl
|
|
160
|
-
}
|
|
161
|
-
|
|
162
159
|
private setSessionCookie(session: Session) {
|
|
163
160
|
const { email, account_type, sub } = session.getTokenData()
|
|
164
161
|
if (!email || !sub) return
|
package/src/index.ts
CHANGED
package/src/utils/cookies.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { ThirdPartyLoginParams } from "@stack-spot/auth"
|
|
2
|
+
import { DOMAIN_REGEX } from './regex'
|
|
2
3
|
|
|
3
4
|
const portalUrl = new URL(location.href)
|
|
4
|
-
const
|
|
5
|
-
const cookieDomain = domainRegex.exec(portalUrl.host)?.[0]
|
|
5
|
+
const cookieDomain = DOMAIN_REGEX.exec(portalUrl.host)?.[0]
|
|
6
6
|
const defaultCookieAttributes = `domain=${cookieDomain}; SameSite=Strict;`
|
|
7
7
|
const sessionKey = `stk-session${cookieDomain}`
|
|
8
8
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Code taken from https://blog.ag-grid.com/avoiding-react-18-double-mount/
|
|
5
|
+
*
|
|
6
|
+
* Attention: don't use this hook unless you really have to!
|
|
7
|
+
*
|
|
8
|
+
* This hook fixes the React 18 behavior of calling useEffect hooks twice in strict/development mode, which ruins some mounting/unmounting
|
|
9
|
+
* behaviors.
|
|
10
|
+
*
|
|
11
|
+
* @param effect refer to React's useEffect.
|
|
12
|
+
*/
|
|
13
|
+
export const useEffectOnce = (effect: () => void | (() => void)) => {
|
|
14
|
+
const effectFn = useRef<() => void | (() => void)>(effect)
|
|
15
|
+
const destroyFn = useRef<void | (() => void)>()
|
|
16
|
+
const effectCalled = useRef(false)
|
|
17
|
+
const rendered = useRef(false)
|
|
18
|
+
const [, setVal] = useState<number>(0)
|
|
19
|
+
|
|
20
|
+
if (effectCalled.current) {
|
|
21
|
+
rendered.current = true
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
// only execute the effect first time around
|
|
26
|
+
if (!effectCalled.current) {
|
|
27
|
+
destroyFn.current = effectFn.current()
|
|
28
|
+
effectCalled.current = true
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// this forces one render after the effect is run
|
|
32
|
+
setVal((val) => val + 1)
|
|
33
|
+
|
|
34
|
+
return () => {
|
|
35
|
+
// if the comp didn't render since the useEffect was called,
|
|
36
|
+
// we know it's the dummy React cycle
|
|
37
|
+
if (!rendered.current) return
|
|
38
|
+
|
|
39
|
+
// otherwise this is not a dummy destroy, so call the destroy func
|
|
40
|
+
if (destroyFn.current) destroyFn.current()
|
|
41
|
+
}
|
|
42
|
+
}, [])
|
|
43
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
export const redirect = async (url: string) => {
|
|
4
|
+
window.location.href = url
|
|
5
|
+
/**
|
|
6
|
+
* This is intentional. The promise bellow will never be fulfilled.
|
|
7
|
+
* Once the set href is not instantaneous, this will guarantee no further code is executed until the user is really redirected.
|
|
8
|
+
* Particularly useful to prevent flickering page renders on scenarios with redirects.
|
|
9
|
+
*/
|
|
10
|
+
await new Promise(() => '')
|
|
11
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const DOMAIN_REGEX = new RegExp(/(\.*(prd|stg|dev)*.stackspot.com)|localhost/)
|