mirror of
https://github.com/gromlab-ru/slm-design.git
synced 2026-08-22 15:30:16 +03:00
feat: add example
This commit is contained in:
19
examples/demo-frontend/src/domains/auth/auth.constants.ts
Normal file
19
examples/demo-frontend/src/domains/auth/auth.constants.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { DemoAccount } from './types/demo-account.type'
|
||||
|
||||
/**
|
||||
* Учётные записи Simple API для воспроизводимых auth/RBAC-сценариев.
|
||||
*/
|
||||
export const AUTH_DEMO_ACCOUNTS: readonly DemoAccount[] = [
|
||||
{
|
||||
email: 'admin@demo.local',
|
||||
password: 'demo1234',
|
||||
role: 'admin',
|
||||
description: 'Catalog CRUD and every order'
|
||||
},
|
||||
{
|
||||
email: 'customer@demo.local',
|
||||
password: 'demo1234',
|
||||
role: 'customer',
|
||||
description: 'Checkout and own orders'
|
||||
}
|
||||
]
|
||||
8
examples/demo-frontend/src/domains/auth/auth.context.ts
Normal file
8
examples/demo-frontend/src/domains/auth/auth.context.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
import type { AuthContextValue } from './types/auth-context.type'
|
||||
|
||||
/**
|
||||
* Внутренний React context application-scoped auth API.
|
||||
*/
|
||||
export const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
393
examples/demo-frontend/src/domains/auth/auth.provider.tsx
Normal file
393
examples/demo-frontend/src/domains/auth/auth.provider.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
'use client'
|
||||
|
||||
import { startTransition, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import {
|
||||
clearSimpleAuthSessionScope,
|
||||
discardSimpleAuthSession,
|
||||
readSimpleAuthSession,
|
||||
replaceSimpleAuthSession,
|
||||
subscribeSimpleAuthSession
|
||||
} from '@/infra/simple-auth-session'
|
||||
import type { SimpleAuthSession } from '@/infra/simple-auth-session'
|
||||
import { simpleRestApi } from '@/infra/simple-rest-api'
|
||||
import type { Result } from '@/shared/types/result.type'
|
||||
|
||||
import { AuthContext } from './auth.context'
|
||||
import { mapAuthError } from './errors/auth-error.mapper'
|
||||
import { mapAuthUser } from './mappers/auth-user.mapper'
|
||||
import type { AuthError } from './types/auth-error.type'
|
||||
import type { AuthContextValue, SignInCredentials } from './types/auth-context.type'
|
||||
import type { AuthProviderProps } from './types/auth-provider-props.type'
|
||||
import type { AuthUser } from './types/auth-user.type'
|
||||
|
||||
const SESSION_STORAGE_ERROR: AuthError = {
|
||||
code: 'service-unavailable',
|
||||
message: 'Browser storage is unavailable. The session was not changed.'
|
||||
}
|
||||
const SESSION_CHANGED_ERROR: AuthError = {
|
||||
code: 'session-expired',
|
||||
message: 'The active session changed before the request completed.'
|
||||
}
|
||||
|
||||
/**
|
||||
* Владеет пользовательской сессией на всём времени жизни приложения.
|
||||
*
|
||||
* Используется для:
|
||||
* - восстановления профиля из CAS-protected JWT session
|
||||
* - предоставления login/logout API всем composition-модулям
|
||||
*/
|
||||
export const AuthProvider = (props: AuthProviderProps) => {
|
||||
const { children } = props
|
||||
const [status, setStatus] = useState<AuthContextValue['status']>('checking')
|
||||
const [user, setUser] = useState<AuthUser | null>(null)
|
||||
const [sessionKey, setSessionKey] = useState<string | null>(null)
|
||||
const [sessionError, setSessionError] = useState<AuthError | null>(null)
|
||||
const sessionKeyRef = useRef<string | null>(null)
|
||||
const profileRequestIdRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true
|
||||
|
||||
/**
|
||||
* Загружает authority-профиль только для всё ещё актуальной session scope.
|
||||
*/
|
||||
const loadSessionUser = async (session: SimpleAuthSession): Promise<void> => {
|
||||
const requestId = ++profileRequestIdRef.current
|
||||
|
||||
try {
|
||||
const response = await simpleRestApi.users.simpleUsersMe()
|
||||
const currentResult = readSimpleAuthSession()
|
||||
|
||||
if (
|
||||
!isActive ||
|
||||
requestId !== profileRequestIdRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (currentResult.status === 'unavailable') {
|
||||
setSessionError(SESSION_STORAGE_ERROR)
|
||||
setStatus('unavailable')
|
||||
return
|
||||
}
|
||||
|
||||
if (currentResult.session?.sessionId !== session.sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
sessionKeyRef.current = session.sessionId
|
||||
setSessionKey(session.sessionId)
|
||||
setUser(mapAuthUser(response.data))
|
||||
setSessionError(null)
|
||||
setStatus('authenticated')
|
||||
})
|
||||
} catch (error) {
|
||||
const currentResult = readSimpleAuthSession()
|
||||
|
||||
if (!isActive || requestId !== profileRequestIdRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (currentResult.status === 'unavailable') {
|
||||
setSessionError(SESSION_STORAGE_ERROR)
|
||||
setStatus('unavailable')
|
||||
return
|
||||
}
|
||||
|
||||
const currentSession = currentResult.session
|
||||
|
||||
if (currentSession === null) {
|
||||
sessionKeyRef.current = null
|
||||
setSessionKey(null)
|
||||
setUser(null)
|
||||
setSessionError(null)
|
||||
setStatus('guest')
|
||||
return
|
||||
}
|
||||
|
||||
if (currentSession.sessionId === session.sessionId) {
|
||||
startTransition(() => {
|
||||
sessionKeyRef.current = session.sessionId
|
||||
setSessionKey(session.sessionId)
|
||||
setSessionError(mapAuthError(error))
|
||||
setStatus('unavailable')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribe = subscribeSimpleAuthSession((result) => {
|
||||
if (!isActive) {
|
||||
return
|
||||
}
|
||||
|
||||
if (result.status === 'unavailable') {
|
||||
profileRequestIdRef.current += 1
|
||||
setSessionError(SESSION_STORAGE_ERROR)
|
||||
setStatus('unavailable')
|
||||
return
|
||||
}
|
||||
|
||||
const session = result.session
|
||||
|
||||
if (session === null) {
|
||||
profileRequestIdRef.current += 1
|
||||
sessionKeyRef.current = null
|
||||
setSessionKey(null)
|
||||
setUser(null)
|
||||
setSessionError(null)
|
||||
setStatus('guest')
|
||||
return
|
||||
}
|
||||
|
||||
if (session.sessionId !== sessionKeyRef.current) {
|
||||
profileRequestIdRef.current += 1
|
||||
sessionKeyRef.current = session.sessionId
|
||||
setSessionKey(session.sessionId)
|
||||
setUser(null)
|
||||
setSessionError(null)
|
||||
setStatus('checking')
|
||||
void loadSessionUser(session)
|
||||
}
|
||||
})
|
||||
const initialResult = readSimpleAuthSession()
|
||||
|
||||
if (initialResult.status === 'unavailable') {
|
||||
queueMicrotask(() => {
|
||||
if (isActive) {
|
||||
setSessionError(SESSION_STORAGE_ERROR)
|
||||
setStatus('unavailable')
|
||||
}
|
||||
})
|
||||
} else if (initialResult.session === null) {
|
||||
queueMicrotask(() => {
|
||||
if (isActive) {
|
||||
setStatus('guest')
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const session = initialResult.session
|
||||
|
||||
sessionKeyRef.current = session.sessionId
|
||||
void loadSessionUser(session)
|
||||
}
|
||||
|
||||
return () => {
|
||||
isActive = false
|
||||
unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Выполняет login и создаёт новую logical session scope.
|
||||
*/
|
||||
const signIn = async (
|
||||
credentials: SignInCredentials
|
||||
): Promise<Result<AuthUser, AuthError>> => {
|
||||
try {
|
||||
const response = await simpleRestApi.auth.simpleAuthLogin(credentials)
|
||||
const nextUser = mapAuthUser(response.data.user)
|
||||
const session = await replaceSimpleAuthSession(response.data.tokens)
|
||||
|
||||
if (session === null) {
|
||||
return { isSuccess: false, error: SESSION_STORAGE_ERROR }
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
profileRequestIdRef.current += 1
|
||||
sessionKeyRef.current = session.sessionId
|
||||
setSessionKey(session.sessionId)
|
||||
setUser(nextUser)
|
||||
setSessionError(null)
|
||||
setStatus('authenticated')
|
||||
})
|
||||
|
||||
return { isSuccess: true, data: nextUser }
|
||||
} catch (error) {
|
||||
return { isSuccess: false, error: mapAuthError(error) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Локально завершает session до best-effort backend logout.
|
||||
*/
|
||||
const signOut = async (): Promise<void> => {
|
||||
const currentResult = readSimpleAuthSession()
|
||||
|
||||
if (currentResult.status === 'unavailable') {
|
||||
if (currentResult.observedValue === null) {
|
||||
setSessionError(SESSION_STORAGE_ERROR)
|
||||
setStatus('unavailable')
|
||||
return
|
||||
}
|
||||
|
||||
const isDiscarded = await discardSimpleAuthSession(currentResult.observedValue)
|
||||
|
||||
if (!isDiscarded) {
|
||||
const latestResult = readSimpleAuthSession()
|
||||
|
||||
if (latestResult.status === 'unavailable') {
|
||||
setSessionError(SESSION_STORAGE_ERROR)
|
||||
setStatus('unavailable')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
profileRequestIdRef.current += 1
|
||||
sessionKeyRef.current = null
|
||||
setSessionKey(null)
|
||||
setUser(null)
|
||||
setSessionError(null)
|
||||
setStatus('guest')
|
||||
return
|
||||
}
|
||||
|
||||
const session = currentResult.session
|
||||
|
||||
if (session === null) {
|
||||
profileRequestIdRef.current += 1
|
||||
sessionKeyRef.current = null
|
||||
setSessionKey(null)
|
||||
setUser(null)
|
||||
setSessionError(null)
|
||||
setStatus('guest')
|
||||
return
|
||||
}
|
||||
|
||||
const isCleared = await clearSimpleAuthSessionScope(session.sessionId)
|
||||
|
||||
if (!isCleared) {
|
||||
setSessionError(SESSION_STORAGE_ERROR)
|
||||
setStatus('unavailable')
|
||||
return
|
||||
}
|
||||
|
||||
profileRequestIdRef.current += 1
|
||||
sessionKeyRef.current = null
|
||||
setSessionKey(null)
|
||||
setUser(null)
|
||||
setSessionError(null)
|
||||
setStatus('guest')
|
||||
|
||||
try {
|
||||
await simpleRestApi.auth.simpleAuthLogout({
|
||||
refreshToken: session.tokens.refreshToken
|
||||
})
|
||||
} catch {
|
||||
// Remote revocation is best-effort after the local scope is already closed.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет authority-профиль или публикует recoverable session failure.
|
||||
*/
|
||||
const refreshCurrentUser = async (): Promise<Result<AuthUser, AuthError>> => {
|
||||
const expectedResult = readSimpleAuthSession()
|
||||
|
||||
if (expectedResult.status === 'unavailable') {
|
||||
setSessionError(SESSION_STORAGE_ERROR)
|
||||
setStatus('unavailable')
|
||||
return { isSuccess: false, error: SESSION_STORAGE_ERROR }
|
||||
}
|
||||
|
||||
const expectedSession = expectedResult.session
|
||||
|
||||
if (expectedSession === null) {
|
||||
profileRequestIdRef.current += 1
|
||||
sessionKeyRef.current = null
|
||||
setSessionKey(null)
|
||||
setUser(null)
|
||||
setSessionError(null)
|
||||
setStatus('guest')
|
||||
return { isSuccess: false, error: SESSION_CHANGED_ERROR }
|
||||
}
|
||||
|
||||
const requestId = ++profileRequestIdRef.current
|
||||
|
||||
try {
|
||||
const response = await simpleRestApi.users.simpleUsersMe()
|
||||
const nextUser = mapAuthUser(response.data)
|
||||
const currentResult = readSimpleAuthSession()
|
||||
|
||||
if (currentResult.status === 'unavailable') {
|
||||
setSessionError(SESSION_STORAGE_ERROR)
|
||||
setStatus('unavailable')
|
||||
return { isSuccess: false, error: SESSION_STORAGE_ERROR }
|
||||
}
|
||||
|
||||
const currentSession = currentResult.session
|
||||
|
||||
if (
|
||||
requestId !== profileRequestIdRef.current ||
|
||||
currentSession?.sessionId !== expectedSession.sessionId
|
||||
) {
|
||||
return { isSuccess: false, error: SESSION_CHANGED_ERROR }
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
sessionKeyRef.current = expectedSession.sessionId
|
||||
setSessionKey(expectedSession.sessionId)
|
||||
setUser(nextUser)
|
||||
setSessionError(null)
|
||||
setStatus('authenticated')
|
||||
})
|
||||
|
||||
return { isSuccess: true, data: nextUser }
|
||||
} catch (error) {
|
||||
const authError = mapAuthError(error)
|
||||
const currentResult = readSimpleAuthSession()
|
||||
|
||||
if (currentResult.status === 'unavailable') {
|
||||
setSessionError(SESSION_STORAGE_ERROR)
|
||||
setStatus('unavailable')
|
||||
return { isSuccess: false, error: SESSION_STORAGE_ERROR }
|
||||
}
|
||||
|
||||
const currentSession = currentResult.session
|
||||
|
||||
if (
|
||||
requestId !== profileRequestIdRef.current ||
|
||||
currentSession?.sessionId !== expectedSession.sessionId
|
||||
) {
|
||||
return { isSuccess: false, error: SESSION_CHANGED_ERROR }
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
sessionKeyRef.current = expectedSession.sessionId
|
||||
setSessionKey(expectedSession.sessionId)
|
||||
setSessionError(authError)
|
||||
setStatus('unavailable')
|
||||
})
|
||||
|
||||
return { isSuccess: false, error: authError }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сверяет captured session scope с domain state и persisted authority.
|
||||
*/
|
||||
const isCurrentSession = (expectedSessionKey: string): boolean => {
|
||||
const currentResult = readSimpleAuthSession()
|
||||
const persistedSession = currentResult.status === 'ready' ? currentResult.session : null
|
||||
|
||||
return (
|
||||
sessionKeyRef.current === expectedSessionKey &&
|
||||
persistedSession?.sessionId === expectedSessionKey
|
||||
)
|
||||
}
|
||||
|
||||
const value: AuthContextValue = {
|
||||
status,
|
||||
user,
|
||||
sessionKey,
|
||||
sessionError,
|
||||
isCurrentSession,
|
||||
signIn,
|
||||
signOut,
|
||||
refreshCurrentUser
|
||||
}
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { readSimpleApiFailure } from '@/infra/simple-rest-api'
|
||||
|
||||
import type { AuthError } from '../types/auth-error.type'
|
||||
|
||||
/**
|
||||
* Переводит technical failure в ожидаемую ошибку auth-домена.
|
||||
*/
|
||||
export const mapAuthError = (error: unknown): AuthError => {
|
||||
const failure = readSimpleApiFailure(error)
|
||||
|
||||
if (failure.code === 'INVALID_CREDENTIALS') {
|
||||
return { code: 'invalid-credentials', message: 'Email or password is incorrect.' }
|
||||
}
|
||||
|
||||
if (failure.status === 401) {
|
||||
return { code: 'session-expired', message: 'Your session expired. Sign in again.' }
|
||||
}
|
||||
|
||||
if (failure.status === 403) {
|
||||
return { code: 'access-denied', message: 'This account cannot perform that action.' }
|
||||
}
|
||||
|
||||
if (failure.status === 0) {
|
||||
return { code: 'service-unavailable', message: failure.message }
|
||||
}
|
||||
|
||||
return { code: 'unknown', message: 'Authentication could not be completed.' }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { useContext } from 'react'
|
||||
|
||||
import { AuthContext } from '../auth.context'
|
||||
import type { AuthContextValue } from '../types/auth-context.type'
|
||||
|
||||
/**
|
||||
* Возвращает application-scoped auth API текущего пользователя.
|
||||
*/
|
||||
export const useAuth = (): AuthContextValue => {
|
||||
const context = useContext(AuthContext)
|
||||
|
||||
if (context === null) {
|
||||
throw new Error('useAuth must be used inside AuthProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
14
examples/demo-frontend/src/domains/auth/index.ts
Normal file
14
examples/demo-frontend/src/domains/auth/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
'use client'
|
||||
|
||||
export { AuthProvider } from './auth.provider'
|
||||
export { AUTH_DEMO_ACCOUNTS } from './auth.constants'
|
||||
export { useAuth } from './hooks/use-auth.hook'
|
||||
export type { AuthError, AuthErrorCode } from './types/auth-error.type'
|
||||
export type {
|
||||
AuthContextValue,
|
||||
AuthStatus,
|
||||
SignInCredentials
|
||||
} from './types/auth-context.type'
|
||||
export type { AuthProviderProps } from './types/auth-provider-props.type'
|
||||
export type { AuthUser, AuthUserRole } from './types/auth-user.type'
|
||||
export type { DemoAccount } from './types/demo-account.type'
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { SimpleUserDto } from '@/infra/simple-rest-api'
|
||||
|
||||
import type { AuthUser } from '../types/auth-user.type'
|
||||
|
||||
/**
|
||||
* Переводит внешний user DTO в модель auth-домена.
|
||||
*/
|
||||
export const mapAuthUser = (user: SimpleUserDto): AuthUser => {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
avatarUrl: typeof user.avatarUrl === 'string' ? user.avatarUrl : null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Result } from '@/shared/types/result.type'
|
||||
|
||||
import type { AuthError } from './auth-error.type'
|
||||
import type { AuthUser } from './auth-user.type'
|
||||
|
||||
/**
|
||||
* Состояние восстановления пользовательской сессии.
|
||||
*/
|
||||
export type AuthStatus = 'checking' | 'guest' | 'authenticated' | 'unavailable'
|
||||
|
||||
/**
|
||||
* Данные формы входа.
|
||||
*/
|
||||
export type SignInCredentials = {
|
||||
/** Email demo-пользователя. */
|
||||
email: string
|
||||
/** Пароль demo-пользователя. */
|
||||
password: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Публичный API auth-домена в application scope.
|
||||
*/
|
||||
export type AuthContextValue = {
|
||||
/** Текущее состояние восстановления или авторизации. */
|
||||
status: AuthStatus
|
||||
/** Авторизованный пользователь, если сессия активна. */
|
||||
user: AuthUser | null
|
||||
/** Непрозрачный ключ логической сессии для изоляции protected state. */
|
||||
sessionKey: string | null
|
||||
/** Recoverable ошибка проверки сохранённой сессии. */
|
||||
sessionError: AuthError | null
|
||||
/** Проверяет актуальность captured logical session для async workflow. */
|
||||
isCurrentSession: (sessionKey: string) => boolean
|
||||
/** Выполняет login и сохраняет технические JWT-данные. */
|
||||
signIn: (credentials: SignInCredentials) => Promise<Result<AuthUser, AuthError>>
|
||||
/** Завершает локальную и backend-сессию. */
|
||||
signOut: () => Promise<void>
|
||||
/** Повторно загружает профиль после внешнего изменения роли. */
|
||||
refreshCurrentUser: () => Promise<Result<AuthUser, AuthError>>
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Стабильные коды ожидаемых ошибок auth-сценариев.
|
||||
*/
|
||||
export type AuthErrorCode =
|
||||
| 'invalid-credentials'
|
||||
| 'session-expired'
|
||||
| 'access-denied'
|
||||
| 'service-unavailable'
|
||||
| 'unknown'
|
||||
|
||||
/**
|
||||
* Безопасная ошибка auth-домена.
|
||||
*/
|
||||
export type AuthError = {
|
||||
/** Код для UI-ветвления. */
|
||||
code: AuthErrorCode
|
||||
/** Сообщение без transport payload. */
|
||||
message: string
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Props application-scoped auth provider.
|
||||
*/
|
||||
export type AuthProviderProps = {
|
||||
/** Поддерево, использующее auth API. */
|
||||
children: ReactNode
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Роль пользователя в Simple storefront.
|
||||
*/
|
||||
export type AuthUserRole = 'admin' | 'customer'
|
||||
|
||||
/**
|
||||
* Авторизованный пользователь без transport-полей.
|
||||
*/
|
||||
export type AuthUser = {
|
||||
/** Стабильный идентификатор пользователя. */
|
||||
id: string
|
||||
/** Email для входа и отображения профиля. */
|
||||
email: string
|
||||
/** Отображаемое имя. */
|
||||
name: string
|
||||
/** Текущая роль доступа. */
|
||||
role: AuthUserRole
|
||||
/** Необязательный URL аватара. */
|
||||
avatarUrl: string | null
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { AuthUserRole } from './auth-user.type'
|
||||
|
||||
/**
|
||||
* Детерминированная учётная запись fixture-бэкенда.
|
||||
*/
|
||||
export type DemoAccount = {
|
||||
/** Email для формы входа. */
|
||||
email: string
|
||||
/** Общий fixture-пароль. */
|
||||
password: string
|
||||
/** Роль, которую демонстрирует учётная запись. */
|
||||
role: AuthUserRole
|
||||
/** Краткое описание доступных сценариев. */
|
||||
description: string
|
||||
}
|
||||
Reference in New Issue
Block a user