Stabilize auth flow and profile images
This commit is contained in:
@@ -2,7 +2,8 @@
|
||||
<application
|
||||
android:label="tdc114plus"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import 'package:tdc114plus/main.dart' as app;
|
||||
|
||||
const _assumeLoggedIn = bool.fromEnvironment('TDC114_SMOKE_ASSUME_LOGGED_IN');
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('shows Baron SSO login screen', (tester) async {
|
||||
app.main();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
if (_assumeLoggedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(find.text('Baron SSO 로그인'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.open_in_browser), findsOneWidget);
|
||||
expect(find.text('Baron SSO로 로그인'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('does not collect phone number inside the app', (tester) async {
|
||||
if (_assumeLoggedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
app.main();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(TextField), findsNothing);
|
||||
expect(find.textContaining('Hosted Login'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('manual hosted login starts from the SSO button', (
|
||||
tester,
|
||||
) async {
|
||||
if (_assumeLoggedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
app.main();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Baron SSO로 로그인'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('checks post-login directory actions when session is preseeded', (
|
||||
tester,
|
||||
) async {
|
||||
if (!_assumeLoggedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
app.main();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
expect(find.textContaining('검색 결과'), findsOneWidget);
|
||||
|
||||
final addFavorite = find.byTooltip('즐겨찾기 추가');
|
||||
if (addFavorite.evaluate().isNotEmpty) {
|
||||
await tester.tap(addFavorite.first);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('즐겨찾기').first);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('검색 결과'), findsOneWidget);
|
||||
}
|
||||
|
||||
final employeeTile = find.byType(ListTile);
|
||||
expect(employeeTile, findsWidgets);
|
||||
|
||||
await tester.tap(employeeTile.first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('전화'), findsOneWidget);
|
||||
expect(find.text('문자'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
+133
-2
@@ -1,9 +1,140 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'src/features/auth/data/auth_session_store.dart';
|
||||
import 'src/features/auth/domain/auth_models.dart';
|
||||
import 'src/app.dart';
|
||||
import 'src/smoke/smoke_overrides.dart';
|
||||
|
||||
void main() {
|
||||
const _preauthToken = String.fromEnvironment('TDC114_PREAUTH_TOKEN');
|
||||
const _preauthExpiresAt = String.fromEnvironment('TDC114_PREAUTH_EXPIRES_AT');
|
||||
const _preauthUserId = String.fromEnvironment('TDC114_PREAUTH_USER_ID');
|
||||
const _preauthUserName = String.fromEnvironment('TDC114_PREAUTH_USER_NAME');
|
||||
const _preauthUserPhone = String.fromEnvironment('TDC114_PREAUTH_USER_PHONE');
|
||||
const _preauthTenantId = String.fromEnvironment('TDC114_PREAUTH_TENANT_ID');
|
||||
const _preauthTenantName = String.fromEnvironment('TDC114_PREAUTH_TENANT_NAME');
|
||||
const _preauthTenantSlug = String.fromEnvironment('TDC114_PREAUTH_TENANT_SLUG');
|
||||
const _preauthDepartment = String.fromEnvironment('TDC114_PREAUTH_DEPARTMENT');
|
||||
const _preauthGrade = String.fromEnvironment('TDC114_PREAUTH_GRADE');
|
||||
const _preauthPosition = String.fromEnvironment('TDC114_PREAUTH_POSITION');
|
||||
const _preauthJobTitle = String.fromEnvironment('TDC114_PREAUTH_JOB_TITLE');
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
runApp(const ProviderScope(child: Tdc114PlusApp()));
|
||||
runApp(const _BootstrapApp());
|
||||
}
|
||||
|
||||
class _BootstrapApp extends StatefulWidget {
|
||||
const _BootstrapApp();
|
||||
|
||||
@override
|
||||
State<_BootstrapApp> createState() => _BootstrapAppState();
|
||||
}
|
||||
|
||||
class _BootstrapAppState extends State<_BootstrapApp> {
|
||||
late final Future<void> _bootstrapFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bootstrapFuture = _seedSmokeSessionIfConfigured();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<void>(
|
||||
future: _bootstrapFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('앱을 준비하고 있습니다...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (snapshot.hasError) {
|
||||
return MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('앱 시작 중 문제가 발생했습니다.'),
|
||||
const SizedBox(height: 12),
|
||||
Text('${snapshot.error}', textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ProviderScope(
|
||||
overrides: useSmokeMockDirectory
|
||||
? [smokeDirectoryOverride, smokeOrganizationOverride]
|
||||
: const [],
|
||||
child: const Tdc114PlusApp(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _seedSmokeSessionIfConfigured() async {
|
||||
if (_preauthToken.isEmpty ||
|
||||
_preauthExpiresAt.isEmpty ||
|
||||
_preauthUserId.isEmpty ||
|
||||
_preauthUserName.isEmpty ||
|
||||
_preauthUserPhone.isEmpty ||
|
||||
_preauthTenantId.isEmpty ||
|
||||
_preauthTenantName.isEmpty ||
|
||||
_preauthTenantSlug.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final expiresAt = DateTime.tryParse(_preauthExpiresAt);
|
||||
if (expiresAt == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await const AuthSessionStore().save(
|
||||
PhoneLoginResponse(
|
||||
status: 'ok',
|
||||
token: _preauthToken,
|
||||
expiresAt: expiresAt,
|
||||
user: LoginUser(
|
||||
id: _preauthUserId,
|
||||
name: _preauthUserName,
|
||||
phoneNumber: _preauthUserPhone,
|
||||
tenantId: _preauthTenantId,
|
||||
tenantName: _preauthTenantName,
|
||||
tenantSlug: _preauthTenantSlug,
|
||||
department: _valueOrNull(_preauthDepartment),
|
||||
grade: _valueOrNull(_preauthGrade),
|
||||
position: _valueOrNull(_preauthPosition),
|
||||
jobTitle: _valueOrNull(_preauthJobTitle),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String? _valueOrNull(String value) => value.trim().isEmpty ? null : value;
|
||||
|
||||
@@ -14,7 +14,7 @@ class Tdc114PlusApp extends StatelessWidget {
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF006D77)),
|
||||
useMaterial3: true,
|
||||
),
|
||||
routerConfig: appRouter,
|
||||
routerConfig: createAppRouter(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
abstract class ContactLauncher {
|
||||
const ContactLauncher();
|
||||
|
||||
Future<bool> call(String phoneNumber);
|
||||
|
||||
Future<bool> sms(String phoneNumber);
|
||||
}
|
||||
|
||||
class UrlLauncherContactLauncher implements ContactLauncher {
|
||||
const UrlLauncherContactLauncher();
|
||||
|
||||
@override
|
||||
Future<bool> call(String phoneNumber) async {
|
||||
final uri = buildCallUri(phoneNumber);
|
||||
if (uri == null) {
|
||||
return false;
|
||||
}
|
||||
return launchUrl(uri);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> sms(String phoneNumber) async {
|
||||
final uri = buildSmsUri(phoneNumber);
|
||||
if (uri == null) {
|
||||
return false;
|
||||
}
|
||||
return launchUrl(uri);
|
||||
}
|
||||
}
|
||||
|
||||
Uri? buildCallUri(String phoneNumber) {
|
||||
final normalized = _normalizePhoneNumber(phoneNumber);
|
||||
if (normalized == null) {
|
||||
return null;
|
||||
}
|
||||
return Uri(scheme: 'tel', path: normalized);
|
||||
}
|
||||
|
||||
Uri? buildSmsUri(String phoneNumber) {
|
||||
final normalized = _normalizePhoneNumber(phoneNumber);
|
||||
if (normalized == null) {
|
||||
return null;
|
||||
}
|
||||
return Uri(scheme: 'sms', path: normalized);
|
||||
}
|
||||
|
||||
String? _normalizePhoneNumber(String value) {
|
||||
final normalized = value.replaceAll(RegExp(r'[^0-9+]'), '');
|
||||
if (normalized.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
final contactLauncherProvider = Provider<ContactLauncher>((ref) {
|
||||
return const UrlLauncherContactLauncher();
|
||||
});
|
||||
@@ -1,22 +1,97 @@
|
||||
class AppEnvironment {
|
||||
const AppEnvironment({
|
||||
required this.ssoBaseUrl,
|
||||
required this.orgFrontBaseUrl,
|
||||
required this.apiBaseUrl,
|
||||
required this.authApiBaseUrl,
|
||||
required this.directoryApiBaseUrl,
|
||||
required this.organizationApiBaseUrl,
|
||||
required this.orgContextApiBaseUrl,
|
||||
required this.orgContextTenantSlug,
|
||||
required this.baronKeyId,
|
||||
required this.baronKeySecret,
|
||||
required this.appVersion,
|
||||
required this.buildTimestamp,
|
||||
});
|
||||
|
||||
factory AppEnvironment.fromDartDefine() {
|
||||
return const AppEnvironment(
|
||||
ssoBaseUrl: String.fromEnvironment(
|
||||
'SSO_BASE_URL',
|
||||
defaultValue: 'https://sso.example.invalid',
|
||||
const fallbackBaseUrl = 'https://sso.example.invalid';
|
||||
const ssoBaseUrl = String.fromEnvironment(
|
||||
'SSO_BASE_URL',
|
||||
defaultValue: 'https://sso.hmac.kr',
|
||||
);
|
||||
const sharedApiBaseUrl = String.fromEnvironment(
|
||||
'TDC114_API_BASE',
|
||||
defaultValue: fallbackBaseUrl,
|
||||
);
|
||||
const authApiBaseOverride = String.fromEnvironment(
|
||||
'TDC114_AUTH_API_BASE',
|
||||
defaultValue: '',
|
||||
);
|
||||
const directoryApiBaseOverride = String.fromEnvironment(
|
||||
'TDC114_DIRECTORY_API_BASE',
|
||||
defaultValue: '',
|
||||
);
|
||||
const organizationApiBaseOverride = String.fromEnvironment(
|
||||
'TDC114_ORGANIZATION_API_BASE',
|
||||
defaultValue: '',
|
||||
);
|
||||
const orgContextApiBaseOverride = String.fromEnvironment(
|
||||
'TDC114_ORG_CONTEXT_API_BASE',
|
||||
defaultValue: '',
|
||||
);
|
||||
const orgContextTenantSlug = String.fromEnvironment(
|
||||
'TDC114_ORG_CONTEXT_TENANT_SLUG',
|
||||
defaultValue: 'hanmac-family',
|
||||
);
|
||||
const baronKeyId = String.fromEnvironment(
|
||||
'TDC114_BARON_KEY_ID',
|
||||
defaultValue: '',
|
||||
);
|
||||
const baronKeySecret = String.fromEnvironment(
|
||||
'TDC114_BARON_KEY_SECRET',
|
||||
defaultValue: '',
|
||||
);
|
||||
|
||||
return AppEnvironment(
|
||||
ssoBaseUrl: ssoBaseUrl,
|
||||
apiBaseUrl: sharedApiBaseUrl,
|
||||
authApiBaseUrl: authApiBaseOverride.isEmpty
|
||||
? sharedApiBaseUrl
|
||||
: authApiBaseOverride,
|
||||
directoryApiBaseUrl: directoryApiBaseOverride.isEmpty
|
||||
? sharedApiBaseUrl
|
||||
: directoryApiBaseOverride,
|
||||
organizationApiBaseUrl: organizationApiBaseOverride.isEmpty
|
||||
? sharedApiBaseUrl
|
||||
: organizationApiBaseOverride,
|
||||
orgContextApiBaseUrl: orgContextApiBaseOverride.isEmpty
|
||||
? (organizationApiBaseOverride.isEmpty
|
||||
? sharedApiBaseUrl
|
||||
: organizationApiBaseOverride)
|
||||
: orgContextApiBaseOverride,
|
||||
orgContextTenantSlug: orgContextTenantSlug,
|
||||
baronKeyId: baronKeyId,
|
||||
baronKeySecret: baronKeySecret,
|
||||
appVersion: const String.fromEnvironment(
|
||||
'APP_VERSION',
|
||||
defaultValue: '0.1.0',
|
||||
),
|
||||
orgFrontBaseUrl: String.fromEnvironment(
|
||||
'ORGFRONT_BASE_URL',
|
||||
defaultValue: 'https://orgfront.example.invalid',
|
||||
buildTimestamp: const String.fromEnvironment(
|
||||
'TDC114_BUILD_TIME',
|
||||
defaultValue: '',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final String ssoBaseUrl;
|
||||
final String orgFrontBaseUrl;
|
||||
final String apiBaseUrl;
|
||||
final String authApiBaseUrl;
|
||||
final String directoryApiBaseUrl;
|
||||
final String organizationApiBaseUrl;
|
||||
final String orgContextApiBaseUrl;
|
||||
final String orgContextTenantSlug;
|
||||
final String baronKeyId;
|
||||
final String baronKeySecret;
|
||||
final String appVersion;
|
||||
final String buildTimestamp;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'app_environment.dart';
|
||||
|
||||
final appEnvironmentProvider = Provider<AppEnvironment>((ref) {
|
||||
return AppEnvironment.fromDartDefine();
|
||||
});
|
||||
@@ -21,3 +21,18 @@ class ApiError {
|
||||
return {'error': error, 'code': code, 'details': details};
|
||||
}
|
||||
}
|
||||
|
||||
class ApiException implements Exception {
|
||||
const ApiException({required this.statusCode, required this.apiError});
|
||||
|
||||
final int statusCode;
|
||||
final ApiError apiError;
|
||||
|
||||
String get code => apiError.code;
|
||||
String get message => apiError.error;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ApiException($statusCode, $code, $message)';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
final httpClientProvider = Provider<http.Client>((ref) {
|
||||
final client = http.Client();
|
||||
ref.onDispose(client.close);
|
||||
return client;
|
||||
});
|
||||
@@ -1,18 +1,25 @@
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../features/auth/presentation/auth_gate_screen.dart';
|
||||
import '../../features/auth/presentation/login_screen.dart';
|
||||
import '../../features/directory/presentation/directory_screen.dart';
|
||||
|
||||
final appRouter = GoRouter(
|
||||
initialLocation: LoginScreen.routePath,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: LoginScreen.routePath,
|
||||
builder: (context, state) => const LoginScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: DirectoryScreen.routePath,
|
||||
builder: (context, state) => const DirectoryScreen(),
|
||||
),
|
||||
],
|
||||
);
|
||||
GoRouter createAppRouter() {
|
||||
return GoRouter(
|
||||
initialLocation: AuthGateScreen.routePath,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AuthGateScreen.routePath,
|
||||
builder: (context, state) => const AuthGateScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: LoginScreen.routePath,
|
||||
builder: (context, state) => const LoginScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: DirectoryScreen.routePath,
|
||||
builder: (context, state) => const DirectoryScreen(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../core/network/api_error.dart';
|
||||
import '../domain/auth_models.dart';
|
||||
|
||||
class AuthApiClient {
|
||||
const AuthApiClient({
|
||||
required this.httpClient,
|
||||
required this.baseUri,
|
||||
this.timeout = const Duration(seconds: 10),
|
||||
});
|
||||
|
||||
final http.Client httpClient;
|
||||
final Uri baseUri;
|
||||
final Duration timeout;
|
||||
|
||||
Future<PhoneLoginResponse> phoneLogin(PhoneLoginRequest request) async {
|
||||
final response = await httpClient
|
||||
.post(
|
||||
_resolve('/api/v1/tdc114plus/auth/phone-login'),
|
||||
headers: const {
|
||||
'accept': 'application/json',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: jsonEncode(request.toJson()),
|
||||
)
|
||||
.timeout(timeout);
|
||||
|
||||
final decoded = _decodeObject(response.body);
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw ApiException(
|
||||
statusCode: response.statusCode,
|
||||
apiError: ApiError.fromJson(decoded),
|
||||
);
|
||||
}
|
||||
return PhoneLoginResponse.fromJson(decoded);
|
||||
}
|
||||
|
||||
Future<PhoneLoginLinkInitResponse> requestPhoneLoginLink(
|
||||
PhoneLoginLinkInitRequest request,
|
||||
) async {
|
||||
final response = await httpClient
|
||||
.post(
|
||||
_resolve('/api/v1/auth/link/init'),
|
||||
headers: const {
|
||||
'accept': 'application/json',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: jsonEncode(request.toJson()),
|
||||
)
|
||||
.timeout(timeout);
|
||||
|
||||
final decoded = _decodeObject(response.body);
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw ApiException(
|
||||
statusCode: response.statusCode,
|
||||
apiError: ApiError.fromJson(decoded),
|
||||
);
|
||||
}
|
||||
return PhoneLoginLinkInitResponse.fromJson(decoded);
|
||||
}
|
||||
|
||||
Future<PhoneLoginLinkPollResponse> pollPhoneLoginLink(
|
||||
PhoneLoginLinkPollRequest request,
|
||||
) async {
|
||||
final response = await httpClient
|
||||
.post(
|
||||
_resolve('/api/v1/auth/link/poll'),
|
||||
headers: const {
|
||||
'accept': 'application/json',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: jsonEncode(request.toJson()),
|
||||
)
|
||||
.timeout(timeout);
|
||||
|
||||
final decoded = _decodeObject(response.body);
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw ApiException(
|
||||
statusCode: response.statusCode,
|
||||
apiError: ApiError.fromJson(decoded),
|
||||
);
|
||||
}
|
||||
return PhoneLoginLinkPollResponse.fromJson(decoded);
|
||||
}
|
||||
|
||||
Uri _resolve(String path) {
|
||||
final normalizedBase = baseUri.path.endsWith('/')
|
||||
? baseUri
|
||||
: baseUri.replace(path: '${baseUri.path}/');
|
||||
return normalizedBase.resolve(path.replaceFirst(RegExp(r'^/'), ''));
|
||||
}
|
||||
|
||||
Map<String, dynamic> _decodeObject(String body) {
|
||||
final decoded = jsonDecode(body);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
return decoded;
|
||||
}
|
||||
throw const FormatException('Expected JSON object response');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/config/app_environment_provider.dart';
|
||||
import '../../../core/network/api_error.dart';
|
||||
import '../../../core/network/http_client_provider.dart';
|
||||
import '../domain/auth_models.dart';
|
||||
import 'auth_api_client.dart';
|
||||
import 'auth_session_store.dart';
|
||||
|
||||
abstract class AuthRepository {
|
||||
const AuthRepository();
|
||||
|
||||
Future<PhoneLoginLinkInitResponse> requestPhoneLoginLink(String phoneNumber);
|
||||
|
||||
Future<PhoneLoginLinkPollResponse> pollPhoneLoginLink(String pendingRef);
|
||||
|
||||
Future<PhoneLoginLinkInitResponse?> loadPendingPhoneLoginLink();
|
||||
|
||||
Future<void> clearPendingPhoneLoginLink();
|
||||
|
||||
Future<StoredAuthSession?> loadSession();
|
||||
|
||||
Future<void> logout();
|
||||
}
|
||||
|
||||
abstract class LegacyPhoneLoginRepository {
|
||||
const LegacyPhoneLoginRepository();
|
||||
|
||||
Future<PhoneLoginResponse> phoneLogin(String phoneNumber);
|
||||
}
|
||||
|
||||
class RemoteAuthRepository
|
||||
implements AuthRepository, LegacyPhoneLoginRepository {
|
||||
const RemoteAuthRepository({
|
||||
required this.apiClient,
|
||||
required this.sessionStore,
|
||||
required this.appVersion,
|
||||
});
|
||||
|
||||
final AuthApiClient apiClient;
|
||||
final AuthSessionStore sessionStore;
|
||||
final String appVersion;
|
||||
|
||||
@override
|
||||
Future<PhoneLoginResponse> phoneLogin(String phoneNumber) async {
|
||||
final normalizedPhone = _normalizePhoneNumber(phoneNumber);
|
||||
if (normalizedPhone.length < 9) {
|
||||
throw _invalidPhoneNumber();
|
||||
}
|
||||
|
||||
final response = await apiClient.phoneLogin(
|
||||
PhoneLoginRequest(
|
||||
phoneNumber: normalizedPhone,
|
||||
device: LoginDeviceInfo(
|
||||
platform: _platformName(),
|
||||
appVersion: appVersion,
|
||||
deviceName: _deviceName(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await sessionStore.save(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PhoneLoginLinkInitResponse> requestPhoneLoginLink(
|
||||
String phoneNumber,
|
||||
) async {
|
||||
final normalizedPhone = _normalizePhoneNumber(phoneNumber);
|
||||
if (normalizedPhone.length < 9) {
|
||||
throw _invalidPhoneNumber();
|
||||
}
|
||||
|
||||
final response = await apiClient.requestPhoneLoginLink(
|
||||
PhoneLoginLinkInitRequest(
|
||||
phoneNumber: normalizedPhone,
|
||||
device: LoginDeviceInfo(
|
||||
platform: _platformName(),
|
||||
appVersion: appVersion,
|
||||
deviceName: _deviceName(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await sessionStore.savePendingLink(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PhoneLoginLinkPollResponse> pollPhoneLoginLink(
|
||||
String pendingRef,
|
||||
) async {
|
||||
final response = await apiClient.pollPhoneLoginLink(
|
||||
PhoneLoginLinkPollRequest(pendingRef: pendingRef),
|
||||
);
|
||||
final session = response.session;
|
||||
debugPrint(
|
||||
'RemoteAuthRepository.pollPhoneLoginLink status=${response.status} hasSession=${session != null} token=${session?.token ?? ''} expiresAt=${session?.expiresAt.toUtc().toIso8601String() ?? ''}',
|
||||
);
|
||||
if (session != null) {
|
||||
await sessionStore.save(session);
|
||||
await sessionStore.clearPendingLink();
|
||||
} else if (response.isExpired) {
|
||||
await sessionStore.clearPendingLink();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PhoneLoginLinkInitResponse?> loadPendingPhoneLoginLink() {
|
||||
return sessionStore.loadPendingLink();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearPendingPhoneLoginLink() {
|
||||
return sessionStore.clearPendingLink();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<StoredAuthSession?> loadSession() => sessionStore.load();
|
||||
|
||||
@override
|
||||
Future<void> logout() => sessionStore.clear();
|
||||
|
||||
String _normalizePhoneNumber(String value) {
|
||||
return value.replaceAll(RegExp(r'[^0-9+]'), '');
|
||||
}
|
||||
|
||||
ApiException _invalidPhoneNumber() {
|
||||
return const ApiException(
|
||||
statusCode: 400,
|
||||
apiError: ApiError(
|
||||
error: '전화번호 형식을 확인해 주세요.',
|
||||
code: 'invalid_phone_number',
|
||||
details: {},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _platformName() {
|
||||
if (kIsWeb) {
|
||||
return 'web';
|
||||
}
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.android:
|
||||
return 'android';
|
||||
case TargetPlatform.iOS:
|
||||
return 'ios';
|
||||
case TargetPlatform.macOS:
|
||||
return 'macos';
|
||||
case TargetPlatform.windows:
|
||||
return 'windows';
|
||||
case TargetPlatform.linux:
|
||||
return 'linux';
|
||||
case TargetPlatform.fuchsia:
|
||||
return 'fuchsia';
|
||||
}
|
||||
}
|
||||
|
||||
String _deviceName() {
|
||||
if (kIsWeb) {
|
||||
return 'flutter-web';
|
||||
}
|
||||
return _platformName();
|
||||
}
|
||||
}
|
||||
|
||||
final authApiClientProvider = Provider<AuthApiClient>((ref) {
|
||||
final environment = ref.watch(appEnvironmentProvider);
|
||||
return AuthApiClient(
|
||||
httpClient: ref.watch(httpClientProvider),
|
||||
baseUri: Uri.parse(environment.authApiBaseUrl),
|
||||
);
|
||||
});
|
||||
|
||||
final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
||||
final environment = ref.watch(appEnvironmentProvider);
|
||||
return RemoteAuthRepository(
|
||||
apiClient: ref.watch(authApiClientProvider),
|
||||
sessionStore: ref.watch(authSessionStoreProvider),
|
||||
appVersion: environment.appVersion,
|
||||
);
|
||||
});
|
||||
|
||||
final legacyPhoneLoginRepositoryProvider = Provider<LegacyPhoneLoginRepository>(
|
||||
(ref) {
|
||||
final environment = ref.watch(appEnvironmentProvider);
|
||||
return RemoteAuthRepository(
|
||||
apiClient: ref.watch(authApiClientProvider),
|
||||
sessionStore: ref.watch(authSessionStoreProvider),
|
||||
appVersion: environment.appVersion,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,178 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../domain/auth_models.dart';
|
||||
|
||||
class AuthSessionStore {
|
||||
const AuthSessionStore();
|
||||
|
||||
static const _tokenKey = 'tdc114plus.auth.token';
|
||||
static const _expiresAtKey = 'tdc114plus.auth.expiresAt';
|
||||
static const _userKey = 'tdc114plus.auth.user';
|
||||
static const _orgContextCredentialKey =
|
||||
'tdc114plus.auth.orgContextCredential';
|
||||
static const _pendingRefKey = 'tdc114plus.auth.pending.ref';
|
||||
static const _pendingExpiresAtKey = 'tdc114plus.auth.pending.expiresAt';
|
||||
static const _pendingResendAfterAtKey =
|
||||
'tdc114plus.auth.pending.resendAfterAt';
|
||||
static const _pendingPollIntervalKey =
|
||||
'tdc114plus.auth.pending.pollInterval';
|
||||
static const _pendingProviderKey = 'tdc114plus.auth.pending.provider';
|
||||
|
||||
Future<void> save(PhoneLoginResponse response) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_tokenKey, response.token);
|
||||
await prefs.setString(
|
||||
_expiresAtKey,
|
||||
response.expiresAt.toUtc().toIso8601String(),
|
||||
);
|
||||
await prefs.setString(_userKey, jsonEncode(response.user.toJson()));
|
||||
final orgContextCredential = response.orgContextCredential;
|
||||
if (orgContextCredential == null) {
|
||||
await prefs.remove(_orgContextCredentialKey);
|
||||
} else {
|
||||
await prefs.setString(
|
||||
_orgContextCredentialKey,
|
||||
jsonEncode(orgContextCredential.toJson()),
|
||||
);
|
||||
}
|
||||
debugPrint(
|
||||
'AuthSessionStore.save token=${response.token} tokenLength=${response.token.length} expiresAt=${response.expiresAt.toUtc().toIso8601String()} orgContextCredential=${orgContextCredential != null}',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> savePendingLink(PhoneLoginLinkInitResponse response) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final now = DateTime.now().toUtc();
|
||||
await prefs.setString(_pendingRefKey, response.pendingRef);
|
||||
await prefs.setString(
|
||||
_pendingExpiresAtKey,
|
||||
now.add(Duration(seconds: response.expiresIn)).toIso8601String(),
|
||||
);
|
||||
await prefs.setString(
|
||||
_pendingResendAfterAtKey,
|
||||
now.add(Duration(seconds: response.resendAfter)).toIso8601String(),
|
||||
);
|
||||
await prefs.setInt(_pendingPollIntervalKey, response.interval);
|
||||
final provider = response.provider;
|
||||
if (provider == null || provider.trim().isEmpty) {
|
||||
await prefs.remove(_pendingProviderKey);
|
||||
} else {
|
||||
await prefs.setString(_pendingProviderKey, provider);
|
||||
}
|
||||
}
|
||||
|
||||
Future<PhoneLoginLinkInitResponse?> loadPendingLink() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final pendingRef = prefs.getString(_pendingRefKey);
|
||||
final expiresAtValue = prefs.getString(_pendingExpiresAtKey);
|
||||
if (pendingRef == null ||
|
||||
pendingRef.trim().isEmpty ||
|
||||
expiresAtValue == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final now = DateTime.now().toUtc();
|
||||
final expiresAt = DateTime.tryParse(expiresAtValue)?.toUtc();
|
||||
if (expiresAt == null || !expiresAt.isAfter(now)) {
|
||||
await clearPendingLink();
|
||||
return null;
|
||||
}
|
||||
|
||||
final resendAfterAt = DateTime.tryParse(
|
||||
prefs.getString(_pendingResendAfterAtKey) ?? '',
|
||||
)?.toUtc();
|
||||
final expiresIn = expiresAt.difference(now).inSeconds;
|
||||
final resendAfter = resendAfterAt == null || !resendAfterAt.isAfter(now)
|
||||
? 0
|
||||
: resendAfterAt.difference(now).inSeconds;
|
||||
return PhoneLoginLinkInitResponse(
|
||||
status: 'pending',
|
||||
pendingRef: pendingRef,
|
||||
expiresIn: expiresIn,
|
||||
interval: prefs.getInt(_pendingPollIntervalKey) ?? 3,
|
||||
resendAfter: resendAfter,
|
||||
provider: prefs.getString(_pendingProviderKey),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clearPendingLink() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_pendingRefKey);
|
||||
await prefs.remove(_pendingExpiresAtKey);
|
||||
await prefs.remove(_pendingResendAfterAtKey);
|
||||
await prefs.remove(_pendingPollIntervalKey);
|
||||
await prefs.remove(_pendingProviderKey);
|
||||
}
|
||||
|
||||
Future<StoredAuthSession?> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final token = prefs.getString(_tokenKey);
|
||||
final expiresAtValue = prefs.getString(_expiresAtKey);
|
||||
final userValue = prefs.getString(_userKey);
|
||||
final orgContextCredentialValue = prefs.getString(_orgContextCredentialKey);
|
||||
if (token == null || expiresAtValue == null || userValue == null) {
|
||||
debugPrint(
|
||||
'AuthSessionStore.load missing token=${token != null} expiresAt=${expiresAtValue != null} user=${userValue != null}',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
final session = StoredAuthSession(
|
||||
token: token,
|
||||
expiresAt: DateTime.parse(expiresAtValue),
|
||||
user: LoginUser.fromJson(jsonDecode(userValue) as Map<String, dynamic>),
|
||||
orgContextCredential: _decodeOrgContextCredential(
|
||||
orgContextCredentialValue,
|
||||
),
|
||||
);
|
||||
debugPrint(
|
||||
'AuthSessionStore.load token=${session.token} tokenLength=${session.token.length} expiresAt=${session.expiresAt.toUtc().toIso8601String()} expired=${session.isExpired} orgContextCredential=${session.orgContextCredential != null}',
|
||||
);
|
||||
return session;
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_tokenKey);
|
||||
await prefs.remove(_expiresAtKey);
|
||||
await prefs.remove(_userKey);
|
||||
await prefs.remove(_orgContextCredentialKey);
|
||||
await clearPendingLink();
|
||||
debugPrint('AuthSessionStore.clear');
|
||||
}
|
||||
|
||||
OrgContextCredential? _decodeOrgContextCredential(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return OrgContextCredential.fromJsonOrNull(jsonDecode(value));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class StoredAuthSession {
|
||||
const StoredAuthSession({
|
||||
required this.token,
|
||||
required this.expiresAt,
|
||||
required this.user,
|
||||
this.orgContextCredential,
|
||||
});
|
||||
|
||||
final String token;
|
||||
final DateTime expiresAt;
|
||||
final LoginUser user;
|
||||
final OrgContextCredential? orgContextCredential;
|
||||
|
||||
bool get isExpired => !expiresAt.isAfter(DateTime.now().toUtc());
|
||||
}
|
||||
|
||||
final authSessionStoreProvider = Provider<AuthSessionStore>((ref) {
|
||||
return const AuthSessionStore();
|
||||
});
|
||||
@@ -101,19 +101,24 @@ class PhoneLoginResponse {
|
||||
required this.token,
|
||||
required this.expiresAt,
|
||||
required this.user,
|
||||
this.orgContextCredential,
|
||||
});
|
||||
|
||||
final String status;
|
||||
final String token;
|
||||
final DateTime expiresAt;
|
||||
final LoginUser user;
|
||||
final OrgContextCredential? orgContextCredential;
|
||||
|
||||
factory PhoneLoginResponse.fromJson(Map<String, dynamic> json) {
|
||||
return PhoneLoginResponse(
|
||||
status: json['status'] as String? ?? '',
|
||||
token: json['token'] as String? ?? '',
|
||||
token: json['token'] as String? ?? json['accessToken'] as String? ?? '',
|
||||
expiresAt: DateTime.parse(json['expiresAt'] as String),
|
||||
user: LoginUser.fromJson(json['user'] as Map<String, dynamic>? ?? {}),
|
||||
orgContextCredential: OrgContextCredential.fromJsonOrNull(
|
||||
json['orgContextCredential'] ?? json['org_context'],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,10 +128,176 @@ class PhoneLoginResponse {
|
||||
'token': token,
|
||||
'expiresAt': expiresAt.toUtc().toIso8601String(),
|
||||
'user': user.toJson(),
|
||||
if (orgContextCredential != null)
|
||||
'orgContextCredential': orgContextCredential!.toJson(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class OrgContextCredential {
|
||||
const OrgContextCredential({
|
||||
required this.baseUrl,
|
||||
required this.tenantSlug,
|
||||
required this.keyId,
|
||||
required this.keySecret,
|
||||
this.expiresAt,
|
||||
});
|
||||
|
||||
final String baseUrl;
|
||||
final String tenantSlug;
|
||||
final String keyId;
|
||||
final String keySecret;
|
||||
final DateTime? expiresAt;
|
||||
|
||||
bool get isUsable {
|
||||
return baseUrl.trim().isNotEmpty &&
|
||||
tenantSlug.trim().isNotEmpty &&
|
||||
keyId.trim().isNotEmpty &&
|
||||
keySecret.trim().isNotEmpty &&
|
||||
(expiresAt == null || expiresAt!.isAfter(DateTime.now().toUtc()));
|
||||
}
|
||||
|
||||
factory OrgContextCredential.fromJson(Map<String, dynamic> json) {
|
||||
return OrgContextCredential(
|
||||
baseUrl: _readString(json, const ['baseUrl', 'base_url']),
|
||||
tenantSlug: _readString(json, const ['tenantSlug', 'tenant_slug']),
|
||||
keyId: _readString(json, const ['keyId', 'key_id']),
|
||||
keySecret: _readString(json, const ['keySecret', 'key_secret']),
|
||||
expiresAt: _readDate(json['expiresAt'] ?? json['expires_at']),
|
||||
);
|
||||
}
|
||||
|
||||
static OrgContextCredential? fromJsonOrNull(Object? value) {
|
||||
if (value is! Map) {
|
||||
return null;
|
||||
}
|
||||
final credential = OrgContextCredential.fromJson(
|
||||
Map<String, dynamic>.from(value),
|
||||
);
|
||||
return credential.isUsable ? credential : null;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'baseUrl': baseUrl,
|
||||
'tenantSlug': tenantSlug,
|
||||
'keyId': keyId,
|
||||
'keySecret': keySecret,
|
||||
if (expiresAt != null) 'expiresAt': expiresAt!.toUtc().toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
static String _readString(Map<String, dynamic> json, List<String> keys) {
|
||||
for (final key in keys) {
|
||||
final value = json[key];
|
||||
if (value is String && value.trim().isNotEmpty) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
static DateTime? _readDate(Object? value) {
|
||||
if (value is! String || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return DateTime.tryParse(value)?.toUtc();
|
||||
}
|
||||
}
|
||||
|
||||
class PhoneLoginLinkInitRequest {
|
||||
const PhoneLoginLinkInitRequest({
|
||||
required this.phoneNumber,
|
||||
required this.device,
|
||||
});
|
||||
|
||||
final String phoneNumber;
|
||||
final LoginDeviceInfo device;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {'phoneNumber': phoneNumber, 'device': device.toJson()};
|
||||
}
|
||||
}
|
||||
|
||||
class PhoneLoginLinkInitResponse {
|
||||
const PhoneLoginLinkInitResponse({
|
||||
required this.status,
|
||||
required this.pendingRef,
|
||||
required this.expiresIn,
|
||||
required this.interval,
|
||||
required this.resendAfter,
|
||||
this.provider,
|
||||
});
|
||||
|
||||
final String status;
|
||||
final String pendingRef;
|
||||
final int expiresIn;
|
||||
final int interval;
|
||||
final int resendAfter;
|
||||
final String? provider;
|
||||
|
||||
factory PhoneLoginLinkInitResponse.fromJson(Map<String, dynamic> json) {
|
||||
return PhoneLoginLinkInitResponse(
|
||||
status: json['status'] as String? ?? '',
|
||||
pendingRef: json['pendingRef'] as String? ?? '',
|
||||
expiresIn: json['expiresIn'] as int? ?? 180,
|
||||
interval: json['interval'] as int? ?? json['pollInterval'] as int? ?? 3,
|
||||
resendAfter: json['resendAfter'] as int? ?? 30,
|
||||
provider: json['provider'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PhoneLoginLinkPollRequest {
|
||||
const PhoneLoginLinkPollRequest({required this.pendingRef});
|
||||
|
||||
final String pendingRef;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {'pendingRef': pendingRef};
|
||||
}
|
||||
}
|
||||
|
||||
class PhoneLoginLinkPollResponse {
|
||||
const PhoneLoginLinkPollResponse({
|
||||
required this.status,
|
||||
this.code,
|
||||
this.interval,
|
||||
this.session,
|
||||
});
|
||||
|
||||
final String status;
|
||||
final String? code;
|
||||
final int? interval;
|
||||
final PhoneLoginResponse? session;
|
||||
|
||||
bool get isPending =>
|
||||
status == 'pending' ||
|
||||
code == 'authorization_pending' ||
|
||||
code == 'slow_down';
|
||||
|
||||
bool get isExpired => code == 'expired_token';
|
||||
|
||||
factory PhoneLoginLinkPollResponse.fromJson(Map<String, dynamic> json) {
|
||||
PhoneLoginResponse? session;
|
||||
if (json['session'] is Map<String, dynamic>) {
|
||||
session = PhoneLoginResponse.fromJson(
|
||||
json['session'] as Map<String, dynamic>,
|
||||
);
|
||||
} else if ((json['token'] != null || json['accessToken'] != null) &&
|
||||
json['expiresAt'] != null) {
|
||||
session = PhoneLoginResponse.fromJson(json);
|
||||
}
|
||||
|
||||
return PhoneLoginLinkPollResponse(
|
||||
status: json['status'] as String? ?? '',
|
||||
code: json['code'] as String?,
|
||||
interval: json['interval'] as int? ?? json['pollInterval'] as int?,
|
||||
session: session,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UserPermissions {
|
||||
const UserPermissions({
|
||||
required this.directory,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../data/auth_repository.dart';
|
||||
import '../data/auth_session_store.dart';
|
||||
import 'login_screen.dart';
|
||||
import '../../directory/presentation/directory_screen.dart';
|
||||
|
||||
class AuthGateScreen extends ConsumerStatefulWidget {
|
||||
const AuthGateScreen({super.key});
|
||||
|
||||
static const routePath = '/';
|
||||
|
||||
@override
|
||||
ConsumerState<AuthGateScreen> createState() => _AuthGateScreenState();
|
||||
}
|
||||
|
||||
class _AuthGateScreenState extends ConsumerState<AuthGateScreen> {
|
||||
var _resolved = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<StoredAuthSession?>(
|
||||
future: ref.read(authRepositoryProvider).loadSession(),
|
||||
builder: (context, snapshot) {
|
||||
if (!_resolved && snapshot.connectionState == ConnectionState.done) {
|
||||
_resolved = true;
|
||||
final router = GoRouter.of(context);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
final session = snapshot.data;
|
||||
if (session == null || session.isExpired) {
|
||||
if (session != null) {
|
||||
await ref.read(authRepositoryProvider).logout();
|
||||
}
|
||||
if (mounted) {
|
||||
router.go(LoginScreen.routePath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (mounted) {
|
||||
router.go(DirectoryScreen.routePath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return const Scaffold(
|
||||
body: SafeArea(child: Center(child: CircularProgressIndicator())),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +1,705 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../core/config/app_environment_provider.dart';
|
||||
import '../../../core/network/api_error.dart';
|
||||
import '../data/auth_repository.dart';
|
||||
import '../domain/auth_models.dart';
|
||||
import '../../directory/presentation/directory_screen.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
class LoginScreen extends ConsumerStatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
static const routePath = '/login';
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
with WidgetsBindingObserver {
|
||||
final _phoneController = TextEditingController();
|
||||
|
||||
Timer? _pollTimer;
|
||||
Timer? _countdownTimer;
|
||||
|
||||
var _isSubmitting = false;
|
||||
var _pollInFlight = false;
|
||||
String? _errorMessage;
|
||||
String? _statusMessage;
|
||||
PhoneLoginLinkInitResponse? _pendingLink;
|
||||
var _secondsUntilExpiry = 0;
|
||||
var _secondsUntilResend = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
unawaited(_restorePendingLink());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_pollTimer?.cancel();
|
||||
_countdownTimer?.cancel();
|
||||
_phoneController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
if (_phoneController.text.trim().isEmpty) {
|
||||
bool get _hasPendingLink => _pendingLink != null;
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state != AppLifecycleState.resumed || !_hasPendingLink) {
|
||||
return;
|
||||
}
|
||||
context.go(DirectoryScreen.routePath);
|
||||
_ensurePolling();
|
||||
unawaited(_pollPendingLink());
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final phoneNumber = _phoneController.text.trim();
|
||||
if (phoneNumber.isEmpty) {
|
||||
setState(() {
|
||||
_statusMessage = null;
|
||||
_errorMessage = '전화번호를 입력해 주세요.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
_pendingLink = null;
|
||||
_secondsUntilExpiry = 0;
|
||||
_secondsUntilResend = 0;
|
||||
_errorMessage = null;
|
||||
_statusMessage = '로그인 링크를 요청하고 있습니다.';
|
||||
});
|
||||
|
||||
try {
|
||||
_pollTimer?.cancel();
|
||||
_countdownTimer?.cancel();
|
||||
await ref.read(authRepositoryProvider).clearPendingPhoneLoginLink();
|
||||
|
||||
final response = await ref
|
||||
.read(authRepositoryProvider)
|
||||
.requestPhoneLoginLink(phoneNumber);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
_startPendingLinkFlow(response);
|
||||
setState(() {
|
||||
_statusMessage = '문자 링크를 누른 뒤 TDC114PLUS 앱으로 돌아오세요.';
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_statusMessage = null;
|
||||
_errorMessage = '로그인 링크 요청 실패: ${_messageForError(error)}';
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isSubmitting = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _restorePendingLink() async {
|
||||
final pendingLink = await ref
|
||||
.read(authRepositoryProvider)
|
||||
.loadPendingPhoneLoginLink();
|
||||
if (!mounted || pendingLink == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_pendingLink = pendingLink;
|
||||
_secondsUntilExpiry = pendingLink.expiresIn;
|
||||
_secondsUntilResend = pendingLink.resendAfter;
|
||||
_statusMessage = '이전 로그인 승인 상태를 확인하고 있습니다.';
|
||||
_errorMessage = null;
|
||||
});
|
||||
_startPendingTimers(pendingLink);
|
||||
unawaited(_pollPendingLink());
|
||||
}
|
||||
|
||||
void _startPendingLinkFlow(PhoneLoginLinkInitResponse response) {
|
||||
_pollTimer?.cancel();
|
||||
_countdownTimer?.cancel();
|
||||
|
||||
setState(() {
|
||||
_pendingLink = response;
|
||||
_secondsUntilExpiry = response.expiresIn;
|
||||
_secondsUntilResend = response.resendAfter;
|
||||
});
|
||||
|
||||
_startPendingTimers(response);
|
||||
}
|
||||
|
||||
void _startPendingTimers(PhoneLoginLinkInitResponse response) {
|
||||
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final nextExpiry = _secondsUntilExpiry > 0 ? _secondsUntilExpiry - 1 : 0;
|
||||
final nextResend = _secondsUntilResend > 0 ? _secondsUntilResend - 1 : 0;
|
||||
setState(() {
|
||||
_secondsUntilExpiry = nextExpiry;
|
||||
_secondsUntilResend = nextResend;
|
||||
});
|
||||
if (nextExpiry == 0) {
|
||||
_pollTimer?.cancel();
|
||||
_countdownTimer?.cancel();
|
||||
setState(() {
|
||||
_pendingLink = null;
|
||||
_statusMessage = null;
|
||||
_errorMessage = '로그인 링크 유효시간이 지났습니다. 다시 요청해 주세요.';
|
||||
});
|
||||
unawaited(
|
||||
ref.read(authRepositoryProvider).clearPendingPhoneLoginLink(),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
_ensurePolling();
|
||||
unawaited(_pollPendingLink());
|
||||
}
|
||||
|
||||
void _ensurePolling() {
|
||||
final pendingLink = _pendingLink;
|
||||
if (pendingLink == null || _pollTimer?.isActive == true) {
|
||||
return;
|
||||
}
|
||||
_pollTimer = Timer.periodic(
|
||||
Duration(seconds: pendingLink.interval.clamp(1, 30)),
|
||||
(_) => _pollPendingLink(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pollPendingLink() async {
|
||||
final pendingRef = _pendingLink?.pendingRef;
|
||||
if (pendingRef == null || _pollInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
_pollInFlight = true;
|
||||
try {
|
||||
final response = await ref
|
||||
.read(authRepositoryProvider)
|
||||
.pollPhoneLoginLink(pendingRef);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.session != null) {
|
||||
_pollTimer?.cancel();
|
||||
_countdownTimer?.cancel();
|
||||
setState(() {
|
||||
_pendingLink = null;
|
||||
_statusMessage = '로그인이 완료되었습니다. 앱 화면으로 이동합니다.';
|
||||
_errorMessage = null;
|
||||
});
|
||||
if (mounted) {
|
||||
context.go(DirectoryScreen.routePath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.isExpired) {
|
||||
_pollTimer?.cancel();
|
||||
_countdownTimer?.cancel();
|
||||
setState(() {
|
||||
_pendingLink = null;
|
||||
_statusMessage = null;
|
||||
_errorMessage = '로그인 링크 유효시간이 지났습니다. 다시 요청해 주세요.';
|
||||
});
|
||||
unawaited(
|
||||
ref.read(authRepositoryProvider).clearPendingPhoneLoginLink(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = null;
|
||||
_statusMessage = '문자 링크를 누른 뒤 TDC114PLUS 앱으로 돌아오세요.';
|
||||
});
|
||||
}
|
||||
|
||||
final nextInterval = response.interval;
|
||||
if (nextInterval != null &&
|
||||
_pendingLink != null &&
|
||||
nextInterval != _pendingLink!.interval) {
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = Timer.periodic(
|
||||
Duration(seconds: nextInterval.clamp(1, 30)),
|
||||
(_) => _pollPendingLink(),
|
||||
);
|
||||
setState(() {
|
||||
_pendingLink = PhoneLoginLinkInitResponse(
|
||||
status: _pendingLink!.status,
|
||||
pendingRef: _pendingLink!.pendingRef,
|
||||
expiresIn: _secondsUntilExpiry,
|
||||
interval: nextInterval,
|
||||
resendAfter: _secondsUntilResend,
|
||||
provider: _pendingLink!.provider,
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (error is ApiException &&
|
||||
(error.code == 'pending_ref_not_found' ||
|
||||
error.code == 'pending_ref_expired')) {
|
||||
_pollTimer?.cancel();
|
||||
_countdownTimer?.cancel();
|
||||
unawaited(
|
||||
ref.read(authRepositoryProvider).clearPendingPhoneLoginLink(),
|
||||
);
|
||||
setState(() {
|
||||
_pendingLink = null;
|
||||
_secondsUntilExpiry = 0;
|
||||
_secondsUntilResend = 0;
|
||||
_statusMessage = null;
|
||||
_errorMessage = '이전 로그인 요청이 만료되었습니다. 다시 보내기를 눌러 주세요.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_errorMessage = '승인 상태 확인 실패: ${_messageForError(error)}';
|
||||
});
|
||||
} finally {
|
||||
_pollInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _resetPendingState() {
|
||||
_pollTimer?.cancel();
|
||||
_countdownTimer?.cancel();
|
||||
unawaited(ref.read(authRepositoryProvider).clearPendingPhoneLoginLink());
|
||||
setState(() {
|
||||
_pendingLink = null;
|
||||
_secondsUntilExpiry = 0;
|
||||
_secondsUntilResend = 0;
|
||||
_statusMessage = null;
|
||||
_errorMessage = null;
|
||||
});
|
||||
}
|
||||
|
||||
String _messageForError(Object error) {
|
||||
if (error is ApiException) {
|
||||
final message = error.apiError.error.trim();
|
||||
if (message.isNotEmpty) {
|
||||
return message;
|
||||
}
|
||||
if (error.code.isNotEmpty) {
|
||||
return error.code;
|
||||
}
|
||||
return '서버 응답 오류(${error.statusCode})';
|
||||
}
|
||||
if (error is TimeoutException) {
|
||||
return '서버 응답 시간이 초과되었습니다.';
|
||||
}
|
||||
if (error is SocketException || error is http.ClientException) {
|
||||
return '인증 서버에 연결하지 못했습니다.';
|
||||
}
|
||||
if (error is Exception &&
|
||||
error.toString().contains('invalid_phone_number')) {
|
||||
return '전화번호 형식을 확인해 주세요.';
|
||||
}
|
||||
return '로그인 링크를 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final environment = ref.watch(appEnvironmentProvider);
|
||||
final buildTimestamp = environment.buildTimestamp.trim();
|
||||
final canResend = !_isSubmitting && _secondsUntilResend == 0;
|
||||
final keyboardInset = MediaQuery.viewInsetsOf(context).bottom;
|
||||
final keyboardVisible = keyboardInset > 0;
|
||||
const background = Color(0xFF030616);
|
||||
const surface = Color(0xFF10182A);
|
||||
const surfaceBorder = Color(0xFF1D2A44);
|
||||
const primary = Color(0xFFA8DEFF);
|
||||
const onDark = Color(0xFFE8F2FF);
|
||||
const muted = Color(0xFFA8B4C7);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('tdc114plus')),
|
||||
backgroundColor: background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: const Color(0xFF0E1729),
|
||||
foregroundColor: onDark,
|
||||
title: const Text('Baron SW 포털'),
|
||||
actions: const [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(Icons.dark_mode_outlined),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: keyboardVisible
|
||||
? AnimatedPadding(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
curve: Curves.easeOut,
|
||||
padding: EdgeInsets.only(bottom: keyboardInset),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
color: background,
|
||||
padding: const EdgeInsets.fromLTRB(24, 10, 24, 12),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
child: _buildLoginLinkButton(canResend: canResend),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
body: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
children: [
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
'Baron SSO 로그인',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Baron SSO에 등록된 전화번호를 입력하세요.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
TextField(
|
||||
controller: _phoneController,
|
||||
keyboardType: TextInputType.phone,
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
labelText: '전화번호',
|
||||
prefixIcon: Icon(Icons.phone_android),
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.fromLTRB(24, 24, 24, keyboardVisible ? 96 : 24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: surface,
|
||||
border: Border.all(color: surfaceBorder),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x66000000),
|
||||
blurRadius: 24,
|
||||
offset: Offset(0, 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 28, 24, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'TDC114PLUS',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'Baron SSO Login',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: muted,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const CircleAvatar(
|
||||
radius: 42,
|
||||
backgroundColor: Color(0xFF26334C),
|
||||
child: Icon(Icons.person, color: primary, size: 44),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'로그인 링크 발송',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineSmall
|
||||
?.copyWith(
|
||||
color: primary,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'문자 승인 후 앱으로 돌아오면 자동 로그인됩니다.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: muted, height: 1.5),
|
||||
),
|
||||
if (buildTimestamp.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'빌드 시각: $buildTimestamp',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Color(0x99FFFFFF),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0A5D43),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: const Color(0xFF2FA977)),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x553CD694),
|
||||
blurRadius: 22,
|
||||
offset: Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 14, 18, 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.phone_android,
|
||||
color: Colors.white,
|
||||
size: 44,
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _phoneController,
|
||||
enabled:
|
||||
!_hasPendingLink && !_isSubmitting,
|
||||
keyboardType: TextInputType.phone,
|
||||
inputFormatters: const [
|
||||
_PhoneNumberTextInputFormatter(),
|
||||
],
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
cursorColor: Colors.white,
|
||||
decoration: const InputDecoration(
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
counterText: '',
|
||||
hintText: '010-0000-0000',
|
||||
hintStyle: TextStyle(
|
||||
color: Color(0x66FFFFFF),
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Divider(
|
||||
height: 1,
|
||||
thickness: 3,
|
||||
indent: 72,
|
||||
endIndent: 12,
|
||||
color: Color(0x553CD694),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
'문자 수신 가능한 전화번호를 입력하세요.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Color(0xCCFFFFFF),
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_statusMessage != null) ...[
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
_statusMessage!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: primary, height: 1.5),
|
||||
),
|
||||
],
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFFF9CA3),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_hasPendingLink) ...[
|
||||
const SizedBox(height: 20),
|
||||
_PendingLinkStatus(
|
||||
expiresIn: _secondsUntilExpiry,
|
||||
resendAfter: _secondsUntilResend,
|
||||
provider: _pendingLink?.provider,
|
||||
),
|
||||
],
|
||||
if (!keyboardVisible) ...[
|
||||
const SizedBox(height: 24),
|
||||
_buildLoginLinkButton(canResend: canResend),
|
||||
if (_hasPendingLink) ...[
|
||||
const SizedBox(height: 10),
|
||||
OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: onDark,
|
||||
side: const BorderSide(color: Color(0xFF34425C)),
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
onPressed: _resetPendingState,
|
||||
child: const Text('다른 번호 입력'),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: _submit,
|
||||
icon: const Icon(Icons.login),
|
||||
label: const Text('로그인'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoginLinkButton({required bool canResend}) {
|
||||
const primary = Color(0xFFA8DEFF);
|
||||
return FilledButton.icon(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: primary,
|
||||
foregroundColor: const Color(0xFF07101F),
|
||||
disabledBackgroundColor: const Color(0xFF314158),
|
||||
disabledForegroundColor: const Color(0xFF91A0B6),
|
||||
minimumSize: const Size.fromHeight(54),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
onPressed: _isSubmitting || (_hasPendingLink && !canResend)
|
||||
? null
|
||||
: _submit,
|
||||
icon: _isSubmitting
|
||||
? const SizedBox.square(
|
||||
dimension: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Icon(_hasPendingLink ? Icons.refresh : Icons.link),
|
||||
label: Text(
|
||||
_isSubmitting
|
||||
? '로그인 링크 요청 중'
|
||||
: _hasPendingLink
|
||||
? '로그인 링크 다시 보내기'
|
||||
: '로그인 링크 보내기',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PhoneNumberTextInputFormatter extends TextInputFormatter {
|
||||
const _PhoneNumberTextInputFormatter();
|
||||
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
final digits = newValue.text.replaceAll(RegExp(r'[^0-9]'), '');
|
||||
final limited = digits.length > 11 ? digits.substring(0, 11) : digits;
|
||||
final formatted = _formatPhoneDigits(limited);
|
||||
return TextEditingValue(
|
||||
text: formatted,
|
||||
selection: TextSelection.collapsed(offset: formatted.length),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatPhoneDigits(String digits) {
|
||||
if (digits.length <= 3) {
|
||||
return digits;
|
||||
}
|
||||
if (digits.length <= 7) {
|
||||
return '${digits.substring(0, 3)}-${digits.substring(3)}';
|
||||
}
|
||||
return '${digits.substring(0, 3)}-${digits.substring(3, 7)}-${digits.substring(7)}';
|
||||
}
|
||||
}
|
||||
|
||||
class _PendingLinkStatus extends StatelessWidget {
|
||||
const _PendingLinkStatus({
|
||||
required this.expiresIn,
|
||||
required this.resendAfter,
|
||||
required this.provider,
|
||||
});
|
||||
|
||||
final int expiresIn;
|
||||
final int resendAfter;
|
||||
final String? provider;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF07101F),
|
||||
border: Border.all(color: const Color(0xFF263653)),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'승인 대기 중',
|
||||
style: TextStyle(
|
||||
color: Color(0xFFA8DEFF),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'문자 링크를 승인한 뒤 이 앱으로 돌아오면 자동으로 로그인됩니다.',
|
||||
style: TextStyle(color: Color(0xFFE8F2FF), height: 1.45),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'남은 유효시간: ${_formatDuration(expiresIn)}',
|
||||
style: const TextStyle(color: Color(0xFFA8B4C7)),
|
||||
),
|
||||
Text(
|
||||
'재전송 가능까지: ${_formatDuration(resendAfter)}',
|
||||
style: const TextStyle(color: Color(0xFFA8B4C7)),
|
||||
),
|
||||
if (provider != null && provider!.trim().isNotEmpty)
|
||||
Text(
|
||||
'인증 공급자: $provider',
|
||||
style: const TextStyle(color: Color(0xFFA8B4C7)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _formatDuration(int seconds) {
|
||||
final safeSeconds = seconds < 0 ? 0 : seconds;
|
||||
final minutes = safeSeconds ~/ 60;
|
||||
final remainingSeconds = safeSeconds % 60;
|
||||
return '${minutes.toString().padLeft(2, '0')}:${remainingSeconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../core/config/app_environment_provider.dart';
|
||||
import '../../../core/network/api_error.dart';
|
||||
import '../../../core/network/http_client_provider.dart';
|
||||
import '../../auth/data/auth_session_store.dart';
|
||||
import '../domain/employee.dart';
|
||||
|
||||
class DirectoryApiClient {
|
||||
const DirectoryApiClient({
|
||||
required this.httpClient,
|
||||
required this.baseUri,
|
||||
required this.sessionStore,
|
||||
this.timeout = const Duration(seconds: 10),
|
||||
});
|
||||
|
||||
final http.Client httpClient;
|
||||
final Uri baseUri;
|
||||
final AuthSessionStore sessionStore;
|
||||
final Duration timeout;
|
||||
|
||||
Future<EmployeeListResponse> listEmployees({
|
||||
String? query,
|
||||
String? tenantId,
|
||||
String? tenantSlug,
|
||||
String? department,
|
||||
int limit = 50,
|
||||
int offset = 0,
|
||||
String? cursor,
|
||||
}) async {
|
||||
final response = await httpClient
|
||||
.get(
|
||||
_resolve(
|
||||
'/api/v1/tdc114plus/directory/employees',
|
||||
queryParameters: {
|
||||
'q': query,
|
||||
'tenantId': tenantId,
|
||||
'tenantSlug': tenantSlug,
|
||||
'department': department,
|
||||
'limit': '$limit',
|
||||
'offset': '$offset',
|
||||
'cursor': cursor,
|
||||
},
|
||||
),
|
||||
headers: await _headers(),
|
||||
)
|
||||
.timeout(timeout);
|
||||
return EmployeeListResponse.fromJson(_decodeOk(response));
|
||||
}
|
||||
|
||||
Future<EmployeeDetail> getEmployee(String employeeId) async {
|
||||
final response = await httpClient
|
||||
.get(
|
||||
_resolve('/api/v1/tdc114plus/directory/employees/$employeeId'),
|
||||
headers: await _headers(),
|
||||
)
|
||||
.timeout(timeout);
|
||||
return EmployeeDetail.fromJson(_decodeOk(response));
|
||||
}
|
||||
|
||||
Future<Map<String, String>> _headers() async {
|
||||
final session = await sessionStore.load();
|
||||
return {
|
||||
'accept': 'application/json',
|
||||
if (session?.token != null) 'authorization': 'Bearer ${session!.token}',
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _decodeOk(http.Response response) {
|
||||
final decoded = _decodeObject(response.body);
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw ApiException(
|
||||
statusCode: response.statusCode,
|
||||
apiError: ApiError.fromJson(decoded),
|
||||
);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
Uri _resolve(String path, {Map<String, String?> queryParameters = const {}}) {
|
||||
final normalizedBase = baseUri.path.endsWith('/')
|
||||
? baseUri
|
||||
: baseUri.replace(path: '${baseUri.path}/');
|
||||
final uri = normalizedBase.resolve(path.replaceFirst(RegExp(r'^/'), ''));
|
||||
final filteredQuery = Map<String, String>.fromEntries(
|
||||
queryParameters.entries
|
||||
.where((entry) {
|
||||
return entry.value != null && entry.value!.trim().isNotEmpty;
|
||||
})
|
||||
.map((entry) => MapEntry(entry.key, entry.value!)),
|
||||
);
|
||||
return uri.replace(queryParameters: filteredQuery);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _decodeObject(String body) {
|
||||
final decoded = jsonDecode(body);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
return decoded;
|
||||
}
|
||||
throw const FormatException('Expected JSON object response');
|
||||
}
|
||||
}
|
||||
|
||||
final directoryApiClientProvider = Provider<DirectoryApiClient>((ref) {
|
||||
final environment = ref.watch(appEnvironmentProvider);
|
||||
return DirectoryApiClient(
|
||||
httpClient: ref.watch(httpClientProvider),
|
||||
baseUri: Uri.parse(environment.directoryApiBaseUrl),
|
||||
sessionStore: ref.watch(authSessionStoreProvider),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../organization/data/org_context_api_client.dart';
|
||||
import 'directory_api_client.dart';
|
||||
import '../domain/employee.dart';
|
||||
|
||||
class DirectoryQuery {
|
||||
const DirectoryQuery({
|
||||
required this.query,
|
||||
required this.tenantSlug,
|
||||
this.department,
|
||||
this.tenantSlugs = const <String>[],
|
||||
});
|
||||
|
||||
final String query;
|
||||
final String tenantSlug;
|
||||
final String? department;
|
||||
final List<String> tenantSlugs;
|
||||
|
||||
String? get apiQuery => query.trim().isEmpty ? null : query.trim();
|
||||
String? get apiTenantSlug => tenantSlug == 'all' ? null : tenantSlug;
|
||||
List<String>? get apiTenantSlugs {
|
||||
if (tenantSlugs.isNotEmpty) {
|
||||
return tenantSlugs;
|
||||
}
|
||||
final slug = apiTenantSlug;
|
||||
return slug == null ? null : [slug];
|
||||
}
|
||||
|
||||
String? get apiDepartment {
|
||||
if (department == null) {
|
||||
return null;
|
||||
}
|
||||
final normalized = department!.trim();
|
||||
return normalized.isEmpty ? null : normalized;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
other is DirectoryQuery &&
|
||||
other.query == query &&
|
||||
other.tenantSlug == tenantSlug &&
|
||||
other.department == department &&
|
||||
listEquals(other.tenantSlugs, tenantSlugs);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(query, tenantSlug, department, Object.hashAll(tenantSlugs));
|
||||
}
|
||||
|
||||
abstract class DirectoryRepository {
|
||||
const DirectoryRepository();
|
||||
|
||||
Future<List<Employee>> loadEmployees(DirectoryQuery query);
|
||||
}
|
||||
|
||||
abstract class EmployeeDetailRepository {
|
||||
const EmployeeDetailRepository();
|
||||
|
||||
Future<EmployeeDetail> loadEmployeeDetail(String employeeId);
|
||||
}
|
||||
|
||||
class RemoteDirectoryRepository implements DirectoryRepository {
|
||||
const RemoteDirectoryRepository({required this.orgContextApiClient});
|
||||
|
||||
final OrgContextApiClient orgContextApiClient;
|
||||
|
||||
@override
|
||||
Future<List<Employee>> loadEmployees(DirectoryQuery query) async {
|
||||
final snapshot = await orgContextApiClient.fetchOrgContext(
|
||||
tenantSlug: query.apiTenantSlug,
|
||||
);
|
||||
final employees = snapshot.employees.where((employee) {
|
||||
return _matchesTenant(employee, query.apiTenantSlugs) &&
|
||||
_matchesDepartment(employee, query.apiDepartment) &&
|
||||
_matchesQuery(employee, query.apiQuery);
|
||||
}).toList();
|
||||
|
||||
employees.sort(_compareEmployees);
|
||||
return employees;
|
||||
}
|
||||
|
||||
bool _matchesTenant(Employee employee, List<String>? tenantSlugs) {
|
||||
return tenantSlugs == null || tenantSlugs.contains(employee.tenantSlug);
|
||||
}
|
||||
|
||||
bool _matchesDepartment(Employee employee, String? department) {
|
||||
if (department == null) {
|
||||
return true;
|
||||
}
|
||||
return employee.department == department ||
|
||||
employee.tenantName == department;
|
||||
}
|
||||
|
||||
bool _matchesQuery(Employee employee, String? query) {
|
||||
if (query == null) {
|
||||
return true;
|
||||
}
|
||||
final normalizedQuery = query.toLowerCase();
|
||||
final queryDigits = query.replaceAll(RegExp(r'[^0-9]'), '');
|
||||
final fields = [
|
||||
employee.name,
|
||||
employee.phoneNumber,
|
||||
employee.phoneDisplay,
|
||||
employee.email,
|
||||
employee.tenantName,
|
||||
employee.department,
|
||||
employee.grade,
|
||||
employee.position,
|
||||
employee.jobTitle,
|
||||
].whereType<String>();
|
||||
|
||||
return fields.any((field) {
|
||||
final normalizedField = field.toLowerCase();
|
||||
if (normalizedField.contains(normalizedQuery)) {
|
||||
return true;
|
||||
}
|
||||
if (queryDigits.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
final fieldDigits = field.replaceAll(RegExp(r'[^0-9]'), '');
|
||||
return fieldDigits.contains(queryDigits);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class RemoteEmployeeDetailRepository implements EmployeeDetailRepository {
|
||||
const RemoteEmployeeDetailRepository({required this.directoryApiClient});
|
||||
|
||||
final DirectoryApiClient directoryApiClient;
|
||||
|
||||
@override
|
||||
Future<EmployeeDetail> loadEmployeeDetail(String employeeId) {
|
||||
return directoryApiClient.getEmployee(employeeId);
|
||||
}
|
||||
}
|
||||
|
||||
int _compareEmployees(Employee left, Employee right) {
|
||||
final tenantCompare = left.tenantName.compareTo(right.tenantName);
|
||||
if (tenantCompare != 0) {
|
||||
return tenantCompare;
|
||||
}
|
||||
|
||||
final departmentCompare = (left.department ?? '').compareTo(
|
||||
right.department ?? '',
|
||||
);
|
||||
if (departmentCompare != 0) {
|
||||
return departmentCompare;
|
||||
}
|
||||
|
||||
final leaderCompare = _leaderPriority(left).compareTo(
|
||||
_leaderPriority(right),
|
||||
);
|
||||
if (leaderCompare != 0) {
|
||||
return leaderCompare;
|
||||
}
|
||||
|
||||
final rankCompare = _rankPriority(left).compareTo(_rankPriority(right));
|
||||
if (rankCompare != 0) {
|
||||
return rankCompare;
|
||||
}
|
||||
|
||||
final nameCompare = left.name.compareTo(right.name);
|
||||
if (nameCompare != 0) {
|
||||
return nameCompare;
|
||||
}
|
||||
|
||||
return (left.sortOrder ?? 1 << 30).compareTo(right.sortOrder ?? 1 << 30);
|
||||
}
|
||||
|
||||
int _leaderPriority(Employee employee) {
|
||||
if (employee.isManager) {
|
||||
return 0;
|
||||
}
|
||||
final position = employee.position?.trim() ?? '';
|
||||
return position.contains('팀장') ? 0 : 1;
|
||||
}
|
||||
|
||||
int _rankPriority(Employee employee) {
|
||||
const priorityByToken = {
|
||||
'사장': 0,
|
||||
'부사장': 1,
|
||||
'수석': 2,
|
||||
'전무': 3,
|
||||
'상무': 4,
|
||||
'이사': 5,
|
||||
'책임': 6,
|
||||
'부장': 7,
|
||||
'선임': 8,
|
||||
'과장': 9,
|
||||
'대리': 10,
|
||||
'사원': 11,
|
||||
};
|
||||
final values = [
|
||||
employee.grade?.trim() ?? '',
|
||||
employee.position?.trim() ?? '',
|
||||
employee.jobTitle?.trim() ?? '',
|
||||
];
|
||||
for (final value in values) {
|
||||
for (final entry in priorityByToken.entries) {
|
||||
if (value.contains(entry.key)) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return priorityByToken.length + 1;
|
||||
}
|
||||
|
||||
final directoryRepositoryProvider = Provider<DirectoryRepository>((ref) {
|
||||
return RemoteDirectoryRepository(
|
||||
orgContextApiClient: ref.watch(orgContextApiClientProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final employeeDetailRepositoryProvider = Provider<EmployeeDetailRepository>((ref) {
|
||||
return RemoteEmployeeDetailRepository(
|
||||
directoryApiClient: ref.watch(directoryApiClientProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final directoryEmployeesProvider = FutureProvider.autoDispose
|
||||
.family<List<Employee>, DirectoryQuery>((ref, query) {
|
||||
return ref.watch(directoryRepositoryProvider).loadEmployees(query);
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../core/config/app_environment_provider.dart';
|
||||
import '../../../core/network/http_client_provider.dart';
|
||||
import '../../auth/data/auth_session_store.dart';
|
||||
import '../domain/employee.dart';
|
||||
|
||||
class ProfileImageApiClient {
|
||||
ProfileImageApiClient({
|
||||
required this.httpClient,
|
||||
required this.baseUri,
|
||||
required this.sessionStore,
|
||||
this.timeout = const Duration(seconds: 5),
|
||||
this.cacheTtl = const Duration(minutes: 10),
|
||||
});
|
||||
|
||||
static final Map<String, _ProfileImageCacheEntry> _cache = {};
|
||||
static final RegExp _uuidPattern = RegExp(
|
||||
r'^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
final http.Client httpClient;
|
||||
final Uri baseUri;
|
||||
final AuthSessionStore sessionStore;
|
||||
final Duration timeout;
|
||||
final Duration cacheTtl;
|
||||
|
||||
Future<String?> resolveImageUrl(Employee employee) async {
|
||||
final explicitUrl = employee.profileImageUrl?.trim() ?? '';
|
||||
if (explicitUrl.isNotEmpty) {
|
||||
return explicitUrl;
|
||||
}
|
||||
|
||||
final uuidFallbackUrl = _uuidFallbackUrl(employee.id);
|
||||
|
||||
final email = employee.email?.trim().toLowerCase() ?? '';
|
||||
if (email.isEmpty) {
|
||||
_debug(
|
||||
'skip auth lookup: employee=${employee.name}, id=${employee.id}, email missing, uuidFallback=${uuidFallbackUrl != null}',
|
||||
);
|
||||
return uuidFallbackUrl;
|
||||
}
|
||||
|
||||
final session = await sessionStore.load();
|
||||
if (session == null || session.isExpired || session.token.trim().isEmpty) {
|
||||
_debug(
|
||||
'skip auth lookup: employee=${employee.name}, email=$email, session unavailable, uuidFallback=${uuidFallbackUrl != null}',
|
||||
);
|
||||
return uuidFallbackUrl;
|
||||
}
|
||||
|
||||
final companyCode = _resolveCompanyCode(employee, email: email);
|
||||
final cacheKey = '${companyCode ?? ''}|$email';
|
||||
final now = DateTime.now().toUtc();
|
||||
final cached = _cache[cacheKey];
|
||||
if (cached != null && cached.expiresAt.isAfter(now)) {
|
||||
final resolved = await cached.future;
|
||||
return resolved ?? uuidFallbackUrl;
|
||||
}
|
||||
|
||||
final future = _fetchImageUrl(
|
||||
companyCode: companyCode,
|
||||
email: email,
|
||||
accessToken: session.token.trim(),
|
||||
);
|
||||
_cache[cacheKey] = _ProfileImageCacheEntry(
|
||||
future: future,
|
||||
expiresAt: now.add(cacheTtl),
|
||||
);
|
||||
try {
|
||||
final resolved = await future;
|
||||
if (resolved == null && uuidFallbackUrl != null) {
|
||||
_debug(
|
||||
'auth lookup empty: employee=${employee.name}, email=$email, fallback to uuid url',
|
||||
);
|
||||
}
|
||||
return resolved ?? uuidFallbackUrl;
|
||||
} catch (_) {
|
||||
if (identical(_cache[cacheKey]?.future, future)) {
|
||||
_cache.remove(cacheKey);
|
||||
}
|
||||
if (uuidFallbackUrl != null) {
|
||||
_debug(
|
||||
'auth lookup failed: employee=${employee.name}, email=$email, fallback to uuid url',
|
||||
);
|
||||
}
|
||||
return uuidFallbackUrl;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _fetchImageUrl({
|
||||
required String? companyCode,
|
||||
required String email,
|
||||
required String accessToken,
|
||||
}) async {
|
||||
final appSessionToken = accessToken.trim();
|
||||
final queryParameters = <String, String>{'email': email};
|
||||
if (companyCode != null && companyCode.isNotEmpty) {
|
||||
queryParameters['comp'] = companyCode;
|
||||
}
|
||||
|
||||
_debug(
|
||||
'request profile image: email=$email, comp=${companyCode ?? ''}, base=${baseUri.toString()}',
|
||||
);
|
||||
|
||||
final response = await httpClient
|
||||
.get(
|
||||
_resolve('/api/v1/profile-image', queryParameters: queryParameters),
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
if (appSessionToken.isNotEmpty)
|
||||
'Authorization': 'Bearer $appSessionToken',
|
||||
if (appSessionToken.isNotEmpty) 'X-App-Session': appSessionToken,
|
||||
},
|
||||
)
|
||||
.timeout(timeout);
|
||||
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
_debug(
|
||||
'profile image response not ok: email=$email, status=${response.statusCode}',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final found = decoded['found'] as bool? ?? false;
|
||||
final imageUrl = decoded['imageUrl'] as String? ?? '';
|
||||
if (!found || imageUrl.trim().isEmpty) {
|
||||
_debug('profile image not found: email=$email');
|
||||
return null;
|
||||
}
|
||||
_debug('profile image resolved: email=$email, url=${imageUrl.trim()}');
|
||||
return imageUrl.trim();
|
||||
}
|
||||
|
||||
Uri _resolve(
|
||||
String path, {
|
||||
Map<String, String> queryParameters = const {},
|
||||
}) {
|
||||
final normalizedBase = baseUri.path.endsWith('/')
|
||||
? baseUri
|
||||
: baseUri.replace(path: '${baseUri.path}/');
|
||||
final resolved = normalizedBase.resolve(path.replaceFirst(RegExp(r'^/'), ''));
|
||||
return resolved.replace(queryParameters: queryParameters);
|
||||
}
|
||||
|
||||
String? _uuidFallbackUrl(String employeeId) {
|
||||
final normalized = employeeId.trim();
|
||||
if (!_uuidPattern.hasMatch(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return 'https://baroncs.co.kr/employee_img/$normalized.jpg';
|
||||
}
|
||||
|
||||
static void _debug(String message) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[ProfileImageApiClient] $message');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String? _resolveCompanyCode(Employee employee, {required String email}) {
|
||||
final tenantSlug = employee.tenantSlug.trim().toLowerCase();
|
||||
final tenantName = employee.tenantName.trim().toLowerCase();
|
||||
|
||||
if (tenantSlug.contains('saman') || tenantName.contains('삼안')) {
|
||||
return 'SAMAN';
|
||||
}
|
||||
if (tenantSlug.contains('hanmac') || tenantName.contains('한맥')) {
|
||||
return 'HANMAC';
|
||||
}
|
||||
if (tenantSlug == 'ptc' || tenantName == 'ptc') {
|
||||
return 'PTC';
|
||||
}
|
||||
if (tenantSlug == 'tdc' || tenantName == 'tdc') {
|
||||
return 'TDC';
|
||||
}
|
||||
|
||||
if (email.endsWith('@samaneng.com')) {
|
||||
return 'SAMAN';
|
||||
}
|
||||
if (email.endsWith('@hanmaceng.co.kr')) {
|
||||
return 'HANMAC';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
class _ProfileImageCacheEntry {
|
||||
const _ProfileImageCacheEntry({
|
||||
required this.future,
|
||||
required this.expiresAt,
|
||||
});
|
||||
|
||||
final Future<String?> future;
|
||||
final DateTime expiresAt;
|
||||
}
|
||||
|
||||
final profileImageApiClientProvider = Provider<ProfileImageApiClient>((ref) {
|
||||
final environment = ref.watch(appEnvironmentProvider);
|
||||
return ProfileImageApiClient(
|
||||
httpClient: ref.watch(httpClientProvider),
|
||||
baseUri: Uri.parse(environment.authApiBaseUrl),
|
||||
sessionStore: ref.watch(authSessionStoreProvider),
|
||||
);
|
||||
});
|
||||
@@ -15,6 +15,7 @@ class Employee {
|
||||
this.status,
|
||||
this.profileImageUrl,
|
||||
this.sortOrder,
|
||||
this.isManager = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
@@ -32,6 +33,7 @@ class Employee {
|
||||
final String? status;
|
||||
final String? profileImageUrl;
|
||||
final int? sortOrder;
|
||||
final bool isManager;
|
||||
|
||||
factory Employee.fromJson(Map<String, dynamic> json) {
|
||||
return Employee(
|
||||
@@ -50,6 +52,7 @@ class Employee {
|
||||
status: json['status'] as String?,
|
||||
profileImageUrl: json['profileImageUrl'] as String?,
|
||||
sortOrder: json['sortOrder'] as int?,
|
||||
isManager: json['isManager'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,6 +73,7 @@ class Employee {
|
||||
'status': status,
|
||||
'profileImageUrl': profileImageUrl,
|
||||
'sortOrder': sortOrder,
|
||||
'isManager': isManager,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -151,6 +155,7 @@ class EmployeeDetail extends Employee {
|
||||
super.status,
|
||||
super.profileImageUrl,
|
||||
super.sortOrder,
|
||||
super.isManager,
|
||||
required this.joinedTenants,
|
||||
required this.actions,
|
||||
});
|
||||
@@ -175,6 +180,7 @@ class EmployeeDetail extends Employee {
|
||||
status: json['status'] as String?,
|
||||
profileImageUrl: json['profileImageUrl'] as String?,
|
||||
sortOrder: json['sortOrder'] as int?,
|
||||
isManager: json['isManager'] as bool? ?? false,
|
||||
joinedTenants: (json['joinedTenants'] as List<dynamic>? ?? [])
|
||||
.map((item) => TenantRef.fromJson(item as Map<String, dynamic>))
|
||||
.toList(),
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import '../../organization/domain/organization_models.dart';
|
||||
|
||||
class TenantNavigationModel {
|
||||
const TenantNavigationModel({
|
||||
required this.selectedTenant,
|
||||
required this.selectedCompanyTenant,
|
||||
required this.pinnedTenant,
|
||||
required this.companyTenants,
|
||||
required this.breadcrumbTenants,
|
||||
required this.visibleChips,
|
||||
required this.childTenants,
|
||||
required this.selectedTenantSubtreeSlugs,
|
||||
});
|
||||
|
||||
final TenantSummary? selectedTenant;
|
||||
final TenantSummary? selectedCompanyTenant;
|
||||
final TenantSummary? pinnedTenant;
|
||||
final List<TenantSummary> companyTenants;
|
||||
final List<TenantSummary> breadcrumbTenants;
|
||||
final List<TenantSummary> visibleChips;
|
||||
final List<TenantSummary> childTenants;
|
||||
final List<String> selectedTenantSubtreeSlugs;
|
||||
|
||||
bool get showsCompanyDirectory => selectedTenant == null;
|
||||
bool get hasChildren => childTenants.isNotEmpty;
|
||||
}
|
||||
|
||||
TenantNavigationModel buildTenantNavigationModel({
|
||||
required List<TenantSummary> tenants,
|
||||
required String selectedTenantSlug,
|
||||
required String pinnedTenantSlug,
|
||||
}) {
|
||||
final selectedTenant = _findTenantBySlug(tenants, selectedTenantSlug);
|
||||
final pinnedTenant = _findTenantBySlug(tenants, pinnedTenantSlug);
|
||||
final companyTenants = [
|
||||
for (final tenant in tenants)
|
||||
if (tenant.type == 'COMPANY') tenant,
|
||||
];
|
||||
final breadcrumbTenants = selectedTenant == null
|
||||
? const <TenantSummary>[]
|
||||
: _buildBreadcrumbTenants(tenants, selectedTenant);
|
||||
final selectedCompanyTenant = _resolveSelectedCompanyTenant(
|
||||
selectedTenant: selectedTenant,
|
||||
breadcrumbTenants: breadcrumbTenants,
|
||||
);
|
||||
final childTenants = selectedTenant == null
|
||||
? companyTenants
|
||||
: [
|
||||
for (final tenant in tenants)
|
||||
if (tenant.parentId == selectedTenant.id) tenant,
|
||||
];
|
||||
final selectedTenantSubtreeSlugs = selectedTenant == null
|
||||
? const <String>[]
|
||||
: buildTenantSubtreeSlugs(tenants: tenants, rootTenant: selectedTenant);
|
||||
|
||||
final visibleChips = <TenantSummary>[];
|
||||
final seenSlugs = <String>{};
|
||||
|
||||
void addChip(TenantSummary tenant) {
|
||||
if (tenant.slug.trim().isEmpty || !seenSlugs.add(tenant.slug)) {
|
||||
return;
|
||||
}
|
||||
visibleChips.add(tenant);
|
||||
}
|
||||
|
||||
for (final tenant in companyTenants) {
|
||||
addChip(tenant);
|
||||
}
|
||||
|
||||
if (pinnedTenant != null && pinnedTenant.type != 'COMPANY') {
|
||||
addChip(pinnedTenant);
|
||||
}
|
||||
|
||||
for (final tenant in breadcrumbTenants) {
|
||||
if (tenant.type != 'COMPANY') {
|
||||
addChip(tenant);
|
||||
}
|
||||
}
|
||||
|
||||
return TenantNavigationModel(
|
||||
selectedTenant: selectedTenant,
|
||||
selectedCompanyTenant: selectedCompanyTenant,
|
||||
pinnedTenant: pinnedTenant,
|
||||
companyTenants: companyTenants,
|
||||
breadcrumbTenants: breadcrumbTenants,
|
||||
visibleChips: visibleChips,
|
||||
childTenants: childTenants,
|
||||
selectedTenantSubtreeSlugs: selectedTenantSubtreeSlugs,
|
||||
);
|
||||
}
|
||||
|
||||
List<String> buildTenantSubtreeSlugs({
|
||||
required List<TenantSummary> tenants,
|
||||
required TenantSummary rootTenant,
|
||||
}) {
|
||||
final tenantsByParentId = <String, List<TenantSummary>>{};
|
||||
for (final tenant in tenants) {
|
||||
final parentId = tenant.parentId;
|
||||
if (parentId == null || parentId.trim().isEmpty) {
|
||||
continue;
|
||||
}
|
||||
tenantsByParentId
|
||||
.putIfAbsent(parentId, () => <TenantSummary>[])
|
||||
.add(tenant);
|
||||
}
|
||||
|
||||
final slugs = <String>[];
|
||||
final queue = <TenantSummary>[rootTenant];
|
||||
final visitedIds = <String>{};
|
||||
while (queue.isNotEmpty) {
|
||||
final current = queue.removeAt(0);
|
||||
if (!visitedIds.add(current.id)) {
|
||||
continue;
|
||||
}
|
||||
if (current.slug.trim().isNotEmpty) {
|
||||
slugs.add(current.slug);
|
||||
}
|
||||
queue.addAll(tenantsByParentId[current.id] ?? const <TenantSummary>[]);
|
||||
}
|
||||
return slugs;
|
||||
}
|
||||
|
||||
String resolvePinnedTenantSlug({
|
||||
required List<TenantSummary> tenants,
|
||||
required String companySlug,
|
||||
required String? departmentName,
|
||||
}) {
|
||||
final normalizedDepartment = departmentName?.trim();
|
||||
if (normalizedDepartment == null || normalizedDepartment.isEmpty) {
|
||||
return companySlug;
|
||||
}
|
||||
|
||||
final companyTenant = _findTenantBySlug(tenants, companySlug);
|
||||
if (companyTenant == null) {
|
||||
return companySlug;
|
||||
}
|
||||
|
||||
final match = _findDescendantTenantByName(
|
||||
tenants: tenants,
|
||||
rootTenantId: companyTenant.id,
|
||||
tenantName: normalizedDepartment,
|
||||
);
|
||||
return match?.slug ?? companySlug;
|
||||
}
|
||||
|
||||
List<TenantSummary> _buildBreadcrumbTenants(
|
||||
List<TenantSummary> tenants,
|
||||
TenantSummary selectedTenant,
|
||||
) {
|
||||
final tenantsById = {for (final tenant in tenants) tenant.id: tenant};
|
||||
final reversed = <TenantSummary>[selectedTenant];
|
||||
var current = selectedTenant;
|
||||
while (current.parentId != null && current.parentId!.trim().isNotEmpty) {
|
||||
final parent = tenantsById[current.parentId!];
|
||||
if (parent == null) {
|
||||
break;
|
||||
}
|
||||
reversed.add(parent);
|
||||
current = parent;
|
||||
}
|
||||
return reversed.reversed.toList(growable: false);
|
||||
}
|
||||
|
||||
TenantSummary? _findTenantBySlug(List<TenantSummary> tenants, String slug) {
|
||||
for (final tenant in tenants) {
|
||||
if (tenant.slug == slug) {
|
||||
return tenant;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
TenantSummary? _resolveSelectedCompanyTenant({
|
||||
required TenantSummary? selectedTenant,
|
||||
required List<TenantSummary> breadcrumbTenants,
|
||||
}) {
|
||||
if (selectedTenant == null) {
|
||||
return null;
|
||||
}
|
||||
if (selectedTenant.type == 'COMPANY') {
|
||||
return selectedTenant;
|
||||
}
|
||||
for (final tenant in breadcrumbTenants) {
|
||||
if (tenant.type == 'COMPANY') {
|
||||
return tenant;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
TenantSummary? _findDescendantTenantByName({
|
||||
required List<TenantSummary> tenants,
|
||||
required String rootTenantId,
|
||||
required String tenantName,
|
||||
}) {
|
||||
final normalizedName = tenantName.trim().toLowerCase();
|
||||
if (normalizedName.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final tenantsByParentId = <String, List<TenantSummary>>{};
|
||||
for (final tenant in tenants) {
|
||||
final parentId = tenant.parentId;
|
||||
if (parentId == null || parentId.trim().isEmpty) {
|
||||
continue;
|
||||
}
|
||||
tenantsByParentId
|
||||
.putIfAbsent(parentId, () => <TenantSummary>[])
|
||||
.add(tenant);
|
||||
}
|
||||
|
||||
final queue = <String>[rootTenantId];
|
||||
final visited = <String>{};
|
||||
while (queue.isNotEmpty) {
|
||||
final currentId = queue.removeAt(0);
|
||||
if (!visited.add(currentId)) {
|
||||
continue;
|
||||
}
|
||||
for (final tenant
|
||||
in tenantsByParentId[currentId] ?? const <TenantSummary>[]) {
|
||||
if (tenant.name.trim().toLowerCase() == normalizedName) {
|
||||
return tenant;
|
||||
}
|
||||
queue.add(tenant.id);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../domain/favorite_employee.dart';
|
||||
|
||||
abstract class FavoritesRepository {
|
||||
const FavoritesRepository();
|
||||
|
||||
Future<List<FavoriteEmployee>> loadFavorites();
|
||||
|
||||
Future<void> toggleFavorite(String employeeId);
|
||||
}
|
||||
|
||||
class SharedPreferencesFavoritesRepository implements FavoritesRepository {
|
||||
SharedPreferencesFavoritesRepository({
|
||||
required SharedPreferencesAsync preferences,
|
||||
}) : this.withStore(SharedPreferencesFavoritesStore(preferences));
|
||||
|
||||
const SharedPreferencesFavoritesRepository.withStore(this.store);
|
||||
|
||||
static const _storageKey = 'tdc114plus.favoriteEmployees';
|
||||
|
||||
final FavoritesKeyValueStore store;
|
||||
|
||||
@override
|
||||
Future<List<FavoriteEmployee>> loadFavorites() async {
|
||||
final encoded = await store.getString(_storageKey);
|
||||
if (encoded == null || encoded.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(encoded) as List<dynamic>;
|
||||
return decoded
|
||||
.map((item) => FavoriteEmployee.fromJson(item as Map<String, dynamic>))
|
||||
.where((favorite) => favorite.employeeId.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> toggleFavorite(String employeeId) async {
|
||||
final normalizedId = employeeId.trim();
|
||||
if (normalizedId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final favorites = await loadFavorites();
|
||||
final nextFavorites = favorites
|
||||
.where((favorite) => favorite.employeeId != normalizedId)
|
||||
.toList();
|
||||
if (nextFavorites.length == favorites.length) {
|
||||
nextFavorites.add(
|
||||
FavoriteEmployee(employeeId: normalizedId, createdAt: DateTime.now()),
|
||||
);
|
||||
}
|
||||
|
||||
await store.setString(
|
||||
_storageKey,
|
||||
jsonEncode(nextFavorites.map((favorite) => favorite.toJson()).toList()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class FavoritesKeyValueStore {
|
||||
const FavoritesKeyValueStore();
|
||||
|
||||
Future<String?> getString(String key);
|
||||
|
||||
Future<void> setString(String key, String value);
|
||||
}
|
||||
|
||||
class SharedPreferencesFavoritesStore implements FavoritesKeyValueStore {
|
||||
const SharedPreferencesFavoritesStore(this._preferences);
|
||||
|
||||
final SharedPreferencesAsync _preferences;
|
||||
|
||||
@override
|
||||
Future<String?> getString(String key) {
|
||||
return _preferences.getString(key);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setString(String key, String value) {
|
||||
return _preferences.setString(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
final favoritesRepositoryProvider = Provider<FavoritesRepository>((ref) {
|
||||
return SharedPreferencesFavoritesRepository(
|
||||
preferences: SharedPreferencesAsync(),
|
||||
);
|
||||
});
|
||||
|
||||
final favoriteEmployeeIdsProvider = FutureProvider<Set<String>>((ref) async {
|
||||
final favorites = await ref
|
||||
.watch(favoritesRepositoryProvider)
|
||||
.loadFavorites();
|
||||
return favorites.map((favorite) => favorite.employeeId).toSet();
|
||||
});
|
||||
@@ -0,0 +1,503 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../core/config/app_environment_provider.dart';
|
||||
import '../../../core/network/api_error.dart';
|
||||
import '../../../core/network/http_client_provider.dart';
|
||||
import '../../directory/domain/employee.dart';
|
||||
import '../../auth/data/auth_session_store.dart';
|
||||
import '../domain/organization_models.dart';
|
||||
|
||||
class OrgContextApiClient {
|
||||
const OrgContextApiClient({
|
||||
required this.httpClient,
|
||||
required this.baseUri,
|
||||
required this.keyId,
|
||||
required this.keySecret,
|
||||
required this.tenantSlug,
|
||||
this.sessionStore,
|
||||
this.timeout = const Duration(seconds: 15),
|
||||
this.cacheTtl = const Duration(minutes: 5),
|
||||
});
|
||||
|
||||
static final Map<String, _OrgContextCacheEntry> _cache = {};
|
||||
|
||||
final http.Client httpClient;
|
||||
final Uri baseUri;
|
||||
final String keyId;
|
||||
final String keySecret;
|
||||
final String tenantSlug;
|
||||
final AuthSessionStore? sessionStore;
|
||||
final Duration timeout;
|
||||
final Duration cacheTtl;
|
||||
|
||||
Future<OrgChartSnapshot> fetchOrgContext({
|
||||
bool forceRefresh = false,
|
||||
String? tenantSlug,
|
||||
}) async {
|
||||
final credential = await _activeCredential(tenantSlug: tenantSlug);
|
||||
final cacheKey = credential.cacheKey;
|
||||
final now = DateTime.now().toUtc();
|
||||
final cached = _cache[cacheKey];
|
||||
if (!forceRefresh && cached != null && cached.expiresAt.isAfter(now)) {
|
||||
return cached.future;
|
||||
}
|
||||
|
||||
final future = _fetchOrgContext(credential);
|
||||
_cache[cacheKey] = _OrgContextCacheEntry(
|
||||
future: future,
|
||||
expiresAt: now.add(cacheTtl),
|
||||
);
|
||||
try {
|
||||
return await future;
|
||||
} catch (_) {
|
||||
if (identical(_cache[cacheKey]?.future, future)) {
|
||||
_cache.remove(cacheKey);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static void clearCacheForTesting() {
|
||||
_cache.clear();
|
||||
}
|
||||
|
||||
Future<OrgChartSnapshot> _fetchOrgContext(
|
||||
_OrgContextCredentialSnapshot credential,
|
||||
) async {
|
||||
final response = await httpClient
|
||||
.get(
|
||||
_resolve(
|
||||
credential.baseUri,
|
||||
'/api/v1/integrations/org-context',
|
||||
queryParameters: {
|
||||
'tenantSlug': credential.tenantSlug,
|
||||
'includeUsers': 'true',
|
||||
'includeUserIds': 'true',
|
||||
},
|
||||
),
|
||||
headers: _headers(credential),
|
||||
)
|
||||
.timeout(timeout);
|
||||
|
||||
return _toSnapshot(_decodeOk(response));
|
||||
}
|
||||
|
||||
Future<_OrgContextCredentialSnapshot> _activeCredential({
|
||||
String? tenantSlug,
|
||||
}) async {
|
||||
final session = await _session();
|
||||
final sessionCredential = session?.orgContextCredential;
|
||||
final requestedTenantSlug = tenantSlug?.trim();
|
||||
if (sessionCredential != null && sessionCredential.isUsable) {
|
||||
return _OrgContextCredentialSnapshot(
|
||||
baseUri: Uri.parse(sessionCredential.baseUrl),
|
||||
tenantSlug: requestedTenantSlug == null || requestedTenantSlug.isEmpty
|
||||
? sessionCredential.tenantSlug
|
||||
: requestedTenantSlug,
|
||||
keyId: sessionCredential.keyId,
|
||||
keySecret: sessionCredential.keySecret,
|
||||
appSessionToken: session?.token ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
return _OrgContextCredentialSnapshot(
|
||||
baseUri: baseUri,
|
||||
tenantSlug: requestedTenantSlug == null || requestedTenantSlug.isEmpty
|
||||
? this.tenantSlug
|
||||
: requestedTenantSlug,
|
||||
keyId: keyId,
|
||||
keySecret: keySecret,
|
||||
appSessionToken: session?.token ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Future<StoredAuthSession?> _session() async {
|
||||
final store = sessionStore;
|
||||
if (store == null) {
|
||||
debugPrint('OrgContextApiClient._session no sessionStore');
|
||||
return null;
|
||||
}
|
||||
final session = await store.load();
|
||||
if (session == null || session.isExpired) {
|
||||
debugPrint(
|
||||
'OrgContextApiClient._session unavailable session=${session != null} expired=${session?.isExpired ?? true}',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
debugPrint(
|
||||
'OrgContextApiClient._session tokenLength=${session.token.length} expiresAt=${session.expiresAt.toUtc().toIso8601String()}',
|
||||
);
|
||||
return session;
|
||||
}
|
||||
|
||||
Map<String, String> _headers(_OrgContextCredentialSnapshot credential) {
|
||||
final appSessionToken = credential.appSessionToken.trim();
|
||||
final headers = {
|
||||
'accept': 'application/json',
|
||||
if (appSessionToken.isNotEmpty) 'Authorization': 'Bearer $appSessionToken',
|
||||
if (appSessionToken.isNotEmpty) 'X-App-Session': appSessionToken,
|
||||
if (credential.keyId.trim().isNotEmpty)
|
||||
'X-Baron-Key-ID': credential.keyId.trim(),
|
||||
if (credential.keySecret.trim().isNotEmpty)
|
||||
'X-Baron-Key-Secret': credential.keySecret.trim(),
|
||||
};
|
||||
debugPrint(
|
||||
'OrgContextApiClient._headers headers=$headers tenantSlug=${credential.tenantSlug} base=${credential.baseUri}',
|
||||
);
|
||||
return headers;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _decodeOk(http.Response response) {
|
||||
final decoded = _decodeObject(response.body);
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw ApiException(
|
||||
statusCode: response.statusCode,
|
||||
apiError: ApiError.fromJson(decoded),
|
||||
);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
Uri _resolve(
|
||||
Uri baseUri,
|
||||
String path, {
|
||||
Map<String, String?> queryParameters = const {},
|
||||
}) {
|
||||
final normalizedBase = baseUri.path.endsWith('/')
|
||||
? baseUri
|
||||
: baseUri.replace(path: '${baseUri.path}/');
|
||||
final uri = normalizedBase.resolve(path.replaceFirst(RegExp(r'^/'), ''));
|
||||
final filteredQuery = Map<String, String>.fromEntries(
|
||||
queryParameters.entries
|
||||
.where((entry) {
|
||||
return entry.value != null && entry.value!.trim().isNotEmpty;
|
||||
})
|
||||
.map((entry) => MapEntry(entry.key, entry.value!)),
|
||||
);
|
||||
return uri.replace(queryParameters: filteredQuery);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _decodeObject(String body) {
|
||||
final decoded = jsonDecode(body);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
return decoded;
|
||||
}
|
||||
throw const FormatException('Expected JSON object response');
|
||||
}
|
||||
|
||||
OrgChartSnapshot _toSnapshot(Map<String, dynamic> json) {
|
||||
final issuedAt = _parseDate(json['issuedAt']) ?? DateTime.now().toUtc();
|
||||
final tenantById = <String, TenantSummary>{};
|
||||
final ownMemberCountByTenantId = <String, int>{};
|
||||
|
||||
final tree = json['tree'];
|
||||
if (tree is Map<String, dynamic>) {
|
||||
_collectTenantTree(tree, tenantById);
|
||||
}
|
||||
|
||||
final tenantItems = <Map<String, dynamic>>[];
|
||||
for (final item in json['tenants'] as List<dynamic>? ?? const []) {
|
||||
if (item is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
tenantItems.add(item);
|
||||
final tenant = _tenantFromJson(item);
|
||||
if (tenant.id.isNotEmpty) {
|
||||
tenantById[tenant.id] = tenant;
|
||||
ownMemberCountByTenantId[tenant.id] = _memberList(item).length;
|
||||
}
|
||||
}
|
||||
|
||||
final employeesByMembership = <String, Employee>{};
|
||||
for (final item in tenantItems) {
|
||||
final tenant = _tenantFromJson(item);
|
||||
if (tenant.id.isNotEmpty) {
|
||||
tenantById[tenant.id] = tenant;
|
||||
}
|
||||
for (final employee in _employeesFromTenant(item, tenant)) {
|
||||
if (employee.id.isNotEmpty) {
|
||||
employeesByMembership['${tenant.id}:${employee.id}'] = employee;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final countedTenantById = _recountTenantMemberCounts(
|
||||
tenantById: tenantById,
|
||||
ownMemberCountByTenantId: ownMemberCountByTenantId,
|
||||
);
|
||||
final tenants = countedTenantById.values.toList()
|
||||
..sort((a, b) {
|
||||
final parentCompare = (a.parentId ?? '').compareTo(b.parentId ?? '');
|
||||
if (parentCompare != 0) {
|
||||
return parentCompare;
|
||||
}
|
||||
return a.name.compareTo(b.name);
|
||||
});
|
||||
|
||||
final employees = employeesByMembership.values.toList();
|
||||
return OrgChartSnapshot(
|
||||
tenants: tenants,
|
||||
employees: employees,
|
||||
generatedAt: issuedAt.toUtc(),
|
||||
cache: const OrgChartCacheInfo(source: 'baron-org-context', hit: false),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, TenantSummary> _recountTenantMemberCounts({
|
||||
required Map<String, TenantSummary> tenantById,
|
||||
required Map<String, int> ownMemberCountByTenantId,
|
||||
}) {
|
||||
final childrenByParentId = <String, List<TenantSummary>>{};
|
||||
for (final tenant in tenantById.values) {
|
||||
final parentId = tenant.parentId;
|
||||
if (parentId == null || parentId.trim().isEmpty) {
|
||||
continue;
|
||||
}
|
||||
childrenByParentId
|
||||
.putIfAbsent(parentId, () => <TenantSummary>[])
|
||||
.add(tenant);
|
||||
}
|
||||
|
||||
final totalCountByTenantId = <String, int>{};
|
||||
|
||||
int totalFor(String tenantId) {
|
||||
final cached = totalCountByTenantId[tenantId];
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
final ownCount =
|
||||
ownMemberCountByTenantId[tenantId] ??
|
||||
tenantById[tenantId]?.memberCount ??
|
||||
0;
|
||||
final total = (childrenByParentId[tenantId] ?? const <TenantSummary>[])
|
||||
.fold<int>(ownCount, (sum, child) => sum + totalFor(child.id));
|
||||
totalCountByTenantId[tenantId] = total;
|
||||
return total;
|
||||
}
|
||||
|
||||
return {
|
||||
for (final entry in tenantById.entries)
|
||||
entry.key: _copyTenantWithCounts(
|
||||
entry.value,
|
||||
memberCount:
|
||||
ownMemberCountByTenantId[entry.key] ?? entry.value.memberCount,
|
||||
totalMemberCount: totalFor(entry.key),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
TenantSummary _copyTenantWithCounts(
|
||||
TenantSummary tenant, {
|
||||
required int memberCount,
|
||||
required int totalMemberCount,
|
||||
}) {
|
||||
return TenantSummary(
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
slug: tenant.slug,
|
||||
type: tenant.type,
|
||||
parentId: tenant.parentId,
|
||||
memberCount: memberCount,
|
||||
totalMemberCount: totalMemberCount,
|
||||
);
|
||||
}
|
||||
|
||||
void _collectTenantTree(
|
||||
Map<String, dynamic> json,
|
||||
Map<String, TenantSummary> tenantById,
|
||||
) {
|
||||
final tenant = _tenantFromJson(json);
|
||||
if (tenant.id.isNotEmpty) {
|
||||
tenantById[tenant.id] = tenant;
|
||||
}
|
||||
|
||||
final children = json['children'];
|
||||
if (children is List<dynamic>) {
|
||||
for (final child in children) {
|
||||
if (child is Map<String, dynamic>) {
|
||||
_collectTenantTree(child, tenantById);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TenantSummary _tenantFromJson(Map<String, dynamic> json) {
|
||||
final memberCount = _readInt(json['memberCount']);
|
||||
final totalMemberCount = _readInt(json['totalMemberCount']) ?? memberCount;
|
||||
return TenantSummary(
|
||||
id: _readString(json, ['id']),
|
||||
name: _readString(json, ['name']),
|
||||
slug: _readString(json, ['slug']),
|
||||
type: _readString(json, ['type', 'orgUnitType']),
|
||||
parentId: _nullableString(json['parentId']),
|
||||
memberCount: memberCount ?? _memberList(json).length,
|
||||
totalMemberCount: totalMemberCount ?? _memberList(json).length,
|
||||
);
|
||||
}
|
||||
|
||||
List<Employee> _employeesFromTenant(
|
||||
Map<String, dynamic> tenantJson,
|
||||
TenantSummary tenant,
|
||||
) {
|
||||
final members = _memberList(tenantJson);
|
||||
return members.indexed.map((entry) {
|
||||
final index = entry.$1;
|
||||
final member = entry.$2;
|
||||
final rawPhone = _readString(member, [
|
||||
'phoneNumber',
|
||||
'phone',
|
||||
'mobile',
|
||||
'mobilePhone',
|
||||
]);
|
||||
final email = _nullableString(member['email']);
|
||||
final memberId = _readString(member, [
|
||||
'id',
|
||||
'userId',
|
||||
'employeeId',
|
||||
'loginId',
|
||||
]);
|
||||
final id = memberId.isNotEmpty
|
||||
? memberId
|
||||
: [
|
||||
tenant.id,
|
||||
email,
|
||||
_readString(member, ['name']),
|
||||
rawPhone,
|
||||
].whereType<String>().where((value) => value.isNotEmpty).join(':');
|
||||
|
||||
return Employee(
|
||||
id: id,
|
||||
name: _readString(member, ['name']),
|
||||
phoneNumber: rawPhone,
|
||||
phoneDisplay: _formatPhone(rawPhone),
|
||||
email: email,
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
tenantSlug: tenant.slug,
|
||||
department: _nullableString(member['department']),
|
||||
grade: _nullableString(member['grade']),
|
||||
position: _nullableString(member['position']),
|
||||
jobTitle: _nullableString(member['jobTitle']),
|
||||
status: _nullableString(member['status']),
|
||||
profileImageUrl: _nullableString(member['profileImageUrl']),
|
||||
sortOrder: _readInt(member['sortOrder']) ?? index,
|
||||
isManager: member['isManager'] as bool? ?? false,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _memberList(Map<String, dynamic> json) {
|
||||
final members = json['members'];
|
||||
if (members is! List<dynamic>) {
|
||||
return const [];
|
||||
}
|
||||
return members
|
||||
.whereType<Map>()
|
||||
.map((member) => Map<String, dynamic>.from(member))
|
||||
.toList();
|
||||
}
|
||||
|
||||
DateTime? _parseDate(Object? value) {
|
||||
if (value is! String || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return DateTime.tryParse(value);
|
||||
}
|
||||
|
||||
int? _readInt(Object? value) {
|
||||
if (value is int) {
|
||||
return value;
|
||||
}
|
||||
if (value is num) {
|
||||
return value.toInt();
|
||||
}
|
||||
if (value is String) {
|
||||
return int.tryParse(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _readString(Map<String, dynamic> json, List<String> keys) {
|
||||
for (final key in keys) {
|
||||
final value = _nullableString(json[key]);
|
||||
if (value != null) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
String? _nullableString(Object? value) {
|
||||
if (value is! String) {
|
||||
return null;
|
||||
}
|
||||
final trimmed = value.trim();
|
||||
return trimmed.isEmpty ? null : trimmed;
|
||||
}
|
||||
|
||||
String? _formatPhone(String value) {
|
||||
var digits = value.replaceAll(RegExp(r'[^0-9]'), '');
|
||||
if (digits.startsWith('82') && digits.length >= 11) {
|
||||
digits = '0${digits.substring(2)}';
|
||||
}
|
||||
if (digits.length == 11 && digits.startsWith('010')) {
|
||||
return '${digits.substring(0, 3)}-${digits.substring(3, 7)}-${digits.substring(7)}';
|
||||
}
|
||||
return value.trim().isEmpty ? null : value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
final orgContextApiClientProvider = Provider<OrgContextApiClient>((ref) {
|
||||
final environment = ref.watch(appEnvironmentProvider);
|
||||
return OrgContextApiClient(
|
||||
httpClient: ref.watch(httpClientProvider),
|
||||
baseUri: Uri.parse(environment.orgContextApiBaseUrl),
|
||||
keyId: environment.baronKeyId,
|
||||
keySecret: environment.baronKeySecret,
|
||||
tenantSlug: environment.orgContextTenantSlug,
|
||||
sessionStore: ref.watch(authSessionStoreProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final orgContextSnapshotProvider = FutureProvider.autoDispose<OrgChartSnapshot>(
|
||||
(ref) {
|
||||
return ref.watch(orgContextApiClientProvider).fetchOrgContext();
|
||||
},
|
||||
);
|
||||
|
||||
class _OrgContextCredentialSnapshot {
|
||||
const _OrgContextCredentialSnapshot({
|
||||
required this.baseUri,
|
||||
required this.tenantSlug,
|
||||
required this.keyId,
|
||||
required this.keySecret,
|
||||
required this.appSessionToken,
|
||||
});
|
||||
|
||||
final Uri baseUri;
|
||||
final String tenantSlug;
|
||||
final String keyId;
|
||||
final String keySecret;
|
||||
final String appSessionToken;
|
||||
|
||||
String get cacheKey {
|
||||
return [
|
||||
baseUri.toString(),
|
||||
tenantSlug,
|
||||
appSessionToken.isEmpty ? keyId : appSessionToken,
|
||||
].join('|');
|
||||
}
|
||||
}
|
||||
|
||||
class _OrgContextCacheEntry {
|
||||
const _OrgContextCacheEntry({required this.future, required this.expiresAt});
|
||||
|
||||
final Future<OrgChartSnapshot> future;
|
||||
final DateTime expiresAt;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../core/config/app_environment_provider.dart';
|
||||
import '../../../core/network/api_error.dart';
|
||||
import '../../../core/network/http_client_provider.dart';
|
||||
import '../../auth/data/auth_session_store.dart';
|
||||
import '../domain/organization_models.dart';
|
||||
import 'org_context_api_client.dart';
|
||||
|
||||
abstract class OrganizationRepository {
|
||||
const OrganizationRepository();
|
||||
|
||||
Future<TenantListResponse> listTenants();
|
||||
|
||||
Future<OrgChartSnapshot> getOrgChart({
|
||||
String? tenantId,
|
||||
bool refresh = false,
|
||||
});
|
||||
}
|
||||
|
||||
class OrganizationApiClient {
|
||||
const OrganizationApiClient({
|
||||
required this.httpClient,
|
||||
required this.baseUri,
|
||||
required this.sessionStore,
|
||||
this.timeout = const Duration(seconds: 10),
|
||||
});
|
||||
|
||||
final http.Client httpClient;
|
||||
final Uri baseUri;
|
||||
final AuthSessionStore sessionStore;
|
||||
final Duration timeout;
|
||||
|
||||
Future<TenantListResponse> listTenants() async {
|
||||
final response = await httpClient
|
||||
.get(
|
||||
_resolve('/api/v1/tdc114plus/organization/tenants'),
|
||||
headers: await _headers(),
|
||||
)
|
||||
.timeout(timeout);
|
||||
return TenantListResponse.fromJson(_decodeOk(response));
|
||||
}
|
||||
|
||||
Future<OrgChartSnapshot> getOrgChart({
|
||||
String? tenantId,
|
||||
bool refresh = false,
|
||||
}) async {
|
||||
final response = await httpClient
|
||||
.get(
|
||||
_resolve(
|
||||
'/api/v1/tdc114plus/organization/orgchart',
|
||||
queryParameters: {
|
||||
'tenantId': tenantId,
|
||||
if (refresh) 'refresh': 'true',
|
||||
},
|
||||
),
|
||||
headers: await _headers(),
|
||||
)
|
||||
.timeout(timeout);
|
||||
return OrgChartSnapshot.fromJson(_decodeOk(response));
|
||||
}
|
||||
|
||||
Future<Map<String, String>> _headers() async {
|
||||
final session = await sessionStore.load();
|
||||
return {
|
||||
'accept': 'application/json',
|
||||
if (session?.token != null) 'authorization': 'Bearer ${session!.token}',
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _decodeOk(http.Response response) {
|
||||
final decoded = _decodeObject(response.body);
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw ApiException(
|
||||
statusCode: response.statusCode,
|
||||
apiError: ApiError.fromJson(decoded),
|
||||
);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
Uri _resolve(String path, {Map<String, String?> queryParameters = const {}}) {
|
||||
final normalizedBase = baseUri.path.endsWith('/')
|
||||
? baseUri
|
||||
: baseUri.replace(path: '${baseUri.path}/');
|
||||
final uri = normalizedBase.resolve(path.replaceFirst(RegExp(r'^/'), ''));
|
||||
final filteredQuery = Map<String, String>.fromEntries(
|
||||
queryParameters.entries
|
||||
.where((entry) {
|
||||
return entry.value != null && entry.value!.trim().isNotEmpty;
|
||||
})
|
||||
.map((entry) => MapEntry(entry.key, entry.value!)),
|
||||
);
|
||||
return uri.replace(queryParameters: filteredQuery);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _decodeObject(String body) {
|
||||
final decoded = jsonDecode(body);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
return decoded;
|
||||
}
|
||||
throw const FormatException('Expected JSON object response');
|
||||
}
|
||||
}
|
||||
|
||||
class RemoteOrganizationRepository implements OrganizationRepository {
|
||||
const RemoteOrganizationRepository({required this.apiClient});
|
||||
|
||||
final OrganizationApiClient apiClient;
|
||||
|
||||
@override
|
||||
Future<TenantListResponse> listTenants() => apiClient.listTenants();
|
||||
|
||||
@override
|
||||
Future<OrgChartSnapshot> getOrgChart({
|
||||
String? tenantId,
|
||||
bool refresh = false,
|
||||
}) {
|
||||
return apiClient.getOrgChart(tenantId: tenantId, refresh: refresh);
|
||||
}
|
||||
}
|
||||
|
||||
class OrgContextOrganizationRepository implements OrganizationRepository {
|
||||
const OrgContextOrganizationRepository({required this.orgContextApiClient});
|
||||
|
||||
final OrgContextApiClient orgContextApiClient;
|
||||
|
||||
@override
|
||||
Future<TenantListResponse> listTenants() async {
|
||||
final snapshot = await orgContextApiClient.fetchOrgContext();
|
||||
return TenantListResponse(
|
||||
items: snapshot.tenants,
|
||||
generatedAt: snapshot.generatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<OrgChartSnapshot> getOrgChart({
|
||||
String? tenantId,
|
||||
bool refresh = false,
|
||||
}) async {
|
||||
final snapshot = await orgContextApiClient.fetchOrgContext();
|
||||
if (tenantId == null || tenantId.trim().isEmpty) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
return OrgChartSnapshot(
|
||||
tenants: snapshot.tenants
|
||||
.where(
|
||||
(tenant) => tenant.id == tenantId || tenant.parentId == tenantId,
|
||||
)
|
||||
.toList(),
|
||||
employees: snapshot.employees
|
||||
.where((employee) => employee.tenantId == tenantId)
|
||||
.toList(),
|
||||
generatedAt: snapshot.generatedAt,
|
||||
cache: snapshot.cache,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final organizationApiClientProvider = Provider<OrganizationApiClient>((ref) {
|
||||
final environment = ref.watch(appEnvironmentProvider);
|
||||
return OrganizationApiClient(
|
||||
httpClient: ref.watch(httpClientProvider),
|
||||
baseUri: Uri.parse(environment.organizationApiBaseUrl),
|
||||
sessionStore: ref.watch(authSessionStoreProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final organizationRepositoryProvider = Provider<OrganizationRepository>((ref) {
|
||||
return OrgContextOrganizationRepository(
|
||||
orgContextApiClient: ref.watch(orgContextApiClientProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final tenantListProvider = FutureProvider.autoDispose<List<TenantSummary>>((
|
||||
ref,
|
||||
) async {
|
||||
final response = await ref
|
||||
.watch(organizationRepositoryProvider)
|
||||
.listTenants();
|
||||
return response.items;
|
||||
});
|
||||
|
||||
final orgChartProvider = FutureProvider.autoDispose
|
||||
.family<OrgChartSnapshot, String?>((ref, tenantId) async {
|
||||
return ref
|
||||
.watch(organizationRepositoryProvider)
|
||||
.getOrgChart(tenantId: tenantId);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import '../features/directory/data/directory_repository.dart';
|
||||
import '../features/directory/data/mock_directory_data.dart';
|
||||
import '../features/directory/domain/employee.dart';
|
||||
import '../features/organization/data/organization_api_client.dart';
|
||||
import '../features/organization/domain/organization_models.dart';
|
||||
|
||||
const useSmokeMockDirectory = bool.fromEnvironment(
|
||||
'TDC114_SMOKE_USE_MOCK_DIRECTORY',
|
||||
);
|
||||
|
||||
final smokeDirectoryOverride = directoryRepositoryProvider.overrideWithValue(
|
||||
const _SmokeDirectoryRepository(),
|
||||
);
|
||||
|
||||
final smokeOrganizationOverride = organizationRepositoryProvider
|
||||
.overrideWithValue(const _SmokeOrganizationRepository());
|
||||
|
||||
class _SmokeDirectoryRepository implements DirectoryRepository {
|
||||
const _SmokeDirectoryRepository();
|
||||
|
||||
@override
|
||||
Future<List<Employee>> loadEmployees(DirectoryQuery query) async {
|
||||
final normalizedQuery = query.query.trim();
|
||||
final normalizedDigits = _digitsOnly(normalizedQuery);
|
||||
final selectedTenant = _findTenantBySlug(query.apiTenantSlug);
|
||||
|
||||
return mockEmployees.where((employee) {
|
||||
final matchesTenant =
|
||||
query.apiTenantSlug == null ||
|
||||
employee.tenantSlug == query.apiTenantSlug ||
|
||||
(selectedTenant != null &&
|
||||
selectedTenant.type != 'COMPANY' &&
|
||||
(employee.department ?? '') == selectedTenant.name);
|
||||
final matchesDepartment =
|
||||
query.apiDepartment == null ||
|
||||
(employee.department ?? '') == query.apiDepartment;
|
||||
if (!matchesTenant) {
|
||||
return false;
|
||||
}
|
||||
if (!matchesDepartment) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalizedQuery.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final matchesName = employee.name.contains(normalizedQuery);
|
||||
final matchesDepartmentQuery = (employee.department ?? '').contains(
|
||||
normalizedQuery,
|
||||
);
|
||||
final matchesPhone =
|
||||
normalizedDigits.isNotEmpty &&
|
||||
(_digitsOnly(employee.phoneNumber).contains(normalizedDigits) ||
|
||||
_digitsOnly(
|
||||
employee.phoneDisplay ?? '',
|
||||
).contains(normalizedDigits));
|
||||
return matchesName || matchesDepartmentQuery || matchesPhone;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
String _digitsOnly(String value) => value.replaceAll(RegExp(r'[^0-9]'), '');
|
||||
|
||||
TenantSummary? _findTenantBySlug(String? slug) {
|
||||
if (slug == null || slug.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
for (final tenant in mockTenants) {
|
||||
if (tenant.slug == slug) {
|
||||
return tenant;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class _SmokeOrganizationRepository implements OrganizationRepository {
|
||||
const _SmokeOrganizationRepository();
|
||||
|
||||
@override
|
||||
Future<OrgChartSnapshot> getOrgChart({
|
||||
String? tenantId,
|
||||
bool refresh = false,
|
||||
}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TenantListResponse> listTenants() async {
|
||||
return TenantListResponse(
|
||||
items: mockTenants,
|
||||
generatedAt: DateTime.parse('2026-07-02T00:00:00Z'),
|
||||
);
|
||||
}
|
||||
}
|
||||
+40
-1
@@ -90,7 +90,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.15.1"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
@@ -158,6 +158,11 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_driver:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -197,6 +202,11 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
fuchsia_remote_debug_protocol:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -237,6 +247,11 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
integration_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
intl:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -397,6 +412,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.2"
|
||||
process:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: process
|
||||
sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.5"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -562,6 +585,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
sync_http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sync_http
|
||||
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -722,6 +753,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
webdriver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webdriver
|
||||
sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
webkit_inspection_protocol:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -34,6 +34,7 @@ dependencies:
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
crypto: ^3.0.6
|
||||
easy_localization: ^3.0.7
|
||||
flutter_riverpod: ^3.0.3
|
||||
go_router: ^17.0.1
|
||||
@@ -45,6 +46,8 @@ dependencies:
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
integration_test:
|
||||
sdk: flutter
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:tdc114plus/src/core/network/api_error.dart';
|
||||
import 'package:tdc114plus/src/features/auth/data/auth_api_client.dart';
|
||||
import 'package:tdc114plus/src/features/auth/domain/auth_models.dart';
|
||||
|
||||
void main() {
|
||||
test('posts phone login request to tdc114plus namespace', () async {
|
||||
final client = AuthApiClient(
|
||||
httpClient: MockClient((request) async {
|
||||
expect(
|
||||
request.url.toString(),
|
||||
'https://sso.example.test/api/v1/tdc114plus/auth/phone-login',
|
||||
);
|
||||
expect(request.method, 'POST');
|
||||
expect(request.headers['content-type'], 'application/json');
|
||||
expect(jsonDecode(request.body)['phoneNumber'], '01012345678');
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'status': 'ok',
|
||||
'token': 'session-token',
|
||||
'expiresAt': '2026-07-02T12:00:00Z',
|
||||
'user': {
|
||||
'id': 'user-uuid',
|
||||
'name': 'User One',
|
||||
'phoneNumber': '+821012345678',
|
||||
'tenantId': 'tenant-uuid',
|
||||
'tenantName': 'Hanmac',
|
||||
'tenantSlug': 'hanmac',
|
||||
},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}),
|
||||
baseUri: Uri.parse('https://sso.example.test'),
|
||||
);
|
||||
|
||||
final response = await client.phoneLogin(
|
||||
const PhoneLoginRequest(
|
||||
phoneNumber: '01012345678',
|
||||
device: LoginDeviceInfo(
|
||||
platform: 'android',
|
||||
appVersion: '0.1.0',
|
||||
deviceName: 'Pixel 8',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.token, 'session-token');
|
||||
expect(response.user.tenantSlug, 'hanmac');
|
||||
});
|
||||
|
||||
test('throws ApiException when login fails', () async {
|
||||
final client = AuthApiClient(
|
||||
httpClient: MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'error': 'login failed',
|
||||
'code': 'login_failed',
|
||||
'details': {},
|
||||
}),
|
||||
401,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}),
|
||||
baseUri: Uri.parse('https://sso.example.test'),
|
||||
);
|
||||
|
||||
expect(
|
||||
() => client.phoneLogin(
|
||||
const PhoneLoginRequest(
|
||||
phoneNumber: '01012345678',
|
||||
device: LoginDeviceInfo(
|
||||
platform: 'android',
|
||||
appVersion: '0.1.0',
|
||||
deviceName: 'Pixel 8',
|
||||
),
|
||||
),
|
||||
),
|
||||
throwsA(
|
||||
isA<ApiException>()
|
||||
.having((error) => error.statusCode, 'statusCode', 401)
|
||||
.having((error) => error.code, 'code', 'login_failed'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('requests phone link from auth server namespace', () async {
|
||||
final client = AuthApiClient(
|
||||
httpClient: MockClient((request) async {
|
||||
expect(
|
||||
request.url.toString(),
|
||||
'https://sso.example.test/api/v1/auth/link/init',
|
||||
);
|
||||
expect(request.method, 'POST');
|
||||
expect(jsonDecode(request.body)['phoneNumber'], '01012345678');
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'status': 'pending',
|
||||
'pendingRef': 'pending-ref',
|
||||
'expiresIn': 180,
|
||||
'pollInterval': 3,
|
||||
'resendAfter': 30,
|
||||
'provider': 'Ory (Kratos/Hydra)',
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}),
|
||||
baseUri: Uri.parse('https://sso.example.test'),
|
||||
);
|
||||
|
||||
final response = await client.requestPhoneLoginLink(
|
||||
const PhoneLoginLinkInitRequest(
|
||||
phoneNumber: '01012345678',
|
||||
device: LoginDeviceInfo(
|
||||
platform: 'android',
|
||||
appVersion: '0.1.0',
|
||||
deviceName: 'Pixel 8',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.pendingRef, 'pending-ref');
|
||||
expect(response.interval, 3);
|
||||
});
|
||||
|
||||
test('polls headless phone link and parses completed session', () async {
|
||||
final client = AuthApiClient(
|
||||
httpClient: MockClient((request) async {
|
||||
expect(
|
||||
request.url.toString(),
|
||||
'https://sso.example.test/api/v1/auth/link/poll',
|
||||
);
|
||||
expect(jsonDecode(request.body)['pendingRef'], 'pending-ref');
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'status': 'ok',
|
||||
'session': {
|
||||
'status': 'ok',
|
||||
'accessToken': 'session-token',
|
||||
'expiresAt': '2026-07-02T12:00:00Z',
|
||||
'user': {
|
||||
'id': 'user-uuid',
|
||||
'name': 'User One',
|
||||
'phoneNumber': '+821012345678',
|
||||
'tenantId': 'tenant-uuid',
|
||||
'tenantName': 'Hanmac',
|
||||
'tenantSlug': 'hanmac',
|
||||
},
|
||||
},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}),
|
||||
baseUri: Uri.parse('https://sso.example.test'),
|
||||
);
|
||||
|
||||
final response = await client.pollPhoneLoginLink(
|
||||
const PhoneLoginLinkPollRequest(pendingRef: 'pending-ref'),
|
||||
);
|
||||
|
||||
expect(response.session?.token, 'session-token');
|
||||
expect(response.session?.user.tenantSlug, 'hanmac');
|
||||
});
|
||||
|
||||
test('parses completed auth-server poll response with top-level accessToken', () async {
|
||||
final client = AuthApiClient(
|
||||
httpClient: MockClient((request) async {
|
||||
expect(
|
||||
request.url.toString(),
|
||||
'https://auth.example.test/api/v1/auth/link/poll',
|
||||
);
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'status': 'ok',
|
||||
'accessToken': 'app-session-jwt',
|
||||
'expiresAt': '2026-07-13T16:32:56+09:00',
|
||||
'user': {
|
||||
'id': 'baron-user',
|
||||
'name': 'Baron User',
|
||||
'phoneNumber': '01091365338',
|
||||
},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}),
|
||||
baseUri: Uri.parse('https://auth.example.test'),
|
||||
);
|
||||
|
||||
final response = await client.pollPhoneLoginLink(
|
||||
const PhoneLoginLinkPollRequest(pendingRef: 'pending-ref'),
|
||||
);
|
||||
|
||||
expect(response.status, 'ok');
|
||||
expect(response.session?.token, 'app-session-jwt');
|
||||
expect(response.session?.user.phoneNumber, '01091365338');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:tdc114plus/src/core/network/api_error.dart';
|
||||
import 'package:tdc114plus/src/features/auth/data/auth_api_client.dart';
|
||||
import 'package:tdc114plus/src/features/auth/data/auth_repository.dart';
|
||||
import 'package:tdc114plus/src/features/auth/data/auth_session_store.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
test('normalizes phone number and stores successful session', () async {
|
||||
final repository = RemoteAuthRepository(
|
||||
apiClient: AuthApiClient(
|
||||
httpClient: MockClient((request) async {
|
||||
expect(jsonDecode(request.body)['phoneNumber'], '01012345678');
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'status': 'ok',
|
||||
'token': 'session-token',
|
||||
'expiresAt': '2026-07-02T12:00:00Z',
|
||||
'user': {
|
||||
'id': 'user-uuid',
|
||||
'name': 'User One',
|
||||
'phoneNumber': '+821012345678',
|
||||
'tenantId': 'tenant-uuid',
|
||||
'tenantName': 'Hanmac',
|
||||
'tenantSlug': 'hanmac',
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}),
|
||||
baseUri: Uri.parse('https://sso.example.test'),
|
||||
),
|
||||
sessionStore: const AuthSessionStore(),
|
||||
appVersion: '0.1.0',
|
||||
);
|
||||
|
||||
await repository.phoneLogin('010-1234-5678');
|
||||
final session = await repository.loadSession();
|
||||
|
||||
expect(session?.token, 'session-token');
|
||||
expect(session?.user.id, 'user-uuid');
|
||||
});
|
||||
|
||||
test('rejects invalid phone number before calling API', () async {
|
||||
var called = false;
|
||||
final repository = RemoteAuthRepository(
|
||||
apiClient: AuthApiClient(
|
||||
httpClient: MockClient((request) async {
|
||||
called = true;
|
||||
return http.Response('{}', 200);
|
||||
}),
|
||||
baseUri: Uri.parse('https://sso.example.test'),
|
||||
),
|
||||
sessionStore: const AuthSessionStore(),
|
||||
appVersion: '0.1.0',
|
||||
);
|
||||
|
||||
expect(
|
||||
() => repository.phoneLogin('123'),
|
||||
throwsA(
|
||||
isA<ApiException>().having(
|
||||
(error) => error.code,
|
||||
'code',
|
||||
'invalid_phone_number',
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(called, isFalse);
|
||||
});
|
||||
|
||||
test('normalizes phone number before requesting login link', () async {
|
||||
final repository = RemoteAuthRepository(
|
||||
apiClient: AuthApiClient(
|
||||
httpClient: MockClient((request) async {
|
||||
expect(request.url.path, '/api/v1/auth/link/init');
|
||||
expect(jsonDecode(request.body)['phoneNumber'], '01012345678');
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'status': 'pending',
|
||||
'pendingRef': 'pending-ref',
|
||||
'expiresIn': 180,
|
||||
'pollInterval': 3,
|
||||
'resendAfter': 30,
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}),
|
||||
baseUri: Uri.parse('https://sso.example.test'),
|
||||
),
|
||||
sessionStore: const AuthSessionStore(),
|
||||
appVersion: '0.1.0',
|
||||
);
|
||||
|
||||
final response = await repository.requestPhoneLoginLink('010-1234-5678');
|
||||
|
||||
expect(response.pendingRef, 'pending-ref');
|
||||
});
|
||||
|
||||
test('stores session when login link polling completes', () async {
|
||||
final repository = RemoteAuthRepository(
|
||||
apiClient: AuthApiClient(
|
||||
httpClient: MockClient((request) async {
|
||||
expect(request.url.path, '/api/v1/auth/link/poll');
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'status': 'ok',
|
||||
'accessToken': 'session-token',
|
||||
'expiresAt': '2026-07-02T12:00:00Z',
|
||||
'user': {
|
||||
'id': 'user-uuid',
|
||||
'name': 'User One',
|
||||
'phoneNumber': '+821012345678',
|
||||
'tenantId': 'tenant-uuid',
|
||||
'tenantName': 'Hanmac',
|
||||
'tenantSlug': 'hanmac',
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}),
|
||||
baseUri: Uri.parse('https://sso.example.test'),
|
||||
),
|
||||
sessionStore: const AuthSessionStore(),
|
||||
appVersion: '0.1.0',
|
||||
);
|
||||
|
||||
final response = await repository.pollPhoneLoginLink('pending-ref');
|
||||
final session = await repository.loadSession();
|
||||
|
||||
expect(response.session?.token, 'session-token');
|
||||
expect(session?.token, 'session-token');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:tdc114plus/src/features/auth/data/auth_session_store.dart';
|
||||
import 'package:tdc114plus/src/features/auth/domain/auth_models.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
test('saves loads and clears login session', () async {
|
||||
const store = AuthSessionStore();
|
||||
final response = PhoneLoginResponse(
|
||||
status: 'ok',
|
||||
token: 'session-token',
|
||||
expiresAt: DateTime.parse('2026-07-02T12:00:00Z'),
|
||||
user: const LoginUser(
|
||||
id: 'user-uuid',
|
||||
name: 'User One',
|
||||
phoneNumber: '+821012345678',
|
||||
tenantId: 'tenant-uuid',
|
||||
tenantName: 'Hanmac',
|
||||
tenantSlug: 'hanmac',
|
||||
),
|
||||
orgContextCredential: OrgContextCredential(
|
||||
baseUrl: 'https://sadmin.hmac.kr',
|
||||
tenantSlug: 'hanmac-family',
|
||||
keyId: 'session-key-id',
|
||||
keySecret: 'session-key-secret',
|
||||
expiresAt: DateTime.parse('2099-07-02T13:00:00Z'),
|
||||
),
|
||||
);
|
||||
|
||||
await store.save(response);
|
||||
final loaded = await store.load();
|
||||
|
||||
expect(loaded?.token, 'session-token');
|
||||
expect(loaded?.user.name, 'User One');
|
||||
expect(loaded?.orgContextCredential?.keyId, 'session-key-id');
|
||||
expect(loaded?.orgContextCredential?.keySecret, 'session-key-secret');
|
||||
|
||||
await store.clear();
|
||||
expect(await store.load(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:tdc114plus/src/core/actions/contact_launcher.dart';
|
||||
|
||||
void main() {
|
||||
test('buildCallUri normalizes separators and keeps country code', () {
|
||||
final uri = buildCallUri('+82 10-1234-5678');
|
||||
|
||||
expect(uri, isNotNull);
|
||||
expect(uri.toString(), 'tel:+821012345678');
|
||||
});
|
||||
|
||||
test('buildSmsUri returns null for empty phone number', () {
|
||||
final uri = buildSmsUri(' - ');
|
||||
|
||||
expect(uri, isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:tdc114plus/src/features/auth/data/auth_session_store.dart';
|
||||
import 'package:tdc114plus/src/features/auth/domain/auth_models.dart';
|
||||
import 'package:tdc114plus/src/features/directory/data/directory_api_client.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
await const AuthSessionStore().save(
|
||||
PhoneLoginResponse(
|
||||
status: 'ok',
|
||||
token: 'session-token',
|
||||
expiresAt: DateTime.parse('2026-07-02T12:00:00Z'),
|
||||
user: const LoginUser(
|
||||
id: 'user-uuid',
|
||||
name: 'User One',
|
||||
phoneNumber: '+821012345678',
|
||||
tenantId: 'tenant-uuid',
|
||||
tenantName: 'Hanmac',
|
||||
tenantSlug: 'hanmac',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('lists employees with bearer token and query filters', () async {
|
||||
final client = DirectoryApiClient(
|
||||
httpClient: MockClient((request) async {
|
||||
expect(request.headers['authorization'], 'Bearer session-token');
|
||||
expect(request.url.path, '/api/v1/tdc114plus/directory/employees');
|
||||
expect(request.url.queryParameters['q'], 'kim');
|
||||
expect(request.url.queryParameters['tenantSlug'], 'hanmac');
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'items': [
|
||||
{
|
||||
'id': 'user-uuid',
|
||||
'name': 'User One',
|
||||
'phoneNumber': '+821012345678',
|
||||
'phoneDisplay': '010-1234-5678',
|
||||
'tenantId': 'tenant-uuid',
|
||||
'tenantName': 'Hanmac',
|
||||
'tenantSlug': 'hanmac',
|
||||
'status': 'active',
|
||||
},
|
||||
],
|
||||
'limit': 50,
|
||||
'offset': 0,
|
||||
'total': 1,
|
||||
'nextCursor': '',
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}),
|
||||
baseUri: Uri.parse('https://sso.example.test'),
|
||||
sessionStore: const AuthSessionStore(),
|
||||
);
|
||||
|
||||
final response = await client.listEmployees(
|
||||
query: 'kim',
|
||||
tenantSlug: 'hanmac',
|
||||
);
|
||||
|
||||
expect(response.total, 1);
|
||||
expect(response.items.single.phoneDisplay, '010-1234-5678');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tdc114plus/src/features/directory/presentation/directory_navigation.dart';
|
||||
import 'package:tdc114plus/src/features/organization/domain/organization_models.dart';
|
||||
|
||||
void main() {
|
||||
const tenants = [
|
||||
TenantSummary(
|
||||
id: 'company-a',
|
||||
name: '총괄기획실/기술',
|
||||
slug: 'hq-tech',
|
||||
type: 'COMPANY',
|
||||
memberCount: 10,
|
||||
totalMemberCount: 50,
|
||||
),
|
||||
TenantSummary(
|
||||
id: 'company-b',
|
||||
name: '바른컨설턴트',
|
||||
slug: 'consulting',
|
||||
type: 'COMPANY',
|
||||
memberCount: 5,
|
||||
totalMemberCount: 20,
|
||||
),
|
||||
TenantSummary(
|
||||
id: 'team-is3',
|
||||
name: 'IS3',
|
||||
slug: 'is3',
|
||||
type: 'ORGANIZATION',
|
||||
parentId: 'company-a',
|
||||
memberCount: 3,
|
||||
totalMemberCount: 3,
|
||||
),
|
||||
TenantSummary(
|
||||
id: 'team-ptc',
|
||||
name: 'PTC',
|
||||
slug: 'ptc',
|
||||
type: 'ORGANIZATION',
|
||||
parentId: 'company-a',
|
||||
memberCount: 2,
|
||||
totalMemberCount: 2,
|
||||
),
|
||||
];
|
||||
|
||||
test('shows pinned team chip for selected company', () {
|
||||
final layout = buildTenantNavigationModel(
|
||||
tenants: tenants,
|
||||
selectedTenantSlug: 'hq-tech',
|
||||
pinnedTenantSlug: 'is3',
|
||||
);
|
||||
|
||||
expect(layout.companyTenants.map((tenant) => tenant.slug), [
|
||||
'hq-tech',
|
||||
'consulting',
|
||||
]);
|
||||
expect(layout.visibleChips.map((tenant) => tenant.slug), [
|
||||
'hq-tech',
|
||||
'consulting',
|
||||
'is3',
|
||||
]);
|
||||
});
|
||||
|
||||
test('builds breadcrumb and child tenants for selected child team', () {
|
||||
final layout = buildTenantNavigationModel(
|
||||
tenants: tenants,
|
||||
selectedTenantSlug: 'is3',
|
||||
pinnedTenantSlug: 'is3',
|
||||
);
|
||||
|
||||
expect(layout.selectedTenant?.slug, 'is3');
|
||||
expect(layout.breadcrumbTenants.map((tenant) => tenant.slug), [
|
||||
'hq-tech',
|
||||
'is3',
|
||||
]);
|
||||
expect(layout.childTenants, isEmpty);
|
||||
});
|
||||
|
||||
test('shows company drilldown for all selection', () {
|
||||
final layout = buildTenantNavigationModel(
|
||||
tenants: tenants,
|
||||
selectedTenantSlug: 'all',
|
||||
pinnedTenantSlug: 'is3',
|
||||
);
|
||||
|
||||
expect(layout.selectedTenant, isNull);
|
||||
expect(layout.childTenants.map((tenant) => tenant.slug), [
|
||||
'hq-tech',
|
||||
'consulting',
|
||||
]);
|
||||
expect(layout.visibleChips.map((tenant) => tenant.slug), [
|
||||
'hq-tech',
|
||||
'consulting',
|
||||
'is3',
|
||||
]);
|
||||
});
|
||||
|
||||
test('builds selected tenant subtree slugs for scoped search', () {
|
||||
final layout = buildTenantNavigationModel(
|
||||
tenants: tenants,
|
||||
selectedTenantSlug: 'hq-tech',
|
||||
pinnedTenantSlug: 'is3',
|
||||
);
|
||||
|
||||
expect(layout.selectedTenantSubtreeSlugs, ['hq-tech', 'is3', 'ptc']);
|
||||
});
|
||||
|
||||
test('resolves pinned team slug from company and department name', () {
|
||||
final slug = resolvePinnedTenantSlug(
|
||||
tenants: tenants,
|
||||
companySlug: 'hq-tech',
|
||||
departmentName: 'IS3',
|
||||
);
|
||||
|
||||
expect(slug, 'is3');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:tdc114plus/src/features/directory/data/directory_repository.dart';
|
||||
import 'package:tdc114plus/src/features/directory/domain/employee.dart';
|
||||
|
||||
void main() {
|
||||
test('DirectoryQuery maps all tenant filter to nullable API parameters', () {
|
||||
const query = DirectoryQuery(query: ' 김하늘 ', tenantSlug: 'all');
|
||||
|
||||
expect(query.apiQuery, '김하늘');
|
||||
expect(query.apiTenantSlug, isNull);
|
||||
});
|
||||
|
||||
test('DirectoryQuery keeps selected tenant slug for API requests', () {
|
||||
const query = DirectoryQuery(query: '', tenantSlug: 'hanmac');
|
||||
|
||||
expect(query.apiQuery, isNull);
|
||||
expect(query.apiTenantSlug, 'hanmac');
|
||||
});
|
||||
|
||||
test('DirectoryRepository now focuses on employee lists only', () {
|
||||
const employees = [
|
||||
Employee(
|
||||
id: 'user-001',
|
||||
name: '김하늘',
|
||||
phoneNumber: '+821012345678',
|
||||
tenantId: 'tenant-hanmac',
|
||||
tenantName: '한맥',
|
||||
tenantSlug: 'hanmac',
|
||||
),
|
||||
];
|
||||
|
||||
expect(employees.single.name, '김하늘');
|
||||
expect(employees.single.tenantSlug, 'hanmac');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:tdc114plus/src/features/auth/data/auth_session_store.dart';
|
||||
import 'package:tdc114plus/src/features/auth/domain/auth_models.dart';
|
||||
import 'package:tdc114plus/src/features/directory/data/profile_image_api_client.dart';
|
||||
import 'package:tdc114plus/src/features/directory/domain/employee.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
test('keeps explicit profileImageUrl without auth lookup', () async {
|
||||
var requestCount = 0;
|
||||
final client = ProfileImageApiClient(
|
||||
httpClient: MockClient((request) async {
|
||||
requestCount += 1;
|
||||
return http.Response('{}', 500);
|
||||
}),
|
||||
baseUri: Uri.parse('https://114-auth.hmac.kr'),
|
||||
sessionStore: const AuthSessionStore(),
|
||||
);
|
||||
|
||||
const employee = Employee(
|
||||
id: 'user-1',
|
||||
name: '강경훈',
|
||||
phoneNumber: '01012345678',
|
||||
email: 'khkang@samaneng.com',
|
||||
tenantId: 'tenant-1',
|
||||
tenantName: '삼안',
|
||||
tenantSlug: 'saman',
|
||||
profileImageUrl: 'https://cdn.example.test/khkang.jpg',
|
||||
);
|
||||
|
||||
final imageUrl = await client.resolveImageUrl(employee);
|
||||
|
||||
expect(imageUrl, 'https://cdn.example.test/khkang.jpg');
|
||||
expect(requestCount, 0);
|
||||
});
|
||||
|
||||
test('uses auth profile-image endpoint when explicit profile image is absent', () async {
|
||||
const store = AuthSessionStore();
|
||||
await store.save(
|
||||
PhoneLoginResponse(
|
||||
status: 'ok',
|
||||
token: 'app-session-token',
|
||||
expiresAt: DateTime.now().toUtc().add(const Duration(hours: 1)),
|
||||
user: const LoginUser(
|
||||
id: 'user-1',
|
||||
name: '강경훈',
|
||||
phoneNumber: '01012345678',
|
||||
tenantId: 'tenant-1',
|
||||
tenantName: '삼안',
|
||||
tenantSlug: 'saman',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final client = ProfileImageApiClient(
|
||||
httpClient: MockClient((request) async {
|
||||
expect(request.url.host, '114-auth.hmac.kr');
|
||||
expect(request.url.path, '/api/v1/profile-image');
|
||||
expect(request.url.queryParameters['comp'], 'SAMAN');
|
||||
expect(request.url.queryParameters['email'], 'khkang@samaneng.com');
|
||||
expect(request.headers['Authorization'], 'Bearer app-session-token');
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'found': true,
|
||||
'source': 'BARON_UUID_R2',
|
||||
'imageUrl':
|
||||
'https://baroncs.co.kr/employee_img/bebb832c-052e-493d-b5a9-732518d67685.jpg',
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}),
|
||||
baseUri: Uri.parse('https://114-auth.hmac.kr'),
|
||||
sessionStore: store,
|
||||
);
|
||||
|
||||
const employee = Employee(
|
||||
id: 'bebb832c-052e-493d-b5a9-732518d67685',
|
||||
name: '강경훈',
|
||||
phoneNumber: '01012345678',
|
||||
email: 'khkang@samaneng.com',
|
||||
tenantId: 'tenant-1',
|
||||
tenantName: '삼안',
|
||||
tenantSlug: 'saman',
|
||||
);
|
||||
|
||||
final imageUrl = await client.resolveImageUrl(employee);
|
||||
|
||||
expect(
|
||||
imageUrl,
|
||||
'https://baroncs.co.kr/employee_img/bebb832c-052e-493d-b5a9-732518d67685.jpg',
|
||||
);
|
||||
});
|
||||
|
||||
test('returns null when employee email is missing', () async {
|
||||
final client = ProfileImageApiClient(
|
||||
httpClient: MockClient((request) async {
|
||||
fail('auth lookup should not be sent without email');
|
||||
}),
|
||||
baseUri: Uri.parse('https://114-auth.hmac.kr'),
|
||||
sessionStore: const AuthSessionStore(),
|
||||
);
|
||||
|
||||
const employee = Employee(
|
||||
id: 'user-2',
|
||||
name: '직원',
|
||||
phoneNumber: '01000000000',
|
||||
tenantId: 'tenant-2',
|
||||
tenantName: '한맥',
|
||||
tenantSlug: 'hanmac',
|
||||
);
|
||||
|
||||
expect(await client.resolveImageUrl(employee), isNull);
|
||||
});
|
||||
|
||||
test('falls back to public uuid image when employee email is missing', () async {
|
||||
final client = ProfileImageApiClient(
|
||||
httpClient: MockClient((request) async {
|
||||
fail('auth lookup should not be sent without email');
|
||||
}),
|
||||
baseUri: Uri.parse('https://114-auth.hmac.kr'),
|
||||
sessionStore: const AuthSessionStore(),
|
||||
);
|
||||
|
||||
const employee = Employee(
|
||||
id: 'bebb832c-052e-493d-b5a9-732518d67685',
|
||||
name: '강경훈',
|
||||
phoneNumber: '01012345678',
|
||||
tenantId: 'tenant-1',
|
||||
tenantName: '삼안',
|
||||
tenantSlug: 'saman',
|
||||
);
|
||||
|
||||
expect(
|
||||
await client.resolveImageUrl(employee),
|
||||
'https://baroncs.co.kr/employee_img/bebb832c-052e-493d-b5a9-732518d67685.jpg',
|
||||
);
|
||||
});
|
||||
|
||||
test('allows auth lookup without company code when tenant mapping is unknown', () async {
|
||||
const store = AuthSessionStore();
|
||||
await store.save(
|
||||
PhoneLoginResponse(
|
||||
status: 'ok',
|
||||
token: 'app-session-token',
|
||||
expiresAt: DateTime.now().toUtc().add(const Duration(hours: 1)),
|
||||
user: const LoginUser(
|
||||
id: 'user-3',
|
||||
name: '직원',
|
||||
phoneNumber: '01011112222',
|
||||
tenantId: 'tenant-3',
|
||||
tenantName: 'Unknown',
|
||||
tenantSlug: 'unknown',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final client = ProfileImageApiClient(
|
||||
httpClient: MockClient((request) async {
|
||||
expect(request.url.queryParameters['comp'], isNull);
|
||||
expect(request.url.queryParameters['email'], 'person@example.com');
|
||||
return http.Response(
|
||||
jsonEncode({'found': false, 'source': 'DEFAULT'}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}),
|
||||
baseUri: Uri.parse('https://114-auth.hmac.kr'),
|
||||
sessionStore: store,
|
||||
);
|
||||
|
||||
const employee = Employee(
|
||||
id: 'user-3',
|
||||
name: '직원',
|
||||
phoneNumber: '01011112222',
|
||||
email: 'person@example.com',
|
||||
tenantId: 'tenant-3',
|
||||
tenantName: 'Unknown',
|
||||
tenantSlug: 'unknown',
|
||||
);
|
||||
|
||||
expect(await client.resolveImageUrl(employee), isNull);
|
||||
});
|
||||
|
||||
test('falls back to public uuid image when auth lookup returns not found', () async {
|
||||
const store = AuthSessionStore();
|
||||
await store.save(
|
||||
PhoneLoginResponse(
|
||||
status: 'ok',
|
||||
token: 'app-session-token',
|
||||
expiresAt: DateTime.now().toUtc().add(const Duration(hours: 1)),
|
||||
user: const LoginUser(
|
||||
id: 'user-4',
|
||||
name: '강경훈',
|
||||
phoneNumber: '01012345678',
|
||||
tenantId: 'tenant-1',
|
||||
tenantName: '삼안',
|
||||
tenantSlug: 'saman',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final client = ProfileImageApiClient(
|
||||
httpClient: MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({'found': false, 'source': 'DEFAULT'}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}),
|
||||
baseUri: Uri.parse('https://114-auth.hmac.kr'),
|
||||
sessionStore: store,
|
||||
);
|
||||
|
||||
const employee = Employee(
|
||||
id: 'bebb832c-052e-493d-b5a9-732518d67685',
|
||||
name: '강경훈',
|
||||
phoneNumber: '01012345678',
|
||||
email: 'khkang@samaneng.com',
|
||||
tenantId: 'tenant-1',
|
||||
tenantName: '삼안',
|
||||
tenantSlug: 'saman',
|
||||
);
|
||||
|
||||
expect(
|
||||
await client.resolveImageUrl(employee),
|
||||
'https://baroncs.co.kr/employee_img/bebb832c-052e-493d-b5a9-732518d67685.jpg',
|
||||
);
|
||||
});
|
||||
|
||||
test('uses uuid fallback even when auth not-found result is cached', () async {
|
||||
const store = AuthSessionStore();
|
||||
await store.save(
|
||||
PhoneLoginResponse(
|
||||
status: 'ok',
|
||||
token: 'app-session-token',
|
||||
expiresAt: DateTime.now().toUtc().add(const Duration(hours: 1)),
|
||||
user: const LoginUser(
|
||||
id: 'user-5',
|
||||
name: '직원',
|
||||
phoneNumber: '01012345678',
|
||||
tenantId: 'tenant-1',
|
||||
tenantName: '삼안',
|
||||
tenantSlug: 'saman',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
var requestCount = 0;
|
||||
final client = ProfileImageApiClient(
|
||||
httpClient: MockClient((request) async {
|
||||
requestCount += 1;
|
||||
return http.Response(
|
||||
jsonEncode({'found': false, 'source': 'DEFAULT'}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}),
|
||||
baseUri: Uri.parse('https://114-auth.hmac.kr'),
|
||||
sessionStore: store,
|
||||
);
|
||||
|
||||
const firstEmployee = Employee(
|
||||
id: 'legacy-user-id',
|
||||
name: '직원',
|
||||
phoneNumber: '01012345678',
|
||||
email: 'cached-null@example.com',
|
||||
tenantId: 'tenant-1',
|
||||
tenantName: '삼안',
|
||||
tenantSlug: 'saman',
|
||||
);
|
||||
const secondEmployee = Employee(
|
||||
id: 'bebb832c-052e-493d-b5a9-732518d67685',
|
||||
name: '직원',
|
||||
phoneNumber: '01012345678',
|
||||
email: 'cached-null@example.com',
|
||||
tenantId: 'tenant-1',
|
||||
tenantName: '삼안',
|
||||
tenantSlug: 'saman',
|
||||
);
|
||||
|
||||
expect(await client.resolveImageUrl(firstEmployee), isNull);
|
||||
expect(
|
||||
await client.resolveImageUrl(secondEmployee),
|
||||
'https://baroncs.co.kr/employee_img/bebb832c-052e-493d-b5a9-732518d67685.jpg',
|
||||
);
|
||||
expect(requestCount, 1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:tdc114plus/src/features/favorites/data/favorites_repository.dart';
|
||||
|
||||
void main() {
|
||||
test('loads empty favorites when storage is empty', () async {
|
||||
final repository = SharedPreferencesFavoritesRepository.withStore(
|
||||
_MemoryFavoritesStore(),
|
||||
);
|
||||
|
||||
final favorites = await repository.loadFavorites();
|
||||
|
||||
expect(favorites, isEmpty);
|
||||
});
|
||||
|
||||
test('toggles favorites in local storage', () async {
|
||||
final repository = SharedPreferencesFavoritesRepository.withStore(
|
||||
_MemoryFavoritesStore(),
|
||||
);
|
||||
|
||||
await repository.toggleFavorite('user-001');
|
||||
final added = await repository.loadFavorites();
|
||||
|
||||
expect(added.map((favorite) => favorite.employeeId), ['user-001']);
|
||||
|
||||
await repository.toggleFavorite('user-001');
|
||||
final removed = await repository.loadFavorites();
|
||||
|
||||
expect(removed, isEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
class _MemoryFavoritesStore implements FavoritesKeyValueStore {
|
||||
final _values = <String, String>{};
|
||||
|
||||
@override
|
||||
Future<String?> getString(String key) async {
|
||||
return _values[key];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setString(String key, String value) async {
|
||||
_values[key] = value;
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,54 @@ void main() {
|
||||
expect(response.user.tenantSlug, 'hanmac');
|
||||
});
|
||||
|
||||
test('PhoneLoginLinkInitResponse parses pending auth response', () {
|
||||
final response = PhoneLoginLinkInitResponse.fromJson({
|
||||
'status': 'pending',
|
||||
'pendingRef': 'pending-ref',
|
||||
'expiresIn': 180,
|
||||
'interval': 3,
|
||||
'resendAfter': 30,
|
||||
'provider': 'Ory (Kratos/Hydra)',
|
||||
});
|
||||
|
||||
expect(response.pendingRef, 'pending-ref');
|
||||
expect(response.interval, 3);
|
||||
expect(response.provider, 'Ory (Kratos/Hydra)');
|
||||
});
|
||||
|
||||
test('PhoneLoginLinkPollResponse parses completed session', () {
|
||||
final response = PhoneLoginLinkPollResponse.fromJson({
|
||||
'status': 'ok',
|
||||
'session': {
|
||||
'status': 'ok',
|
||||
'token': 'baron-sso-session-token',
|
||||
'expiresAt': '2026-07-02T12:00:00Z',
|
||||
'user': {
|
||||
'id': 'user-uuid',
|
||||
'name': 'User One',
|
||||
'phoneNumber': '+821012345678',
|
||||
'tenantId': 'tenant-uuid',
|
||||
'tenantName': 'Hanmac',
|
||||
'tenantSlug': 'hanmac',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.session?.token, 'baron-sso-session-token');
|
||||
expect(response.isPending, isFalse);
|
||||
});
|
||||
|
||||
test('PhoneLoginLinkPollResponse detects pending response', () {
|
||||
final response = PhoneLoginLinkPollResponse.fromJson({
|
||||
'status': 'pending',
|
||||
'code': 'authorization_pending',
|
||||
'interval': 5,
|
||||
});
|
||||
|
||||
expect(response.isPending, isTrue);
|
||||
expect(response.interval, 5);
|
||||
});
|
||||
|
||||
test('CurrentUser parses permissions and can map to employee', () {
|
||||
final user = CurrentUser.fromJson({
|
||||
'id': 'user-uuid',
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:tdc114plus/src/features/directory/data/directory_repository.dart';
|
||||
import 'package:tdc114plus/src/features/auth/data/auth_session_store.dart';
|
||||
import 'package:tdc114plus/src/features/auth/domain/auth_models.dart';
|
||||
import 'package:tdc114plus/src/features/organization/data/org_context_api_client.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
OrgContextApiClient.clearCacheForTesting();
|
||||
});
|
||||
|
||||
test('maps Baron org-context response to app org chart snapshot', () async {
|
||||
final client = OrgContextApiClient(
|
||||
httpClient: MockClient((request) async {
|
||||
expect(request.url.path, '/api/v1/integrations/org-context');
|
||||
expect(request.url.queryParameters['tenantSlug'], 'hanmac-family');
|
||||
expect(request.url.queryParameters['includeUsers'], 'true');
|
||||
expect(request.url.queryParameters['includeUserIds'], 'true');
|
||||
expect(request.headers['X-Baron-Key-ID'], 'key-id');
|
||||
expect(request.headers['X-Baron-Key-Secret'], 'key-secret');
|
||||
|
||||
return _jsonResponse(_orgContextResponse());
|
||||
}),
|
||||
baseUri: Uri.parse('https://sadmin.hmac.kr'),
|
||||
keyId: 'key-id',
|
||||
keySecret: 'key-secret',
|
||||
tenantSlug: 'hanmac-family',
|
||||
);
|
||||
|
||||
final snapshot = await client.fetchOrgContext();
|
||||
|
||||
expect(snapshot.tenants.map((tenant) => tenant.slug), contains('is3'));
|
||||
expect(snapshot.employees, hasLength(4));
|
||||
expect(snapshot.employees.first.tenantName, 'IS3');
|
||||
expect(snapshot.employees.first.phoneDisplay, '010-3189-1514');
|
||||
|
||||
final center = snapshot.tenants.singleWhere(
|
||||
(tenant) => tenant.slug == 'center',
|
||||
);
|
||||
final is3 = snapshot.tenants.singleWhere((tenant) => tenant.slug == 'is3');
|
||||
expect(center.totalMemberCount, 4);
|
||||
expect(is3.memberCount, 2);
|
||||
expect(is3.totalMemberCount, 2);
|
||||
});
|
||||
|
||||
test('uses session org-context credential before env fallback', () async {
|
||||
const store = AuthSessionStore();
|
||||
await store.save(
|
||||
PhoneLoginResponse(
|
||||
status: 'ok',
|
||||
token: 'session-token',
|
||||
expiresAt: DateTime.now().toUtc().add(const Duration(hours: 1)),
|
||||
user: const LoginUser(
|
||||
id: 'user-id',
|
||||
name: 'User',
|
||||
phoneNumber: '',
|
||||
tenantId: '',
|
||||
tenantName: '',
|
||||
tenantSlug: '',
|
||||
),
|
||||
orgContextCredential: const OrgContextCredential(
|
||||
baseUrl: 'https://session.example.test',
|
||||
tenantSlug: 'session-family',
|
||||
keyId: 'session-key-id',
|
||||
keySecret: 'session-key-secret',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final client = OrgContextApiClient(
|
||||
httpClient: MockClient((request) async {
|
||||
expect(request.url.host, 'session.example.test');
|
||||
expect(request.url.queryParameters['tenantSlug'], 'session-family');
|
||||
expect(request.headers['Authorization'], 'Bearer session-token');
|
||||
expect(request.headers['X-Baron-Key-ID'], 'session-key-id');
|
||||
expect(request.headers['X-Baron-Key-Secret'], 'session-key-secret');
|
||||
return _jsonResponse(_orgContextResponse());
|
||||
}),
|
||||
baseUri: Uri.parse('https://env.example.test'),
|
||||
keyId: 'env-key-id',
|
||||
keySecret: 'env-key-secret',
|
||||
tenantSlug: 'env-family',
|
||||
sessionStore: store,
|
||||
);
|
||||
|
||||
await client.fetchOrgContext();
|
||||
});
|
||||
|
||||
test('uses requested tenant slug for scoped org-context reads', () async {
|
||||
final client = OrgContextApiClient(
|
||||
httpClient: MockClient((request) async {
|
||||
expect(request.url.queryParameters['tenantSlug'], 'is3');
|
||||
return _jsonResponse(_orgContextResponse());
|
||||
}),
|
||||
baseUri: Uri.parse('https://sadmin.hmac.kr'),
|
||||
keyId: 'key-id',
|
||||
keySecret: 'key-secret',
|
||||
tenantSlug: 'hanmac-family',
|
||||
);
|
||||
|
||||
await client.fetchOrgContext(tenantSlug: 'is3');
|
||||
});
|
||||
|
||||
test('caches org-context response by requested tenant slug', () async {
|
||||
final requestedTenantSlugs = <String>[];
|
||||
final client = OrgContextApiClient(
|
||||
httpClient: MockClient((request) async {
|
||||
requestedTenantSlugs.add(request.url.queryParameters['tenantSlug']!);
|
||||
return _jsonResponse(_orgContextResponse());
|
||||
}),
|
||||
baseUri: Uri.parse('https://114-auth.hmac.kr'),
|
||||
keyId: '',
|
||||
keySecret: '',
|
||||
tenantSlug: 'hanmac-family',
|
||||
);
|
||||
|
||||
await client.fetchOrgContext(tenantSlug: 'is3');
|
||||
await client.fetchOrgContext(tenantSlug: 'is3');
|
||||
await client.fetchOrgContext(tenantSlug: 'samahn');
|
||||
|
||||
expect(requestedTenantSlugs, ['is3', 'samahn']);
|
||||
});
|
||||
|
||||
test('uses app session bearer token for auth-server org-context proxy', () async {
|
||||
const store = AuthSessionStore();
|
||||
await store.save(
|
||||
PhoneLoginResponse(
|
||||
status: 'ok',
|
||||
token: 'app-session-token',
|
||||
expiresAt: DateTime.now().toUtc().add(const Duration(hours: 1)),
|
||||
user: const LoginUser(
|
||||
id: 'user-id',
|
||||
name: 'User',
|
||||
phoneNumber: '',
|
||||
tenantId: '',
|
||||
tenantName: '',
|
||||
tenantSlug: '',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final client = OrgContextApiClient(
|
||||
httpClient: MockClient((request) async {
|
||||
expect(request.url.host, '114-auth.hmac.kr');
|
||||
expect(request.url.path, '/api/v1/integrations/org-context');
|
||||
expect(request.headers['Authorization'], 'Bearer app-session-token');
|
||||
expect(request.headers.containsKey('X-Baron-Key-ID'), isFalse);
|
||||
expect(request.headers.containsKey('X-Baron-Key-Secret'), isFalse);
|
||||
return _jsonResponse(_orgContextResponse());
|
||||
}),
|
||||
baseUri: Uri.parse('https://114-auth.hmac.kr'),
|
||||
keyId: '',
|
||||
keySecret: '',
|
||||
tenantSlug: 'hanmac-family',
|
||||
sessionStore: store,
|
||||
);
|
||||
|
||||
await client.fetchOrgContext();
|
||||
});
|
||||
|
||||
test('caches org-context response for repeated badge navigation reads', () async {
|
||||
var requestCount = 0;
|
||||
final client = OrgContextApiClient(
|
||||
httpClient: MockClient((request) async {
|
||||
requestCount += 1;
|
||||
expect(request.url.path, '/api/v1/integrations/org-context');
|
||||
return _jsonResponse(_orgContextResponse());
|
||||
}),
|
||||
baseUri: Uri.parse('https://114-auth.hmac.kr'),
|
||||
keyId: '',
|
||||
keySecret: '',
|
||||
tenantSlug: 'hanmac-family',
|
||||
);
|
||||
|
||||
await client.fetchOrgContext();
|
||||
await client.fetchOrgContext();
|
||||
|
||||
expect(requestCount, 1);
|
||||
});
|
||||
|
||||
test(
|
||||
'falls back to env org-context credential without session credential',
|
||||
() async {
|
||||
final client = OrgContextApiClient(
|
||||
httpClient: MockClient((request) async {
|
||||
expect(request.url.host, 'env.example.test');
|
||||
expect(request.url.queryParameters['tenantSlug'], 'env-family');
|
||||
expect(request.headers['X-Baron-Key-ID'], 'env-key-id');
|
||||
expect(request.headers['X-Baron-Key-Secret'], 'env-key-secret');
|
||||
return _jsonResponse(_orgContextResponse());
|
||||
}),
|
||||
baseUri: Uri.parse('https://env.example.test'),
|
||||
keyId: 'env-key-id',
|
||||
keySecret: 'env-key-secret',
|
||||
tenantSlug: 'env-family',
|
||||
sessionStore: const AuthSessionStore(),
|
||||
);
|
||||
|
||||
await client.fetchOrgContext();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'remote directory repository filters org-context employees locally',
|
||||
() async {
|
||||
final client = OrgContextApiClient(
|
||||
httpClient: MockClient((request) async {
|
||||
expect(request.url.queryParameters['tenantSlug'], 'is3');
|
||||
return _jsonResponse(_orgContextResponse());
|
||||
}),
|
||||
baseUri: Uri.parse('https://sadmin.hmac.kr'),
|
||||
keyId: 'key-id',
|
||||
keySecret: 'key-secret',
|
||||
tenantSlug: 'hanmac-family',
|
||||
);
|
||||
final repository = RemoteDirectoryRepository(orgContextApiClient: client);
|
||||
|
||||
final employees = await repository.loadEmployees(
|
||||
const DirectoryQuery(query: '한', tenantSlug: 'is3'),
|
||||
);
|
||||
|
||||
expect(employees.map((employee) => employee.name), ['한승민']);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'remote directory repository searches selected tenant subtree',
|
||||
() async {
|
||||
final client = OrgContextApiClient(
|
||||
httpClient: MockClient((request) async {
|
||||
expect(request.url.queryParameters['tenantSlug'], 'center');
|
||||
return _jsonResponse(_orgContextResponse());
|
||||
}),
|
||||
baseUri: Uri.parse('https://sadmin.hmac.kr'),
|
||||
keyId: 'key-id',
|
||||
keySecret: 'key-secret',
|
||||
tenantSlug: 'hanmac-family',
|
||||
);
|
||||
final repository = RemoteDirectoryRepository(orgContextApiClient: client);
|
||||
|
||||
final employees = await repository.loadEmployees(
|
||||
const DirectoryQuery(
|
||||
query: '박',
|
||||
tenantSlug: 'center',
|
||||
tenantSlugs: ['center', 'is3', 'is2'],
|
||||
),
|
||||
);
|
||||
|
||||
expect(employees.map((employee) => employee.name), ['박주한']);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'remote directory repository preserves department memberships for duplicate users',
|
||||
() async {
|
||||
final client = OrgContextApiClient(
|
||||
httpClient: MockClient((_) async {
|
||||
return _jsonResponse(_orgContextResponse());
|
||||
}),
|
||||
baseUri: Uri.parse('https://sadmin.hmac.kr'),
|
||||
keyId: 'key-id',
|
||||
keySecret: 'key-secret',
|
||||
tenantSlug: 'hanmac-family',
|
||||
);
|
||||
final repository = RemoteDirectoryRepository(orgContextApiClient: client);
|
||||
|
||||
final employees = await repository.loadEmployees(
|
||||
const DirectoryQuery(query: '', tenantSlug: 'all', department: 'IS3'),
|
||||
);
|
||||
|
||||
expect(employees.map((employee) => employee.name), ['한승민', '김윤재']);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _orgContextResponse() {
|
||||
return {
|
||||
'schemaVersion': 'baron.org-context.v1',
|
||||
'issuedAt': '2026-07-08T10:07:07.182Z',
|
||||
'scope': {'tenantId': 'family-id', 'tenantSlug': 'hanmac-family'},
|
||||
'tree': {
|
||||
'id': 'family-id',
|
||||
'type': 'COMPANY_GROUP',
|
||||
'name': '한맥가족',
|
||||
'slug': 'hanmac-family',
|
||||
'memberCount': 0,
|
||||
'children': [
|
||||
{
|
||||
'id': 'center-id',
|
||||
'type': 'DEPARTMENT',
|
||||
'name': '총괄기획&기술개발센터',
|
||||
'slug': 'center',
|
||||
'parentId': 'family-id',
|
||||
'memberCount': 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
'tenants': [
|
||||
{
|
||||
'id': 'is3-id',
|
||||
'type': 'TEAM',
|
||||
'name': 'IS3',
|
||||
'slug': 'is3',
|
||||
'parentId': 'center-id',
|
||||
'memberCount': 0,
|
||||
'members': [
|
||||
{
|
||||
'id': 'user-han',
|
||||
'name': '한승민',
|
||||
'phone': '+821031891514',
|
||||
'email': 'han@example.com',
|
||||
'department': 'IS3',
|
||||
'position': '팀장',
|
||||
'status': 'active',
|
||||
},
|
||||
{
|
||||
'id': 'user-kim',
|
||||
'name': '김윤재',
|
||||
'phone': '01097479838',
|
||||
'email': 'kim@example.com',
|
||||
'department': 'IS3',
|
||||
'position': '연구원',
|
||||
'status': 'active',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'id': 'is2-id',
|
||||
'type': 'TEAM',
|
||||
'name': 'IS2',
|
||||
'slug': 'is2',
|
||||
'parentId': 'center-id',
|
||||
'memberCount': 0,
|
||||
'members': [
|
||||
{
|
||||
'id': 'user-park',
|
||||
'name': '박주한',
|
||||
'phone': '01089553850',
|
||||
'email': 'park@example.com',
|
||||
'department': 'IS2',
|
||||
'position': '연구원',
|
||||
'status': 'active',
|
||||
},
|
||||
{
|
||||
'id': 'user-han',
|
||||
'name': '한승민',
|
||||
'phone': '01031891514',
|
||||
'email': 'han@example.com',
|
||||
'department': 'IS2',
|
||||
'position': '팀장',
|
||||
'status': 'active',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
http.Response _jsonResponse(Map<String, dynamic> body) {
|
||||
return http.Response.bytes(
|
||||
utf8.encode(jsonEncode(body)),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:tdc114plus/src/features/auth/data/auth_session_store.dart';
|
||||
import 'package:tdc114plus/src/features/auth/domain/auth_models.dart';
|
||||
import 'package:tdc114plus/src/features/organization/data/organization_api_client.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
await const AuthSessionStore().save(
|
||||
PhoneLoginResponse(
|
||||
status: 'ok',
|
||||
token: 'session-token',
|
||||
expiresAt: DateTime.parse('2026-07-02T12:00:00Z'),
|
||||
user: const LoginUser(
|
||||
id: 'user-uuid',
|
||||
name: 'User One',
|
||||
phoneNumber: '+821012345678',
|
||||
tenantId: 'tenant-uuid',
|
||||
tenantName: 'Hanmac',
|
||||
tenantSlug: 'hanmac',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('loads org chart with bearer token', () async {
|
||||
final client = OrganizationApiClient(
|
||||
httpClient: MockClient((request) async {
|
||||
expect(request.headers['authorization'], 'Bearer session-token');
|
||||
expect(request.url.path, '/api/v1/tdc114plus/organization/orgchart');
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'tenants': [
|
||||
{
|
||||
'id': 'tenant-uuid',
|
||||
'name': 'Hanmac',
|
||||
'slug': 'hanmac',
|
||||
'type': 'COMPANY',
|
||||
'memberCount': 1,
|
||||
'totalMemberCount': 1,
|
||||
},
|
||||
],
|
||||
'employees': [
|
||||
{
|
||||
'id': 'user-uuid',
|
||||
'name': 'User One',
|
||||
'phoneNumber': '+821012345678',
|
||||
'tenantId': 'tenant-uuid',
|
||||
'tenantName': 'Hanmac',
|
||||
'tenantSlug': 'hanmac',
|
||||
},
|
||||
],
|
||||
'generatedAt': '2026-07-02T12:00:00Z',
|
||||
'cache': {'source': 'db', 'hit': false, 'ttlSeconds': 0},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}),
|
||||
baseUri: Uri.parse('https://sso.example.test'),
|
||||
sessionStore: const AuthSessionStore(),
|
||||
);
|
||||
|
||||
final snapshot = await client.getOrgChart();
|
||||
|
||||
expect(snapshot.tenants.single.slug, 'hanmac');
|
||||
expect(snapshot.employees.single.name, 'User One');
|
||||
});
|
||||
}
|
||||
+1515
-13
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
import 'package:integration_test/integration_test_driver.dart';
|
||||
|
||||
Future<void> main() => integrationDriver();
|
||||
Reference in New Issue
Block a user