1
0
forked from baron/baron-sso

e2e 구조변경

This commit is contained in:
Lectom C Han
2026-02-24 15:23:36 +09:00
parent 3fdcaa5832
commit 4ffe5110dd
46 changed files with 2735 additions and 393 deletions

View File

@@ -54,12 +54,12 @@ empty_detail = "앱을 연동하면 최근 활동과 상태가 표시됩니다."
error = "연동 정보를 불러오지 못했습니다."
[msg.userfront.dashboard.approved_session]
copy_click = "{label}: {id}\\\\\\\\n클릭하면 복사됩니다."
copy_tap = "{label}: {id}\\\\\\\\n탭하면 복사됩니다."
copy_click = "{label}: {id} \\n클릭하면 복사됩니다."
copy_tap = "{label}: {id} \\n탭하면 복사됩니다."
none = "{label} 없음"
[msg.userfront.dashboard.revoke]
confirm = "{app} 앱과의 연동을 해지하시겠습니까?\\\\\\\\n해지하면 다음 로그인 시 다시 동의가 필요합니다."
confirm = "{app} 앱과의 연동을 해지하시겠습니까? \\n해지하면 다음 로그인 시 다시 동의가 필요합니다."
error = "해지 실패: {error}"
success = "{app} 연동이 해지되었습니다."

View File

