feat: complete Phase 2.2 Merchant Auth, Store PIN recovery, biometrics, and secure session management
This commit is contained in:
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()
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
@@ -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"
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user