diff --git a/.cursor/rules/project-structure.mdc b/.cursor/rules/project-structure.mdc index 29f1823..1dcb2b0 100644 --- a/.cursor/rules/project-structure.mdc +++ b/.cursor/rules/project-structure.mdc @@ -390,6 +390,7 @@ app → pages → widgets → features → entities → shared - **Standalone Components** - No NgModules - **Zoneless Change Detection** - Experimental Angular feature - **Signals** - Primary state management +- **HttpOnly Cookies** - Secure token storage with Fetch API ### Styling - **SCSS** with modular architecture @@ -403,6 +404,12 @@ app → pages → widgets → features → entities → shared - **Prettier** + **Stylelint** for code formatting - **Husky** + **lint-staged** for pre-commit hooks +### Security & Authentication +- **HttpOnly Cookies** - Secure token storage preventing XSS attacks +- **Fetch API** - Modern HTTP client with better cookie support +- **CORS Credentials** - Automatic inclusion of cookies in requests +- **Token Refresh** - Automatic token renewal via HttpOnly cookies + ## Component Structure Pattern Every component follows this structure: @@ -450,6 +457,46 @@ import { SomeService } from '@/shared/services/some'; import { SomeComponent } from '@/pages/some-page'; ``` +## HTTP Client Configuration + +### HttpOnly Cookies & Fetch API + +The application uses HttpOnly cookies for secure token storage with Fetch API for optimal compatibility: + +```typescript +// app.config.ts +provideHttpClient( + withInterceptors([credentialsInterceptor, authInterceptor]), + withFetch() // Critical for HttpOnly cookies support +) +``` + +#### Why withFetch() is Required: + +1. **HttpOnly Cookie Support**: Fetch API has better support for HttpOnly cookies than XMLHttpRequest +2. **CORS Credentials**: Automatic handling of `credentials: 'include'` for cross-origin requests +3. **Modern Standard**: Fetch API is the modern standard for HTTP requests +4. **Security**: Better integration with secure cookie policies + +#### Credentials Interceptor: + +```typescript +// credentials.interceptor.ts +export const credentialsInterceptor: HttpInterceptorFn = (req, next) => { + const modifiedReq = req.clone({ + withCredentials: true, // Automatically includes HttpOnly cookies + }); + return next(modifiedReq); +}; +``` + +#### Key Benefits: + +- **XSS Protection**: Tokens stored in HttpOnly cookies are inaccessible to JavaScript +- **Automatic Inclusion**: Cookies automatically sent with every request +- **Server-Side Control**: Token lifecycle managed entirely by the server +- **No Client Storage**: No need for localStorage or sessionStorage for tokens + ## Routing Standards ### Route Naming Convention diff --git a/docs/plans/input-fields-components-plan.md b/docs/plans/input-fields-components-plan.md new file mode 100644 index 0000000..9ed8148 --- /dev/null +++ b/docs/plans/input-fields-components-plan.md @@ -0,0 +1,669 @@ +# Input Fields Components Implementation Plan + +## Обзор + +Создание специализированных переиспользуемых компонентов для различных типов полей ввода в проекте. Компоненты будут основаны на существующем паттерне `SelectFieldComponent` и интегрированы в `shared/ui` слой согласно Feature-Sliced Design архитектуре. + +## Текущая ситуация + +### Анализ существующего кода + +**Числовые поля (3 поля в basic-data-form):** +```html +Age + + + + +``` + +**Текстовые поля (6 полей в login/register формах):** +```html +Email + + + + +``` + +### Выявленные паттерны +1. Единообразная структура с `tuiLabel` и `tui-textfield` +2. Различные типы input: `text`, `password`, `number` +3. Общие атрибуты: `placeholder`, `formControlName`, `disabled` +4. Специфичные для числовых: `min`, `max` +5. Интеграция с Angular Reactive Forms + +### Приоритеты +1. **Высокий приоритет**: Числовые поля (3 поля, сложная валидация) +2. **Средний приоритет**: Текстовые поля (6 полей, простая структура) +3. **Низкий приоритет**: Телефонные поля (пока не используются) + +## Архитектурное решение + +### Структура компонентов +``` +src/shared/ui/ +├── number-field/ # Специализированный компонент для числовых полей +│ ├── number-field.component.ts +│ ├── number-field.component.html +│ ├── number-field.component.scss +│ ├── number-field.component.spec.ts +│ └── index.ts +├── text-field/ # Специализированный компонент для текстовых полей +│ ├── text-field.component.ts +│ ├── text-field.component.html +│ ├── text-field.component.scss +│ ├── text-field.component.spec.ts +│ └── index.ts +└── phone-field/ # Специализированный компонент для телефонных полей (будущее) + ├── phone-field.component.ts + ├── phone-field.component.html + ├── phone-field.component.scss + ├── phone-field.component.spec.ts + └── index.ts +``` + +### Интеграция в FSD архитектуру +- **Слой**: `shared/ui` (переиспользуемые UI компоненты) +- **Импорт**: Компоненты будут доступны через `@/shared` +- **Зависимости**: Только от Taiga UI и Angular Forms +- **Принцип**: Один компонент = одна ответственность + +## Техническая спецификация + +### 1. NumberFieldComponent + +#### Интерфейс и типы +```typescript +// Типы для числовых полей +export type NumberFieldValue = number | null; + +// Интерфейс для числовых ограничений +interface NumberConstraints { + min?: number; + max?: number; + step?: number; + precision?: number; +} +``` + +#### Input параметры +```typescript +// Обязательные +readonly label = input.required(); +readonly placeholder = input.required(); + +// Опциональные общие +readonly disabled = input(false); +readonly required = input(false); +readonly readonly = input(false); +readonly suffix = input(''); // единицы измерения (kg, cm) + +// Для числовых полей +readonly min = input(); +readonly max = input(); +readonly step = input(1); +readonly precision = input(0); + +// Для стилизации +readonly size = input<'s' | 'm' | 'l'>('m'); +``` + +#### Output события +```typescript +readonly valueChange = output(); +``` + +#### ControlValueAccessor реализация +```typescript +implements ControlValueAccessor { + writeValue(value: number | null): void + registerOnChange(fn: (value: number | null) => void): void + registerOnTouched(fn: () => void): void + setDisabledState?(isDisabled: boolean): void +} +``` + +#### Template структура +```html + + + {{ label() }} + @if (required()) {*} + @if (suffix(); as suffixText) {({{ suffixText }})} + + + + + +``` + +### 2. TextFieldComponent + +#### Интерфейс и типы +```typescript +// Типы для текстовых полей +export type TextFieldValue = string | null; +export type TextFieldType = 'text' | 'email' | 'password' | 'url'; + +// Интерфейс для текстовых ограничений +interface TextConstraints { + minlength?: number; + maxlength?: number; + pattern?: string; +} +``` + +#### Input параметры +```typescript +// Обязательные +readonly label = input.required(); +readonly placeholder = input.required(); + +// Опциональные общие +readonly disabled = input(false); +readonly required = input(false); +readonly readonly = input(false); + +// Для текстовых полей +readonly type = input('text'); +readonly minlength = input(); +readonly maxlength = input(); +readonly pattern = input(); + +// Для стилизации +readonly size = input<'s' | 'm' | 'l'>('m'); +``` + +#### Output события +```typescript +readonly valueChange = output(); +``` + +#### ControlValueAccessor реализация +```typescript +implements ControlValueAccessor { + writeValue(value: string | null): void + registerOnChange(fn: (value: string | null) => void): void + registerOnTouched(fn: () => void): void + setDisabledState?(isDisabled: boolean): void +} +``` + +#### Template структура +```html + + + {{ label() }} + @if (required()) {*} + + + + + +``` + +## Пошаговый план реализации + +### Этап 1: NumberFieldComponent (Приоритет 1) +**Цель**: Создать специализированный компонент для числовых полей + +**Задачи**: +1. Создать файловую структуру `src/shared/ui/number-field/` +2. Реализовать TypeScript класс с правильным ControlValueAccessor +3. Создать HTML template с `tuiInputNumber` +4. Добавить стили в соответствии с существующим паттерном +5. Создать unit тесты +6. Экспортировать компонент через `index.ts` + +**Критерии готовности**: +- Компонент работает с `formControlName` +- Корректная обработка `min`, `max`, `step`, `precision` +- Поддержка `suffix` для единиц измерения +- Покрытие тестами минимум 80% + +### Этап 2: Интеграция NumberFieldComponent +**Цель**: Заменить числовые поля в basic-data-form + +**Задачи**: +1. Обновить экспорты в `shared/index.ts` +2. Заменить 3 числовых поля в `basic-data-form.component.html`: + - Age (min: 10, max: 120) + - Height (min: 100, max: 250, suffix: 'cm') + - Weight (min: 30, max: 300, suffix: 'kg') +3. Обновить импорты в `basic-data-form.component.ts` +4. Протестировать интеграцию +5. Проверить, что форма работает корректно + +**Критерии готовности**: +- Все числовые поля заменены на `NumberFieldComponent` +- Форма работает без регрессий +- Валидация работает корректно +- Автосохранение формы работает + +### Этап 3: TextFieldComponent (Приоритет 2) +**Цель**: Создать специализированный компонент для текстовых полей + +**Задачи**: +1. Создать файловую структуру `src/shared/ui/text-field/` +2. Реализовать TypeScript класс с поддержкой разных типов +3. Создать HTML template с `tuiTextfield` +4. Добавить стили +5. Создать unit тесты +6. Экспортировать компонент + +**Критерии готовности**: +- Поддержка типов: `text`, `email`, `password`, `url` +- Корректная работа с `minlength`, `maxlength`, `pattern` +- Покрытие тестами минимум 80% + +### Этап 4: Интеграция TextFieldComponent (Опционально) +**Цель**: Заменить текстовые поля в login/register формах + +**Задачи**: +1. Обновить экспорты в `shared/index.ts` +2. Заменить текстовые поля в `login.component.html` +3. Заменить текстовые поля в `register.component.html` +4. Протестировать интеграцию +5. Проверить работу форм + +**Критерии готовности**: +- Текстовые поля заменены на `TextFieldComponent` +- Формы работают без регрессий +- Валидация работает корректно + +### Этап 5: PhoneFieldComponent (Будущее) +**Цель**: Создать компонент для телефонных полей при необходимости + +**Задачи**: +1. Создать компонент с `tuiInputPhone` +2. Добавить поддержку различных форматов телефонов +3. Интегрировать при появлении потребности + +## Детали реализации + +### Imports и Dependencies + +#### NumberFieldComponent +```typescript +import { ChangeDetectionStrategy, Component, input, output, signal, computed, inject } from '@angular/core'; +import { FormsModule, NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms'; +import { TuiTextfield } from '@taiga-ui/core'; +import { TuiInputNumber } from '@taiga-ui/kit'; +import type { TuiSizeS, TuiSizeM, TuiSizeL } from '@taiga-ui/core'; +``` + +#### TextFieldComponent +```typescript +import { ChangeDetectionStrategy, Component, input, output, signal, computed, inject } from '@angular/core'; +import { FormsModule, NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms'; +import { TuiTextfield } from '@taiga-ui/core'; +import { TuiInput } from '@taiga-ui/kit'; +import type { TuiSizeS, TuiSizeM, TuiSizeL } from '@taiga-ui/core'; +``` + +### Provider конфигурация +```typescript +providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: NumberFieldComponent, // или TextFieldComponent + multi: true, + }, +] +``` + +### Signals архитектура + +#### NumberFieldComponent +```typescript +private readonly internalValue = signal(null); +private readonly isTouched = signal(false); + +readonly currentValue = computed(() => this.internalValue()); + +private _onChange = (value: number | null): void => { + void value; +}; +private _onTouched = (): void => {}; + +onValueChange(event: Event): void { + const target = event.target as HTMLInputElement; + const value = target.value === '' ? null : Number(target.value); + this.internalValue.set(value); + this._onChange(value); +} + +onTouched(): void { + this.isTouched.set(true); + this._onTouched(); +} +``` + +#### TextFieldComponent +```typescript +private readonly internalValue = signal(null); +private readonly isTouched = signal(false); + +readonly currentValue = computed(() => this.internalValue()); + +private _onChange = (value: string | null): void => { + void value; +}; +private _onTouched = (): void => {}; + +onValueChange(event: Event): void { + const target = event.target as HTMLInputElement; + const value = target.value === '' ? null : target.value; + this.internalValue.set(value); + this._onChange(value); +} + +onTouched(): void { + this.isTouched.set(true); + this._onTouched(); +} +``` + +## Примеры использования + +### NumberFieldComponent +```html + + + + + + + + + + + +``` + +### TextFieldComponent +```html + + + + + + + + +``` + +## Рефакторинг существующего кода + +### 1. basic-data-form.component.html +```html + + + Age + + + + + + + + + + +``` + +### 2. basic-data-form.component.ts +```typescript +// Добавить импорт +import { NumberFieldComponent } from '@/shared'; + +// Обновить imports +imports: [ + ReactiveFormsModule, + TuiButton, + TuiInputNumber, // Убрать после замены всех полей + TuiTextfield, // Убрать после замены всех полей + NumberFieldComponent, // Добавить + SelectFieldComponent, + SectionBlockComponent, + FormAutosaveDirective, +], +``` + +## Тестирование + +### Unit тесты для NumberFieldComponent +```typescript +describe('NumberFieldComponent', () => { + describe('Component Creation', () => { + it('should create component with required inputs'); + it('should generate unique field ID'); + }); + + describe('Value Handling', () => { + it('should emit valueChange when value changes'); + it('should handle null values correctly'); + it('should convert string input to number'); + it('should respect min/max constraints'); + }); + + describe('ControlValueAccessor', () => { + it('should implement writeValue correctly'); + it('should call onChange when value changes'); + it('should handle disabled state'); + it('should call onTouched on blur'); + }); + + describe('Form Integration', () => { + it('should work with reactive forms'); + it('should maintain form validation state'); + it('should preserve existing form behavior'); + }); + + describe('Number Constraints', () => { + it('should respect min constraint'); + it('should respect max constraint'); + it('should respect step constraint'); + it('should respect precision constraint'); + }); +}); +``` + +### Unit тесты для TextFieldComponent +```typescript +describe('TextFieldComponent', () => { + describe('Component Creation', () => { + it('should create component with required inputs'); + it('should generate unique field ID'); + }); + + describe('Value Handling', () => { + it('should emit valueChange when value changes'); + it('should handle null values correctly'); + it('should handle string values correctly'); + }); + + describe('ControlValueAccessor', () => { + it('should implement writeValue correctly'); + it('should call onChange when value changes'); + it('should handle disabled state'); + it('should call onTouched on blur'); + }); + + describe('Field Types', () => { + it('should support text type'); + it('should support email type'); + it('should support password type'); + it('should support url type'); + }); + + describe('Text Constraints', () => { + it('should respect minlength constraint'); + it('should respect maxlength constraint'); + it('should respect pattern constraint'); + }); +}); +``` + +### Integration тесты +```typescript +describe('Input Fields Form Integration', () => { + it('should integrate NumberFieldComponent with basic-data-form correctly'); + it('should maintain form validation state'); + it('should preserve existing form behavior'); + it('should work with form autosave'); +}); +``` + +## Критерии качества + +### Производительность +- Использование OnPush change detection +- Минимальное количество re-renders +- Efficient signal usage +- Правильная типизация для избежания лишних вычислений + +### Доступность (A11y) +- Корректные ARIA атрибуты +- Уникальные ID для полей +- Поддержка клавиатурной навигации +- Семантически правильная разметка + +### Тестирование +- Покрытие тестами минимум 80% +- Тесты для всех публичных методов +- Тесты интеграции с формами +- Тесты для всех типов полей + +### Совместимость +- Работа во всех поддерживаемых браузерах +- Корректная работа на мобильных устройствах +- Соответствие Angular best practices +- Соответствие Taiga UI guidelines + +## Риски и их митигация + +### Риск 1: Нарушение существующей функциональности +**Митигация**: Пошаговая замена с тщательным тестированием каждого этапа + +### Риск 2: Несовместимость с существующими стилями +**Митигация**: Использование тех же CSS классов и BEM структуры как в SelectFieldComponent + +### Риск 3: Проблемы с типизацией +**Митигация**: Строгая типизация TypeScript, тестирование с различными типами данных + +### Риск 4: Производительность +**Митигация**: Использование OnPush и signals, профилирование производительности + +## Timeline + +### Week 1: NumberFieldComponent +- Создание NumberFieldComponent +- Unit тесты +- Интеграция в basic-data-form + +### Week 2: TextFieldComponent (Опционально) +- Создание TextFieldComponent +- Unit тесты +- Интеграция в login/register формы + +### Week 3: Testing & Documentation +- Integration тесты +- Документация +- Финальное тестирование + +## Заключение + +Данный план обеспечивает создание специализированных, типобезопасных и хорошо протестированных компонентов для полей ввода, которые: + +1. Соответствуют архитектуре FSD +2. Интегрируются с Angular Reactive Forms +3. Используют современные Angular паттерны (signals, standalone components) +4. Обеспечивают единообразный UX +5. Минимизируют дублирование кода +6. Упрощают создание новых форм в будущем + +**Приоритет**: Начать с NumberFieldComponent как наиболее критичного компонента, затем расширять функциональность по мере необходимости. diff --git a/docs/plans/number-field-component-plan.md b/docs/plans/number-field-component-plan.md index fb0b7d4..bae60c7 100644 --- a/docs/plans/number-field-component-plan.md +++ b/docs/plans/number-field-component-plan.md @@ -1,426 +1,120 @@ -# Text Field Component Implementation Plan +# NumberFieldComponent Implementation Plan ## Обзор -Создание универсального переиспользуемого компонента `TextFieldComponent` для унификации всех типов текстовых полей в проекте. Компонент будет основан на существующем паттерне `SelectFieldComponent` и интегрирован в shared/ui слой согласно Feature-Sliced Design архитектуре. +Создание специализированного компонента `NumberFieldComponent` для унификации числовых полей в проекте. Компонент будет основан на существующем паттерне `SelectFieldComponent` и интегрирован в `shared/ui` слой согласно Feature-Sliced Design архитектуре. -## Текущая ситуация +## Проблема -### Проблема -В коде форм повторяется один и тот же паттерн для текстовых полей разных типов: +В `basic-data-form.component.html` повторяется один и тот же паттерн для числовых полей: -**Числовые поля:** ```html -Weight (kg) +Age - - - -``` - -**Текстовые поля (потенциальные случаи):** -```html - -Email - - - - - - -Password - - - - - - -Name - - + ``` -### Выявленные паттерны -1. Единообразная структура с `tuiLabel` и `tui-textfield` -2. Различные типы input: `text`, `email`, `password`, `number` (с `tuiInputNumber`) -3. Общие атрибуты: `placeholder`, `formControlName`, `disabled` -4. Специфичные для числовых полей: `min`, `max`, `step`, `precision` -5. Специфичные для текстовых: `maxlength`, `minlength`, `pattern` -6. Необходимость интеграции с Angular Reactive Forms +**Поля для замены:** +- Age (min: 10, max: 120) +- Height (min: 100, max: 250, suffix: 'cm') +- Weight (min: 30, max: 300, suffix: 'kg') -## Архитектурное решение +## Техническая спецификация -### Расположение компонента +### Структура компонента ``` -src/shared/ui/text-field/ -├── text-field.component.ts -├── text-field.component.html -├── text-field.component.scss -├── text-field.component.spec.ts +src/shared/ui/number-field/ +├── number-field.component.ts +├── number-field.component.html +├── number-field.component.scss +├── number-field.component.spec.ts └── index.ts ``` -### Интеграция в FSD архитектуру -- **Слой**: `shared/ui` (переиспользуемые UI компоненты) -- **Импорт**: Компонент будет доступен через `@/shared` -- **Зависимости**: Только от Taiga UI и Angular Forms - -## Техническая спецификация - -### 1. Интерфейсы и типы - -```typescript -// Тип поля ввода -export type TextFieldType = 'text' | 'email' | 'password' | 'number' | 'tel' | 'url'; - -// Интерфейс для числовых ограничений -interface NumberConstraints { - min?: number; - max?: number; - step?: number; - precision?: number; -} - -// Интерфейс для текстовых ограничений -interface TextConstraints { - minlength?: number; - maxlength?: number; - pattern?: string; -} -``` - -### 2. Input параметры - +### Input параметры ```typescript // Обязательные readonly label = input.required(); readonly placeholder = input.required(); -readonly type = input('text'); -// Опциональные общие +// Опциональные readonly disabled = input(false); readonly required = input(false); readonly readonly = input(false); -readonly suffix = input(''); // единицы измерения (kg, cm) или иконки - -// Для числовых полей (используются только если type === 'number') +readonly suffix = input(''); // единицы измерения (kg, cm) readonly min = input(); readonly max = input(); readonly step = input(1); -readonly precision = input(0); // количество знаков после запятой - -// Для текстовых полей -readonly minlength = input(); -readonly maxlength = input(); -readonly pattern = input(); // regex паттерн - -// Для стилизации -readonly size = input<'s' | 'm' | 'l'>('m'); // размер поля -readonly appearance = input<'outline' | 'fill'>('outline'); // вид +readonly precision = input(0); +readonly size = input<'s' | 'm' | 'l'>('m'); ``` -### 3. Output события - +### Output события ```typescript -readonly valueChange = output(); // всегда строка для унификации +readonly valueChange = output(); ``` -### 4. ControlValueAccessor реализация - -Компонент должен реализовать `ControlValueAccessor` для интеграции с Angular Reactive Forms: - +### ControlValueAccessor реализация ```typescript implements ControlValueAccessor { - // Методы CVA - всегда работаем со строками - writeValue(value: string | null): void - registerOnChange(fn: (value: string | null) => void): void + writeValue(value: number | null): void + registerOnChange(fn: (value: number | null) => void): void registerOnTouched(fn: () => void): void setDisabledState?(isDisabled: boolean): void } ``` -### 5. Валидация - -Встроенная валидация зависит от типа поля: - -**Для числовых полей (type === 'number'):** -- Минимальное значение (`min`) -- Максимальное значение (`max`) -- Обязательность поля (`required`) -- Числовой формат (`step`, `precision`) - -**Для текстовых полей:** -- Минимальная длина (`minlength`) -- Максимальная длина (`maxlength`) -- Паттерн (`pattern` regex) -- Обязательность поля (`required`) - -**Для email полей:** -- Валидный email формат -- Обязательность поля (`required`) - -**Для password полей:** -- Минимальная длина -- Сложность пароля (если задан `pattern`) - -### 6. Template структура - -Используем `tui-textfield` как оболочку и директивы Taiga на стандартном `input`: - +### Template структура ```html -@switch (type()) { - @case ('number') { - - - {{ label() }} - @if (required()) {*} - @if (suffix(); as suffixText) {({{ suffixText }})} - - - - - - } - - @case ('tel') { - - - {{ label() }} - @if (required()) {*} - - - - - - } - - @default { - - - {{ label() }} - @if (required()) {*} - @if (suffix(); as suffixText) {({{ suffixText }})} - - - - - - } -} + + + {{ label() }} + @if (required()) {*} + @if (suffix(); as suffixText) {({{ suffixText }})} + + + + + ``` ## Пошаговый план реализации -### Этап 1: Создание базового компонента -**Цель**: Создать минимальную работающую версию - +### Этап 1: Создание компонента **Задачи**: -1. Создать файловую структуру компонента -2. Реализовать базовый TypeScript класс с обязательными input параметрами -3. Создать минимальный HTML template -4. Добавить базовые стили -5. Создать базовые тесты -6. Экспортировать компонент через `index.ts` - -**Файлы для создания**: -- `text-field.component.ts` - основная логика с поддержкой разных типов -- `text-field.component.html` - template с условной логикой для типов -- `text-field.component.scss` - стили для всех вариантов -- `text-field.component.spec.ts` - тесты для всех типов полей -- `index.ts` - экспорт и типы - -### Этап 2: Реализация ControlValueAccessor -**Цель**: Интеграция с Angular Forms - -**Задачи**: -1. Имплементировать интерфейс `ControlValueAccessor` -2. Добавить поддержку `formControlName` -3. Реализовать двустороннее связывание данных -4. Добавить обработку состояния disabled -5. Протестировать интеграцию с формами - -**Критерии готовности**: -- Компонент работает с `formControlName` -- Корректная обработка `writeValue`, `registerOnChange`, `registerOnTouched` -- Поддержка `setDisabledState` - -### Этап 3: Расширенная функциональность -**Цель**: Добавить дополнительные возможности для всех типов полей - -**Задачи**: -1. **Для числовых полей**: поддержка `precision`, `min`, `max`, `step` -2. **Для текстовых полей**: поддержка `minlength`, `maxlength`, `pattern` -3. **Для всех типов**: отображение суффиксов (`suffix`) -4. Улучшить валидацию для каждого типа поля -5. Добавить поддержку разных размеров (`size`) и внешнего вида (`appearance`) -6. Добавить ARIA атрибуты для доступности - -### Этап 4: Стилизация и UX -**Цель**: Привести к единому дизайну проекта - -**Задачи**: -1. Интегрировать с существующей системой дизайна -2. Добавить стили для состояний (focus, error, disabled) -3. Обеспечить адаптивность для мобильных устройств -4. Добавить анимации при необходимости -5. Протестировать в разных браузерах - -### Этап 5: Интеграция в проект -**Цель**: Заменить существующие реализации во всех формах - +1. Создать файловую структуру +2. Реализовать TypeScript класс с ControlValueAccessor +3. Создать HTML template +4. Добавить стили +5. Создать unit тесты +6. Экспортировать через index.ts + +### Этап 2: Интеграция **Задачи**: 1. Обновить экспорты в `shared/index.ts` -2. **Заменить числовые поля** в `basic-data-form.component.html` -3. **Подготовить к будущему использованию** в других формах (профиль пользователя, настройки) -4. Обновить стили форм для единообразного внешнего вида -5. Запустить тесты для проверки регрессий -6. Обновить документацию в `docs/` - -### Этап 6: Тестирование и документация -**Цель**: Обеспечить качество и удобство использования - -**Задачи**: -1. Написать полный набор unit тестов -2. Протестировать интеграцию с различными формами -3. Проверить покрытие тестами (минимум 80%) -4. Создать примеры использования -5. Обновить проектную документацию - -## Детали реализации +2. Заменить 3 числовых поля в `basic-data-form.component.html` +3. Обновить импорты в `basic-data-form.component.ts` +4. Протестировать интеграцию -### Imports и Dependencies - -```typescript -import { ChangeDetectionStrategy, Component, input, output, signal, computed } from '@angular/core'; -import { FormsModule, NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms'; - -// Taiga UI оболочка и директивы -import { TuiTextfield } from '@taiga-ui/core'; -import { TuiInputNumber, TuiInputPhone } from '@taiga-ui/kit'; -import type { TuiNumberFormat } from '@taiga-ui/core'; - -// Размеры -import { TuiSizeS, TuiSizeM, TuiSizeL } from '@taiga-ui/core'; - -// Типы -export type TextFieldType = 'text' | 'email' | 'password' | 'number' | 'tel' | 'url'; -export type TextFieldSize = TuiSizeS | TuiSizeM | TuiSizeL; -``` - -### Provider конфигурация - -```typescript -providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: TextFieldComponent, - multi: true, - }, -] -``` - -### Signals архитектура - -```typescript -// Внутренние сигналы -private readonly internalValue = signal(null); -private readonly isFocused = signal(false); -private readonly isTouched = signal(false); - -// Computed значения -readonly currentValue = computed(() => this.internalValue()); - -// Computed для числового формата (только для числовых полей) -readonly numberFormat = computed((): TuiNumberFormat | null => { - if (this.type() !== 'number') return null; - - return { - precision: this.precision(), - decimalSeparator: '.', - thousandSeparator: ',', - decimalMode: 'always', - rounding: 'round' - }; -}); - -// Валидация для числовых полей -readonly hasNumberError = computed(() => { - if (this.type() !== 'number') return false; - const raw = this.internalValue(); - if (raw === null || raw === '') return false; - const value = Number(raw); - if (Number.isNaN(value)) return true; - const min = this.min(); - const max = this.max(); - return (min !== undefined && value < min) || (max !== undefined && value > max); -}); - -// Валидация для текстовых полей -readonly hasTextError = computed(() => { - if (this.type() === 'number') return false; - const value = this.internalValue() as string; - const minlength = this.minlength(); - const maxlength = this.maxlength(); - const pattern = this.pattern(); - - if (!value) return false; - - return ( - (minlength !== undefined && value.length < minlength) || - (maxlength !== undefined && value.length > maxlength) || - (pattern && !new RegExp(pattern).test(value)) - ); -}); - -// Общая валидация -readonly hasError = computed(() => this.hasNumberError() || this.hasTextError()); -``` - -### Использование компонента - -После реализации компонент будет использоваться так: +## Примеры использования ```html - - + this.hasNumberError() || this.hasTextError()) formControlName="age" /> - + + + + this.hasNumberError() || this.hasTextError()) suffix="kg" formControlName="weight" /> - - - - - - - - - - - - - - - - - - - - - -``` - -## Рефакторинг существующего кода - -### Файлы для изменения: - -1. **`src/features/calorie-calculation/ui/basic-data-form/basic-data-form.component.html`** - ```html - - - Age - - - - - - - - - - - ``` - -2. **`src/features/calorie-calculation/ui/basic-data-form/basic-data-form.component.ts`** - - Добавить импорт `TextFieldComponent` - - Обновить массив imports в декораторе компонента - -3. **`src/shared/index.ts`** - - Добавить экспорт `TextFieldComponent` - -## Тестирование - -### Unit тесты - -```typescript -describe('TextFieldComponent', () => { - describe('Component Creation', () => { - it('should create component with required inputs'); - it('should throw error if required inputs are missing'); - }); - - describe('Value Handling', () => { - it('should emit valueChange when value changes'); - it('should handle null values correctly'); - it('should respect min/max constraints'); - }); - - describe('ControlValueAccessor', () => { - it('should implement writeValue correctly'); - it('should call onChange when value changes'); - it('should handle disabled state'); - }); - - describe('Form Integration', () => { - it('should work with reactive forms'); - it('should validate min/max in form context'); - it('should show validation errors'); - }); -}); ``` -### Integration тесты +## Критерии готовности -```typescript -describe('TextField Form Integration', () => { - it('should integrate with basic-data-form correctly'); - it('should maintain form validation state'); - it('should preserve existing form behavior'); -}); -``` - -## Критерии качества - -### Производительность -- Использование OnPush change detection -- Минимальное количество re-renders -- Efficient signal usage - -### Доступность (A11y) -- Корректные ARIA атрибуты -- Поддержка клавиатурной навигации -- Семантически правильная разметка - -### Тестирование -- Покрытие тестами минимум 80% -- Тесты для всех публичных методов -- Тесты интеграции с формами - -### Совместимость -- Работа во всех поддерживаемых браузерах -- Корректная работа на мобильных устройствах -- Соответствие Angular best practices - -## Риски и их митигация - -### Риск 1: Нарушение существующей функциональности -**Митигация**: Пошаговая замена с тщательным тестированием каждого этапа - -### Риск 2: Несовместимость с существующими стилями -**Митигация**: Использование тех же CSS классов и BEM структуры - -### Риск 3: Производительность -**Митигация**: Использование OnPush и signals, профилирование производительности - -### Риск 4: Сложность тестирования -**Митигация**: Создание helpers для тестирования, использование existing patterns +- [ ] Компонент работает с `formControlName` +- [ ] Корректная обработка `min`, `max`, `step`, `precision` +- [ ] Поддержка `suffix` для единиц измерения +- [ ] Покрытие тестами минимум 80% +- [ ] Все числовые поля в basic-data-form заменены +- [ ] Форма работает без регрессий +- [ ] Валидация работает корректно +- [ ] Автосохранение формы работает ## Timeline -### Week 1: Foundation -- Этапы 1-2: Создание базового компонента и CVA интеграция - -### Week 2: Features & Styling -- Этапы 3-4: Расширенная функциональность и стилизация - -### Week 3: Integration & Testing -- Этапы 5-6: Интеграция в проект и тестирование - -## Заключение - -Данный план обеспечивает создание переиспользуемого, типобезопасного и хорошо протестированного компонента для числовых полей, который: - -1. Соответствует архитектуре FSD -2. Интегрируется с Angular Reactive Forms -3. Использует современные Angular паттерны (signals, standalone components) -4. Обеспечивает единообразный UX -5. Минимизирует дублирование кода -6. Упрощает создание новых форм в будущем +**Week 1**: Создание компонента и интеграция +- День 1-2: Создание NumberFieldComponent +- День 3-4: Unit тесты +- День 5: Интеграция в basic-data-form -Компонент станет основой для всех числовых полей в приложении и обеспечит лучшую maintainability кодовой базы. +**Результат**: Все числовые поля унифицированы, код стал более maintainable. diff --git a/docs/plans/token-validation-in-auth-guard-plan.md b/docs/plans/token-validation-in-auth-guard-plan.md deleted file mode 100644 index 7fad5f2..0000000 --- a/docs/plans/token-validation-in-auth-guard-plan.md +++ /dev/null @@ -1,311 +0,0 @@ -# План: Проверка живости Access токена в Auth Guard - -## Проблема -Текущий auth guard не проверяет живость (валидность/истечение) access токена. Он только проверяет наличие токена в cookies, что позволяет пользователям с истёкшими токенами проходить guard и получать 401 ошибки только при API запросах. - -## Цель -Реализовать проверку живости access токена в auth guard для предотвращения доступа с истёкшими токенами и улучшения UX. - -## Анализ текущего состояния - -### Текущая логика AuthGuard -```typescript -// src/features/auth/guards/auth.guard.ts -export const authGuard: CanMatchFn = (): boolean => { - const authService = inject(AuthService); - return authService.isAuthenticated(); // Только проверка наличия токена! -}; -``` - -### Проблемы -1. **Нет проверки истечения токена** - guard пропускает истёкшие токены -2. **Нет валидации токена** - guard не проверяет подпись/валидность -3. **Реактивная проверка** - токен проверяется только при API запросах через interceptor - -## Варианты решения - -### Вариант 1: Декодирование JWT и проверка exp (Рекомендуемый) -**Преимущества:** -- Быстрая проверка без сетевых запросов -- Работает офлайн -- Минимальная нагрузка на сервер - -**Недостатки:** -- Не проверяет валидность подписи -- Не учитывает преждевременную инвалидацию токена на сервере - -### Вариант 2: API запрос для валидации токена -**Преимущества:** -- Полная валидация токена на сервере -- Учитывает все виды инвалидации - -**Недостатки:** -- Дополнительные сетевые запросы на каждую навигацию -- Не работает офлайн -- Увеличенная нагрузка на сервер - -### Вариант 3: Гибридный подход -**Описание:** -- Сначала проверка exp в JWT -- При приближении истечения (например, < 5 минут) - проверка через API -- Автоматический refresh при необходимости - -## Выбранное решение: Вариант 1 (Декодирование JWT) - -### Причины выбора -1. **Производительность** - нет дополнительных HTTP запросов -2. **UX** - мгновенная проверка без задержек -3. **Простота** - минимальные изменения в архитектуре -4. **Надёжность** - проверка работает даже офлайн - -## Техническая реализация - -### 1. Создание JWT утилиты -**Файл:** `src/shared/lib/utils/jwt.utils.ts` - -```typescript -export interface JwtPayload { - exp: number; - iat: number; - sub: string; - [key: string]: unknown; -} - -export const decodeJwt = (token: string): JwtPayload | null; -export const isTokenExpired = (token: string): boolean; -export const getTokenExpirationTime = (token: string): Date | null; -export const isTokenExpiringSoon = (token: string, minutesThreshold: number = 5): boolean; -``` - -### 2. Обновление TokenStorageService -**Файл:** `src/shared/services/auth/token-storage.service.ts` - -```typescript -export class TokenStorageService { - // Добавить методы - isAccessTokenValid(): boolean; - isAccessTokenExpiringSoon(minutesThreshold?: number): boolean; -} -``` - -### 3. Обновление AuthService -**Файл:** `src/features/auth/services/auth.service.ts` - -```typescript -export class AuthService { - // Обновить метод проверки аутентификации - isAuthenticated(): boolean; - - // Добавить методы - isTokenValid(): boolean; - private checkTokenValidity(): void; -} -``` - -### 4. Обновление AuthGuard -**Файл:** `src/features/auth/guards/auth.guard.ts` - -```typescript -export const authGuard: CanMatchFn = (): boolean => { - const authService = inject(AuthService); - - if (!authService.isAuthenticated()) { - // Редирект на логин - return false; - } - - if (!authService.isTokenValid()) { - // Попытка автоматического обновления или логаут - return false; - } - - return true; -}; -``` - -## Этапы разработки - -### Этап 1: JWT утилиты -**Задачи:** -1. Создать `jwt.utils.ts` с функциями декодирования -2. Добавить обработку ошибок декодирования -3. Написать тесты для всех утилит -4. Покрыть edge cases (невалидный JWT, отсутствие exp и т.д.) - -**Файлы:** -- `src/shared/lib/utils/jwt.utils.ts` -- `src/shared/lib/utils/jwt.utils.spec.ts` -- `src/shared/lib/utils/index.ts` (экспорт) - -### Этап 2: Обновление TokenStorageService -**Задачи:** -1. Добавить методы проверки валидности токена -2. Интегрировать JWT утилиты -3. Обновить тесты с новыми методами -4. Обеспечить обратную совместимость - -**Файлы:** -- `src/shared/services/auth/token-storage.service.ts` -- `src/shared/services/auth/token-storage.service.spec.ts` - -### Этап 3: Обновление AuthService -**Задачи:** -1. Модифицировать логику `isAuthenticated()` -2. Добавить проверку валидности токена -3. Обновить `initFromStorage()` для учёта exp -4. Написать тесты для новых сценариев - -**Файлы:** -- `src/features/auth/services/auth.service.ts` -- `src/features/auth/services/auth.service.spec.ts` - -### Этап 4: Обновление AuthGuard -**Задачи:** -1. Добавить проверку валидности токена в guard -2. Реализовать обработку истёкших токенов -3. Обновить тесты guard -4. Добавить интеграционные тесты - -**Файлы:** -- `src/features/auth/guards/auth.guard.ts` -- `src/features/auth/guards/auth.guard.spec.ts` - -### Этап 5: Интеграция и оптимизация -**Задачи:** -1. Интеграционное тестирование всех компонентов -2. Проверка работы с auth interceptor -3. Оптимизация производительности -4. Документирование изменений - -## Детали реализации - -### Обработка истёкших токенов в Guard -```typescript -export const authGuard: CanMatchFn = (): boolean => { - const authService = inject(AuthService); - const router = inject(Router); - - if (!authService.isAuthenticated()) { - void router.navigate(['/login']); - return false; - } - - if (!authService.isTokenValid()) { - // Сохранить текущий URL для возврата - const currentUrl = router.url; - if (currentUrl !== '/login' && currentUrl !== '/register') { - sessionStorage.setItem('return_url', currentUrl); - } - - // Попытка автоматического обновления через refresh - const refreshResult = authService.tryRefreshToken(); - if (!refreshResult) { - void router.navigate(['/login']); - return false; - } - } - - return true; -}; -``` - -### Автоматическое обновление токена -```typescript -// В AuthService -tryRefreshToken(): boolean { - const refreshToken = this.tokenStorage.getRefreshToken(); - if (!refreshToken) { - this.logout(); - return false; - } - - // Синхронная проверка - если refresh токен тоже истёк - if (this.tokenStorage.isRefreshTokenExpired()) { - this.logout(); - return false; - } - - // Асинхронное обновление в фоне - this.refreshTokenInBackground(); - return true; -} -``` - -## Тестирование - -### Unit тесты -1. **JWT утилиты** - все функции декодирования и проверки -2. **TokenStorageService** - новые методы валидации -3. **AuthService** - обновлённая логика аутентификации -4. **AuthGuard** - все сценарии проверки токенов - -### Интеграционные тесты -1. **Полный flow** - от guard до API запроса -2. **Обновление токена** - автоматический refresh -3. **Обработка ошибок** - различные виды невалидных токенов - -### Edge cases -1. **Повреждённый JWT** - некорректный формат токена -2. **Отсутствие exp** - токен без времени истечения -3. **Часовые пояса** - корректная работа с UTC -4. **Одновременные запросы** - race conditions при refresh - -## Риски и митигация - -### Технические риски -1. **Производительность** - декодирование JWT на каждую навигацию - - *Митигация*: кеширование результата декодирования - -2. **Безопасность** - клиентская проверка может быть обойдена - - *Митигация*: серверная валидация остаётся основной защитой - -3. **Совместимость** - изменения могут сломать существующую логику - - *Митигация*: тщательное тестирование и обратная совместимость - -### UX риски -1. **Ложные срабатывания** - корректные токены отклоняются из-за ошибок - - *Митигация*: подробное логирование и fallback на interceptor - -2. **Задержки навигации** - медленная проверка токенов - - *Митигация*: оптимизация алгоритмов и кеширование - -## Критерии готовности - -### Функциональные требования -- ✅ Guard проверяет истечение access токена -- ✅ Автоматическое обновление токена при возможности -- ✅ Корректная обработка всех типов невалидных токенов -- ✅ Сохранение return URL при редиректе на логин - -### Нефункциональные требования -- ✅ Покрытие тестами > 90% для новых компонентов -- ✅ Время проверки токена < 1ms -- ✅ Обратная совместимость с существующим API -- ✅ Документация всех изменений - -### Тестирование -- ✅ Все unit тесты проходят -- ✅ Интеграционные тесты покрывают основные сценарии -- ✅ Manual тестирование в различных браузерах -- ✅ E2E тесты для критических путей - -## Дальнейшие улучшения - -### Фаза 2: Проактивное обновление токенов -- Автоматическое обновление токенов за 5 минут до истечения -- Background refresh во время активности пользователя - -### Фаза 3: Улучшенная безопасность -- Проверка подписи JWT на клиенте (опционально) -- Дополнительная валидация claims в токене - -### Фаза 4: Производительность -- Кеширование результатов декодирования JWT -- Оптимизация для high-frequency навигации - ---- - -**Автор:** AI Assistant -**Дата создания:** 2025-01-12 -**Версия:** 1.0 -**Статус:** Планирование diff --git a/package-lock.json b/package-lock.json index 30beddc..57d3191 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "@taiga-ui/polymorpheus": "^4.9.0", "@taiga-ui/styles": "^4.49.0", "@tinkoff/ng-polymorpheus": "^4.3.0", + "jwt-decode": "^4.0.0", "rxjs": "~7.8.0", "taiga-ui": "^4.49.0", "tslib": "^2.3.0" @@ -15047,6 +15048,15 @@ ], "license": "MIT" }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/karma": { "version": "6.4.4", "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.4.tgz", diff --git a/package.json b/package.json index d68af3b..6744801 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@taiga-ui/polymorpheus": "^4.9.0", "@taiga-ui/styles": "^4.49.0", "@tinkoff/ng-polymorpheus": "^4.3.0", + "jwt-decode": "^4.0.0", "rxjs": "~7.8.0", "taiga-ui": "^4.49.0", "tslib": "^2.3.0" diff --git a/qodana.yaml b/qodana.yaml new file mode 100644 index 0000000..db9c0c7 --- /dev/null +++ b/qodana.yaml @@ -0,0 +1,41 @@ +#-------------------------------------------------------------------------------# +# Qodana analysis is configured by qodana.yaml file # +# https://www.jetbrains.com/help/qodana/qodana-yaml.html # +#-------------------------------------------------------------------------------# +version: "1.0" + +#Specify inspection profile for code analysis +profile: + name: qodana.starter + +#Enable inspections +#include: +# - name: + +#Disable inspections +#exclude: +# - name: +# paths: +# - + +#Execute shell command before Qodana execution (Applied in CI/CD pipeline) +#bootstrap: sh ./prepare-qodana.sh + +#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline) +#plugins: +# - id: #(plugin id can be found at https://plugins.jetbrains.com) + +# Quality gate. Will fail the CI/CD pipeline if any condition is not met +# severityThresholds - configures maximum thresholds for different problem severities +# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code +# Code Coverage is available in Ultimate and Ultimate Plus plans +#failureConditions: +# severityThresholds: +# any: 15 +# critical: 5 +# testCoverageThresholds: +# fresh: 70 +# total: 50 + +#Specify Qodana linter for analysis (Applied in CI/CD pipeline) +linter: jetbrains/qodana-js:2025.2 diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index dcfa71b..e85abe7 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -1,7 +1,8 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideLocationMocks } from '@angular/common/testing'; import { ChangeDetectionStrategy, Component } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { RouterTestingModule } from '@angular/router/testing'; +import { provideRouter } from '@angular/router'; import { TuiRoot } from '@taiga-ui/core'; import { AuthService } from '@/features/auth'; import { TelegramService, ThemeService, SwUpdateService } from '@/shared'; @@ -40,9 +41,11 @@ describe('AppComponent', () => { const swUpdateServiceSpy = jasmine.createSpyObj('SwUpdateService', ['checkForUpdate']); configureZonelessTestingModule({ - imports: [AppComponent, RouterTestingModule, MockTuiRootComponent], + imports: [AppComponent, MockTuiRootComponent], providers: [ provideHttpClientTesting(), + provideRouter([]), + provideLocationMocks(), { provide: TelegramService, useValue: telegramServiceSpy }, { provide: ThemeService, useValue: themeServiceSpy }, { provide: AuthService, useValue: authServiceSpy }, diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 022f40a..cc74ffb 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,7 +1,6 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { RouterOutlet } from '@angular/router'; -import { AuthService } from '@/features/auth'; import { NavigationComponent, TelegramService } from '@/shared'; import type { OnInit } from '@angular/core'; @@ -14,10 +13,8 @@ import type { OnInit } from '@angular/core'; }) export class AppComponent implements OnInit { private readonly telegramService = inject(TelegramService); - private readonly authService = inject(AuthService); ngOnInit(): void { this.telegramService.webApp.ready(); - this.authService.initFromStorage(); } } diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 3be2375..e7fd971 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -5,13 +5,14 @@ import { provideServiceWorker } from '@angular/service-worker'; import { provideEventPlugins } from '@taiga-ui/event-plugins'; import { routes } from '@/app/app.routes'; import { authInterceptor } from './interceptors/auth.interceptor'; +import { credentialsInterceptor } from './interceptors/credentials.interceptor'; import type { ApplicationConfig } from '@angular/core'; export const appConfig: ApplicationConfig = { providers: [ provideExperimentalZonelessChangeDetection(), provideRouter(routes), - provideHttpClient(withInterceptors([authInterceptor])), + provideHttpClient(withInterceptors([authInterceptor, credentialsInterceptor])), provideServiceWorker('ngsw-worker.js', { enabled: !isDevMode(), registrationStrategy: 'registerImmediately', diff --git a/src/app/interceptors/auth.interceptor.spec.ts b/src/app/interceptors/auth.interceptor.spec.ts index ff64572..bf09bc8 100644 --- a/src/app/interceptors/auth.interceptor.spec.ts +++ b/src/app/interceptors/auth.interceptor.spec.ts @@ -1,60 +1,38 @@ import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; import { env } from '@/environments/env'; -import { TokenStorageService } from '@/shared/services/auth/token-storage.service'; +import { AuthService } from '@/features/auth'; import { configureZonelessTestingModule } from '@/test-setup'; import { authInterceptor } from './auth.interceptor'; describe('authInterceptor', () => { let http: HttpClient; let httpMock: HttpTestingController; - let tokenStorageService: jasmine.SpyObj; + let authService: jasmine.SpyObj; beforeEach(() => { - const tokenStorageSpy = jasmine.createSpyObj('TokenStorageService', [ + const authServiceSpy = jasmine.createSpyObj('AuthService', [ 'getAccessToken', - 'getRefreshToken', - 'setTokens', - 'clearTokens', + 'setAccessToken', + 'refreshToken$', ]); configureZonelessTestingModule({ providers: [ provideHttpClient(withInterceptors([authInterceptor])), provideHttpClientTesting(), - { provide: TokenStorageService, useValue: tokenStorageSpy }, + { provide: AuthService, useValue: authServiceSpy }, ], }); http = TestBed.inject(HttpClient); httpMock = TestBed.inject(HttpTestingController); - tokenStorageService = TestBed.inject( - TokenStorageService, - ) as jasmine.SpyObj; - }); - - it('should add Authorization header when access token exists', () => { - tokenStorageService.getAccessToken.and.returnValue('access-token'); - - http.get('/api/test').subscribe(); - - const req = httpMock.expectOne('/api/test'); - expect(req.request.headers.get('Authorization')).toBe('Bearer access-token'); - }); - - it('should not add Authorization header when no access token', () => { - tokenStorageService.getAccessToken.and.returnValue(null); - - http.get('/api/test').subscribe(); - - const req = httpMock.expectOne('/api/test'); - expect(req.request.headers.get('Authorization')).toBeNull(); + authService = TestBed.inject(AuthService) as jasmine.SpyObj; }); it('should not intercept auth endpoints', () => { - tokenStorageService.getAccessToken.and.returnValue('access-token'); - http.post(`${env.apiHost}/v1/auth/login`, {}).subscribe(); http.post(`${env.apiHost}/v1/auth/register`, {}).subscribe(); http.post(`${env.apiHost}/v1/auth/refresh`, {}).subscribe(); @@ -68,26 +46,23 @@ describe('authInterceptor', () => { expect(refreshReq.request.headers.get('Authorization')).toBeNull(); }); - it('should queue requests during token refresh and execute them with new token', () => { - tokenStorageService.getAccessToken.and.returnValue('expired-token'); - tokenStorageService.getRefreshToken.and.returnValue('refresh-token'); - - tokenStorageService.setTokens.and.stub(); - - http.get('/api/protected').subscribe(); - - const firstReq = httpMock.expectOne('/api/protected'); - expect(firstReq.request.headers.get('Authorization')).toBe('Bearer expired-token'); - - firstReq.flush(null, { status: 401, statusText: 'Unauthorized' }); - }); - - it('should add auth header to endpoints that contain auth path but are not auth endpoints', () => { - tokenStorageService.getAccessToken.and.returnValue('access-token'); - - http.get('/api/user/auth-status').subscribe(); + it('should handle 401 errors by attempting refresh', (done) => { + authService.getAccessToken.and.returnValue('test-token'); + authService.refreshToken$.and.returnValue(of(true)); + + http.get('/api/protected').subscribe({ + next: () => { + expect(authService.refreshToken$).toHaveBeenCalled(); + done(); + }, + error: () => { + // Expected error + done(); + }, + }); - const req = httpMock.expectOne('/api/user/auth-status'); - expect(req.request.headers.get('Authorization')).toBe('Bearer access-token'); + const req = httpMock.expectOne('/api/protected'); + expect(req.request.headers.get('Authorization')).toBe('Bearer test-token'); + req.flush(null, { status: 401, statusText: 'Unauthorized' }); }); }); diff --git a/src/app/interceptors/auth.interceptor.ts b/src/app/interceptors/auth.interceptor.ts index 6061050..ff0f8af 100644 --- a/src/app/interceptors/auth.interceptor.ts +++ b/src/app/interceptors/auth.interceptor.ts @@ -1,8 +1,7 @@ import { inject } from '@angular/core'; import { Router } from '@angular/router'; import { catchError, switchMap, throwError, defer, finalize } from 'rxjs'; -import { AuthApiService, type RefreshResponse } from '@/features/auth'; -import { TokenStorageService } from '@/shared/services/auth/token-storage.service'; +import { AuthService } from '@/features/auth'; import type { HttpInterceptorFn, @@ -14,23 +13,13 @@ import type { import type { Observable } from 'rxjs'; const AUTH_ENDPOINTS = ['/v1/auth/login', '/v1/auth/register', '/v1/auth/refresh'] as const; - -const createAuthenticatedRequest = ( - req: HttpRequest, - token: string, -): HttpRequest => { - return req.clone({ - setHeaders: { Authorization: `Bearer ${token}` }, - }); -}; - const shouldSkipAuth = (req: HttpRequest): boolean => { return AUTH_ENDPOINTS.some((endpoint) => req.url.endsWith(endpoint)); }; class TokenRefreshManager { private refreshInProgress = false; - private pendingRequests: Array<(accessToken: string) => void> = []; + private pendingRequests: Array<() => void> = []; private static instance: TokenRefreshManager; static getInstance(): TokenRefreshManager { @@ -48,12 +37,12 @@ class TokenRefreshManager { this.refreshInProgress = value; } - addPendingRequest(request: (accessToken: string) => void): void { + addPendingRequest(request: () => void): void { this.pendingRequests.push(request); } - processPendingRequests(newAccessToken: string): void { - this.pendingRequests.forEach((request) => request(newAccessToken)); + processPendingRequests(): void { + this.pendingRequests.forEach((request) => request()); this.pendingRequests = []; } @@ -62,26 +51,23 @@ class TokenRefreshManager { } } -const logout = (router: Router, tokenStorage: TokenStorageService): void => { - tokenStorage.clearTokens(); +const logout = (router: Router): void => { void router.navigate(['/login']); }; const handle401Error = ( req: HttpRequest, next: HttpHandlerFn, - tokenStorage: TokenStorageService, - authApi: AuthApiService, - router: Router, ): Observable> => { const refreshManager = TokenRefreshManager.getInstance(); + const authService = inject(AuthService); + const router = inject(Router); if (refreshManager.isRefreshInProgress) { return defer(() => { return new Promise>>((resolve) => { - refreshManager.addPendingRequest((newAccessToken: string) => { - const newReq = createAuthenticatedRequest(req, newAccessToken); - resolve(next(newReq)); + refreshManager.addPendingRequest(() => { + resolve(next(req)); }); }); }).pipe(switchMap((observable) => observable)); @@ -89,25 +75,20 @@ const handle401Error = ( refreshManager.setRefreshInProgress(true); - const refreshToken = tokenStorage.getRefreshToken(); - - if (!refreshToken) { - refreshManager.setRefreshInProgress(false); - logout(router, tokenStorage); - return throwError(() => new Error('No refresh token')); - } - - return authApi.refresh$(refreshToken).pipe( - switchMap((response: RefreshResponse) => { - tokenStorage.setTokens(response.access_token, refreshToken); - refreshManager.processPendingRequests(response.access_token); - - const newReq = createAuthenticatedRequest(req, response.access_token); - return next(newReq); + return authService.refreshToken$().pipe( + switchMap((success) => { + if (success) { + refreshManager.processPendingRequests(); + return next(req); + } else { + refreshManager.clearPendingRequests(); + logout(router); + return throwError(() => new Error('Token refresh failed')); + } }), catchError((error) => { refreshManager.clearPendingRequests(); - logout(router, tokenStorage); + logout(router); return throwError(() => error); }), finalize(() => { @@ -124,19 +105,23 @@ export const authInterceptor: HttpInterceptorFn = ( return next(req); } - const tokenStorage = inject(TokenStorageService); - const authApi = inject(AuthApiService); - const router = inject(Router); - const accessToken = tokenStorage.getAccessToken(); + const authService = inject(AuthService); - if (accessToken) { - req = createAuthenticatedRequest(req, accessToken); + const accessToken = authService.getAccessToken(); + if (!accessToken) { + return next(req); } - return next(req).pipe( + const authReq = req.clone({ + setHeaders: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + return next(authReq).pipe( catchError((error: HttpErrorResponse) => { if (error.status === 401) { - return handle401Error(req, next, tokenStorage, authApi, router); + return handle401Error(authReq, next); } return throwError(() => error); }), diff --git a/src/app/interceptors/credentials.interceptor.ts b/src/app/interceptors/credentials.interceptor.ts new file mode 100644 index 0000000..9af3365 --- /dev/null +++ b/src/app/interceptors/credentials.interceptor.ts @@ -0,0 +1,25 @@ +import type { + HttpInterceptorFn, + HttpRequest, + HttpHandlerFn, + HttpEvent, +} from '@angular/common/http'; +import type { Observable } from 'rxjs'; + +export const credentialsInterceptor: HttpInterceptorFn = ( + req: HttpRequest, + next: HttpHandlerFn, +): Observable> => { + if (req.withCredentials === true) { + return next(req); + } + + const modifiedReq = req.clone({ + withCredentials: true, + setHeaders: { + 'Content-Type': 'application/json', + }, + }); + + return next(modifiedReq); +}; diff --git a/src/entities/macronutrients/ui/macronutrients-display.component.spec.ts b/src/entities/macronutrients/ui/macronutrients-display.component.spec.ts index f9738d5..0c09886 100644 --- a/src/entities/macronutrients/ui/macronutrients-display.component.spec.ts +++ b/src/entities/macronutrients/ui/macronutrients-display.component.spec.ts @@ -1,7 +1,7 @@ import { TestBed } from '@angular/core/testing'; +import { MacronutrientsDisplayComponent } from '@/entities'; +import type { Macronutrients } from '@/entities'; import { configureZonelessTestingModule } from '@/test-setup'; -import { MacronutrientsDisplayComponent } from './macronutrients-display.component'; -import type { Macronutrients } from '../model/index.js'; import type { ComponentFixture } from '@angular/core/testing'; describe('MacronutrientsDisplayComponent', () => { diff --git a/src/features/auth/guards/auth.guard.spec.ts b/src/features/auth/guards/auth.guard.spec.ts index cee2bf6..f6e1bb5 100644 --- a/src/features/auth/guards/auth.guard.spec.ts +++ b/src/features/auth/guards/auth.guard.spec.ts @@ -1,8 +1,9 @@ import { TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; +import { of } from 'rxjs'; +import { AuthService } from '@/features/auth'; import { configureZonelessTestingModule } from '@/test-setup'; import { authGuard } from './auth.guard'; -import { AuthService } from '../services/auth.service'; import type { Route, UrlSegment } from '@angular/router'; describe('authGuard', () => { @@ -10,7 +11,10 @@ describe('authGuard', () => { let router: jasmine.SpyObj; beforeEach(() => { - const authServiceSpy = jasmine.createSpyObj('AuthService', ['isAuthenticatedAndValid']); + const authServiceSpy = jasmine.createSpyObj('AuthService', [ + 'isAuthenticated', + 'refreshToken$', + ]); const routerSpy = jasmine.createSpyObj('Router', ['navigate']); configureZonelessTestingModule(); @@ -29,43 +33,48 @@ describe('authGuard', () => { }); it('should allow access when user is authenticated and tokens are valid', async () => { - authService.isAuthenticatedAndValid.and.returnValue(Promise.resolve(true)); + authService.isAuthenticated.and.returnValue(true); const result = await TestBed.runInInjectionContext(() => authGuard({} as Route, [] as UrlSegment[]), ); expect(result).toBe(true); - expect(authService.isAuthenticatedAndValid).toHaveBeenCalled(); + expect(authService.isAuthenticated).toHaveBeenCalled(); expect(router.navigate).not.toHaveBeenCalled(); }); - it('should redirect to login when user is not authenticated', async () => { - authService.isAuthenticatedAndValid.and.returnValue(Promise.resolve(false)); + it('should redirect to login when user is not authenticated and refresh fails', async () => { + authService.isAuthenticated.and.returnValue(false); + authService.refreshToken$.and.returnValue(of(false)); const result = await TestBed.runInInjectionContext(() => authGuard({} as Route, [] as UrlSegment[]), ); expect(result).toBe(false); - expect(authService.isAuthenticatedAndValid).toHaveBeenCalled(); + expect(authService.isAuthenticated).toHaveBeenCalled(); + expect(authService.refreshToken$).toHaveBeenCalled(); expect(router.navigate).toHaveBeenCalledWith(['/login']); }); - it('should redirect to login when token validation fails', async () => { - authService.isAuthenticatedAndValid.and.returnValue(Promise.resolve(false)); + it('should allow access when refresh is successful', async () => { + authService.isAuthenticated.and.returnValue(false); + authService.refreshToken$.and.returnValue(of(true)); const result = await TestBed.runInInjectionContext(() => authGuard({} as Route, [] as UrlSegment[]), ); - expect(result).toBe(false); - expect(authService.isAuthenticatedAndValid).toHaveBeenCalled(); - expect(router.navigate).toHaveBeenCalledWith(['/login']); + expect(result).toBe(true); + expect(authService.isAuthenticated).toHaveBeenCalled(); + expect(authService.refreshToken$).toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); }); it('should save return URL when redirecting to login', async () => { - authService.isAuthenticatedAndValid.and.returnValue(Promise.resolve(false)); + authService.isAuthenticated.and.returnValue(false); + authService.refreshToken$.and.returnValue(of(false)); Object.defineProperty(TestBed.inject(Router), 'url', { get: () => '/protected-page', @@ -79,7 +88,8 @@ describe('authGuard', () => { }); it('should not save return URL when already on login page', async () => { - authService.isAuthenticatedAndValid.and.returnValue(Promise.resolve(false)); + authService.isAuthenticated.and.returnValue(false); + authService.refreshToken$.and.returnValue(of(false)); Object.defineProperty(TestBed.inject(Router), 'url', { get: () => '/login', @@ -92,7 +102,8 @@ describe('authGuard', () => { }); it('should not save return URL when already on register page', async () => { - authService.isAuthenticatedAndValid.and.returnValue(Promise.resolve(false)); + authService.isAuthenticated.and.returnValue(false); + authService.refreshToken$.and.returnValue(of(false)); Object.defineProperty(TestBed.inject(Router), 'url', { get: () => '/register', diff --git a/src/features/auth/guards/auth.guard.ts b/src/features/auth/guards/auth.guard.ts index e6dc2cd..e4042f9 100644 --- a/src/features/auth/guards/auth.guard.ts +++ b/src/features/auth/guards/auth.guard.ts @@ -1,21 +1,27 @@ import { inject } from '@angular/core'; import { Router, type CanMatchFn } from '@angular/router'; +import { firstValueFrom, map, tap } from 'rxjs'; import { AuthService } from '@/features/auth'; export const authGuard: CanMatchFn = async (): Promise => { const authService = inject(AuthService); const router = inject(Router); - const isAuthenticated = await authService.isAuthenticatedAndValid(); - - if (!isAuthenticated) { - const url = router.url; - if (url !== '/login' && url !== '/register') { - sessionStorage.setItem('return_url', url); - } - void router.navigate(['/login']); - return false; + if (authService.isAuthenticated()) { + return true; } - - return true; + return firstValueFrom( + authService.refreshToken$().pipe( + tap((refreshSuccess) => { + if (!refreshSuccess) { + const url = router.url; + if (url !== '/login' && url !== '/register') { + sessionStorage.setItem('return_url', url); + } + void router.navigate(['/login']); + } + }), + map((refreshSuccess) => refreshSuccess), + ), + ); }; diff --git a/src/features/auth/guards/guest.guard.ts b/src/features/auth/guards/guest.guard.ts index 83096ba..bf98594 100644 --- a/src/features/auth/guards/guest.guard.ts +++ b/src/features/auth/guards/guest.guard.ts @@ -1,6 +1,6 @@ import { inject } from '@angular/core'; import { Router, type CanMatchFn } from '@angular/router'; -import { AuthService } from '../services/auth.service'; +import { AuthService } from '@/features/auth'; export const guestGuard: CanMatchFn = (): boolean => { const authService = inject(AuthService); diff --git a/src/features/auth/models/auth.types.ts b/src/features/auth/models/auth.types.ts index 7029ff4..49c02b8 100644 --- a/src/features/auth/models/auth.types.ts +++ b/src/features/auth/models/auth.types.ts @@ -7,9 +7,9 @@ export interface LoginRequest { export interface LoginResponse { access_token: string; - refresh_token: string; expires_in: number; token_type: TokenType; + message: string; } export interface RegisterRequest { @@ -19,7 +19,7 @@ export interface RegisterRequest { export interface RefreshResponse { access_token: string; - refresh_token: string; expires_in: number; token_type: TokenType; + message: string; } diff --git a/src/features/auth/services/auth-api.service.spec.ts b/src/features/auth/services/auth-api.service.spec.ts index 2a70f6b..9e4ff1f 100644 --- a/src/features/auth/services/auth-api.service.spec.ts +++ b/src/features/auth/services/auth-api.service.spec.ts @@ -1,14 +1,14 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { env } from '@/environments/env'; -import { configureZonelessTestingModule } from '@/test-setup'; -import { AuthApiService } from './auth-api.service'; +import { AuthApiService } from '@/features/auth'; import type { LoginRequest, RegisterRequest, LoginResponse, RefreshResponse, -} from '../models/auth.types'; +} from '@/features/auth'; +import { configureZonelessTestingModule } from '@/test-setup'; describe('AuthApiService', () => { let service: AuthApiService; @@ -41,10 +41,10 @@ describe('AuthApiService', () => { }; const expectedResponse: LoginResponse = { - access_token: 'access-token', - refresh_token: 'refresh-token', + access_token: 'test-access-token', expires_in: 3600, token_type: 'Bearer', + message: 'Login successful', }; service.login$(loginRequest).subscribe((response) => { @@ -78,21 +78,20 @@ describe('AuthApiService', () => { describe('refresh$', () => { it('should send POST request to refresh endpoint', () => { - const refreshToken = 'refresh-token'; const expectedResponse: RefreshResponse = { access_token: 'new-access-token', - refresh_token: 'new-refresh-token', expires_in: 3600, token_type: 'Bearer', + message: 'Token refreshed', }; - service.refresh$(refreshToken).subscribe((response) => { + service.refresh$().subscribe((response) => { expect(response).toEqual(expectedResponse); }); const req = httpMock.expectOne(`${env.apiHost}/v1/auth/refresh`); expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual({ refresh_token: refreshToken }); + expect(req.request.body).toEqual({}); req.flush(expectedResponse); }); }); @@ -141,9 +140,7 @@ describe('AuthApiService', () => { }); it('should handle refresh error', () => { - const refreshToken = 'invalid-refresh-token'; - - service.refresh$(refreshToken).subscribe({ + service.refresh$().subscribe({ error: (error) => { expect(error).toBeDefined(); expect(typeof error.code).toBe('string'); diff --git a/src/features/auth/services/auth-api.service.ts b/src/features/auth/services/auth-api.service.ts index b1949a0..417cbfc 100644 --- a/src/features/auth/services/auth-api.service.ts +++ b/src/features/auth/services/auth-api.service.ts @@ -2,13 +2,13 @@ import { HttpClient } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; import { catchError } from 'rxjs'; import { env } from '@/environments/env'; -import { handleApiError } from '@/shared/lib/utils'; import type { LoginRequest, LoginResponse, RefreshResponse, RegisterRequest, -} from '../models/auth.types'; +} from '@/features/auth'; +import { handleApiError } from '@/shared/lib/utils'; import type { Observable } from 'rxjs'; @Injectable({ providedIn: 'root' }) @@ -26,11 +26,17 @@ export class AuthApiService { return this.http.post(`${this.baseUrl}register`, body).pipe(catchError(handleApiError)); } - refresh$(refreshToken: string): Observable { + refresh$(): Observable { return this.http - .post(`${this.baseUrl}refresh`, { - refresh_token: refreshToken, - }) + .post(`${this.baseUrl}refresh`, {}) .pipe(catchError(handleApiError)); } + + logout$(): Observable { + return this.http.post(`${this.baseUrl}logout`, {}).pipe(catchError(handleApiError)); + } + + checkAuth$(): Observable { + return this.http.get(`${this.baseUrl}me`).pipe(catchError(handleApiError)); + } } diff --git a/src/features/auth/services/auth.service.spec.ts b/src/features/auth/services/auth.service.spec.ts index c07382d..ce75d7e 100644 --- a/src/features/auth/services/auth.service.spec.ts +++ b/src/features/auth/services/auth.service.spec.ts @@ -1,26 +1,24 @@ import { TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; import { of, throwError } from 'rxjs'; +import { AuthApiService } from '@/features/auth'; +import type { LoginRequest, RegisterRequest, LoginResponse } from '@/features/auth'; import type { ApiError } from '@/shared/lib/types'; -import { TokenStorageService } from '@/shared/services/auth/token-storage.service'; import { configureZonelessTestingModule } from '@/test-setup'; -import { AuthApiService } from './auth-api.service'; import { AuthService } from './auth.service'; -import type { LoginRequest, RegisterRequest, LoginResponse } from '../models/auth.types'; describe('AuthService', () => { let service: AuthService; let authApiService: jasmine.SpyObj; - let tokenStorageService: jasmine.SpyObj; let router: jasmine.SpyObj; beforeEach(() => { - const authApiSpy = jasmine.createSpyObj('AuthApiService', ['login$', 'register$', 'refresh$']); - const tokenStorageSpy = jasmine.createSpyObj('TokenStorageService', [ - 'setTokens', - 'getAccessToken', - 'getRefreshToken', - 'clearTokens', + const authApiSpy = jasmine.createSpyObj('AuthApiService', [ + 'login$', + 'register$', + 'refresh$', + 'logout$', + 'checkAuth$', ]); const routerSpy = jasmine.createSpyObj('Router', ['navigate']); @@ -28,70 +26,28 @@ describe('AuthService', () => { providers: [ AuthService, { provide: AuthApiService, useValue: authApiSpy }, - { provide: TokenStorageService, useValue: tokenStorageSpy }, { provide: Router, useValue: routerSpy }, ], }); service = TestBed.inject(AuthService); authApiService = TestBed.inject(AuthApiService) as jasmine.SpyObj; - tokenStorageService = TestBed.inject( - TokenStorageService, - ) as jasmine.SpyObj; router = TestBed.inject(Router) as jasmine.SpyObj; - - sessionStorage.clear(); }); it('should be created', () => { expect(service).toBeTruthy(); }); - describe('isAuthenticated', () => { - it('should return true when valid tokens exist', () => { - tokenStorageService.getAccessToken.and.returnValue('access-token'); - tokenStorageService.getRefreshToken.and.returnValue('refresh-token'); - - service.initFromStorage(); - expect(service.isAuthenticated()).toBe(true); - }); - it('should return false when access token is missing', () => { - tokenStorageService.getAccessToken.and.returnValue(null); - tokenStorageService.getRefreshToken.and.returnValue('refresh-token'); - - service.initFromStorage(); - expect(service.isAuthenticated()).toBe(false); - }); - - it('should return false when refresh token is missing', () => { - tokenStorageService.getAccessToken.and.returnValue('access-token'); - tokenStorageService.getRefreshToken.and.returnValue(null); - - service.initFromStorage(); - expect(service.isAuthenticated()).toBe(false); - }); - - it('should return false when both tokens are missing', () => { - tokenStorageService.getAccessToken.and.returnValue(null); - tokenStorageService.getRefreshToken.and.returnValue(null); - - service.initFromStorage(); - expect(service.isAuthenticated()).toBe(false); - }); - }); - describe('login$', () => { - it('should successfully login and navigate to dashboard', () => { - const loginRequest: LoginRequest = { - email: 'test@example.com', - password: 'password123', - }; - + it('should login successfully with HttpOnly cookies', () => { + const loginRequest: LoginRequest = { email: 'test@test.com', password: 'password' }; const loginResponse: LoginResponse = { - access_token: 'access-token', - refresh_token: 'refresh-token', - expires_in: 3600, + access_token: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0IiwiZXhwIjo5OTk5OTk5OTk5LCJpYXQiOjE1MTYyMzkwMjJ9.test', + expires_in: 900, token_type: 'Bearer', + message: 'Login successful', }; authApiService.login$.and.returnValue(of(loginResponse)); @@ -99,83 +55,24 @@ describe('AuthService', () => { service.login$(loginRequest).subscribe(); expect(authApiService.login$).toHaveBeenCalledWith(loginRequest); - expect(tokenStorageService.setTokens).toHaveBeenCalledWith( - loginResponse.access_token, - loginResponse.refresh_token, - ); - expect(router.navigate).toHaveBeenCalledWith(['/dashboard']); - }); - - it('should navigate to returnUrl when available', () => { - const loginRequest: LoginRequest = { - email: 'test@example.com', - password: 'password123', - }; - - const loginResponse: LoginResponse = { - access_token: 'access-token', - refresh_token: 'refresh-token', - expires_in: 3600, - token_type: 'Bearer', - }; - - sessionStorage.setItem('return_url', '/protected-page'); - authApiService.login$.and.returnValue(of(loginResponse)); - - service.login$(loginRequest).subscribe(); - - expect(router.navigate).toHaveBeenCalledWith(['/protected-page']); - expect(sessionStorage.getItem('return_url')).toBeNull(); + expect(service.isAuthenticated()).toBe(true); }); it('should handle login error', () => { - const loginRequest: LoginRequest = { - email: 'test@example.com', - password: 'wrongpassword', - }; - - const apiError: ApiError = { - code: 'INVALID_CREDENTIALS', - message: 'Invalid email or password', - }; + const loginRequest: LoginRequest = { email: 'test@test.com', password: 'password' }; + const error: ApiError = { code: 'INVALID_CREDENTIALS', message: 'Invalid credentials' }; - authApiService.login$.and.returnValue(throwError(() => apiError)); + authApiService.login$.and.returnValue(throwError(() => error)); service.login$(loginRequest).subscribe(); - expect(service.error()).toBe('Invalid email or password'); - expect(router.navigate).not.toHaveBeenCalled(); - }); - - it('should set loading state during login', () => { - const loginRequest: LoginRequest = { - email: 'test@example.com', - password: 'password123', - }; - - const loginResponse: LoginResponse = { - access_token: 'access-token', - refresh_token: 'refresh-token', - expires_in: 3600, - token_type: 'Bearer', - }; - - authApiService.login$.and.returnValue(of(loginResponse)); - - expect(service.loading()).toBe(false); - - service.login$(loginRequest).subscribe(); - - expect(service.loading()).toBe(false); // Should be false after completion + expect(service.error()).toBe('Invalid credentials'); }); }); describe('register$', () => { - it('should successfully register and navigate to login', () => { - const registerRequest: RegisterRequest = { - email: 'test@example.com', - password: 'password123', - }; + it('should register successfully', () => { + const registerRequest: RegisterRequest = { email: 'test@test.com', password: 'password' }; authApiService.register$.and.returnValue(of(undefined)); @@ -185,303 +82,75 @@ describe('AuthService', () => { expect(router.navigate).toHaveBeenCalledWith(['/login']); }); - it('should handle registration error', () => { - const registerRequest: RegisterRequest = { - email: 'test@example.com', - password: 'password123', - }; + it('should handle register error', () => { + const registerRequest: RegisterRequest = { email: 'test@test.com', password: 'password' }; + const error: ApiError = { code: 'EMAIL_EXISTS', message: 'Email already exists' }; - const apiError: ApiError = { - code: 'EMAIL_ALREADY_EXISTS', - message: 'Email is already registered', - }; - - authApiService.register$.and.returnValue(throwError(() => apiError)); + authApiService.register$.and.returnValue(throwError(() => error)); service.register$(registerRequest).subscribe(); - expect(service.error()).toBe('Email is already registered'); - expect(router.navigate).not.toHaveBeenCalled(); + expect(service.error()).toBe('Email already exists'); }); }); describe('logout', () => { - it('should clear tokens and navigate to login', () => { - service.logout(); - - expect(tokenStorageService.clearTokens).toHaveBeenCalled(); - expect(router.navigate).toHaveBeenCalledWith(['/login']); - }); - }); + it('should logout successfully', () => { + authApiService.logout$.and.returnValue(of(undefined)); - describe('clearError', () => { - it('should clear error message', () => { - service.error.set('Some error'); - service.clearError(); + service.logout(); - expect(service.error()).toBeNull(); + expect(authApiService.logout$).toHaveBeenCalled(); }); }); - describe('isTokenExpired (private method)', () => { - it('should return true when token is null', () => { - const result = ( - service as unknown as { isTokenExpired: (token: string | null) => boolean } - ).isTokenExpired(null); - expect(result).toBe(true); - }); - - it('should return true when token is empty string', () => { - const result = ( - service as unknown as { isTokenExpired: (token: string | null) => boolean } - ).isTokenExpired(''); - expect(result).toBe(true); - }); - - it('should return true when token cannot be decoded', () => { - const result = ( - service as unknown as { isTokenExpired: (token: string | null) => boolean } - ).isTokenExpired('invalid-token'); - expect(result).toBe(true); - }); + describe('isAuthenticated', () => { + it('should return true when token is valid', () => { + service.setAccessToken( + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0IiwiZXhwIjo5OTk5OTk5OTk5LCJpYXQiOjE1MTYyMzkwMjJ9.test', + ); - it('should return true when token is expired', () => { - const expiredTime = Math.floor(Date.now() / 1000) - 3600; - const expiredToken = `header.${btoa(JSON.stringify({ exp: expiredTime }))}.signature`; + const result = service.isAuthenticated(); - const result = ( - service as unknown as { isTokenExpired: (token: string | null) => boolean } - ).isTokenExpired(expiredToken); expect(result).toBe(true); }); - it('should return false when token is valid and not expired', () => { - const futureTime = Math.floor(Date.now() / 1000) + 3600; - const validToken = `header.${btoa(JSON.stringify({ exp: futureTime }))}.signature`; - - const result = ( - service as unknown as { isTokenExpired: (token: string | null) => boolean } - ).isTokenExpired(validToken); - expect(result).toBe(false); - }); - }); - - describe('refreshTokenSync', () => { - it('should successfully refresh token and return true', async () => { - const refreshResponse = { - access_token: 'new-access-token', - refresh_token: 'new-refresh-token', - expires_in: 3600, - token_type: 'Bearer' as const, - }; - - tokenStorageService.getRefreshToken.and.returnValue('refresh-token'); - authApiService.refresh$.and.returnValue(of(refreshResponse)); - - const result = await service.refreshTokenSync(); - - expect(result).toBe(true); - expect(authApiService.refresh$).toHaveBeenCalledWith('refresh-token'); - expect(tokenStorageService.setTokens).toHaveBeenCalledWith( - 'new-access-token', - 'new-refresh-token', + it('should return false when token is expired', () => { + service.setAccessToken( + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0IiwiZXhwIjoxLCJpYXQiOjE1MTYyMzkwMjJ9.test', ); - expect(service.isAuthenticated()).toBe(true); - }); - it('should logout and return false when refresh token is missing', async () => { - tokenStorageService.getRefreshToken.and.returnValue(null); - spyOn(service, 'logout'); - - const result = await service.refreshTokenSync(); + const result = service.isAuthenticated(); expect(result).toBe(false); - expect(service.logout).toHaveBeenCalled(); - expect(authApiService.refresh$).not.toHaveBeenCalled(); }); - it('should logout and return false when refresh fails', async () => { - tokenStorageService.getRefreshToken.and.returnValue('refresh-token'); - authApiService.refresh$.and.returnValue(throwError(() => new Error('Refresh failed'))); - spyOn(service, 'logout'); - - const result = await service.refreshTokenSync(); + it('should return false when no token', () => { + const result = service.isAuthenticated(); expect(result).toBe(false); - expect(service.logout).toHaveBeenCalled(); - expect(authApiService.refresh$).toHaveBeenCalledWith('refresh-token'); }); }); - describe('refreshTokensInBackground', () => { - it('should not call refreshTokenSync if already refreshing', () => { - service.isRefreshingTokens.set(true); - spyOn(service, 'refreshTokenSync'); + describe('getAccessToken', () => { + it('should return token when valid', () => { + const token = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0IiwiZXhwIjo5OTk5OTk5OTk5LCJpYXQiOjE1MTYyMzkwMjJ9.test'; + service.setAccessToken(token); - (service as unknown as { refreshTokensInBackground: () => void }).refreshTokensInBackground(); + const result = service.getAccessToken(); - expect(service.refreshTokenSync).not.toHaveBeenCalled(); + expect(result).toBe(token); }); - it('should call refreshTokenSync if not refreshing', () => { - service.isRefreshingTokens.set(false); - spyOn(service, 'refreshTokenSync').and.returnValue(Promise.resolve(true)); - - (service as unknown as { refreshTokensInBackground: () => void }).refreshTokensInBackground(); - - expect(service.refreshTokenSync).toHaveBeenCalled(); - }); - - it('should handle errors silently', () => { - service.isRefreshingTokens.set(false); - spyOn(service, 'refreshTokenSync').and.returnValue(Promise.reject(new Error('Test error'))); - - expect(() => - ( - service as unknown as { refreshTokensInBackground: () => void } - ).refreshTokensInBackground(), - ).not.toThrow(); - }); - }); - - describe('isAuthenticatedAndValid', () => { - it('should return false when both tokens are missing', async () => { - tokenStorageService.getAccessToken.and.returnValue(null); - tokenStorageService.getRefreshToken.and.returnValue(null); - - const result = await service.isAuthenticatedAndValid(); - - expect(result).toBe(false); - }); - - it('should return true when only access token exists and is valid', async () => { - const futureTime = Math.floor(Date.now() / 1000) + 3600; - const validToken = `header.${btoa(JSON.stringify({ exp: futureTime }))}.signature`; - - tokenStorageService.getAccessToken.and.returnValue(validToken); - tokenStorageService.getRefreshToken.and.returnValue(null); - spyOn( - service as unknown as { refreshTokensInBackground: () => void }, - 'refreshTokensInBackground', + it('should return null when token is expired', () => { + service.setAccessToken( + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0IiwiZXhwIjoxLCJpYXQiOjE1MTYyMzkwMjJ9.test', ); - const result = await service.isAuthenticatedAndValid(); + const result = service.getAccessToken(); - expect(result).toBe(true); - expect( - (service as unknown as { refreshTokensInBackground: () => void }).refreshTokensInBackground, - ).not.toHaveBeenCalled(); - }); - - it('should return true when only refresh token exists and is valid', async () => { - const futureTime = Math.floor(Date.now() / 1000) + 3600; - const validRefreshToken = `header.${btoa(JSON.stringify({ exp: futureTime }))}.signature`; - const refreshResponse = { - access_token: 'new-access-token', - refresh_token: 'new-refresh-token', - expires_in: 3600, - token_type: 'Bearer' as const, - }; - - tokenStorageService.getAccessToken.and.returnValue(null); - tokenStorageService.getRefreshToken.and.returnValue(validRefreshToken); - authApiService.refresh$.and.returnValue(of(refreshResponse)); - spyOn( - service as unknown as { refreshTokensInBackground: () => void }, - 'refreshTokensInBackground', - ); - - const result = await service.isAuthenticatedAndValid(); - - expect(result).toBe(true); - expect( - (service as unknown as { refreshTokensInBackground: () => void }).refreshTokensInBackground, - ).toHaveBeenCalled(); - }); - - it('should return true when both tokens exist and access token is valid', async () => { - const futureTime = Math.floor(Date.now() / 1000) + 3600; - const validToken = `header.${btoa(JSON.stringify({ exp: futureTime }))}.signature`; - - tokenStorageService.getAccessToken.and.returnValue(validToken); - tokenStorageService.getRefreshToken.and.returnValue('refresh-token'); - spyOn( - service as unknown as { refreshTokensInBackground: () => void }, - 'refreshTokensInBackground', - ); - - const result = await service.isAuthenticatedAndValid(); - - expect(result).toBe(true); - expect( - (service as unknown as { refreshTokensInBackground: () => void }).refreshTokensInBackground, - ).not.toHaveBeenCalled(); - }); - - it('should refresh token when access token is expired but refresh token is valid', async () => { - const expiredTime = Math.floor(Date.now() / 1000) - 3600; - const expiredAccessToken = `header.${btoa(JSON.stringify({ exp: expiredTime }))}.signature`; - const futureTime = Math.floor(Date.now() / 1000) + 3600; - const validRefreshToken = `header.${btoa(JSON.stringify({ exp: futureTime }))}.signature`; - const refreshResponse = { - access_token: 'new-access-token', - refresh_token: 'new-refresh-token', - expires_in: 3600, - token_type: 'Bearer' as const, - }; - - tokenStorageService.getAccessToken.and.returnValue(expiredAccessToken); - tokenStorageService.getRefreshToken.and.returnValue(validRefreshToken); - authApiService.refresh$.and.returnValue(of(refreshResponse)); - spyOn( - service as unknown as { refreshTokensInBackground: () => void }, - 'refreshTokensInBackground', - ); - - const result = await service.isAuthenticatedAndValid(); - - expect(result).toBe(true); - expect( - (service as unknown as { refreshTokensInBackground: () => void }).refreshTokensInBackground, - ).toHaveBeenCalled(); - }); - - it('should logout and return false when both tokens are expired', async () => { - const expiredTime = Math.floor(Date.now() / 1000) - 3600; - const expiredAccessToken = `header.${btoa(JSON.stringify({ exp: expiredTime }))}.signature`; - const expiredRefreshToken = `header.${btoa(JSON.stringify({ exp: expiredTime }))}.signature`; - - tokenStorageService.getAccessToken.and.returnValue(expiredAccessToken); - tokenStorageService.getRefreshToken.and.returnValue(expiredRefreshToken); - spyOn(service, 'logout'); - - const result = await service.isAuthenticatedAndValid(); - - expect(result).toBe(false); - expect(service.logout).toHaveBeenCalled(); - expect(authApiService.refresh$).not.toHaveBeenCalled(); - }); - - it('should return true when refresh token is valid even if refresh fails in background', async () => { - const expiredTime = Math.floor(Date.now() / 1000) - 3600; - const expiredAccessToken = `header.${btoa(JSON.stringify({ exp: expiredTime }))}.signature`; - const futureTime = Math.floor(Date.now() / 1000) + 3600; - const validRefreshToken = `header.${btoa(JSON.stringify({ exp: futureTime }))}.signature`; - - tokenStorageService.getAccessToken.and.returnValue(expiredAccessToken); - tokenStorageService.getRefreshToken.and.returnValue(validRefreshToken); - authApiService.refresh$.and.returnValue(throwError(() => new Error('Refresh failed'))); - spyOn( - service as unknown as { refreshTokensInBackground: () => void }, - 'refreshTokensInBackground', - ); - - const result = await service.isAuthenticatedAndValid(); - - expect(result).toBe(true); - expect( - (service as unknown as { refreshTokensInBackground: () => void }).refreshTokensInBackground, - ).toHaveBeenCalled(); + expect(result).toBe(null); }); }); }); diff --git a/src/features/auth/services/auth.service.ts b/src/features/auth/services/auth.service.ts index 55f0386..d9498c8 100644 --- a/src/features/auth/services/auth.service.ts +++ b/src/features/auth/services/auth.service.ts @@ -1,46 +1,37 @@ import { inject, Injectable, signal, DestroyRef } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Router } from '@angular/router'; -import { tap, catchError, of, finalize, map, firstValueFrom, timeout } from 'rxjs'; +import { jwtDecode } from 'jwt-decode'; +import { tap, catchError, of, finalize, map } from 'rxjs'; import { AuthApiService } from '@/features/auth'; -import type { - LoginRequest, - RegisterRequest, - LoginResponse, - RefreshResponse, -} from '@/features/auth'; +import type { LoginRequest, RegisterRequest } from '@/features/auth'; import type { ApiError } from '@/shared/lib/types'; -import { getTokenExpirationSeconds } from '@/shared/lib/utils'; -import { TokenStorageService } from '@/shared/services/auth/token-storage.service'; import type { Observable } from 'rxjs'; +interface JwtPayload { + exp: number; + iat: number; + sub: string; +} + @Injectable({ providedIn: 'root' }) export class AuthService { private readonly authApi = inject(AuthApiService); - private readonly tokenStorage = inject(TokenStorageService); private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); - readonly isAuthenticated = signal(false); readonly loading = signal(false); readonly error = signal(null); - readonly isRefreshingTokens = signal(false); - initFromStorage(): void { - const accessToken = this.tokenStorage.getAccessToken(); - const refreshToken = this.tokenStorage.getRefreshToken(); - this.isAuthenticated.set(!!accessToken && !!refreshToken); - } + private accessToken: string | null = null; login$(body: LoginRequest): Observable { this.loading.set(true); this.error.set(null); return this.authApi.login$(body).pipe( - tap((response: LoginResponse) => { - this.tokenStorage.setTokens(response.access_token, response.refresh_token); - this.isAuthenticated.set(true); - + tap((response) => { + this.accessToken = response.access_token; const targetUrl = sessionStorage.getItem('return_url') || '/dashboard'; sessionStorage.removeItem('return_url'); void this.router.navigate([targetUrl]); @@ -73,86 +64,64 @@ export class AuthService { } logout(): void { - this.tokenStorage.clearTokens(); - this.isAuthenticated.set(false); - void this.router.navigate(['/login']); + const handleLogout = (): void => { + this.accessToken = null; + void this.router.navigate(['/login']); + }; + this.authApi + .logout$() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + handleLogout(); + }, + error: () => { + handleLogout(); + }, + }); } clearError(): void { this.error.set(null); } - async refreshTokenSync(): Promise { - if (this.isRefreshingTokens()) { - return false; - } + isAuthenticated(): boolean { + return this.getAccessToken() !== null; + } - this.isRefreshingTokens.set(true); + getAccessToken(): string | null { + if (!this.accessToken) { + return null; + } try { - const refreshToken = this.tokenStorage.getRefreshToken(); - - if (!refreshToken) { - this.logout(); - return false; + const decoded = jwtDecode(this.accessToken); + const currentTime = Math.floor(Date.now() / 1000); + if (decoded.exp <= currentTime) { + this.accessToken = null; + return null; } - - const response: RefreshResponse = await firstValueFrom( - this.authApi.refresh$(refreshToken).pipe(timeout(10000)), - ); - - this.tokenStorage.setTokens(response.access_token, response.refresh_token); - this.isAuthenticated.set(true); - - return true; + return this.accessToken; } catch { - this.logout(); - return false; - } finally { - this.isRefreshingTokens.set(false); - } - } - - async isAuthenticatedAndValid(): Promise { - const accessToken = this.tokenStorage.getAccessToken(); - const refreshToken = this.tokenStorage.getRefreshToken(); - - if (!accessToken && !refreshToken) { - return false; + this.accessToken = null; + return null; } - - if (accessToken && !this.isTokenExpired(accessToken)) { - return true; - } - - if (refreshToken && !this.isTokenExpired(refreshToken)) { - this.refreshTokensInBackground(); - return true; - } - - this.logout(); - return false; } - private refreshTokensInBackground(): void { - if (this.isRefreshingTokens()) { - return; - } - - this.refreshTokenSync().catch(() => {}); + setAccessToken(token: string): void { + this.accessToken = token; } - private isTokenExpired(token: string | null): boolean { - if (!token) { - return true; - } - - const expirationSeconds = getTokenExpirationSeconds(token); - - if (expirationSeconds === null) { - return true; - } - - return expirationSeconds <= 0; + refreshToken$(): Observable { + return this.authApi.refresh$().pipe( + tap((response) => { + this.accessToken = response.access_token; + }), + map(() => true), + catchError(() => { + this.accessToken = null; + return of(false); + }), + ); } } diff --git a/src/features/calorie-calculation/ui/activity-goal-form/activity-goal-form.component.spec.ts b/src/features/calorie-calculation/ui/activity-goal-form/activity-goal-form.component.spec.ts index 7aae1ee..fa35757 100644 --- a/src/features/calorie-calculation/ui/activity-goal-form/activity-goal-form.component.spec.ts +++ b/src/features/calorie-calculation/ui/activity-goal-form/activity-goal-form.component.spec.ts @@ -69,7 +69,7 @@ describe('ActivityGoalFormComponent', () => { spyOn(component.dataChanged, 'emit'); - component.form.patchValue({ activityLevel: 'moderate' }); + component.form.patchValue({ activityLevel: 'moderately_active' }); expect(component.dataChanged.emit).toHaveBeenCalled(); }); @@ -95,8 +95,14 @@ describe('ActivityGoalFormComponent', () => { fixture.detectChanges(); spyOn(component.form, 'getRawValue').and.returnValue({ someOtherField: 'value' } as unknown as { - activityLevel: string | null; - goal: string | null; + activityLevel: + | 'sedentary' + | 'lightly_active' + | 'moderately_active' + | 'very_active' + | 'extremely_active' + | null; + goal: 'lose_weight' | 'maintain_weight' | 'gain_weight' | null; }); expect(() => { diff --git a/src/features/calorie-calculation/ui/activity-goal-form/activity-goal-form.component.ts b/src/features/calorie-calculation/ui/activity-goal-form/activity-goal-form.component.ts index 076da50..2dfcaf1 100644 --- a/src/features/calorie-calculation/ui/activity-goal-form/activity-goal-form.component.ts +++ b/src/features/calorie-calculation/ui/activity-goal-form/activity-goal-form.component.ts @@ -1,6 +1,6 @@ -import { ChangeDetectionStrategy, Component, input, output, computed } from '@angular/core'; +import { ChangeDetectionStrategy, Component, input, output, computed, inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { TuiButton } from '@taiga-ui/core'; import { @@ -42,11 +42,11 @@ export class ActivityGoalFormComponent implements OnInit { ); protected readonly goalOptions = computed(() => generateSelectOptions(GoalOptions)); - readonly form = new FormGroup({ - activityLevel: new FormControl(DEFAULT_ACTIVITY_DATA.activityLevel, [ - Validators.required, - ]), - goal: new FormControl(DEFAULT_ACTIVITY_DATA.goal, [Validators.required]), + private readonly fb = inject(FormBuilder); + + readonly form = this.fb.group({ + activityLevel: [DEFAULT_ACTIVITY_DATA.activityLevel, [Validators.required]], + goal: [DEFAULT_ACTIVITY_DATA.goal, [Validators.required]], }); constructor() { diff --git a/src/features/calorie-calculation/ui/basic-data-form/basic-data-form.component.ts b/src/features/calorie-calculation/ui/basic-data-form/basic-data-form.component.ts index 4d8bb58..15d347c 100644 --- a/src/features/calorie-calculation/ui/basic-data-form/basic-data-form.component.ts +++ b/src/features/calorie-calculation/ui/basic-data-form/basic-data-form.component.ts @@ -1,13 +1,12 @@ -import { ChangeDetectionStrategy, Component, input, output, computed } from '@angular/core'; +import { ChangeDetectionStrategy, Component, input, output, computed, inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { TuiButton, TuiTextfield } from '@taiga-ui/core'; import { TuiInputNumber } from '@taiga-ui/kit'; import { type BasicData, GenderOptions, - type Gender, DEFAULT_BASIC_DATA, isBasicData, } from '@/features/calorie-calculation'; @@ -43,23 +42,19 @@ export class BasicDataFormComponent implements OnInit { protected readonly genderOptions = computed(() => generateSelectOptions(GenderOptions)); - readonly form = new FormGroup({ - gender: new FormControl(DEFAULT_BASIC_DATA.gender, [Validators.required]), - age: new FormControl(DEFAULT_BASIC_DATA.age, [ - Validators.required, - Validators.min(10), - Validators.max(120), - ]), - height: new FormControl(DEFAULT_BASIC_DATA.height, [ - Validators.required, - Validators.min(100), - Validators.max(250), - ]), - weight: new FormControl(DEFAULT_BASIC_DATA.weight, [ - Validators.required, - Validators.min(30), - Validators.max(300), - ]), + private readonly fb = inject(FormBuilder); + + readonly form = this.fb.group({ + gender: [DEFAULT_BASIC_DATA.gender, [Validators.required]], + age: [DEFAULT_BASIC_DATA.age, [Validators.required, Validators.min(10), Validators.max(120)]], + height: [ + DEFAULT_BASIC_DATA.height, + [Validators.required, Validators.min(100), Validators.max(250)], + ], + weight: [ + DEFAULT_BASIC_DATA.weight, + [Validators.required, Validators.min(30), Validators.max(300)], + ], }); constructor() { diff --git a/src/features/calorie-calculation/ui/results-display/results-display.component.html b/src/features/calorie-calculation/ui/results-display/results-display.component.html index 5e3f9d0..745c9e5 100644 --- a/src/features/calorie-calculation/ui/results-display/results-display.component.html +++ b/src/features/calorie-calculation/ui/results-display/results-display.component.html @@ -1,5 +1,5 @@ - @if (results(); as results) { + @if (results();) { { it('should handle simple error response', () => { diff --git a/src/shared/lib/utils/index.ts b/src/shared/lib/utils/index.ts index 055165b..87283e0 100644 --- a/src/shared/lib/utils/index.ts +++ b/src/shared/lib/utils/index.ts @@ -1,3 +1,2 @@ export * from './select-options.utils'; export * from './api-error.utils'; -export * from './jwt.utils'; diff --git a/src/shared/lib/utils/jwt.utils.spec.ts b/src/shared/lib/utils/jwt.utils.spec.ts deleted file mode 100644 index 6eff042..0000000 --- a/src/shared/lib/utils/jwt.utils.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { getTokenExpirationSeconds } from './jwt.utils'; - -describe('JWT Utils', () => { - const createMockJwt = (expirationSeconds: number): string => { - const currentTime = Math.floor(Date.now() / 1000); - const exp = currentTime + expirationSeconds; - const payload = { exp, iat: currentTime, sub: 'user123' }; - const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); - const encodedPayload = btoa(JSON.stringify(payload)); - return `${header}.${encodedPayload}.mock-signature`; - }; - - describe('getTokenExpirationSeconds', () => { - it('should return remaining seconds until expiration', () => { - const expirationSeconds = 3600; // 1 hour - const token = createMockJwt(expirationSeconds); - - const result = getTokenExpirationSeconds(token); - - expect(result).toBeGreaterThan(3590); // Allow for slight timing differences - expect(result).toBeLessThanOrEqual(3600); - }); - - it('should return 0 for expired token', () => { - const expirationSeconds = -3600; // 1 hour ago - const token = createMockJwt(expirationSeconds); - - const result = getTokenExpirationSeconds(token); - - expect(result).toBe(0); - }); - - it('should return null for invalid token format', () => { - const invalidToken = 'invalid.token'; - - const result = getTokenExpirationSeconds(invalidToken); - - expect(result).toBeNull(); - }); - - it('should return null for token without exp claim', () => { - const payload = { iat: Math.floor(Date.now() / 1000), sub: 'user123' }; // No exp field - const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); - const encodedPayload = btoa(JSON.stringify(payload)); - const token = `${header}.${encodedPayload}.mock-signature`; - - const result = getTokenExpirationSeconds(token); - - expect(result).toBeNull(); - }); - - it('should return null for malformed JSON', () => { - const invalidToken = 'header.invalid-json.signature'; - - const result = getTokenExpirationSeconds(invalidToken); - - expect(result).toBeNull(); - }); - }); -}); diff --git a/src/shared/lib/utils/jwt.utils.ts b/src/shared/lib/utils/jwt.utils.ts deleted file mode 100644 index 14e047d..0000000 --- a/src/shared/lib/utils/jwt.utils.ts +++ /dev/null @@ -1,21 +0,0 @@ -export const getTokenExpirationSeconds = (token: string): number | null => { - try { - const parts = token.split('.'); - if (parts.length !== 3) { - return null; - } - - const payload = parts[1]; - const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/')); - const parsed = JSON.parse(decoded); - - if (!parsed.exp) { - return null; - } - - const currentTime = Math.floor(Date.now() / 1000); - return Math.max(0, parsed.exp - currentTime); - } catch { - return null; - } -}; diff --git a/src/shared/services/auth/index.ts b/src/shared/services/auth/index.ts deleted file mode 100644 index cb5b4d0..0000000 --- a/src/shared/services/auth/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './token-storage.service'; diff --git a/src/shared/services/auth/token-storage.service.spec.ts b/src/shared/services/auth/token-storage.service.spec.ts deleted file mode 100644 index dca5213..0000000 --- a/src/shared/services/auth/token-storage.service.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { configureZonelessTestingModule } from '@/test-setup'; -import { TokenStorageService } from './token-storage.service'; - -describe('TokenStorageService', () => { - let service: TokenStorageService; - - beforeEach(() => { - configureZonelessTestingModule(); - - TestBed.configureTestingModule({}); - service = TestBed.inject(TokenStorageService); - - document.cookie = 'access_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; - document.cookie = 'refresh_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); - - describe('setTokens', () => { - it('should set tokens in cookies', () => { - service.setTokens('access-token', 'refresh-token'); - - expect(document.cookie).toContain('access_token=access-token'); - expect(document.cookie).toContain('refresh_token=refresh-token'); - }); - }); - - describe('getAccessToken', () => { - it('should return access token from cookies', () => { - service.setTokens('test-token', 'refresh-token'); - - expect(service.getAccessToken()).toBe('test-token'); - }); - - it('should return null when no token is set', () => { - expect(service.getAccessToken()).toBeNull(); - }); - }); - - describe('getRefreshToken', () => { - it('should return refresh token from cookies', () => { - service.setTokens('access-token', 'test-refresh'); - - expect(service.getRefreshToken()).toBe('test-refresh'); - }); - - it('should return null when no token is set', () => { - expect(service.getRefreshToken()).toBeNull(); - }); - }); - - describe('clearTokens', () => { - it('should clear both tokens', () => { - service.setTokens('access-token', 'refresh-token'); - - service.clearTokens(); - - expect(service.getAccessToken()).toBeNull(); - expect(service.getRefreshToken()).toBeNull(); - }); - }); -}); diff --git a/src/shared/services/auth/token-storage.service.ts b/src/shared/services/auth/token-storage.service.ts deleted file mode 100644 index 666cca4..0000000 --- a/src/shared/services/auth/token-storage.service.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Injectable } from '@angular/core'; -import { getTokenExpirationSeconds } from '@/shared/lib/utils'; - -@Injectable({ providedIn: 'root' }) -export class TokenStorageService { - private readonly ACCESS_TOKEN_KEY = 'access_token'; - private readonly REFRESH_TOKEN_KEY = 'refresh_token'; - - private setCookie(name: string, value: string, expiresInSeconds: number): void { - const expires = new Date(); - expires.setTime(expires.getTime() + expiresInSeconds * 1000); - const isSecure = - window.location.protocol === 'https:' || window.location.hostname === 'localhost'; - const secureFlag = isSecure ? ';secure' : ''; - document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;samesite=strict${secureFlag}`; - } - - private getCookie(name: string): string | null { - return ( - document.cookie - .split(';') - .map((cookie) => cookie.trim()) - .find((cookie) => cookie.startsWith(`${name}=`)) - ?.split('=')[1] || null - ); - } - - private deleteCookie(name: string): void { - document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/;`; - } - - getAccessToken(): string | null { - return this.getCookie(this.ACCESS_TOKEN_KEY); - } - - getRefreshToken(): string | null { - return this.getCookie(this.REFRESH_TOKEN_KEY); - } - - setTokens(accessToken: string, refreshToken: string): void { - const accessTokenExpiration = getTokenExpirationSeconds(accessToken) ?? 900; // fallback to 15 minutes - const refreshTokenExpiration = getTokenExpirationSeconds(refreshToken) ?? 7 * 24 * 60 * 60; // fallback to 7 days - - this.setCookie(this.ACCESS_TOKEN_KEY, accessToken, accessTokenExpiration); - this.setCookie(this.REFRESH_TOKEN_KEY, refreshToken, refreshTokenExpiration); - } - - clearTokens(): void { - this.deleteCookie(this.ACCESS_TOKEN_KEY); - this.deleteCookie(this.REFRESH_TOKEN_KEY); - } -} diff --git a/src/shared/ui/navigation/navigation.component.spec.ts b/src/shared/ui/navigation/navigation.component.spec.ts index 012a698..2420a0b 100644 --- a/src/shared/ui/navigation/navigation.component.spec.ts +++ b/src/shared/ui/navigation/navigation.component.spec.ts @@ -1,5 +1,6 @@ +import { provideLocationMocks } from '@angular/common/testing'; import { TestBed } from '@angular/core/testing'; -import { RouterTestingModule } from '@angular/router/testing'; +import { provideRouter } from '@angular/router'; import { TuiButton, TuiIcon } from '@taiga-ui/core'; import { ThemeService } from '@/shared'; import { configureZonelessTestingModule } from '@/test-setup'; @@ -16,8 +17,12 @@ describe('NavigationComponent', () => { const themeServiceSpy = jasmine.createSpyObj('ThemeService', ['toggleTheme', 'isDark']); configureZonelessTestingModule({ - imports: [NavigationComponent, RouterTestingModule, TuiButton, TuiIcon], - providers: [{ provide: ThemeService, useValue: themeServiceSpy }], + imports: [NavigationComponent, TuiButton, TuiIcon], + providers: [ + provideRouter([]), + provideLocationMocks(), + { provide: ThemeService, useValue: themeServiceSpy }, + ], }); fixture = TestBed.createComponent(NavigationComponent); diff --git a/src/test-setup.ts b/src/test-setup.ts index ca01c2d..07996ff 100644 --- a/src/test-setup.ts +++ b/src/test-setup.ts @@ -10,7 +10,3 @@ export function configureZonelessTestingModule( providers: [provideExperimentalZonelessChangeDetection(), ...(config.providers || [])], }); } - -export function createServiceInInjectionContext(token: unknown): T { - return TestBed.runInInjectionContext(() => TestBed.inject(token as never)); -}