feat(user): 사용자 자기정보 편집과 관리자 재설정 플로우를 연동
- lib/widgets/app_shell.dart에서 내 정보 다이얼로그를 추가하고 UserRepository.updateMe·비밀번호 변경 로직을 연결 - lib/features/masters/user/* 모듈에 phone·forcePasswordChange·passwordUpdatedAt 필드를 반영하고 reset-password/update-me API를 사용 - lib/core/validation/password_rules.dart을 신설해 비밀번호 정책 검증을 공통화하고 신규 위젯·테스트에서 재사용 - doc/stock_approval_system_api_v4.md 등 문서를 users 스펙 개편 내용으로 갱신하고 user_management_plan.md를 추가 - test/widgets/app_shell_test.dart 등에서 자기정보 수정·비밀번호 재설정 시나리오를 검증하고 기존 테스트를 보강
This commit is contained in:
55
lib/core/validation/password_rules.dart
Normal file
55
lib/core/validation/password_rules.dart
Normal file
@@ -0,0 +1,55 @@
|
||||
/// 비밀번호 정책을 검증하기 위한 규칙 모음.
|
||||
class PasswordRules {
|
||||
PasswordRules._();
|
||||
|
||||
/// 허용 최소 길이.
|
||||
static const int minLength = 8;
|
||||
|
||||
/// 허용 최대 길이.
|
||||
static const int maxLength = 24;
|
||||
|
||||
static final RegExp _uppercase = RegExp(r'[A-Z]');
|
||||
static final RegExp _lowercase = RegExp(r'[a-z]');
|
||||
static final RegExp _digit = RegExp(r'[0-9]');
|
||||
static final RegExp _special = RegExp(
|
||||
"[!@#\$%\\^&*()_+\\-={}\\[\\]\\\\|:;\"'<>,.?/~`]",
|
||||
);
|
||||
|
||||
/// 입력이 모든 비밀번호 규칙을 만족하는지 검사한다.
|
||||
static bool isValid(String value) => validate(value).isEmpty;
|
||||
|
||||
/// 비밀번호 정책 위반 항목을 반환한다.
|
||||
static List<PasswordRuleViolation> validate(String value) {
|
||||
final violations = <PasswordRuleViolation>[];
|
||||
final length = value.length;
|
||||
if (length < minLength) {
|
||||
violations.add(PasswordRuleViolation.tooShort);
|
||||
}
|
||||
if (length > maxLength) {
|
||||
violations.add(PasswordRuleViolation.tooLong);
|
||||
}
|
||||
if (!_uppercase.hasMatch(value)) {
|
||||
violations.add(PasswordRuleViolation.missingUppercase);
|
||||
}
|
||||
if (!_lowercase.hasMatch(value)) {
|
||||
violations.add(PasswordRuleViolation.missingLowercase);
|
||||
}
|
||||
if (!_digit.hasMatch(value)) {
|
||||
violations.add(PasswordRuleViolation.missingDigit);
|
||||
}
|
||||
if (!_special.hasMatch(value)) {
|
||||
violations.add(PasswordRuleViolation.missingSpecial);
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
}
|
||||
|
||||
/// 비밀번호 규칙 위반 유형.
|
||||
enum PasswordRuleViolation {
|
||||
tooShort,
|
||||
tooLong,
|
||||
missingUppercase,
|
||||
missingLowercase,
|
||||
missingDigit,
|
||||
missingSpecial,
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
|
||||
import '../../../core/network/interceptors/auth_interceptor.dart';
|
||||
import '../../../core/services/token_storage.dart';
|
||||
import '../domain/entities/auth_session.dart';
|
||||
import '../domain/entities/authenticated_user.dart';
|
||||
import '../domain/entities/login_request.dart';
|
||||
import '../domain/repositories/auth_repository.dart';
|
||||
|
||||
@@ -69,6 +70,22 @@ class AuthService extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// 현재 세션의 사용자 정보를 갱신한다.
|
||||
void updateSessionUser(AuthenticatedUser user) {
|
||||
final current = _session;
|
||||
if (current == null) {
|
||||
return;
|
||||
}
|
||||
_session = AuthSession(
|
||||
accessToken: current.accessToken,
|
||||
refreshToken: current.refreshToken,
|
||||
expiresAt: current.expiresAt,
|
||||
user: user,
|
||||
permissions: current.permissions,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _persistSession(AuthSession session) async {
|
||||
_session = session;
|
||||
await _tokenStorage.writeAccessToken(session.accessToken);
|
||||
|
||||
@@ -96,6 +96,7 @@ AuthenticatedUser _parseUser(Map<String, dynamic> json) {
|
||||
final name = _readString(json, 'name') ?? '';
|
||||
final employeeNo = _readString(json, 'employee_no');
|
||||
final email = _readString(json, 'email');
|
||||
final phone = _readString(json, 'phone');
|
||||
final group = JsonUtils.extractMap(
|
||||
json,
|
||||
keys: const ['group', 'primary_group'],
|
||||
@@ -105,6 +106,7 @@ AuthenticatedUser _parseUser(Map<String, dynamic> json) {
|
||||
name: name,
|
||||
employeeNo: employeeNo,
|
||||
email: email,
|
||||
phone: phone,
|
||||
primaryGroupId: _readOptionalInt(group, 'id'),
|
||||
primaryGroupName: _readString(group, 'name'),
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ class AuthenticatedUser {
|
||||
required this.name,
|
||||
this.employeeNo,
|
||||
this.email,
|
||||
this.phone,
|
||||
this.primaryGroupId,
|
||||
this.primaryGroupName,
|
||||
});
|
||||
@@ -21,9 +22,31 @@ class AuthenticatedUser {
|
||||
/// 이메일
|
||||
final String? email;
|
||||
|
||||
/// 연락처
|
||||
final String? phone;
|
||||
|
||||
/// 기본 소속 그룹 ID
|
||||
final int? primaryGroupId;
|
||||
|
||||
/// 기본 소속 그룹명
|
||||
final String? primaryGroupName;
|
||||
|
||||
AuthenticatedUser copyWith({
|
||||
String? name,
|
||||
String? employeeNo,
|
||||
String? email,
|
||||
String? phone,
|
||||
int? primaryGroupId,
|
||||
String? primaryGroupName,
|
||||
}) {
|
||||
return AuthenticatedUser(
|
||||
id: id,
|
||||
name: name ?? this.name,
|
||||
employeeNo: employeeNo ?? this.employeeNo,
|
||||
email: email ?? this.email,
|
||||
phone: phone ?? this.phone,
|
||||
primaryGroupId: primaryGroupId ?? this.primaryGroupId,
|
||||
primaryGroupName: primaryGroupName ?? this.primaryGroupName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ class UserDto {
|
||||
this.isActive = true,
|
||||
this.isDeleted = false,
|
||||
this.note,
|
||||
this.forcePasswordChange = false,
|
||||
this.passwordUpdatedAt,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
});
|
||||
@@ -28,6 +30,8 @@ class UserDto {
|
||||
final bool isActive;
|
||||
final bool isDeleted;
|
||||
final String? note;
|
||||
final bool forcePasswordChange;
|
||||
final DateTime? passwordUpdatedAt;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
@@ -35,16 +39,22 @@ class UserDto {
|
||||
factory UserDto.fromJson(Map<String, dynamic> json) {
|
||||
return UserDto(
|
||||
id: json['id'] as int?,
|
||||
employeeNo: json['employee_no'] as String,
|
||||
employeeName: json['employee_name'] as String,
|
||||
employeeNo:
|
||||
json['employee_id'] as String? ??
|
||||
json['employee_no'] as String? ??
|
||||
'-',
|
||||
employeeName:
|
||||
json['name'] as String? ?? json['employee_name'] as String? ?? '-',
|
||||
email: json['email'] as String?,
|
||||
mobileNo: json['mobile_no'] as String?,
|
||||
mobileNo: json['phone'] as String? ?? json['mobile_no'] as String?,
|
||||
group: json['group'] is Map<String, dynamic>
|
||||
? UserGroupDto.fromJson(json['group'] as Map<String, dynamic>)
|
||||
: null,
|
||||
isActive: (json['is_active'] as bool?) ?? true,
|
||||
isDeleted: (json['is_deleted'] as bool?) ?? false,
|
||||
note: json['note'] as String?,
|
||||
forcePasswordChange: (json['force_password_change'] as bool?) ?? false,
|
||||
passwordUpdatedAt: _parseDate(json['password_updated_at']),
|
||||
createdAt: _parseDate(json['created_at']),
|
||||
updatedAt: _parseDate(json['updated_at']),
|
||||
);
|
||||
@@ -61,6 +71,8 @@ class UserDto {
|
||||
isActive: isActive,
|
||||
isDeleted: isDeleted,
|
||||
note: note,
|
||||
forcePasswordChange: forcePasswordChange,
|
||||
passwordUpdatedAt: passwordUpdatedAt,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ class UserRepositoryRemote implements UserRepository {
|
||||
|
||||
final ApiClient _api;
|
||||
|
||||
static const _basePath = '${ApiRoutes.apiV1}/employees';
|
||||
static const _basePath = '${ApiRoutes.apiV1}/users';
|
||||
|
||||
/// 사용자 목록을 조회한다.
|
||||
@override
|
||||
@@ -31,7 +31,7 @@ class UserRepositoryRemote implements UserRepository {
|
||||
'page_size': pageSize,
|
||||
if (query != null && query.isNotEmpty) 'q': query,
|
||||
if (groupId != null) 'group_id': groupId,
|
||||
if (isActive != null) 'active': isActive,
|
||||
if (isActive != null) 'is_active': isActive,
|
||||
'include': 'group',
|
||||
},
|
||||
options: Options(responseType: ResponseType.json),
|
||||
@@ -62,6 +62,27 @@ class UserRepositoryRemote implements UserRepository {
|
||||
return UserDto.fromJson(_api.unwrapAsMap(response)).toEntity();
|
||||
}
|
||||
|
||||
/// 로그인한 사용자의 정보를 수정한다.
|
||||
@override
|
||||
Future<UserAccount> updateMe(UserProfileUpdateInput input) async {
|
||||
final response = await _api.patch<Map<String, dynamic>>(
|
||||
'$_basePath/me',
|
||||
data: input.toPayload(),
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return UserDto.fromJson(_api.unwrapAsMap(response)).toEntity();
|
||||
}
|
||||
|
||||
/// 관리자가 특정 사용자의 비밀번호를 재설정한다.
|
||||
@override
|
||||
Future<UserAccount> resetPassword(int id) async {
|
||||
final response = await _api.post<Map<String, dynamic>>(
|
||||
'$_basePath/$id/reset-password',
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return UserDto.fromJson(_api.unwrapAsMap(response)).toEntity();
|
||||
}
|
||||
|
||||
/// 사용자를 삭제한다.
|
||||
@override
|
||||
Future<void> delete(int id) async {
|
||||
|
||||
@@ -10,6 +10,8 @@ class UserAccount {
|
||||
this.isActive = true,
|
||||
this.isDeleted = false,
|
||||
this.note,
|
||||
this.forcePasswordChange = false,
|
||||
this.passwordUpdatedAt,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
});
|
||||
@@ -23,6 +25,8 @@ class UserAccount {
|
||||
final bool isActive;
|
||||
final bool isDeleted;
|
||||
final String? note;
|
||||
final bool forcePasswordChange;
|
||||
final DateTime? passwordUpdatedAt;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
@@ -37,6 +41,8 @@ class UserAccount {
|
||||
bool? isActive,
|
||||
bool? isDeleted,
|
||||
String? note,
|
||||
bool? forcePasswordChange,
|
||||
DateTime? passwordUpdatedAt,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
@@ -50,6 +56,8 @@ class UserAccount {
|
||||
isActive: isActive ?? this.isActive,
|
||||
isDeleted: isDeleted ?? this.isDeleted,
|
||||
note: note ?? this.note,
|
||||
forcePasswordChange: forcePasswordChange ?? this.forcePasswordChange,
|
||||
passwordUpdatedAt: passwordUpdatedAt ?? this.passwordUpdatedAt,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
@@ -73,6 +81,8 @@ class UserInput {
|
||||
this.email,
|
||||
this.mobileNo,
|
||||
this.isActive = true,
|
||||
this.forcePasswordChange,
|
||||
this.password,
|
||||
this.note,
|
||||
});
|
||||
|
||||
@@ -82,18 +92,48 @@ class UserInput {
|
||||
final String? email;
|
||||
final String? mobileNo;
|
||||
final bool isActive;
|
||||
final bool? forcePasswordChange;
|
||||
final String? password;
|
||||
final String? note;
|
||||
|
||||
/// API 요청 바디로 직렬화한다.
|
||||
Map<String, dynamic> toPayload() {
|
||||
return {
|
||||
'employee_no': employeeNo,
|
||||
'employee_name': employeeName,
|
||||
'employee_id': employeeNo,
|
||||
'name': employeeName,
|
||||
'group_id': groupId,
|
||||
'email': email,
|
||||
'mobile_no': mobileNo,
|
||||
if (email != null) 'email': email,
|
||||
if (mobileNo != null) 'phone': mobileNo,
|
||||
'is_active': isActive,
|
||||
'note': note,
|
||||
if (forcePasswordChange != null)
|
||||
'force_password_change': forcePasswordChange,
|
||||
if (password != null) 'password': password,
|
||||
if (note != null) 'note': note,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 자기 정보 수정 입력 모델.
|
||||
class UserProfileUpdateInput {
|
||||
const UserProfileUpdateInput({
|
||||
this.email,
|
||||
this.phone,
|
||||
this.password,
|
||||
this.currentPassword,
|
||||
});
|
||||
|
||||
final String? email;
|
||||
final String? phone;
|
||||
final String? password;
|
||||
final String? currentPassword;
|
||||
|
||||
/// 자기 정보 수정 요청 바디를 직렬화한다.
|
||||
Map<String, dynamic> toPayload() {
|
||||
return {
|
||||
if (email != null) 'email': email,
|
||||
if (phone != null) 'phone': phone,
|
||||
if (password != null) 'password': password,
|
||||
if (currentPassword != null) 'current_password': currentPassword,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,12 @@ abstract class UserRepository {
|
||||
/// 사용자 정보를 수정한다.
|
||||
Future<UserAccount> update(int id, UserInput input);
|
||||
|
||||
/// 로그인한 본인 정보를 수정한다.
|
||||
Future<UserAccount> updateMe(UserProfileUpdateInput input);
|
||||
|
||||
/// 특정 사용자의 비밀번호를 재설정한다.
|
||||
Future<UserAccount> resetPassword(int id);
|
||||
|
||||
/// 사용자를 삭제한다.
|
||||
Future<void> delete(int id);
|
||||
|
||||
|
||||
@@ -163,6 +163,23 @@ class UserController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// 관리자가 사용자의 비밀번호를 재설정한다.
|
||||
Future<UserAccount?> resetPassword(int id) async {
|
||||
_setSubmitting(true);
|
||||
try {
|
||||
final updated = await _userRepository.resetPassword(id);
|
||||
await fetch(page: _result?.page ?? 1);
|
||||
return updated;
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
_errorMessage = failure.describe();
|
||||
notifyListeners();
|
||||
return null;
|
||||
} finally {
|
||||
_setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 사용자를 삭제한다.
|
||||
Future<bool> delete(int id) async {
|
||||
_setSubmitting(true);
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:superport_v2/widgets/components/superport_pagination_controls.da
|
||||
|
||||
import '../../../../../core/config/environment.dart';
|
||||
import '../../../../../core/permissions/permission_manager.dart';
|
||||
import '../../../../../core/validation/password_rules.dart';
|
||||
import '../../../../../widgets/spec_page.dart';
|
||||
import '../../../group/domain/entities/group.dart';
|
||||
import '../../../group/domain/repositories/group_repository.dart';
|
||||
@@ -318,6 +319,9 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
||||
: (user) => _openUserForm(context, user: user),
|
||||
onDelete: _controller.isSubmitting ? null : _confirmDelete,
|
||||
onRestore: _controller.isSubmitting ? null : _restoreUser,
|
||||
onResetPassword: _controller.isSubmitting
|
||||
? null
|
||||
: _confirmResetPassword,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -367,13 +371,17 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
||||
final mobileController = TextEditingController(
|
||||
text: existing?.mobileNo ?? '',
|
||||
);
|
||||
final passwordController = TextEditingController();
|
||||
final noteController = TextEditingController(text: existing?.note ?? '');
|
||||
final groupNotifier = ValueNotifier<int?>(existing?.group?.id);
|
||||
final isActiveNotifier = ValueNotifier<bool>(existing?.isActive ?? true);
|
||||
final saving = ValueNotifier<bool>(false);
|
||||
final codeError = ValueNotifier<String?>(null);
|
||||
final nameError = ValueNotifier<String?>(null);
|
||||
final emailError = ValueNotifier<String?>(null);
|
||||
final phoneError = ValueNotifier<String?>(null);
|
||||
final groupError = ValueNotifier<String?>(null);
|
||||
final passwordError = ValueNotifier<String?>(null);
|
||||
|
||||
if (groupNotifier.value == null && _controller.groups.length == 1) {
|
||||
groupNotifier.value = _controller.groups.first.id;
|
||||
@@ -398,13 +406,30 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
||||
final note = noteController.text.trim();
|
||||
final groupId = groupNotifier.value;
|
||||
|
||||
if (!isEdit) {
|
||||
final password = passwordController.text;
|
||||
if (password.isEmpty) {
|
||||
passwordError.value = '임시 비밀번호를 입력하세요.';
|
||||
} else {
|
||||
final violations = PasswordRules.validate(password);
|
||||
passwordError.value = violations.isEmpty
|
||||
? null
|
||||
: _describePasswordViolations(violations);
|
||||
}
|
||||
}
|
||||
|
||||
codeError.value = code.isEmpty ? '사번을 입력하세요.' : null;
|
||||
nameError.value = name.isEmpty ? '성명을 입력하세요.' : null;
|
||||
emailError.value = email.isEmpty ? '이메일을 입력하세요.' : null;
|
||||
phoneError.value = mobile.isEmpty ? '연락처를 입력하세요.' : null;
|
||||
groupError.value = groupId == null ? '그룹을 선택하세요.' : null;
|
||||
|
||||
if (codeError.value != null ||
|
||||
nameError.value != null ||
|
||||
groupError.value != null) {
|
||||
emailError.value != null ||
|
||||
phoneError.value != null ||
|
||||
groupError.value != null ||
|
||||
(!isEdit && passwordError.value != null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -420,6 +445,8 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
||||
email: email.isEmpty ? null : email,
|
||||
mobileNo: mobile.isEmpty ? null : mobile,
|
||||
isActive: isActiveNotifier.value,
|
||||
password: isEdit ? null : passwordController.text,
|
||||
forcePasswordChange: isEdit ? null : true,
|
||||
note: note.isEmpty ? null : note,
|
||||
);
|
||||
final response = isEdit
|
||||
@@ -470,6 +497,7 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ShadInput(
|
||||
key: const ValueKey('user_form_employee'),
|
||||
controller: codeController,
|
||||
readOnly: isEdit,
|
||||
onChanged: (_) {
|
||||
@@ -503,6 +531,7 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ShadInput(
|
||||
key: const ValueKey('user_form_name'),
|
||||
controller: nameController,
|
||||
onChanged: (_) {
|
||||
if (nameController.text.trim().isNotEmpty) {
|
||||
@@ -525,21 +554,120 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_FormField(
|
||||
label: '이메일',
|
||||
child: ShadInput(
|
||||
controller: emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
if (!isEdit) ...[
|
||||
const SizedBox(height: 16),
|
||||
ValueListenableBuilder<String?>(
|
||||
valueListenable: passwordError,
|
||||
builder: (_, errorText, __) {
|
||||
return _FormField(
|
||||
label: '임시 비밀번호',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ShadInput(
|
||||
key: const ValueKey('user_form_password'),
|
||||
controller: passwordController,
|
||||
obscureText: true,
|
||||
placeholder: const Text('임시 비밀번호를 입력하세요'),
|
||||
onChanged: (_) {
|
||||
final value = passwordController.text;
|
||||
if (value.isEmpty) {
|
||||
passwordError.value = null;
|
||||
return;
|
||||
}
|
||||
if (PasswordRules.isValid(value)) {
|
||||
passwordError.value = null;
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'비밀번호는 8~24자이며 대문자, 소문자, 숫자, 특수문자를 각각 1자 이상 포함해야 합니다.',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
if (errorText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
errorText,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: materialTheme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
ValueListenableBuilder<String?>(
|
||||
valueListenable: emailError,
|
||||
builder: (_, errorText, __) {
|
||||
return _FormField(
|
||||
label: '이메일',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ShadInput(
|
||||
key: const ValueKey('user_form_email'),
|
||||
controller: emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
onChanged: (_) {
|
||||
if (emailController.text.trim().isNotEmpty) {
|
||||
emailError.value = null;
|
||||
}
|
||||
},
|
||||
),
|
||||
if (errorText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
errorText,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: materialTheme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_FormField(
|
||||
label: '연락처',
|
||||
child: ShadInput(
|
||||
controller: mobileController,
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
ValueListenableBuilder<String?>(
|
||||
valueListenable: phoneError,
|
||||
builder: (_, errorText, __) {
|
||||
return _FormField(
|
||||
label: '연락처',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ShadInput(
|
||||
key: const ValueKey('user_form_phone'),
|
||||
controller: mobileController,
|
||||
keyboardType: TextInputType.phone,
|
||||
onChanged: (_) {
|
||||
if (mobileController.text.trim().isNotEmpty) {
|
||||
phoneError.value = null;
|
||||
}
|
||||
},
|
||||
),
|
||||
if (errorText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
errorText,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: materialTheme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ValueListenableBuilder<int?>(
|
||||
@@ -638,13 +766,17 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
||||
nameController.dispose();
|
||||
emailController.dispose();
|
||||
mobileController.dispose();
|
||||
passwordController.dispose();
|
||||
noteController.dispose();
|
||||
groupNotifier.dispose();
|
||||
isActiveNotifier.dispose();
|
||||
saving.dispose();
|
||||
codeError.dispose();
|
||||
nameError.dispose();
|
||||
emailError.dispose();
|
||||
phoneError.dispose();
|
||||
groupError.dispose();
|
||||
passwordError.dispose();
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(UserAccount user) async {
|
||||
@@ -684,6 +816,47 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmResetPassword(UserAccount user) async {
|
||||
final userId = user.id;
|
||||
if (userId == null) {
|
||||
_showSnack('ID 정보가 없어 비밀번호를 재설정할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
final confirmed = await SuperportDialog.show<bool>(
|
||||
context: context,
|
||||
dialog: SuperportDialog(
|
||||
title: '비밀번호 재설정',
|
||||
description:
|
||||
'"${user.employeeName}" 사용자의 비밀번호를 재설정하고 임시 비밀번호를 이메일로 발송합니다.',
|
||||
actions: [
|
||||
ShadButton.ghost(
|
||||
onPressed: () =>
|
||||
Navigator.of(context, rootNavigator: true).pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
ShadButton(
|
||||
onPressed: () =>
|
||||
Navigator.of(context, rootNavigator: true).pop(true),
|
||||
child: const Text('재설정'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
final updated = await _controller.resetPassword(userId);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (updated != null) {
|
||||
_showSnack('임시 비밀번호를 이메일로 발송했습니다.');
|
||||
} else if (_controller.errorMessage != null) {
|
||||
_showSnack(_controller.errorMessage!);
|
||||
_controller.clearError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showSnack(String message) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
@@ -695,6 +868,33 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
||||
messenger.showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
String _describePasswordViolations(List<PasswordRuleViolation> violations) {
|
||||
final messages = <String>[];
|
||||
for (final violation in violations) {
|
||||
switch (violation) {
|
||||
case PasswordRuleViolation.tooShort:
|
||||
messages.add('최소 8자 이상 입력해야 합니다.');
|
||||
break;
|
||||
case PasswordRuleViolation.tooLong:
|
||||
messages.add('최대 24자 이하로 입력해야 합니다.');
|
||||
break;
|
||||
case PasswordRuleViolation.missingUppercase:
|
||||
messages.add('대문자를 최소 1자 포함해야 합니다.');
|
||||
break;
|
||||
case PasswordRuleViolation.missingLowercase:
|
||||
messages.add('소문자를 최소 1자 포함해야 합니다.');
|
||||
break;
|
||||
case PasswordRuleViolation.missingDigit:
|
||||
messages.add('숫자를 최소 1자 포함해야 합니다.');
|
||||
break;
|
||||
case PasswordRuleViolation.missingSpecial:
|
||||
messages.add('특수문자를 최소 1자 포함해야 합니다.');
|
||||
break;
|
||||
}
|
||||
}
|
||||
return messages.join('\n');
|
||||
}
|
||||
|
||||
List<Widget> _buildAuditInfo(UserAccount user, ShadThemeData theme) {
|
||||
return [
|
||||
const SizedBox(height: 20),
|
||||
@@ -722,12 +922,14 @@ class _UserTable extends StatelessWidget {
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
required this.onRestore,
|
||||
required this.onResetPassword,
|
||||
});
|
||||
|
||||
final List<UserAccount> users;
|
||||
final void Function(UserAccount user)? onEdit;
|
||||
final void Function(UserAccount user)? onDelete;
|
||||
final void Function(UserAccount user)? onRestore;
|
||||
final void Function(UserAccount user)? onResetPassword;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -761,31 +963,40 @@ class _UserTable extends StatelessWidget {
|
||||
: user.updatedAt!.toLocal().toIso8601String(),
|
||||
].map((text) => ShadTableCell(child: Text(text))).toList()..add(
|
||||
ShadTableCell(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ShadButton.ghost(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: onEdit == null ? null : () => onEdit!(user),
|
||||
child: const Icon(LucideIcons.pencil, size: 16),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
user.isDeleted
|
||||
? ShadButton.ghost(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: onRestore == null
|
||||
? null
|
||||
: () => onRestore!(user),
|
||||
child: const Icon(LucideIcons.history, size: 16),
|
||||
)
|
||||
: ShadButton.ghost(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: onDelete == null
|
||||
? null
|
||||
: () => onDelete!(user),
|
||||
child: const Icon(LucideIcons.trash2, size: 16),
|
||||
),
|
||||
],
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
ShadButton.ghost(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: onResetPassword == null
|
||||
? null
|
||||
: () => onResetPassword!(user),
|
||||
child: const Icon(LucideIcons.refreshCcw, size: 16),
|
||||
),
|
||||
ShadButton.ghost(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: onEdit == null ? null : () => onEdit!(user),
|
||||
child: const Icon(LucideIcons.pencil, size: 16),
|
||||
),
|
||||
user.isDeleted
|
||||
? ShadButton.ghost(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: onRestore == null
|
||||
? null
|
||||
: () => onRestore!(user),
|
||||
child: const Icon(LucideIcons.history, size: 16),
|
||||
)
|
||||
: ShadButton.ghost(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: onDelete == null
|
||||
? null
|
||||
: () => onDelete!(user),
|
||||
child: const Icon(LucideIcons.trash2, size: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,13 +2,18 @@ import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../core/constants/app_sections.dart';
|
||||
import '../core/permissions/permission_manager.dart';
|
||||
import '../core/network/failure.dart';
|
||||
import '../core/theme/theme_controller.dart';
|
||||
import '../core/validation/password_rules.dart';
|
||||
import '../features/auth/application/auth_service.dart';
|
||||
import '../features/auth/domain/entities/auth_session.dart';
|
||||
import '../features/masters/user/domain/entities/user.dart';
|
||||
import '../features/masters/user/domain/repositories/user_repository.dart';
|
||||
import 'components/superport_dialog.dart';
|
||||
|
||||
/// 앱 기본 레이아웃을 제공하는 셸 위젯. 사이드 네비게이션과 AppBar를 구성한다.
|
||||
@@ -411,60 +416,631 @@ class _AccountMenuButton extends StatelessWidget {
|
||||
return AnimatedBuilder(
|
||||
animation: service,
|
||||
builder: (context, _) {
|
||||
final session = service.session;
|
||||
return IconButton(
|
||||
tooltip: '계정 정보',
|
||||
tooltip: '내 정보',
|
||||
icon: const Icon(lucide.LucideIcons.userRound),
|
||||
onPressed: () => _handlePressed(context, session),
|
||||
onPressed: () => _handlePressed(context),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handlePressed(
|
||||
BuildContext context,
|
||||
AuthSession? session,
|
||||
) async {
|
||||
final shouldLogout = await SuperportDialog.show<bool>(
|
||||
Future<void> _handlePressed(BuildContext context) async {
|
||||
final userRepository = GetIt.I<UserRepository>();
|
||||
final result = await showDialog<_AccountDialogResult>(
|
||||
context: context,
|
||||
dialog: SuperportDialog(
|
||||
title: '계정 정보',
|
||||
description: session == null
|
||||
? '로그인 정보를 찾을 수 없습니다.'
|
||||
: '현재 로그인된 계정 세부 정보를 확인하세요.',
|
||||
footer: Builder(
|
||||
builder: (dialogContext) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ShadButton.outline(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text('닫기'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ShadButton.destructive(
|
||||
onPressed: session == null
|
||||
? null
|
||||
: () => Navigator.of(dialogContext).pop(true),
|
||||
child: const Text('로그아웃'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
scrollable: session != null && session.permissions.length > 6,
|
||||
child: _AccountInfoContent(session: session),
|
||||
barrierDismissible: false,
|
||||
builder: (_) => _AccountDialog(
|
||||
authService: service,
|
||||
userRepository: userRepository,
|
||||
hostContext: context,
|
||||
),
|
||||
);
|
||||
if (shouldLogout == true) {
|
||||
await service.clearSession();
|
||||
if (!context.mounted) return;
|
||||
context.go(loginRoutePath);
|
||||
|
||||
switch (result) {
|
||||
case _AccountDialogResult.logout:
|
||||
await service.clearSession();
|
||||
if (context.mounted) {
|
||||
context.go(loginRoutePath);
|
||||
}
|
||||
break;
|
||||
case _AccountDialogResult.passwordChanged:
|
||||
final confirmed = await _showMandatoryLogoutDialog(context);
|
||||
if (confirmed == true) {
|
||||
await service.clearSession();
|
||||
if (context.mounted) {
|
||||
context.go(loginRoutePath);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case _AccountDialogResult.none:
|
||||
case null:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool?> _showMandatoryLogoutDialog(BuildContext context) {
|
||||
return SuperportDialog.show<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
dialog: SuperportDialog(
|
||||
title: '비밀번호 변경 완료',
|
||||
description: '비밀번호가 변경되었습니다. 다시 로그인해주세요.',
|
||||
showCloseButton: false,
|
||||
primaryAction: ShadButton(
|
||||
onPressed: () => Navigator.of(context, rootNavigator: true).pop(true),
|
||||
child: const Text('확인'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _AccountDialogResult { none, logout, passwordChanged }
|
||||
|
||||
class _AccountDialog extends StatefulWidget {
|
||||
const _AccountDialog({
|
||||
required this.authService,
|
||||
required this.userRepository,
|
||||
required this.hostContext,
|
||||
});
|
||||
|
||||
final AuthService authService;
|
||||
final UserRepository userRepository;
|
||||
final BuildContext hostContext;
|
||||
|
||||
@override
|
||||
State<_AccountDialog> createState() => _AccountDialogState();
|
||||
}
|
||||
|
||||
class _AccountDialogState extends State<_AccountDialog> {
|
||||
static final RegExp _emailRegExp = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
|
||||
static final RegExp _phoneRegExp = RegExp(r'^[0-9+\-\s]{7,}$');
|
||||
|
||||
late final TextEditingController _emailController;
|
||||
late final TextEditingController _phoneController;
|
||||
String? _emailError;
|
||||
String? _phoneError;
|
||||
String? _generalError;
|
||||
bool _isSaving = false;
|
||||
late String _initialEmail;
|
||||
late String _initialPhone;
|
||||
|
||||
AuthSession? get _session => widget.authService.session;
|
||||
|
||||
bool get _isDirty =>
|
||||
_emailController.text.trim() != _initialEmail ||
|
||||
_phoneController.text.trim() != _initialPhone;
|
||||
|
||||
bool get _canEdit => _session != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final session = _session;
|
||||
_initialEmail = session?.user.email ?? '';
|
||||
_initialPhone = session?.user.phone ?? '';
|
||||
_emailController = TextEditingController(text: _initialEmail)
|
||||
..addListener(_handleChanged);
|
||||
_phoneController = TextEditingController(text: _initialPhone)
|
||||
..addListener(_handleChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_phoneController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleChanged() {
|
||||
if ((_emailError != null || _phoneError != null) && mounted) {
|
||||
setState(() {
|
||||
_emailError = null;
|
||||
_phoneError = null;
|
||||
_generalError = null;
|
||||
});
|
||||
} else {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final session = _session;
|
||||
return _wrapWithWillPop(
|
||||
SuperportDialog(
|
||||
title: '내 정보',
|
||||
description: session == null
|
||||
? '로그인 정보를 찾을 수 없습니다.'
|
||||
: '${session.user.name}님의 계정 정보를 확인하고 수정하세요.',
|
||||
scrollable: true,
|
||||
showCloseButton: false,
|
||||
footer: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ShadButton.outline(
|
||||
onPressed: _isSaving
|
||||
? null
|
||||
: () => Navigator.of(
|
||||
context,
|
||||
rootNavigator: true,
|
||||
).pop(_AccountDialogResult.none),
|
||||
child: const Text('닫기'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ShadButton.destructive(
|
||||
onPressed: !_canEdit || _isSaving
|
||||
? null
|
||||
: () => Navigator.of(
|
||||
context,
|
||||
rootNavigator: true,
|
||||
).pop(_AccountDialogResult.logout),
|
||||
child: const Text('로그아웃'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: _AccountDialogBody(
|
||||
session: session,
|
||||
emailController: _emailController,
|
||||
phoneController: _phoneController,
|
||||
emailError: _emailError,
|
||||
phoneError: _phoneError,
|
||||
generalError: _generalError,
|
||||
isSaving: _isSaving,
|
||||
canEdit: _canEdit,
|
||||
onSave: _saveProfile,
|
||||
onPasswordChange: _handlePasswordChange,
|
||||
canSave: _isDirty && !_isSaving && _canEdit,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _wrapWithWillPop(Widget child) {
|
||||
return WillPopScope(onWillPop: () async => !_isSaving, child: child);
|
||||
}
|
||||
|
||||
Future<void> _saveProfile() async {
|
||||
if (!_canEdit || _isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
final email = _emailController.text.trim();
|
||||
final phone = _phoneController.text.trim();
|
||||
var hasError = false;
|
||||
|
||||
if (email.isEmpty || !_emailRegExp.hasMatch(email)) {
|
||||
_emailError = '올바른 이메일 주소를 입력하세요.';
|
||||
hasError = true;
|
||||
}
|
||||
if (phone.isEmpty || !_phoneRegExp.hasMatch(phone)) {
|
||||
_phoneError = '연락처는 숫자/+, -/공백만 사용해 7자 이상 입력하세요.';
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSaving = true;
|
||||
_generalError = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final result = await widget.userRepository.updateMe(
|
||||
UserProfileUpdateInput(email: email, phone: phone),
|
||||
);
|
||||
final updatedEmail = result.email ?? email;
|
||||
final updatedPhone = result.mobileNo ?? phone;
|
||||
|
||||
_initialEmail = updatedEmail;
|
||||
_initialPhone = updatedPhone;
|
||||
|
||||
if (_emailController.text != updatedEmail) {
|
||||
_emailController.text = updatedEmail;
|
||||
}
|
||||
if (_phoneController.text != updatedPhone) {
|
||||
_phoneController.text = updatedPhone;
|
||||
}
|
||||
|
||||
final session = _session;
|
||||
if (session != null) {
|
||||
final updatedUser = session.user.copyWith(
|
||||
email: updatedEmail,
|
||||
phone: updatedPhone,
|
||||
name: result.employeeName,
|
||||
employeeNo: result.employeeNo,
|
||||
);
|
||||
widget.authService.updateSessionUser(updatedUser);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSaving = false;
|
||||
_emailError = null;
|
||||
_phoneError = null;
|
||||
});
|
||||
_showSnack('프로필 정보를 저장했습니다.');
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
setState(() {
|
||||
_isSaving = false;
|
||||
_generalError = failure.describe();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handlePasswordChange() async {
|
||||
if (!_canEdit || _isSaving) {
|
||||
return;
|
||||
}
|
||||
final changed = await _PasswordChangeDialog.show(
|
||||
context: context,
|
||||
userRepository: widget.userRepository,
|
||||
);
|
||||
if (changed == true && mounted) {
|
||||
Navigator.of(
|
||||
context,
|
||||
rootNavigator: true,
|
||||
).pop(_AccountDialogResult.passwordChanged);
|
||||
}
|
||||
}
|
||||
|
||||
void _showSnack(String message) {
|
||||
final messenger = ScaffoldMessenger.maybeOf(widget.hostContext);
|
||||
messenger?.showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
}
|
||||
|
||||
class _AccountDialogBody extends StatelessWidget {
|
||||
const _AccountDialogBody({
|
||||
required this.session,
|
||||
required this.emailController,
|
||||
required this.phoneController,
|
||||
required this.emailError,
|
||||
required this.phoneError,
|
||||
required this.generalError,
|
||||
required this.isSaving,
|
||||
required this.canEdit,
|
||||
required this.onSave,
|
||||
required this.onPasswordChange,
|
||||
required this.canSave,
|
||||
});
|
||||
|
||||
final AuthSession? session;
|
||||
final TextEditingController emailController;
|
||||
final TextEditingController phoneController;
|
||||
final String? emailError;
|
||||
final String? phoneError;
|
||||
final String? generalError;
|
||||
final bool isSaving;
|
||||
final bool canEdit;
|
||||
final bool canSave;
|
||||
final VoidCallback onSave;
|
||||
final VoidCallback onPasswordChange;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_AccountInfoContent(session: session),
|
||||
const SizedBox(height: 24),
|
||||
Divider(color: Theme.of(context).colorScheme.outlineVariant),
|
||||
const SizedBox(height: 16),
|
||||
Text('연락처 / 이메일 수정', style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 12),
|
||||
_LabeledField(
|
||||
label: '이메일',
|
||||
controller: emailController,
|
||||
fieldKey: const ValueKey('account_email_field'),
|
||||
enabled: canEdit && !isSaving,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
errorText: emailError,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_LabeledField(
|
||||
label: '연락처',
|
||||
controller: phoneController,
|
||||
fieldKey: const ValueKey('account_phone_field'),
|
||||
enabled: canEdit && !isSaving,
|
||||
keyboardType: TextInputType.phone,
|
||||
errorText: phoneError,
|
||||
),
|
||||
if (generalError != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
generalError!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: ShadButton(
|
||||
onPressed: canSave ? onSave : null,
|
||||
child: isSaving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('저장'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Divider(color: Theme.of(context).colorScheme.outlineVariant),
|
||||
const SizedBox(height: 16),
|
||||
Text('보안', style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 12),
|
||||
ShadButton.outline(
|
||||
onPressed: isSaving ? null : onPasswordChange,
|
||||
child: const Text('비밀번호 변경'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LabeledField extends StatelessWidget {
|
||||
const _LabeledField({
|
||||
required this.label,
|
||||
required this.controller,
|
||||
this.fieldKey,
|
||||
this.enabled = true,
|
||||
this.keyboardType,
|
||||
this.errorText,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final TextEditingController controller;
|
||||
final Key? fieldKey;
|
||||
final bool enabled;
|
||||
final TextInputType? keyboardType;
|
||||
final String? errorText;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final materialTheme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: theme.textTheme.small),
|
||||
const SizedBox(height: 6),
|
||||
ShadInput(
|
||||
key: fieldKey,
|
||||
controller: controller,
|
||||
enabled: enabled,
|
||||
keyboardType: keyboardType,
|
||||
),
|
||||
if (errorText != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
errorText!,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: materialTheme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PasswordChangeDialog extends StatefulWidget {
|
||||
const _PasswordChangeDialog({required this.userRepository});
|
||||
|
||||
final UserRepository userRepository;
|
||||
|
||||
static Future<bool?> show({
|
||||
required BuildContext context,
|
||||
required UserRepository userRepository,
|
||||
}) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => _PasswordChangeDialog(userRepository: userRepository),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<_PasswordChangeDialog> createState() => _PasswordChangeDialogState();
|
||||
}
|
||||
|
||||
class _PasswordChangeDialogState extends State<_PasswordChangeDialog> {
|
||||
final TextEditingController _currentController = TextEditingController();
|
||||
final TextEditingController _newController = TextEditingController();
|
||||
final TextEditingController _confirmController = TextEditingController();
|
||||
|
||||
String? _currentError;
|
||||
String? _newError;
|
||||
String? _confirmError;
|
||||
String? _generalError;
|
||||
bool _isSaving = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_currentController.dispose();
|
||||
_newController.dispose();
|
||||
_confirmController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SuperportDialog(
|
||||
title: '비밀번호 변경',
|
||||
showCloseButton: !_isSaving,
|
||||
primaryAction: ShadButton(
|
||||
onPressed: _isSaving ? null : _handleSubmit,
|
||||
child: _isSaving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('변경'),
|
||||
),
|
||||
secondaryAction: ShadButton.outline(
|
||||
onPressed: _isSaving
|
||||
? null
|
||||
: () => Navigator.of(context, rootNavigator: true).pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_PasswordField(
|
||||
label: '현재 비밀번호',
|
||||
controller: _currentController,
|
||||
fieldKey: const ValueKey('account_current_password'),
|
||||
errorText: _currentError,
|
||||
enabled: !_isSaving,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_PasswordField(
|
||||
label: '새 비밀번호',
|
||||
controller: _newController,
|
||||
fieldKey: const ValueKey('account_new_password'),
|
||||
errorText: _newError,
|
||||
enabled: !_isSaving,
|
||||
helper: '8~24자, 대문자/소문자/숫자/특수문자 각 1자 이상 포함',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_PasswordField(
|
||||
label: '새 비밀번호 확인',
|
||||
controller: _confirmController,
|
||||
fieldKey: const ValueKey('account_confirm_password'),
|
||||
errorText: _confirmError,
|
||||
enabled: !_isSaving,
|
||||
),
|
||||
if (_generalError != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_generalError!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleSubmit() async {
|
||||
if (_isSaving) return;
|
||||
final current = _currentController.text.trim();
|
||||
final next = _newController.text.trim();
|
||||
final confirm = _confirmController.text.trim();
|
||||
|
||||
var hasError = false;
|
||||
if (current.isEmpty) {
|
||||
_currentError = '현재 비밀번호를 입력하세요.';
|
||||
hasError = true;
|
||||
} else {
|
||||
_currentError = null;
|
||||
}
|
||||
if (!PasswordRules.isValid(next)) {
|
||||
_newError = '비밀번호 정책을 만족하도록 입력하세요.';
|
||||
hasError = true;
|
||||
} else {
|
||||
_newError = null;
|
||||
}
|
||||
if (next != confirm) {
|
||||
_confirmError = '새 비밀번호가 일치하지 않습니다.';
|
||||
hasError = true;
|
||||
} else {
|
||||
_confirmError = null;
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSaving = true;
|
||||
_generalError = null;
|
||||
});
|
||||
|
||||
try {
|
||||
await widget.userRepository.updateMe(
|
||||
UserProfileUpdateInput(password: next, currentPassword: current),
|
||||
);
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop(true);
|
||||
}
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
setState(() {
|
||||
_generalError = failure.describe();
|
||||
_isSaving = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _PasswordField extends StatelessWidget {
|
||||
const _PasswordField({
|
||||
required this.label,
|
||||
required this.controller,
|
||||
this.helper,
|
||||
this.errorText,
|
||||
this.enabled = true,
|
||||
this.fieldKey,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final TextEditingController controller;
|
||||
final String? helper;
|
||||
final String? errorText;
|
||||
final bool enabled;
|
||||
final Key? fieldKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final materialTheme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: theme.textTheme.small),
|
||||
const SizedBox(height: 6),
|
||||
ShadInput(
|
||||
key: fieldKey,
|
||||
controller: controller,
|
||||
enabled: enabled,
|
||||
obscureText: true,
|
||||
),
|
||||
if (helper != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(helper!, style: theme.textTheme.muted),
|
||||
),
|
||||
if (errorText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
errorText!,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: materialTheme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 로그인된 계정의 핵심 정보를 보여주는 다이얼로그 본문.
|
||||
@@ -499,6 +1075,7 @@ class _AccountInfoContent extends StatelessWidget {
|
||||
_AccountInfoRow(label: '이름', value: user.name),
|
||||
_AccountInfoRow(label: '사번', value: user.employeeNo ?? '-'),
|
||||
_AccountInfoRow(label: '이메일', value: user.email ?? '-'),
|
||||
_AccountInfoRow(label: '연락처', value: user.phone ?? '-'),
|
||||
_AccountInfoRow(label: '기본 그룹', value: user.primaryGroupName ?? '-'),
|
||||
_AccountInfoRow(label: '토큰 만료', value: expiryLabel),
|
||||
_AccountInfoRow(
|
||||
|
||||
Reference in New Issue
Block a user