@massimo.mazzoleni/cognito-max 1.0.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/README.md +2410 -0
- package/dist/chunk-AD7T42HJ.js +3 -0
- package/dist/chunk-AD7T42HJ.js.map +1 -0
- package/dist/chunk-DKPFVGTY.js +683 -0
- package/dist/chunk-DKPFVGTY.js.map +1 -0
- package/dist/chunk-N4OQLBV6.js +135 -0
- package/dist/chunk-N4OQLBV6.js.map +1 -0
- package/dist/client-63FraVdm.d.ts +69 -0
- package/dist/client-BAoL8h4E.d.cts +69 -0
- package/dist/core/index.cjs +696 -0
- package/dist/core/index.cjs.map +1 -0
- package/dist/core/index.d.cts +3 -0
- package/dist/core/index.d.ts +3 -0
- package/dist/core/index.js +4 -0
- package/dist/core/index.js.map +1 -0
- package/dist/errors-BkUDHleb.d.cts +22 -0
- package/dist/errors-BkUDHleb.d.ts +22 -0
- package/dist/index.cjs +696 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/react/index.cjs +844 -0
- package/dist/react/index.cjs.map +1 -0
- package/dist/react/index.d.cts +104 -0
- package/dist/react/index.d.ts +104 -0
- package/dist/react/index.js +64 -0
- package/dist/react/index.js.map +1 -0
- package/dist/types-bxA1vonL.d.cts +113 -0
- package/dist/types-bxA1vonL.d.ts +113 -0
- package/dist/ui/index.cjs +1183 -0
- package/dist/ui/index.cjs.map +1 -0
- package/dist/ui/index.d.cts +241 -0
- package/dist/ui/index.d.ts +241 -0
- package/dist/ui/index.js +1109 -0
- package/dist/ui/index.js.map +1 -0
- package/package.json +81 -0
- package/src/core/client.ts +604 -0
- package/src/core/errors.ts +91 -0
- package/src/core/event-bus.ts +41 -0
- package/src/core/index.ts +5 -0
- package/src/core/internal/converters.ts +32 -0
- package/src/core/storage.ts +79 -0
- package/src/core/types.ts +87 -0
- package/src/index.ts +1 -0
- package/src/react/components/ProtectedRoute.tsx +56 -0
- package/src/react/context.tsx +126 -0
- package/src/react/hooks/useAuth.ts +75 -0
- package/src/react/hooks/useMfa.ts +19 -0
- package/src/react/hooks/useSession.ts +16 -0
- package/src/react/hooks/useUser.ts +24 -0
- package/src/react/index.ts +10 -0
- package/src/ui/components/ChangePasswordForm.tsx +105 -0
- package/src/ui/components/ForgotPasswordForm.tsx +159 -0
- package/src/ui/components/MfaSetupWizard.tsx +136 -0
- package/src/ui/components/RegisterForm.tsx +159 -0
- package/src/ui/components/SignInForm.tsx +296 -0
- package/src/ui/hooks/useChangePasswordForm.ts +81 -0
- package/src/ui/hooks/useForgotPasswordForm.ts +109 -0
- package/src/ui/hooks/useMfaSetup.ts +93 -0
- package/src/ui/hooks/useRegisterForm.ts +120 -0
- package/src/ui/hooks/useSignInForm.ts +245 -0
- package/src/ui/index.ts +31 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
type Listener<T> = T extends void ? () => void : (payload: T) => void
|
|
2
|
+
|
|
3
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
4
|
+
export class TypedEventEmitter<TEvents extends Record<keyof TEvents, any>> {
|
|
5
|
+
private readonly _listeners = new Map<keyof TEvents, Set<(...args: any[]) => void>>()
|
|
6
|
+
|
|
7
|
+
on<K extends keyof TEvents>(event: K, listener: Listener<TEvents[K]>): () => void {
|
|
8
|
+
if (!this._listeners.has(event)) {
|
|
9
|
+
this._listeners.set(event, new Set())
|
|
10
|
+
}
|
|
11
|
+
this._listeners.get(event)!.add(listener as (...args: any[]) => void)
|
|
12
|
+
return () => this.off(event, listener)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
off<K extends keyof TEvents>(event: K, listener: Listener<TEvents[K]>): void {
|
|
16
|
+
this._listeners.get(event)?.delete(listener as (...args: any[]) => void)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
once<K extends keyof TEvents>(event: K, listener: Listener<TEvents[K]>): () => void {
|
|
20
|
+
const unsub = this.on(event, ((...args: any[]) => {
|
|
21
|
+
unsub()
|
|
22
|
+
;(listener as any)(...args)
|
|
23
|
+
}) as Listener<TEvents[K]>)
|
|
24
|
+
return unsub
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
protected emit<K extends keyof TEvents>(
|
|
28
|
+
event: K,
|
|
29
|
+
...args: TEvents[K] extends void ? [] : [TEvents[K]]
|
|
30
|
+
): void {
|
|
31
|
+
this._listeners.get(event)?.forEach(l => l(...args))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
removeAllListeners(event?: keyof TEvents): void {
|
|
35
|
+
if (event !== undefined) {
|
|
36
|
+
this._listeners.delete(event)
|
|
37
|
+
} else {
|
|
38
|
+
this._listeners.clear()
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { CognitoUserSession } from 'amazon-cognito-identity-js'
|
|
2
|
+
import type { AuthSession, AuthUser } from '../types'
|
|
3
|
+
|
|
4
|
+
export function buildAuthSession(session: CognitoUserSession): AuthSession {
|
|
5
|
+
const expiresAt = new Date(session.getAccessToken().getExpiration() * 1000)
|
|
6
|
+
return {
|
|
7
|
+
accessToken: session.getAccessToken().getJwtToken(),
|
|
8
|
+
idToken: session.getIdToken().getJwtToken(),
|
|
9
|
+
refreshToken: session.getRefreshToken().getToken(),
|
|
10
|
+
expiresAt,
|
|
11
|
+
isValid: () => session.isValid(),
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function buildAuthUser(session: CognitoUserSession, fallbackUsername: string): AuthUser {
|
|
16
|
+
// Estraiamo tutte le info dall'ID token senza roundtrip API
|
|
17
|
+
const payload = session.getIdToken().decodePayload() as Record<string, unknown>
|
|
18
|
+
|
|
19
|
+
const str = (v: unknown): string => (v != null ? String(v) : '')
|
|
20
|
+
const bool = (v: unknown): boolean => v === true || v === 'true'
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
sub: str(payload['sub']),
|
|
24
|
+
username: str(payload['cognito:username'] ?? fallbackUsername),
|
|
25
|
+
email: str(payload['email']),
|
|
26
|
+
emailVerified: bool(payload['email_verified']),
|
|
27
|
+
groups: Array.isArray(payload['cognito:groups'])
|
|
28
|
+
? (payload['cognito:groups'] as string[])
|
|
29
|
+
: [],
|
|
30
|
+
attributes: Object.fromEntries(Object.entries(payload).map(([k, v]) => [k, str(v)])),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export interface StorageAdapter {
|
|
2
|
+
getItem(key: string): string | null
|
|
3
|
+
setItem(key: string, value: string): void
|
|
4
|
+
removeItem(key: string): void
|
|
5
|
+
clear?(): void
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class LocalStorageAdapter implements StorageAdapter {
|
|
9
|
+
getItem(key: string): string | null {
|
|
10
|
+
return localStorage.getItem(key)
|
|
11
|
+
}
|
|
12
|
+
setItem(key: string, value: string): void {
|
|
13
|
+
localStorage.setItem(key, value)
|
|
14
|
+
}
|
|
15
|
+
removeItem(key: string): void {
|
|
16
|
+
localStorage.removeItem(key)
|
|
17
|
+
}
|
|
18
|
+
clear(): void {
|
|
19
|
+
localStorage.clear()
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class SessionStorageAdapter implements StorageAdapter {
|
|
24
|
+
getItem(key: string): string | null {
|
|
25
|
+
return sessionStorage.getItem(key)
|
|
26
|
+
}
|
|
27
|
+
setItem(key: string, value: string): void {
|
|
28
|
+
sessionStorage.setItem(key, value)
|
|
29
|
+
}
|
|
30
|
+
removeItem(key: string): void {
|
|
31
|
+
sessionStorage.removeItem(key)
|
|
32
|
+
}
|
|
33
|
+
clear(): void {
|
|
34
|
+
sessionStorage.clear()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Safe for SSR / Node.js environments
|
|
39
|
+
export class InMemoryStorageAdapter implements StorageAdapter {
|
|
40
|
+
private readonly store = new Map<string, string>()
|
|
41
|
+
|
|
42
|
+
getItem(key: string): string | null {
|
|
43
|
+
return this.store.get(key) ?? null
|
|
44
|
+
}
|
|
45
|
+
setItem(key: string, value: string): void {
|
|
46
|
+
this.store.set(key, value)
|
|
47
|
+
}
|
|
48
|
+
removeItem(key: string): void {
|
|
49
|
+
this.store.delete(key)
|
|
50
|
+
}
|
|
51
|
+
clear(): void {
|
|
52
|
+
this.store.clear()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Uses localStorage when available, falls back to in-memory (e.g. SSR)
|
|
57
|
+
export class AutoStorageAdapter implements StorageAdapter {
|
|
58
|
+
private readonly delegate: StorageAdapter
|
|
59
|
+
|
|
60
|
+
constructor() {
|
|
61
|
+
this.delegate =
|
|
62
|
+
typeof window !== 'undefined' && window.localStorage
|
|
63
|
+
? new LocalStorageAdapter()
|
|
64
|
+
: new InMemoryStorageAdapter()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
getItem(key: string): string | null {
|
|
68
|
+
return this.delegate.getItem(key)
|
|
69
|
+
}
|
|
70
|
+
setItem(key: string, value: string): void {
|
|
71
|
+
this.delegate.setItem(key, value)
|
|
72
|
+
}
|
|
73
|
+
removeItem(key: string): void {
|
|
74
|
+
this.delegate.removeItem(key)
|
|
75
|
+
}
|
|
76
|
+
clear(): void {
|
|
77
|
+
this.delegate.clear?.()
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// ─── State machine ────────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export type AuthState =
|
|
4
|
+
| 'idle'
|
|
5
|
+
| 'loading'
|
|
6
|
+
| 'authenticated'
|
|
7
|
+
| 'unauthenticated'
|
|
8
|
+
| 'mfa_required'
|
|
9
|
+
| 'new_password_required'
|
|
10
|
+
| 'confirm_signup'
|
|
11
|
+
|
|
12
|
+
// ─── MFA ──────────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export type MfaType = 'TOTP' | 'SMS' | 'NOMFA'
|
|
15
|
+
|
|
16
|
+
export interface MfaSetupResult {
|
|
17
|
+
secretCode: string
|
|
18
|
+
/** otpauth://totp/<issuer>:<email>?secret=<secret>&issuer=<issuer> */
|
|
19
|
+
qrCodeUri: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface MfaPreference {
|
|
23
|
+
enabled: boolean
|
|
24
|
+
preferred: MfaType | null
|
|
25
|
+
totp: boolean
|
|
26
|
+
sms: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── User & Session ───────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
export interface AuthUser {
|
|
32
|
+
sub: string
|
|
33
|
+
username: string
|
|
34
|
+
email: string
|
|
35
|
+
emailVerified: boolean
|
|
36
|
+
groups: string[]
|
|
37
|
+
attributes: Record<string, string>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface AuthSession {
|
|
41
|
+
accessToken: string
|
|
42
|
+
idToken: string
|
|
43
|
+
refreshToken: string
|
|
44
|
+
expiresAt: Date
|
|
45
|
+
isValid(): boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── Sign-in result (discriminated union) ────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
export type SignInResult =
|
|
51
|
+
| { status: 'SUCCESS'; user: AuthUser; session: AuthSession }
|
|
52
|
+
| { status: 'MFA_REQUIRED'; mfaType: MfaType; challengeSession: string }
|
|
53
|
+
| { status: 'NEW_PASSWORD_REQUIRED'; requiredAttributes: string[]; challengeSession: string }
|
|
54
|
+
| { status: 'MFA_SETUP_REQUIRED'; challengeSession: string }
|
|
55
|
+
| { status: 'CONFIRM_SIGNUP' }
|
|
56
|
+
|
|
57
|
+
// ─── Config ───────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
import type { StorageAdapter } from './storage'
|
|
60
|
+
|
|
61
|
+
export interface AuthConfig {
|
|
62
|
+
userPoolId: string
|
|
63
|
+
clientId: string
|
|
64
|
+
region: string
|
|
65
|
+
storage?: StorageAdapter
|
|
66
|
+
/** Rinnova il token automaticamente prima della scadenza. Default: true */
|
|
67
|
+
autoRefresh?: boolean
|
|
68
|
+
/** Secondi prima della scadenza in cui scatta il refresh. Default: 300 */
|
|
69
|
+
refreshMarginSeconds?: number
|
|
70
|
+
/** Label mostrata nell'app authenticator (es. "MioApp"). Default: clientId */
|
|
71
|
+
totpIssuer?: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ResolvedAuthConfig extends Required<AuthConfig> {}
|
|
75
|
+
|
|
76
|
+
// ─── Events ───────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
export interface AuthEvents {
|
|
79
|
+
signedIn: AuthUser
|
|
80
|
+
signedOut: void
|
|
81
|
+
tokenRefreshed: AuthSession
|
|
82
|
+
sessionExpired: void
|
|
83
|
+
mfaRequired: { mfaType: MfaType; challengeSession: string }
|
|
84
|
+
newPasswordRequired: { requiredAttributes: string[]; challengeSession: string }
|
|
85
|
+
userUpdated: AuthUser
|
|
86
|
+
stateChanged: AuthState
|
|
87
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './core'
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { useAuthContext } from '../context'
|
|
3
|
+
|
|
4
|
+
export interface ProtectedRouteProps {
|
|
5
|
+
children: ReactNode
|
|
6
|
+
/** Mostrato mentre lo stato auth è in inizializzazione (idle/loading). Default: null */
|
|
7
|
+
loading?: ReactNode
|
|
8
|
+
/**
|
|
9
|
+
* Mostrato quando l'utente non è autenticato o non ha i permessi richiesti.
|
|
10
|
+
* Con react-router-dom: <Navigate to="/login" replace />
|
|
11
|
+
*/
|
|
12
|
+
fallback?: ReactNode
|
|
13
|
+
/** L'utente deve appartenere ad almeno uno di questi gruppi Cognito */
|
|
14
|
+
requiredGroups?: string[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function ProtectedRoute({
|
|
18
|
+
children,
|
|
19
|
+
loading = null,
|
|
20
|
+
fallback = null,
|
|
21
|
+
requiredGroups,
|
|
22
|
+
}: ProtectedRouteProps) {
|
|
23
|
+
const { state, user, isLoading, isAuthenticated } = useAuthContext()
|
|
24
|
+
|
|
25
|
+
if (isLoading) return <>{loading}</>
|
|
26
|
+
|
|
27
|
+
if (!isAuthenticated) return <>{fallback}</>
|
|
28
|
+
|
|
29
|
+
if (requiredGroups?.length) {
|
|
30
|
+
const userGroups = user?.groups ?? []
|
|
31
|
+
const hasAccess = requiredGroups.some((g) => userGroups.includes(g))
|
|
32
|
+
if (!hasAccess) return <>{fallback}</>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Unused var from destructuring: kept for clarity
|
|
36
|
+
void state
|
|
37
|
+
|
|
38
|
+
return <>{children}</>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Hook imperativo per logica di redirect inline ──────────────────────────────
|
|
42
|
+
|
|
43
|
+
export interface UseRequireAuthOptions {
|
|
44
|
+
requiredGroups?: string[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function useRequireAuth(options?: UseRequireAuthOptions) {
|
|
48
|
+
const { state, user, isLoading, isAuthenticated } = useAuthContext()
|
|
49
|
+
|
|
50
|
+
const isAllowed =
|
|
51
|
+
isAuthenticated &&
|
|
52
|
+
(!options?.requiredGroups?.length ||
|
|
53
|
+
options.requiredGroups.some((g) => (user?.groups ?? []).includes(g)))
|
|
54
|
+
|
|
55
|
+
return { isAllowed, isLoading, state, user }
|
|
56
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useState,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from 'react'
|
|
9
|
+
|
|
10
|
+
import { CognitoAuthClient } from '../core/client'
|
|
11
|
+
import type { AuthConfig, AuthSession, AuthState, AuthUser } from '../core/types'
|
|
12
|
+
|
|
13
|
+
// ─── Context shape ─────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export interface AuthContextValue {
|
|
16
|
+
client: CognitoAuthClient
|
|
17
|
+
state: AuthState
|
|
18
|
+
user: AuthUser | null
|
|
19
|
+
session: AuthSession | null
|
|
20
|
+
isLoading: boolean
|
|
21
|
+
isAuthenticated: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const AuthContext = createContext<AuthContextValue | null>(null)
|
|
25
|
+
|
|
26
|
+
// ─── Internal hook ──────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
export function useAuthContext(): AuthContextValue {
|
|
29
|
+
const ctx = useContext(AuthContext)
|
|
30
|
+
if (!ctx) {
|
|
31
|
+
throw new Error('useAuthContext must be used inside <AuthProvider>')
|
|
32
|
+
}
|
|
33
|
+
return ctx
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─── Provider ──────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export interface AuthProviderProps {
|
|
39
|
+
config: AuthConfig
|
|
40
|
+
children: ReactNode
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function AuthProvider({ config, children }: AuthProviderProps) {
|
|
44
|
+
// Client è stabile per tutta la vita del provider — il config viene ignorato dopo il mount
|
|
45
|
+
const [client] = useState(() => new CognitoAuthClient(config))
|
|
46
|
+
|
|
47
|
+
const [authState, setAuthState] = useState<AuthState>('idle')
|
|
48
|
+
const [user, setUser] = useState<AuthUser | null>(null)
|
|
49
|
+
const [session, setSession] = useState<AuthSession | null>(null)
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
let active = true
|
|
53
|
+
|
|
54
|
+
// Ripristina la sessione dallo storage (localStorage / adapter configurato)
|
|
55
|
+
client
|
|
56
|
+
.getCurrentUser()
|
|
57
|
+
.then(async (currentUser) => {
|
|
58
|
+
if (!active) return
|
|
59
|
+
if (currentUser) {
|
|
60
|
+
const s = await client.getSession().catch(() => null)
|
|
61
|
+
if (active) {
|
|
62
|
+
setUser(currentUser)
|
|
63
|
+
setSession(s)
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
if (active) setAuthState('unauthenticated')
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
.catch(() => {
|
|
70
|
+
if (active) setAuthState('unauthenticated')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const unsubs = [
|
|
74
|
+
client.on('stateChanged', (s: AuthState) => {
|
|
75
|
+
if (active) setAuthState(s)
|
|
76
|
+
// Pulisce user/session per tutti gli stati non autenticati
|
|
77
|
+
if (s === 'unauthenticated') {
|
|
78
|
+
setUser(null)
|
|
79
|
+
setSession(null)
|
|
80
|
+
}
|
|
81
|
+
}),
|
|
82
|
+
client.on('signedIn', (u) => {
|
|
83
|
+
if (!active) return
|
|
84
|
+
setUser(u)
|
|
85
|
+
client
|
|
86
|
+
.getSession()
|
|
87
|
+
.then((s) => { if (active) setSession(s) })
|
|
88
|
+
.catch(() => {})
|
|
89
|
+
}),
|
|
90
|
+
client.on('signedOut', () => {
|
|
91
|
+
if (active) {
|
|
92
|
+
setUser(null)
|
|
93
|
+
setSession(null)
|
|
94
|
+
}
|
|
95
|
+
}),
|
|
96
|
+
client.on('tokenRefreshed', (s) => {
|
|
97
|
+
if (active) setSession(s)
|
|
98
|
+
}),
|
|
99
|
+
client.on('sessionExpired', () => {
|
|
100
|
+
if (active) {
|
|
101
|
+
setUser(null)
|
|
102
|
+
setSession(null)
|
|
103
|
+
}
|
|
104
|
+
}),
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
return () => {
|
|
108
|
+
active = false
|
|
109
|
+
unsubs.forEach((unsub) => unsub())
|
|
110
|
+
}
|
|
111
|
+
}, [client])
|
|
112
|
+
|
|
113
|
+
const value = useMemo<AuthContextValue>(
|
|
114
|
+
() => ({
|
|
115
|
+
client,
|
|
116
|
+
state: authState,
|
|
117
|
+
user,
|
|
118
|
+
session,
|
|
119
|
+
isLoading: authState === 'idle' || authState === 'loading',
|
|
120
|
+
isAuthenticated: authState === 'authenticated',
|
|
121
|
+
}),
|
|
122
|
+
[client, authState, user, session],
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
|
126
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { useAuthContext } from '../context'
|
|
3
|
+
import type { MfaType, SignInResult } from '../../core/types'
|
|
4
|
+
|
|
5
|
+
export function useAuth() {
|
|
6
|
+
const { client, state, user, isLoading, isAuthenticated } = useAuthContext()
|
|
7
|
+
|
|
8
|
+
// useMemo garantisce riferimenti stabili tra i re-render (client non cambia mai)
|
|
9
|
+
const actions = useMemo(
|
|
10
|
+
() => ({
|
|
11
|
+
signIn: (email: string, password: string): Promise<SignInResult> =>
|
|
12
|
+
client.signIn(email, password),
|
|
13
|
+
|
|
14
|
+
signOut: (global?: boolean): Promise<void> =>
|
|
15
|
+
client.signOut(global),
|
|
16
|
+
|
|
17
|
+
signUp: (
|
|
18
|
+
email: string,
|
|
19
|
+
password: string,
|
|
20
|
+
attributes?: Record<string, string>,
|
|
21
|
+
): Promise<void> => client.signUp(email, password, attributes),
|
|
22
|
+
|
|
23
|
+
confirmSignUp: (email: string, code: string): Promise<void> =>
|
|
24
|
+
client.confirmSignUp(email, code),
|
|
25
|
+
|
|
26
|
+
resendConfirmationCode: (email: string): Promise<void> =>
|
|
27
|
+
client.resendConfirmationCode(email),
|
|
28
|
+
|
|
29
|
+
forgotPassword: (email: string): Promise<void> =>
|
|
30
|
+
client.forgotPassword(email),
|
|
31
|
+
|
|
32
|
+
confirmForgotPassword: (
|
|
33
|
+
email: string,
|
|
34
|
+
code: string,
|
|
35
|
+
newPassword: string,
|
|
36
|
+
): Promise<void> => client.confirmForgotPassword(email, code, newPassword),
|
|
37
|
+
|
|
38
|
+
changePassword: (current: string, next: string): Promise<void> =>
|
|
39
|
+
client.changePassword(current, next),
|
|
40
|
+
|
|
41
|
+
respondToMfaChallenge: (
|
|
42
|
+
challengeSession: string,
|
|
43
|
+
code: string,
|
|
44
|
+
mfaType: MfaType,
|
|
45
|
+
): Promise<SignInResult> =>
|
|
46
|
+
client.respondToMfaChallenge(challengeSession, code, mfaType),
|
|
47
|
+
|
|
48
|
+
respondToNewPasswordChallenge: (
|
|
49
|
+
challengeSession: string,
|
|
50
|
+
newPassword: string,
|
|
51
|
+
userAttributes?: Record<string, string>,
|
|
52
|
+
): Promise<SignInResult> =>
|
|
53
|
+
client.respondToNewPasswordChallenge(challengeSession, newPassword, userAttributes),
|
|
54
|
+
|
|
55
|
+
setupTotpChallenge: (challengeSession: string) =>
|
|
56
|
+
client.setupTotpChallenge(challengeSession),
|
|
57
|
+
|
|
58
|
+
verifyTotpChallenge: (challengeSession: string, code: string): Promise<SignInResult> =>
|
|
59
|
+
client.verifyTotpChallenge(challengeSession, code),
|
|
60
|
+
}),
|
|
61
|
+
[client],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
// Stato
|
|
66
|
+
user,
|
|
67
|
+
state,
|
|
68
|
+
isLoading,
|
|
69
|
+
isAuthenticated,
|
|
70
|
+
// Azioni
|
|
71
|
+
...actions,
|
|
72
|
+
// Client raw per casi avanzati
|
|
73
|
+
client,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useAuthContext } from '../context'
|
|
2
|
+
import type { MfaType } from '../../core/types'
|
|
3
|
+
|
|
4
|
+
export function useMfa() {
|
|
5
|
+
const { client } = useAuthContext()
|
|
6
|
+
|
|
7
|
+
return {
|
|
8
|
+
/** Avvia il setup TOTP — restituisce secretCode e qrCodeUri per l'app authenticator */
|
|
9
|
+
setup: () => client.setupTotp(),
|
|
10
|
+
/** Conferma il codice TOTP generato dall'app per completare il setup */
|
|
11
|
+
verifySetup: (code: string) => client.verifyTotpSetup(code),
|
|
12
|
+
/** Legge le preferenze MFA correnti (TOTP abilitato, SMS abilitato, preferito) */
|
|
13
|
+
getPreference: () => client.getMfaPreference(),
|
|
14
|
+
/** Imposta il tipo MFA preferito ('TOTP' | 'SMS') */
|
|
15
|
+
setPreference: (type: MfaType) => client.setMfaPreference(type),
|
|
16
|
+
/** Disabilita completamente il secondo fattore */
|
|
17
|
+
disable: () => client.disableMfa(),
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useAuthContext } from '../context'
|
|
2
|
+
|
|
3
|
+
export function useSession() {
|
|
4
|
+
const { session, state, client } = useAuthContext()
|
|
5
|
+
|
|
6
|
+
return {
|
|
7
|
+
accessToken: session?.accessToken ?? null,
|
|
8
|
+
idToken: session?.idToken ?? null,
|
|
9
|
+
refreshToken: session?.refreshToken ?? null,
|
|
10
|
+
expiresAt: session?.expiresAt ?? null,
|
|
11
|
+
isExpired: session ? !session.isValid() : true,
|
|
12
|
+
isAuthenticated: state === 'authenticated',
|
|
13
|
+
/** Forza il rinnovo del token e aggiorna il context */
|
|
14
|
+
refresh: () => client.getSession(),
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useAuthContext } from '../context'
|
|
2
|
+
|
|
3
|
+
export function useUser() {
|
|
4
|
+
const { user, client } = useAuthContext()
|
|
5
|
+
|
|
6
|
+
return {
|
|
7
|
+
user,
|
|
8
|
+
attributes: user?.attributes ?? {},
|
|
9
|
+
groups: user?.groups ?? [],
|
|
10
|
+
/** Aggiorna uno o più attributi utente in Cognito */
|
|
11
|
+
update: (attributes: Record<string, string>) =>
|
|
12
|
+
client.updateUserAttributes(attributes),
|
|
13
|
+
/** Verifica email o phone_number con il codice ricevuto */
|
|
14
|
+
verifyAttribute: (attribute: 'email' | 'phone_number', code: string) =>
|
|
15
|
+
client.verifyUserAttribute(attribute, code),
|
|
16
|
+
/** Invia un nuovo codice di verifica per email o phone_number */
|
|
17
|
+
sendVerificationCode: (attribute: 'email' | 'phone_number') =>
|
|
18
|
+
client.sendAttributeVerificationCode(attribute),
|
|
19
|
+
/** Recupera gli attributi aggiornati direttamente da Cognito */
|
|
20
|
+
refreshAttributes: () => client.getUserAttributes(),
|
|
21
|
+
/** Elimina definitivamente l'account dell'utente corrente */
|
|
22
|
+
deleteAccount: () => client.deleteUser(),
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { AuthProvider, useAuthContext } from './context'
|
|
2
|
+
export type { AuthProviderProps, AuthContextValue } from './context'
|
|
3
|
+
|
|
4
|
+
export { useAuth } from './hooks/useAuth'
|
|
5
|
+
export { useSession } from './hooks/useSession'
|
|
6
|
+
export { useUser } from './hooks/useUser'
|
|
7
|
+
export { useMfa } from './hooks/useMfa'
|
|
8
|
+
|
|
9
|
+
export { ProtectedRoute, useRequireAuth } from './components/ProtectedRoute'
|
|
10
|
+
export type { ProtectedRouteProps, UseRequireAuthOptions } from './components/ProtectedRoute'
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { useId, useRef } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
useChangePasswordForm,
|
|
4
|
+
type UseChangePasswordFormOptions,
|
|
5
|
+
} from '../hooks/useChangePasswordForm'
|
|
6
|
+
|
|
7
|
+
export interface ChangePasswordFormProps extends UseChangePasswordFormOptions {
|
|
8
|
+
className?: string
|
|
9
|
+
labels?: {
|
|
10
|
+
currentPassword?: string
|
|
11
|
+
newPassword?: string
|
|
12
|
+
confirmPassword?: string
|
|
13
|
+
submit?: string
|
|
14
|
+
successMessage?: string
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function ChangePasswordForm({
|
|
19
|
+
className,
|
|
20
|
+
labels = {},
|
|
21
|
+
onSuccess,
|
|
22
|
+
onError,
|
|
23
|
+
}: ChangePasswordFormProps) {
|
|
24
|
+
const uid = useId()
|
|
25
|
+
const form = useChangePasswordForm({ onSuccess, onError })
|
|
26
|
+
const firstInputRef = useRef<HTMLInputElement>(null)
|
|
27
|
+
|
|
28
|
+
const l = {
|
|
29
|
+
currentPassword: labels.currentPassword ?? 'Password attuale',
|
|
30
|
+
newPassword: labels.newPassword ?? 'Nuova password',
|
|
31
|
+
confirmPassword: labels.confirmPassword ?? 'Conferma nuova password',
|
|
32
|
+
submit: labels.submit ?? 'Cambia password',
|
|
33
|
+
successMessage: labels.successMessage ?? 'Password aggiornata con successo.',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const errorId = `${uid}-error`
|
|
37
|
+
|
|
38
|
+
if (form.success) {
|
|
39
|
+
return <p role="status">{l.successMessage}</p>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<form
|
|
44
|
+
onSubmit={form.onSubmit}
|
|
45
|
+
className={className}
|
|
46
|
+
aria-label="Cambia password"
|
|
47
|
+
>
|
|
48
|
+
<div id={errorId} role="alert" aria-live="assertive" aria-atomic="true">
|
|
49
|
+
{form.error?.message}
|
|
50
|
+
</div>
|
|
51
|
+
<div>
|
|
52
|
+
<label htmlFor={`${uid}-current-password`}>{l.currentPassword}</label>
|
|
53
|
+
<input
|
|
54
|
+
id={`${uid}-current-password`}
|
|
55
|
+
ref={firstInputRef}
|
|
56
|
+
type="password"
|
|
57
|
+
autoComplete="current-password"
|
|
58
|
+
autoFocus
|
|
59
|
+
required
|
|
60
|
+
aria-required="true"
|
|
61
|
+
aria-invalid={!!form.error}
|
|
62
|
+
aria-describedby={form.error ? errorId : undefined}
|
|
63
|
+
value={form.currentPassword}
|
|
64
|
+
onChange={(e) => form.setCurrentPassword(e.target.value)}
|
|
65
|
+
/>
|
|
66
|
+
</div>
|
|
67
|
+
<div>
|
|
68
|
+
<label htmlFor={`${uid}-new-password`}>{l.newPassword}</label>
|
|
69
|
+
<input
|
|
70
|
+
id={`${uid}-new-password`}
|
|
71
|
+
type="password"
|
|
72
|
+
autoComplete="new-password"
|
|
73
|
+
required
|
|
74
|
+
aria-required="true"
|
|
75
|
+
aria-invalid={!!form.error}
|
|
76
|
+
value={form.newPassword}
|
|
77
|
+
onChange={(e) => form.setNewPassword(e.target.value)}
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
<div>
|
|
81
|
+
<label htmlFor={`${uid}-confirm-password`}>{l.confirmPassword}</label>
|
|
82
|
+
<input
|
|
83
|
+
id={`${uid}-confirm-password`}
|
|
84
|
+
type="password"
|
|
85
|
+
autoComplete="new-password"
|
|
86
|
+
required
|
|
87
|
+
aria-required="true"
|
|
88
|
+
aria-invalid={!!form.error}
|
|
89
|
+
value={form.confirmPassword}
|
|
90
|
+
onChange={(e) => form.setConfirmPassword(e.target.value)}
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
<button type="submit" disabled={form.isLoading} aria-busy={form.isLoading}>
|
|
94
|
+
{form.isLoading ? (
|
|
95
|
+
<>
|
|
96
|
+
<span aria-hidden="true">...</span>
|
|
97
|
+
<span className="sr-only">Caricamento...</span>
|
|
98
|
+
</>
|
|
99
|
+
) : (
|
|
100
|
+
l.submit
|
|
101
|
+
)}
|
|
102
|
+
</button>
|
|
103
|
+
</form>
|
|
104
|
+
)
|
|
105
|
+
}
|