@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/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
@@ -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.setSession(undefined)
98
+ async endSession(redirectToLogin = true) {
99
+ this.current = undefined
99
100
  localStorage.removeItem(sessionKey)
100
101
  sessionCookie.delete()
101
- if (redirectToLogin) this.redirectToLoginUrl()
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
- location.href = authUrl
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
@@ -1,2 +1,5 @@
1
+ export { Authenticated } from './Authenticated'
2
+ export { Login } from './Login'
1
3
  export { SessionManager } from './SessionManager'
2
4
  export { useSession } from './hooks'
5
+
@@ -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 domainRegex = new RegExp(/(\.*(prd|stg|dev)*.stackspot.com)|localhost/)
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/)
package/tsconfig.json CHANGED
@@ -1,7 +1,11 @@
1
1
  {
2
2
  "extends": "../../tsconfig.json",
3
3
  "compilerOptions": {
4
+ "module": "ESNext",
5
+ "jsx": "react-jsx",
4
6
  "rootDir": "src"
5
7
  },
6
- "include": ["src"]
8
+ "include": [
9
+ "src"
10
+ ]
7
11
  }