@@ -1361,6 +1361,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
child: Column(
children: [
TextField(
key: const ValueKey(
'password_login_id_input',
),
controller: _passwordLoginIdController,
decoration: InputDecoration(
labelText: tr(
@@ -1375,6 +1378,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
),
const SizedBox(height: 16),
TextField(
key: const ValueKey(
'password_login_password_input',
),
controller: _passwordController,
obscureText: true,
decoration: InputDecoration(
@@ -1390,6 +1396,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
),
const SizedBox(height: 24),
FilledButton(
key: const ValueKey(
'password_login_submit_button',
),
onPressed: _handlePasswordLogin,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(50),

View File

@@ -192,6 +192,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
),
const SizedBox(height: 40),
TextFormField(
key: const ValueKey('reset_password_new_input'),
controller: _passwordController,
obscureText: _isPasswordObscured,
decoration: InputDecoration(
@@ -263,6 +264,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
),
const SizedBox(height: 16),
TextFormField(
key: const ValueKey('reset_password_confirm_input'),
controller: _confirmPasswordController,
obscureText: _isConfirmPasswordObscured,
decoration: InputDecoration(
@@ -292,6 +294,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
),
const SizedBox(height: 24),
FilledButton(
key: const ValueKey('reset_password_submit_button'),
onPressed: _isLoading ? null : _handlePasswordReset,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(50),

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -31,6 +32,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
static const _surface = Colors.white;
static const _border = Color(0xFFE5E7EB);
static const _subtle = Color(0xFFF7F8FA);
static const double _historySessionMinWidth = 92;
static const double _historyOtherColumnsBaselineWidth = 780;
static const int _historySessionMinVisibleChars = 8;
final ScrollController _pageScrollController = ScrollController();
final ScrollController _rpScrollController = ScrollController();
@@ -1370,6 +1374,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
children: [
LayoutBuilder(
builder: (context, constraints) {
final sessionColumnWidth = _historySessionColumnWidth(
constraints.maxWidth,
);
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
@@ -1379,10 +1386,13 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
horizontalMargin: 12,
columns: [
DataColumn(
label: Text(
tr(
'ui.userfront.audit.table.session_id',
fallback: 'Session ID',
label: SizedBox(
width: sessionColumnWidth,
child: Text(
tr(
'ui.userfront.audit.table.session_id',
fallback: 'Session ID',
),
),
),
),
@@ -1426,10 +1436,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
return DataRow(
cells: [
DataCell(
_selectableText(
log.sessionId.isEmpty
? tr('ui.common.hyphen', fallback: '-')
: log.sessionId,
SizedBox(
width: sessionColumnWidth,
child: _buildHistorySessionIdCell(
log.sessionId.isEmpty
? tr('ui.common.hyphen', fallback: '-')
: log.sessionId,
sessionColumnWidth,
),
),
),
DataCell(
@@ -1474,6 +1488,36 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
);
}
double _historySessionColumnWidth(double maxWidth) {
return math.max(
_historySessionMinWidth,
maxWidth - _historyOtherColumnsBaselineWidth,
);
}
String _compactSessionId(String sessionId) {
if (sessionId.length <= _historySessionMinVisibleChars) {
return sessionId;
}
return '${sessionId.substring(0, _historySessionMinVisibleChars)}...';
}
Widget _buildHistorySessionIdCell(String sessionId, double columnWidth) {
final compactMode = columnWidth <= _historySessionMinWidth + 0.5;
final displayText = compactMode ? _compactSessionId(sessionId) : sessionId;
final textWidget = Text(
displayText,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.ellipsis,
);
if (displayText == sessionId) {
return textWidget;
}
return Tooltip(message: sessionId, child: textWidget);
}
Widget _buildHistoryList(AuthTimelineState state) {
return _buildHistoryContainer(
child: Column(

View File

@@ -41,6 +41,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
bool _phoneTouched = false;
bool _phoneCodeTouched = false;
bool _isSavingField = false;
String? _skipAutoSaveField;
String _initialPhone = '';
bool _isPhoneChanged = false;
@@ -354,6 +355,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
void _autoSaveIfEditing(UserProfile profile, String field) {
if (_editingField != field) return;
if (_skipAutoSaveField == field) {
_skipAutoSaveField = null;
return;
}
if (_isVerifying) return;
if (_isSavingField) return;
if (!_hasFieldChanged(profile, field)) {
@@ -375,6 +380,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
void _handlePhoneFocusChange(UserProfile profile) {
if (_editingField != 'phone') return;
if (_skipAutoSaveField == 'phone') {
_skipAutoSaveField = null;
return;
}
if (_isVerifying) return;
if (_isSavingField) return;
if (_phoneFocus.hasFocus || _phoneCodeFocus.hasFocus) return;
@@ -704,6 +713,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
title: Text(label),
subtitle: Text(displayValue),
trailing: TextButton(
key: Key('profile-$field-edit-button'),
onPressed: isUpdating ? null : () => _startEditing(field, profile),
child: Text(tr('ui.common.edit')),
),
@@ -720,6 +730,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
children: [
Expanded(
child: TextField(
key: Key('profile-$field-input'),
controller: controller,
focusNode: field == 'name' ? _nameFocus : _departmentFocus,
textInputAction: TextInputAction.done,
@@ -731,9 +742,15 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
),
const SizedBox(width: 12),
OutlinedButton(
onPressed: isUpdating ? null : () => _cancelEditing(profile),
child: Text(tr('ui.common.cancel')),
Listener(
onPointerDown: (_) {
_skipAutoSaveField = field;
},
child: OutlinedButton(
key: Key('profile-$field-cancel-button'),
onPressed: isUpdating ? null : () => _cancelEditing(profile),
child: Text(tr('ui.common.cancel')),
),
),
],
),
@@ -796,9 +813,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: isUpdating ? null : () => _cancelEditing(profile),
child: Text(tr('ui.common.cancel')),
Listener(
onPointerDown: (_) {
_skipAutoSaveField = 'phone';
},
child: OutlinedButton(
onPressed: isUpdating ? null : () => _cancelEditing(profile),
child: Text(tr('ui.common.cancel')),
),
),
],
),

View File

@@ -0,0 +1,112 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:userfront/features/profile/data/models/user_profile_model.dart';
import 'package:userfront/features/profile/data/repositories/profile_repository.dart';
import 'package:userfront/features/profile/domain/notifiers/profile_notifier.dart';
class _FakeProfileRepository extends ProfileRepository {
_FakeProfileRepository({
required this.initialProfile,
required this.persistUpdate,
}) : _profile = initialProfile;
final UserProfile initialProfile;
final bool persistUpdate;
UserProfile _profile;
int updateCount = 0;
String? lastRequestedDepartment;
@override
Future<UserProfile> getMyProfile() async {
return _profile;
}
@override
Future<void> updateMyProfile({
required String name,
required String phone,
required String department,
}) async {
updateCount += 1;
lastRequestedDepartment = department;
if (!persistUpdate) {
return;
}
_profile = _profile.copyWith(
name: name,
phone: phone,
department: department,
);
}
}
UserProfile _seedProfile({required String department}) {
return UserProfile(
id: 'user-1',
email: 'qa@example.com',
name: 'QA User',
phone: '01012345678',
department: department,
affiliationType: 'employee',
companyCode: 'BARON',
);
}
void main() {
test('재현: 저장소가 소속 변경을 반영하지 않으면 loadProfile 이후 이전 값으로 보인다', () async {
final repository = _FakeProfileRepository(
initialProfile: _seedProfile(department: 'Old Dept'),
persistUpdate: false,
);
final container = ProviderContainer(
overrides: [profileRepositoryProvider.overrideWithValue(repository)],
);
addTearDown(container.dispose);
final initial = await container.read(profileProvider.future);
expect(initial?.department, 'Old Dept');
await container
.read(profileProvider.notifier)
.updateProfile(
name: 'QA User',
phone: '01012345678',
department: 'New Dept',
);
expect(repository.updateCount, 1);
expect(repository.lastRequestedDepartment, 'New Dept');
await container.read(profileProvider.notifier).loadProfile();
expect(container.read(profileProvider).value?.department, 'Old Dept');
});
test('소속 변경이 저장소에 반영되면 loadProfile 이후에도 변경값이 유지된다', () async {
final repository = _FakeProfileRepository(
initialProfile: _seedProfile(department: 'Old Dept'),
persistUpdate: true,
);
final container = ProviderContainer(
overrides: [profileRepositoryProvider.overrideWithValue(repository)],
);
addTearDown(container.dispose);
final initial = await container.read(profileProvider.future);
expect(initial?.department, 'Old Dept');
await container
.read(profileProvider.notifier)
.updateProfile(
name: 'QA User',
phone: '01012345678',
department: 'New Dept',
);
expect(repository.updateCount, 1);
expect(repository.lastRequestedDepartment, 'New Dept');
await container.read(profileProvider.notifier).loadProfile();
expect(container.read(profileProvider).value?.department, 'New Dept');
});
}