feat: complete Phase 2.2 Merchant Auth, Store PIN recovery, biometrics, and secure session management

This commit is contained in:
ssbg04 committed 2026-10-20 17:03:18 +08:00
1 parent 21a98f8a16
commit 3ae6759388
12 files changed
+1049 -4

No files matched your search

@@ -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);
}
}
@@ -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 {}
@@ -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',
};
}
}
@@ -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;
}
@@ -1,4 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<application
android:label="tindagrid"
android:name="${applicationName}"
@@ -1,5 +1,6 @@
package com.tindagrid.tindagrid
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.android.FlutterFragmentActivity
class MainActivity : FlutterFragmentActivity()
class MainActivity : FlutterActivity()
+99
View File
@@ -0,0 +1,99 @@
class MerchantUser {
final String id;
final String phone;
final String fullName;
final String role;
final bool hasSecurityQuestion;
const MerchantUser({
required this.id,
required this.phone,
required this.fullName,
required this.role,
this.hasSecurityQuestion = false,
});
factory MerchantUser.fromJson(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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,
);
}
}
+94
View File
@@ -0,0 +1,94 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_models.dart';
import 'auth_service.dart';
final authServiceProvider = Provider<AuthService>((ref) {
return AuthService();
});
class AuthNotifier extends Notifier<AuthState> {
@override
AuthState build() {
_loadSession();
return const AuthState(isLoading: true);
}
AuthService get _authService => ref.read(authServiceProvider);
Future<void> _loadSession() async {
final session = await _authService.loadPersistedSession();
state = session;
}
Future<void> 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<void> 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<bool> 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<bool> unlockWithBiometrics() async {
return _authService.authenticateWithBiometrics();
}
Future<void> logout() async {
await _authService.logout();
state = const AuthState(isAuthenticated: false);
}
}
final authProvider = NotifierProvider<AuthNotifier, AuthState>(AuthNotifier.new);
+235
View File
@@ -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<AuthState> 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<String, dynamic>;
if (response.statusCode >= 200 && response.statusCode < 300) {
final user = MerchantUser.fromJson(data['merchant'] as Map<String, dynamic>);
final store = StoreInfo.fromJson(data['store'] as Map<String, dynamic>);
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<AuthState> 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<String, dynamic>;
if (response.statusCode >= 200 && response.statusCode < 300) {
final user = MerchantUser.fromJson(data['merchant'] as Map<String, dynamic>);
final storesList = data['stores'] as List<dynamic>;
final store = storesList.isNotEmpty
? StoreInfo.fromJson(storesList.first as Map<String, dynamic>)
: 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<bool> 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<String, dynamic>;
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<bool> 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<String?> 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<String, dynamic>;
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<AuthState> 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<String, dynamic>);
final store = storeJson != null
? StoreInfo.fromJson(jsonDecode(storeJson) as Map<String, dynamic>)
: null;
return AuthState(
isAuthenticated: true,
user: user,
store: store,
accessToken: accessToken,
);
}
} catch (_) {}
return const AuthState(isAuthenticated: false);
}
/// Logout and clear secure device storage
Future<void> logout() async {
await _storage.delete(key: _keyAccessToken);
await _storage.delete(key: _keyRefreshToken);
await _storage.delete(key: _keyMerchant);
await _storage.delete(key: _keyStore);
}
Future<void> _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()));
}
}
}
+127 -2
View File
@@ -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"
+3
View File
@@ -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:
+148
View File
@@ -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);
});
});
}