feat: add example

This commit is contained in:
2026-08-01 09:31:08 +03:00
parent 15805e28df
commit 26b59686a5
434 changed files with 34975 additions and 4995 deletions

View 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'
}
]

View 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)

View 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>
}

View File

@@ -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.' }
}

View File

@@ -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
}

View 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'

View File

@@ -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
}
}

View File

@@ -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>>
}

View File

@@ -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
}

View File

@@ -0,0 +1,9 @@
import type { ReactNode } from 'react'
/**
* Props application-scoped auth provider.
*/
export type AuthProviderProps = {
/** Поддерево, использующее auth API. */
children: ReactNode
}

View File

@@ -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
}

View File

@@ -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
}