From 3ae6759388e75eb4472019e8ad388cb39aa5d7fd Mon Sep 17 00:00:00 2001 From: ssbg04 Date: Tue, 20 Oct 2026 17:03:18 +0800 Subject: [PATCH] feat: complete Phase 2.2 Merchant Auth, Store PIN recovery, biometrics, and secure session management --- .../api-gateway/src/auth/auth.controller.ts | 31 +++ .../apps/api-gateway/src/auth/auth.module.ts | 19 ++ .../apps/api-gateway/src/auth/auth.service.ts | 213 ++++++++++++++++ .../apps/api-gateway/src/auth/dto/auth.dto.ts | 75 ++++++ .../android/app/src/main/AndroidManifest.xml | 2 + .../com/tindagrid/tindagrid/MainActivity.kt | 5 +- frontend/lib/core/auth/auth_models.dart | 99 ++++++++ frontend/lib/core/auth/auth_provider.dart | 94 +++++++ frontend/lib/core/auth/auth_service.dart | 235 ++++++++++++++++++ frontend/pubspec.lock | 129 +++++++++- frontend/pubspec.yaml | 3 + frontend/test/auth_test.dart | 148 +++++++++++ 12 files changed, 1049 insertions(+), 4 deletions(-) create mode 100644 backend/apps/api-gateway/src/auth/auth.controller.ts create mode 100644 backend/apps/api-gateway/src/auth/auth.module.ts create mode 100644 backend/apps/api-gateway/src/auth/auth.service.ts create mode 100644 backend/apps/api-gateway/src/auth/dto/auth.dto.ts create mode 100644 frontend/lib/core/auth/auth_models.dart create mode 100644 frontend/lib/core/auth/auth_provider.dart create mode 100644 frontend/lib/core/auth/auth_service.dart create mode 100644 frontend/test/auth_test.dart diff --git a/backend/apps/api-gateway/src/auth/auth.controller.ts b/backend/apps/api-gateway/src/auth/auth.controller.ts new file mode 100644 index 0000000..2b9c06e --- /dev/null +++ b/backend/apps/api-gateway/src/auth/auth.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common'; +import { AuthService } from './auth.service.js'; +import { RegisterDto, LoginDto, RecoverPinDto, RefreshTokenDto } from './dto/auth.dto.js'; + +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Post('register') + async register(@Body() dto: RegisterDto) { + return this.authService.register(dto); + } + + @Post('login') + @HttpCode(HttpStatus.OK) + async login(@Body() dto: LoginDto) { + return this.authService.login(dto); + } + + @Post('recover-pin') + @HttpCode(HttpStatus.OK) + async recoverPin(@Body() dto: RecoverPinDto) { + return this.authService.recoverPin(dto); + } + + @Post('refresh') + @HttpCode(HttpStatus.OK) + async refresh(@Body() dto: RefreshTokenDto) { + return this.authService.refreshToken(dto.refreshToken); + } +} diff --git a/backend/apps/api-gateway/src/auth/auth.module.ts b/backend/apps/api-gateway/src/auth/auth.module.ts new file mode 100644 index 0000000..5304d75 --- /dev/null +++ b/backend/apps/api-gateway/src/auth/auth.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { DatabaseModule } from '@lib/database'; +import { AuthController } from './auth.controller.js'; +import { AuthService } from './auth.service.js'; + +@Module({ + imports: [ + DatabaseModule, + JwtModule.register({ + secret: process.env.JWT_SECRET || 'tindagrid_super_secret_jwt_key_change_in_production', + signOptions: { expiresIn: '30d' }, + }), + ], + controllers: [AuthController], + providers: [AuthService], + exports: [AuthService, JwtModule], +}) +export class AuthModule {} diff --git a/backend/apps/api-gateway/src/auth/auth.service.ts b/backend/apps/api-gateway/src/auth/auth.service.ts new file mode 100644 index 0000000..14a8c86 --- /dev/null +++ b/backend/apps/api-gateway/src/auth/auth.service.ts @@ -0,0 +1,213 @@ +import { + Injectable, + ConflictException, + UnauthorizedException, + BadRequestException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { DatabaseService } from '@lib/database'; +import bcrypt from 'bcryptjs'; +import { RegisterDto, LoginDto, RecoverPinDto } from './dto/auth.dto.js'; + +@Injectable() +export class AuthService { + constructor( + private readonly db: DatabaseService, + private readonly jwtService: JwtService, + ) {} + + private normalizePhone(phone: string): string { + const cleaned = phone.replace(/[\s-]/g, ''); + if (cleaned.startsWith('09')) { + return '+63' + cleaned.substring(1); + } + return cleaned; + } + + async register(dto: RegisterDto) { + const normalizedPhone = this.normalizePhone(dto.phone); + + // Check if phone already registered + const existing = await this.db.merchant.findUnique({ + where: { phone: normalizedPhone }, + }); + + if (existing) { + throw new ConflictException( + 'Ang numero ng teleponong ito ay nakarehistro na. Paki-login na lang gamit ang iyong Store PIN.', + ); + } + + // Hash Store PIN + const pinHash = await bcrypt.hash(dto.pin, 10); + + // Hash Security Answer if provided + let securityAnswerHash: string | undefined; + if (dto.securityAnswer) { + const normalizedAnswer = dto.securityAnswer.trim().toLowerCase(); + securityAnswerHash = await bcrypt.hash(normalizedAnswer, 10); + } + + // Generate unique slug for default store + const storeName = dto.storeName || `${dto.fullName}'s Tindahan`; + const baseSlug = storeName + .toLowerCase() + .replace(/[^a-z0-9]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + const randomSuffix = Math.random().toString(36).substring(2, 6); + const storeSlug = `${baseSlug}-${randomSuffix}`; + + // Create Merchant and default Store transactionally + const merchant = await this.db.merchant.create({ + data: { + phone: normalizedPhone, + fullName: dto.fullName, + pinHash, + securityQuestion: dto.securityQuestion, + securityAnswerHash, + backupEmail: dto.backupEmail, + stores: { + create: { + name: storeName, + slug: storeSlug, + isPublic: false, + }, + }, + }, + include: { + stores: true, + }, + }); + + const tokens = await this.generateTokens(merchant.id, merchant.phone, merchant.role); + + return { + message: 'Matagumpay na narehistro ang tindahan!', + merchant: { + id: merchant.id, + phone: merchant.phone, + fullName: merchant.fullName, + role: merchant.role, + hasSecurityQuestion: !!merchant.securityQuestion, + }, + store: merchant.stores[0], + ...tokens, + }; + } + + async login(dto: LoginDto) { + const normalizedPhone = this.normalizePhone(dto.phone); + + const merchant = await this.db.merchant.findUnique({ + where: { phone: normalizedPhone }, + include: { stores: true }, + }); + + if (!merchant || !merchant.pinHash) { + throw new UnauthorizedException('Maling numero ng telepono o Store PIN'); + } + + const isPinValid = await bcrypt.compare(dto.pin, merchant.pinHash); + if (!isPinValid) { + throw new UnauthorizedException('Maling numero ng telepono o Store PIN'); + } + + const tokens = await this.generateTokens(merchant.id, merchant.phone, merchant.role); + + return { + message: 'Maligayang pagbabalik!', + merchant: { + id: merchant.id, + phone: merchant.phone, + fullName: merchant.fullName, + role: merchant.role, + hasSecurityQuestion: !!merchant.securityQuestion, + }, + stores: merchant.stores, + ...tokens, + }; + } + + async recoverPin(dto: RecoverPinDto) { + const normalizedPhone = this.normalizePhone(dto.phone); + + const merchant = await this.db.merchant.findUnique({ + where: { phone: normalizedPhone }, + }); + + if (!merchant || !merchant.securityAnswerHash) { + throw new BadRequestException( + 'Walang naitakdang secret security question para sa numerong ito. Mangyaring gumamit ng ibang paraan ng pagbawi.', + ); + } + + // Verify security question matches + if (merchant.securityQuestion !== dto.securityQuestion) { + throw new BadRequestException('Maling security question'); + } + + // Verify answer + const normalizedAnswer = dto.securityAnswer.trim().toLowerCase(); + const isAnswerValid = await bcrypt.compare(normalizedAnswer, merchant.securityAnswerHash); + if (!isAnswerValid) { + throw new UnauthorizedException('Maling sagot sa security question'); + } + + // Hash and update new PIN + const newPinHash = await bcrypt.hash(dto.newPin, 10); + await this.db.merchant.update({ + where: { id: merchant.id }, + data: { pinHash: newPinHash }, + }); + + return { + success: true, + message: 'Matagumpay na nabago ang iyong Store PIN! Maaari ka nang mag-login gamit ang bagong PIN.', + }; + } + + async refreshToken(refreshTokenString: string) { + try { + const payload = this.jwtService.verify(refreshTokenString, { + secret: process.env.JWT_SECRET || 'tindagrid_super_secret_jwt_key_change_in_production', + }); + + const merchant = await this.db.merchant.findUnique({ + where: { id: payload.sub }, + }); + + if (!merchant) { + throw new UnauthorizedException('Hindi nahanap ang tindahan'); + } + + return this.generateTokens(merchant.id, merchant.phone, merchant.role); + } catch { + throw new UnauthorizedException('Expired o hindi wastong refresh token. Paki-type ang iyong PIN muli.'); + } + } + + private async generateTokens(merchantId: string, phone: string, role: string) { + const payload = { sub: merchantId, phone, role }; + const secret = process.env.JWT_SECRET || 'tindagrid_super_secret_jwt_key_change_in_production'; + + const accessToken = this.jwtService.sign(payload, { + secret, + expiresIn: '30d', + }); + + const refreshToken = this.jwtService.sign( + { sub: merchantId, type: 'refresh' }, + { + secret, + expiresIn: '365d', + }, + ); + + return { + accessToken, + refreshToken, + expiresIn: '30d', + }; + } +} diff --git a/backend/apps/api-gateway/src/auth/dto/auth.dto.ts b/backend/apps/api-gateway/src/auth/dto/auth.dto.ts new file mode 100644 index 0000000..b3b2ac1 --- /dev/null +++ b/backend/apps/api-gateway/src/auth/dto/auth.dto.ts @@ -0,0 +1,75 @@ +import { IsNotEmpty, IsOptional, IsString, Length, Matches } from 'class-validator'; + +export class RegisterDto { + @IsNotEmpty() + @IsString() + @Matches(/^(09|\+639)\d{9}$/, { + message: 'Ang numero ng telepono ay dapat wastong Philippine mobile number (hal. 09171234567 o +639171234567)', + }) + phone!: string; + + @IsNotEmpty() + @IsString() + @Length(2, 100) + fullName!: string; + + @IsNotEmpty() + @IsString() + @Matches(/^\d{4,6}$/, { + message: 'Ang Store PIN ay dapat 4 hanggang 6 na numero lamang', + }) + pin!: string; + + @IsOptional() + @IsString() + securityQuestion?: string; + + @IsOptional() + @IsString() + securityAnswer?: string; + + @IsOptional() + @IsString() + backupEmail?: string; + + @IsOptional() + @IsString() + storeName?: string; +} + +export class LoginDto { + @IsNotEmpty() + @IsString() + phone!: string; + + @IsNotEmpty() + @IsString() + pin!: string; +} + +export class RecoverPinDto { + @IsNotEmpty() + @IsString() + phone!: string; + + @IsNotEmpty() + @IsString() + securityQuestion!: string; + + @IsNotEmpty() + @IsString() + securityAnswer!: string; + + @IsNotEmpty() + @IsString() + @Matches(/^\d{4,6}$/, { + message: 'Ang bagong Store PIN ay dapat 4 hanggang 6 na numero lamang', + }) + newPin!: string; +} + +export class RefreshTokenDto { + @IsNotEmpty() + @IsString() + refreshToken!: string; +} diff --git a/frontend/android/app/src/main/AndroidManifest.xml b/frontend/android/app/src/main/AndroidManifest.xml index 5f57a3a..3ea68fd 100644 --- a/frontend/android/app/src/main/AndroidManifest.xml +++ b/frontend/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,6 @@ + + json) { + return MerchantUser( + id: json['id'] as String, + phone: json['phone'] as String, + fullName: json['fullName'] as String, + role: json['role'] as String? ?? 'MERCHANT', + hasSecurityQuestion: json['hasSecurityQuestion'] as bool? ?? false, + ); + } + + Map toJson() => { + 'id': id, + 'phone': phone, + 'fullName': fullName, + 'role': role, + 'hasSecurityQuestion': hasSecurityQuestion, + }; +} + +class StoreInfo { + final String id; + final String name; + final String slug; + final bool isPublic; + + const StoreInfo({ + required this.id, + required this.name, + required this.slug, + this.isPublic = false, + }); + + factory StoreInfo.fromJson(Map json) { + return StoreInfo( + id: json['id'] as String, + name: json['name'] as String, + slug: json['slug'] as String, + isPublic: json['isPublic'] as bool? ?? false, + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'slug': slug, + 'isPublic': isPublic, + }; +} + +class AuthState { + final bool isAuthenticated; + final MerchantUser? user; + final StoreInfo? store; + final String? accessToken; + final bool isLoading; + final String? errorMessage; + + const AuthState({ + this.isAuthenticated = false, + this.user, + this.store, + this.accessToken, + this.isLoading = false, + this.errorMessage, + }); + + AuthState copyWith({ + bool? isAuthenticated, + MerchantUser? user, + StoreInfo? store, + String? accessToken, + bool? isLoading, + String? errorMessage, + }) { + return AuthState( + isAuthenticated: isAuthenticated ?? this.isAuthenticated, + user: user ?? this.user, + store: store ?? this.store, + accessToken: accessToken ?? this.accessToken, + isLoading: isLoading ?? this.isLoading, + errorMessage: errorMessage, + ); + } +} diff --git a/frontend/lib/core/auth/auth_provider.dart b/frontend/lib/core/auth/auth_provider.dart new file mode 100644 index 0000000..927d134 --- /dev/null +++ b/frontend/lib/core/auth/auth_provider.dart @@ -0,0 +1,94 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'auth_models.dart'; +import 'auth_service.dart'; + +final authServiceProvider = Provider((ref) { + return AuthService(); +}); + +class AuthNotifier extends Notifier { + @override + AuthState build() { + _loadSession(); + return const AuthState(isLoading: true); + } + + AuthService get _authService => ref.read(authServiceProvider); + + Future _loadSession() async { + final session = await _authService.loadPersistedSession(); + state = session; + } + + Future register({ + required String phone, + required String fullName, + required String pin, + String? securityQuestion, + String? securityAnswer, + String? storeName, + }) async { + state = state.copyWith(isLoading: true, errorMessage: null); + try { + final newState = await _authService.register( + phone: phone, + fullName: fullName, + pin: pin, + securityQuestion: securityQuestion, + securityAnswer: securityAnswer, + storeName: storeName, + ); + state = newState; + } catch (e) { + state = state.copyWith(isLoading: false, errorMessage: e.toString()); + rethrow; + } + } + + Future login({ + required String phone, + required String pin, + }) async { + state = state.copyWith(isLoading: true, errorMessage: null); + try { + final newState = await _authService.login(phone: phone, pin: pin); + state = newState; + } catch (e) { + state = state.copyWith(isLoading: false, errorMessage: e.toString()); + rethrow; + } + } + + Future recoverPin({ + required String phone, + required String securityQuestion, + required String securityAnswer, + required String newPin, + }) async { + state = state.copyWith(isLoading: true, errorMessage: null); + try { + final result = await _authService.recoverPin( + phone: phone, + securityQuestion: securityQuestion, + securityAnswer: securityAnswer, + newPin: newPin, + ); + state = state.copyWith(isLoading: false); + return result; + } catch (e) { + state = state.copyWith(isLoading: false, errorMessage: e.toString()); + rethrow; + } + } + + Future unlockWithBiometrics() async { + return _authService.authenticateWithBiometrics(); + } + + Future logout() async { + await _authService.logout(); + state = const AuthState(isAuthenticated: false); + } +} + +final authProvider = NotifierProvider(AuthNotifier.new); diff --git a/frontend/lib/core/auth/auth_service.dart b/frontend/lib/core/auth/auth_service.dart new file mode 100644 index 0000000..7c0bbe4 --- /dev/null +++ b/frontend/lib/core/auth/auth_service.dart @@ -0,0 +1,235 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:http/http.dart' as http; +import 'package:local_auth/local_auth.dart'; +import 'auth_models.dart'; + +class AuthService { + final http.Client _client; + final FlutterSecureStorage _storage; + final LocalAuthentication _localAuth; + final String baseUrl; + + AuthService({ + http.Client? client, + FlutterSecureStorage? storage, + LocalAuthentication? localAuth, + String? baseUrl, + }) : _client = client ?? http.Client(), + _storage = storage ?? const FlutterSecureStorage(), + _localAuth = localAuth ?? LocalAuthentication(), + baseUrl = baseUrl ?? _defaultBaseUrl(); + + static String _defaultBaseUrl() { + // If running in release or web, use the live Render backend + if (kReleaseMode || kIsWeb) { + return 'https://tindagrid-backend.onrender.com'; + } + // For local Android development, 10.0.2.2 points to host machine, or localhost for Chrome + return 'http://localhost:3000'; + } + + static const _keyAccessToken = 'tg_access_token'; + static const _keyRefreshToken = 'tg_refresh_token'; + static const _keyMerchant = 'tg_merchant_user'; + static const _keyStore = 'tg_store_info'; + + /// Register a new merchant with Phone and 4-6 digit Store PIN + Future register({ + required String phone, + required String fullName, + required String pin, + String? securityQuestion, + String? securityAnswer, + String? storeName, + }) async { + final response = await _client.post( + Uri.parse('$baseUrl/auth/register'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'phone': phone, + 'fullName': fullName, + 'pin': pin, + 'securityQuestion': ?securityQuestion, + 'securityAnswer': ?securityAnswer, + 'storeName': ?storeName, + }), + ); + + final data = jsonDecode(response.body) as Map; + + if (response.statusCode >= 200 && response.statusCode < 300) { + final user = MerchantUser.fromJson(data['merchant'] as Map); + final store = StoreInfo.fromJson(data['store'] as Map); + final accessToken = data['accessToken'] as String; + final refreshToken = data['refreshToken'] as String; + + await _persistSession(accessToken, refreshToken, user, store); + + return AuthState( + isAuthenticated: true, + user: user, + store: store, + accessToken: accessToken, + ); + } else { + throw Exception(data['message'] ?? 'Nabigo ang pagpaparehistro'); + } + } + + /// Login with Phone and Store PIN + Future login({ + required String phone, + required String pin, + }) async { + final response = await _client.post( + Uri.parse('$baseUrl/auth/login'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'phone': phone, + 'pin': pin, + }), + ); + + final data = jsonDecode(response.body) as Map; + + if (response.statusCode >= 200 && response.statusCode < 300) { + final user = MerchantUser.fromJson(data['merchant'] as Map); + final storesList = data['stores'] as List; + final store = storesList.isNotEmpty + ? StoreInfo.fromJson(storesList.first as Map) + : null; + final accessToken = data['accessToken'] as String; + final refreshToken = data['refreshToken'] as String; + + await _persistSession(accessToken, refreshToken, user, store); + + return AuthState( + isAuthenticated: true, + user: user, + store: store, + accessToken: accessToken, + ); + } else { + throw Exception(data['message'] ?? 'Maling numero o Store PIN'); + } + } + + /// Recover forgotten PIN using secret Security Question & Answer + Future recoverPin({ + required String phone, + required String securityQuestion, + required String securityAnswer, + required String newPin, + }) async { + final response = await _client.post( + Uri.parse('$baseUrl/auth/recover-pin'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'phone': phone, + 'securityQuestion': securityQuestion, + 'securityAnswer': securityAnswer, + 'newPin': newPin, + }), + ); + + final data = jsonDecode(response.body) as Map; + + if (response.statusCode >= 200 && response.statusCode < 300) { + return data['success'] as bool? ?? true; + } else { + throw Exception(data['message'] ?? 'Nabigo ang pagbawi ng Store PIN'); + } + } + + /// Authenticate device owner using native biometrics (Fingerprint / Face ID) + Future authenticateWithBiometrics({String reason = 'I-verify ang iyong fingerprint para sa Tindahan PIN'}) async { + try { + final canCheck = await _localAuth.canCheckBiometrics; + final isSupported = await _localAuth.isDeviceSupported(); + + if (!canCheck && !isSupported) return false; + + return await _localAuth.authenticate( + localizedReason: reason, + biometricOnly: false, // Allows lock screen pattern/PIN fallback + ); + } catch (_) { + return false; + } + } + + /// Silently refresh the 30-day access token in background + Future refreshAccessToken() async { + final refreshToken = await _storage.read(key: _keyRefreshToken); + if (refreshToken == null) return null; + + try { + final response = await _client.post( + Uri.parse('$baseUrl/auth/refresh'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'refreshToken': refreshToken}), + ); + + if (response.statusCode >= 200 && response.statusCode < 300) { + final data = jsonDecode(response.body) as Map; + final newAccessToken = data['accessToken'] as String; + final newRefreshToken = data['refreshToken'] as String; + + await _storage.write(key: _keyAccessToken, value: newAccessToken); + await _storage.write(key: _keyRefreshToken, value: newRefreshToken); + + return newAccessToken; + } + } catch (_) {} + return null; + } + + /// Load session from encrypted device storage on app launch + Future loadPersistedSession() async { + try { + final accessToken = await _storage.read(key: _keyAccessToken); + final userJson = await _storage.read(key: _keyMerchant); + final storeJson = await _storage.read(key: _keyStore); + + if (accessToken != null && userJson != null) { + final user = MerchantUser.fromJson(jsonDecode(userJson) as Map); + final store = storeJson != null + ? StoreInfo.fromJson(jsonDecode(storeJson) as Map) + : null; + + return AuthState( + isAuthenticated: true, + user: user, + store: store, + accessToken: accessToken, + ); + } + } catch (_) {} + + return const AuthState(isAuthenticated: false); + } + + /// Logout and clear secure device storage + Future logout() async { + await _storage.delete(key: _keyAccessToken); + await _storage.delete(key: _keyRefreshToken); + await _storage.delete(key: _keyMerchant); + await _storage.delete(key: _keyStore); + } + + Future _persistSession( + String accessToken, + String refreshToken, + MerchantUser user, + StoreInfo? store, + ) async { + await _storage.write(key: _keyAccessToken, value: accessToken); + await _storage.write(key: _keyRefreshToken, value: refreshToken); + await _storage.write(key: _keyMerchant, value: jsonEncode(user.toJson())); + if (store != null) { + await _storage.write(key: _keyStore, value: jsonEncode(store.toJson())); + } + } +} diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index fdb95a1..1e3362d 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -217,6 +217,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" file: dependency: transitive description: @@ -246,6 +254,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" flutter_riverpod: dependency: "direct main" description: @@ -254,11 +270,64 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.3" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: d4e1fb6b2cb524868929e78dc0282fa000554b22060fb53789dc481c9fc95bb8 + url: "https://pub.dev" + source: hosted + version: "11.2.0" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: a031ceac9b070e62ef183cd7f7d1a8798ebde402d5049f0fe6eb3a51bd797a21 + url: "https://pub.dev" + source: hosted + version: "0.4.3" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: caa75bd78f017422912e3a904933113b2428eb79f981ac37acd2dd2a700458ea + url: "https://pub.dev" + source: hosted + version: "3.0.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "64951127f001f546891c86f414972bf49ef2c4c8a4c92c65f5790cc0c9100045" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + url: "https://pub.dev" + source: hosted + version: "4.2.2" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" glob: dependency: transitive description: @@ -292,7 +361,7 @@ packages: source: hosted version: "2.0.2" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -315,6 +384,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" io: dependency: transitive description: @@ -395,6 +472,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + local_auth: + dependency: "direct main" + description: + name: local_auth + sha256: ecf24edf2283c509ecd217e3595f6f71034b68888d28ad1dae6bfa0857b816ac + url: "https://pub.dev" + source: hosted + version: "3.0.2" + local_auth_android: + dependency: transitive + description: + name: local_auth_android + sha256: e2ceb84cdecefe1bc0ddd4ac308b0f6525bbfa0a60d0e552082b2970e1fb38c2 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + local_auth_darwin: + dependency: transitive + description: + name: local_auth_darwin + sha256: "45a9db01b57082bc9d6115cb25eb8fc0b72f43e5f330819bdd8352ecdae87589" + url: "https://pub.dev" + source: hosted + version: "2.0.5" + local_auth_platform_interface: + dependency: transitive + description: + name: local_auth_platform_interface + sha256: f98b8e388588583d3f781f6806e4f4c9f9e189d898d27f0c249b93a1973dd122 + url: "https://pub.dev" + source: hosted + version: "1.1.0" + local_auth_windows: + dependency: transitive + description: + name: local_auth_windows + sha256: d72b8a441e5e6a679902f87c51529fcce65f442d77598484cf35454c3d733eda + url: "https://pub.dev" + source: hosted + version: "2.0.2" logging: dependency: transitive description: @@ -768,6 +885,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" + source: hosted + version: "6.4.0" xdg_directories: dependency: transitive description: @@ -794,4 +919,4 @@ packages: version: "2.2.4" sdks: dart: ">=3.12.2 <4.0.0" - flutter: ">=3.38.4" + flutter: ">=3.44.0" diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index fec042b..de30e70 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -43,6 +43,9 @@ dependencies: path: ^1.9.1 uuid: ^4.6.0 drift_flutter: ^0.3.1 + flutter_secure_storage: ^11.2.0 + local_auth: ^3.0.2 + http: ^1.6.0 dev_dependencies: flutter_test: diff --git a/frontend/test/auth_test.dart b/frontend/test/auth_test.dart new file mode 100644 index 0000000..546d587 --- /dev/null +++ b/frontend/test/auth_test.dart @@ -0,0 +1,148 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:tindagrid/core/auth/auth_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // In-memory mock storage for tests + FlutterSecureStorage.setMockInitialValues({}); + + group('AuthService Tests', () { + test('Successful registration parses merchant, store, and persists tokens', () async { + final mockClient = MockClient((request) async { + if (request.url.path == '/auth/register') { + return http.Response( + jsonEncode({ + 'message': 'Matagumpay na narehistro ang tindahan!', + 'merchant': { + 'id': 'm-123', + 'phone': '+639171234567', + 'fullName': 'Aling Nena', + 'role': 'MERCHANT', + 'hasSecurityQuestion': true, + }, + 'store': { + 'id': 's-456', + 'name': 'Aling Nena Store', + 'slug': 'aling-nena-store', + 'isPublic': false, + }, + 'accessToken': 'mock-access-token', + 'refreshToken': 'mock-refresh-token', + 'expiresIn': '30d', + }), + 201, + ); + } + return http.Response('Not Found', 404); + }); + + final authService = AuthService( + client: mockClient, + storage: const FlutterSecureStorage(), + baseUrl: 'http://localhost:3000', + ); + + final state = await authService.register( + phone: '09171234567', + fullName: 'Aling Nena', + pin: '1234', + securityQuestion: 'PET_NAME', + securityAnswer: 'Bantay', + storeName: 'Aling Nena Store', + ); + + expect(state.isAuthenticated, isTrue); + expect(state.user?.fullName, 'Aling Nena'); + expect(state.store?.slug, 'aling-nena-store'); + expect(state.accessToken, 'mock-access-token'); + + // Verify persisted session can be loaded back + final loadedSession = await authService.loadPersistedSession(); + expect(loadedSession.isAuthenticated, isTrue); + expect(loadedSession.user?.phone, '+639171234567'); + expect(loadedSession.store?.name, 'Aling Nena Store'); + }); + + test('Successful login restores merchant session', () async { + final mockClient = MockClient((request) async { + if (request.url.path == '/auth/login') { + return http.Response( + jsonEncode({ + 'message': 'Maligayang pagbabalik!', + 'merchant': { + 'id': 'm-789', + 'phone': '+639189998877', + 'fullName': 'Mang Kanor', + 'role': 'MERCHANT', + 'hasSecurityQuestion': false, + }, + 'stores': [ + { + 'id': 's-789', + 'name': 'Mang Kanor Sari-Sari', + 'slug': 'mang-kanor-sari-sari', + 'isPublic': true, + } + ], + 'accessToken': 'login-access-token', + 'refreshToken': 'login-refresh-token', + 'expiresIn': '30d', + }), + 200, + ); + } + return http.Response('Not Found', 404); + }); + + final authService = AuthService( + client: mockClient, + storage: const FlutterSecureStorage(), + baseUrl: 'http://localhost:3000', + ); + + final state = await authService.login( + phone: '09189998877', + pin: '2580', + ); + + expect(state.isAuthenticated, isTrue); + expect(state.user?.fullName, 'Mang Kanor'); + expect(state.store?.name, 'Mang Kanor Sari-Sari'); + expect(state.accessToken, 'login-access-token'); + }); + + test('PIN recovery returns true when question and answer are verified', () async { + final mockClient = MockClient((request) async { + if (request.url.path == '/auth/recover-pin') { + return http.Response( + jsonEncode({ + 'success': true, + 'message': 'Matagumpay na nabago ang iyong Store PIN!', + }), + 200, + ); + } + return http.Response('Not Found', 404); + }); + + final authService = AuthService( + client: mockClient, + baseUrl: 'http://localhost:3000', + ); + + final success = await authService.recoverPin( + phone: '09171234567', + securityQuestion: 'PET_NAME', + securityAnswer: 'Bantay', + newPin: '9999', + ); + + expect(success, isTrue); + }); + }); +}