forked from baron/baron-sso
540 lines
17 KiB
Dart
540 lines
17 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'dart:async';
|
|
import '../../../../core/services/auth_proxy_service.dart';
|
|
import '../../../../core/i18n/locale_utils.dart';
|
|
import '../../../../core/ui/toast_service.dart';
|
|
|
|
class UserManagementScreen extends StatefulWidget {
|
|
const UserManagementScreen({super.key});
|
|
|
|
@override
|
|
State<UserManagementScreen> createState() => _UserManagementScreenState();
|
|
}
|
|
|
|
class _UserManagementScreenState extends State<UserManagementScreen>
|
|
with SingleTickerProviderStateMixin {
|
|
late TabController _tabController;
|
|
bool _isAuthorized = false;
|
|
String? _verifiedAdminPassword;
|
|
bool _isLoading = false;
|
|
|
|
// --- List Tab Variables ---
|
|
List<dynamic> _users = [];
|
|
final TextEditingController _searchController = TextEditingController();
|
|
Timer? _debounce;
|
|
|
|
// --- Create Tab Controllers ---
|
|
final _formKey = GlobalKey<FormState>();
|
|
final TextEditingController _createLoginIdController =
|
|
TextEditingController();
|
|
final TextEditingController _createEmailController = TextEditingController();
|
|
final TextEditingController _createPhoneController = TextEditingController();
|
|
final TextEditingController _createNameController = TextEditingController();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_tabController = TabController(length: 2, vsync: this);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _verifyAccess());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_tabController.dispose();
|
|
_searchController.dispose();
|
|
_debounce?.cancel();
|
|
_createLoginIdController.dispose();
|
|
_createEmailController.dispose();
|
|
_createPhoneController.dispose();
|
|
_createNameController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
// --- Authentication ---
|
|
Future<void> _verifyAccess() async {
|
|
final passwordController = TextEditingController();
|
|
|
|
final String? inputPassword = await showDialog<String>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text("Admin Authentication Required"),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Text("Please enter the admin password."),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: passwordController,
|
|
obscureText: true,
|
|
decoration: const InputDecoration(
|
|
labelText: "Password",
|
|
border: OutlineInputBorder(),
|
|
),
|
|
autofocus: true,
|
|
onSubmitted: (value) => Navigator.pop(context, value),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, null),
|
|
child: const Text("Cancel"),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(context, passwordController.text),
|
|
child: const Text("Enter"),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (inputPassword == null || inputPassword.isEmpty) {
|
|
if (mounted) context.go(buildLocalizedHomePath(Uri.base));
|
|
return;
|
|
}
|
|
|
|
setState(() => _isLoading = true);
|
|
final isValid = await AuthProxyService.checkAdminAuth(inputPassword);
|
|
setState(() => _isLoading = false);
|
|
|
|
if (isValid) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isAuthorized = true;
|
|
_verifiedAdminPassword = inputPassword;
|
|
});
|
|
_loadUsers();
|
|
}
|
|
} else {
|
|
if (mounted) {
|
|
ToastService.error('Invalid Password');
|
|
context.go(buildLocalizedHomePath(Uri.base));
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- User List Logic ---
|
|
Future<void> _loadUsers({String? query}) async {
|
|
if (_verifiedAdminPassword == null) return;
|
|
setState(() => _isLoading = true);
|
|
try {
|
|
final users = await AuthProxyService.listUsers(
|
|
_verifiedAdminPassword!,
|
|
query: query,
|
|
);
|
|
setState(() => _users = users);
|
|
} catch (e) {
|
|
_showError("Failed to load users: $e");
|
|
} finally {
|
|
if (mounted) setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
|
|
void _onSearchChanged(String query) {
|
|
if (_debounce?.isActive ?? false) _debounce!.cancel();
|
|
_debounce = Timer(const Duration(milliseconds: 500), () {
|
|
_loadUsers(query: query);
|
|
});
|
|
}
|
|
|
|
Future<void> _deleteUser(String loginId) async {
|
|
if (_verifiedAdminPassword == null) return;
|
|
|
|
final confirm = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text("Delete User"),
|
|
content: Text(
|
|
"Are you sure you want to delete $loginId? This cannot be undone.",
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: const Text("Cancel"),
|
|
),
|
|
FilledButton(
|
|
style: FilledButton.styleFrom(backgroundColor: Colors.red),
|
|
onPressed: () => Navigator.pop(context, true),
|
|
child: const Text("Delete"),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (confirm != true) return;
|
|
|
|
setState(() => _isLoading = true);
|
|
try {
|
|
await AuthProxyService.deleteUser(_verifiedAdminPassword!, loginId);
|
|
_showSuccess("User deleted");
|
|
_loadUsers(query: _searchController.text);
|
|
} catch (e) {
|
|
_showError("Failed to delete: $e");
|
|
} finally {
|
|
if (mounted) setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _toggleStatus(String loginId, String currentStatus) async {
|
|
if (_verifiedAdminPassword == null) return;
|
|
final newStatus = (currentStatus == "enabled" || currentStatus == "active")
|
|
? "disabled"
|
|
: "enabled";
|
|
|
|
setState(() => _isLoading = true);
|
|
try {
|
|
await AuthProxyService.updateUserStatus(
|
|
_verifiedAdminPassword!,
|
|
loginId,
|
|
newStatus,
|
|
);
|
|
_showSuccess("User status updated to $newStatus");
|
|
_loadUsers(query: _searchController.text);
|
|
} catch (e) {
|
|
_showError("Failed to update status: $e");
|
|
} finally {
|
|
if (mounted) setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _editUser(Map user) async {
|
|
if (_verifiedAdminPassword == null) return;
|
|
|
|
final loginIDs = (user['loginIds'] as List?) ?? [];
|
|
final loginId = loginIDs.isNotEmpty ? loginIDs.first.toString() : "";
|
|
if (loginId.isEmpty) return;
|
|
|
|
final nameController = TextEditingController(
|
|
text: user['name'] ?? user['user']?['name'] ?? "",
|
|
);
|
|
final emailController = TextEditingController(
|
|
text: user['user']?['email'] ?? "",
|
|
);
|
|
final phoneController = TextEditingController(
|
|
text: user['user']?['phone'] ?? "",
|
|
);
|
|
|
|
final confirm = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text("Edit User: $loginId"),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
TextField(
|
|
controller: nameController,
|
|
decoration: const InputDecoration(labelText: "Name"),
|
|
),
|
|
TextField(
|
|
controller: emailController,
|
|
decoration: const InputDecoration(labelText: "Email"),
|
|
),
|
|
TextField(
|
|
controller: phoneController,
|
|
decoration: const InputDecoration(labelText: "Phone"),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: const Text("Cancel"),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
child: const Text("Save"),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (confirm != true) return;
|
|
|
|
setState(() => _isLoading = true);
|
|
|
|
String? phone = phoneController.text.trim().isEmpty
|
|
? null
|
|
: phoneController.text.trim();
|
|
if (phone != null && !phone.contains('@')) {
|
|
phone = phone.replaceAll(RegExp(r'[-\s]'), '');
|
|
if (phone.startsWith('010')) {
|
|
phone = '+82${phone.substring(1)}';
|
|
}
|
|
}
|
|
|
|
try {
|
|
await AuthProxyService.updateUserDetails(
|
|
adminPassword: _verifiedAdminPassword!,
|
|
loginId: loginId,
|
|
displayName: nameController.text.trim(),
|
|
email: emailController.text.trim(),
|
|
phone: phone,
|
|
);
|
|
_showSuccess("User updated successfully");
|
|
_loadUsers(query: _searchController.text);
|
|
} catch (e) {
|
|
_showError("Update failed: $e");
|
|
} finally {
|
|
if (mounted) setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
|
|
// --- Create User Logic ---
|
|
Future<void> _createUserSubmit() async {
|
|
if (!_formKey.currentState!.validate()) return;
|
|
if (_verifiedAdminPassword == null) return;
|
|
|
|
setState(() => _isLoading = true);
|
|
|
|
String loginId = _createLoginIdController.text.trim();
|
|
if (!loginId.contains('@')) {
|
|
loginId = loginId.replaceAll(RegExp(r'[-\s]'), '');
|
|
if (loginId.startsWith('010')) {
|
|
loginId = '+82${loginId.substring(1)}';
|
|
}
|
|
}
|
|
|
|
String? phone = _createPhoneController.text.trim().isEmpty
|
|
? null
|
|
: _createPhoneController.text.trim();
|
|
if (phone != null && !phone.contains('@')) {
|
|
phone = phone.replaceAll(RegExp(r'[-\s]'), '');
|
|
if (phone.startsWith('010')) {
|
|
phone = '+82${phone.substring(1)}';
|
|
}
|
|
}
|
|
|
|
try {
|
|
await AuthProxyService.createUser(
|
|
loginId: loginId,
|
|
adminPassword: _verifiedAdminPassword!,
|
|
email: _createEmailController.text.trim().isEmpty
|
|
? null
|
|
: _createEmailController.text.trim(),
|
|
phone: phone,
|
|
displayName: _createNameController.text.trim().isEmpty
|
|
? null
|
|
: _createNameController.text.trim(),
|
|
);
|
|
|
|
_showSuccess("User created successfully");
|
|
_formKey.currentState!.reset();
|
|
_createLoginIdController.clear();
|
|
_createEmailController.clear();
|
|
_createPhoneController.clear();
|
|
_createNameController.clear();
|
|
|
|
// Switch to list tab and reload
|
|
_tabController.animateTo(0);
|
|
_loadUsers();
|
|
} catch (e) {
|
|
_showError("Error: $e");
|
|
} finally {
|
|
if (mounted) setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
|
|
// --- UI Helpers ---
|
|
void _showError(String msg) {
|
|
if (!mounted) return;
|
|
ToastService.error(msg);
|
|
}
|
|
|
|
void _showSuccess(String msg) {
|
|
if (!mounted) return;
|
|
ToastService.success(msg);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (!_isAuthorized) {
|
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
|
}
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('User Management'),
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back),
|
|
onPressed: () => context.go(buildLocalizedHomePath(Uri.base)),
|
|
),
|
|
bottom: TabBar(
|
|
controller: _tabController,
|
|
tabs: const [
|
|
Tab(icon: Icon(Icons.list), text: "User List"),
|
|
Tab(icon: Icon(Icons.person_add), text: "Create User"),
|
|
],
|
|
),
|
|
),
|
|
body: TabBarView(
|
|
controller: _tabController,
|
|
children: [_buildUserListTab(), _buildCreateUserTab()],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildUserListTab() {
|
|
return Column(
|
|
children: [
|
|
if (_isLoading) const LinearProgressIndicator(),
|
|
Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: TextField(
|
|
controller: _searchController,
|
|
decoration: const InputDecoration(
|
|
labelText: "Search Users",
|
|
prefixIcon: Icon(Icons.search),
|
|
border: OutlineInputBorder(),
|
|
hintText: "Email, Phone, or Name",
|
|
),
|
|
onChanged: _onSearchChanged,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: _users.isEmpty
|
|
? const Center(child: Text("No users found."))
|
|
: ListView.separated(
|
|
itemCount: _users.length,
|
|
separatorBuilder: (context, index) => const Divider(),
|
|
itemBuilder: (context, index) {
|
|
final user = _users[index];
|
|
// 일부 응답은 최상위 또는 user 하위에 필드를 포함합니다.
|
|
|
|
final loginIDs = (user['loginIds'] as List?) ?? [];
|
|
final loginId = loginIDs.isNotEmpty
|
|
? loginIDs.first.toString()
|
|
: "Unknown ID";
|
|
final name =
|
|
user['name'] ?? user['user']?['name'] ?? "No Name";
|
|
final status = user['status'] ?? "unknown";
|
|
final isEnabled = status == "enabled" || status == "active";
|
|
|
|
return ListTile(
|
|
leading: CircleAvatar(
|
|
backgroundColor: isEnabled
|
|
? Colors.green.shade100
|
|
: Colors.grey.shade300,
|
|
child: Icon(
|
|
Icons.person,
|
|
color: isEnabled ? Colors.green : Colors.grey,
|
|
),
|
|
),
|
|
title: Text(name),
|
|
subtitle: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
loginId,
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
Text(
|
|
"Status: $status",
|
|
style: TextStyle(
|
|
color: isEnabled ? Colors.green : Colors.red,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
trailing: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.edit, color: Colors.blue),
|
|
tooltip: "Edit User",
|
|
onPressed: () => _editUser(user),
|
|
),
|
|
IconButton(
|
|
icon: Icon(
|
|
isEnabled ? Icons.block : Icons.check_circle,
|
|
color: isEnabled ? Colors.orange : Colors.green,
|
|
),
|
|
tooltip: isEnabled ? "Disable User" : "Enable User",
|
|
onPressed: () => _toggleStatus(loginId, status),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.delete, color: Colors.red),
|
|
tooltip: "Delete User",
|
|
onPressed: () => _deleteUser(loginId),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildCreateUserTab() {
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Center(
|
|
child: Container(
|
|
constraints: const BoxConstraints(maxWidth: 600),
|
|
child: Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
if (_isLoading) const LinearProgressIndicator(),
|
|
const SizedBox(height: 20),
|
|
TextFormField(
|
|
controller: _createLoginIdController,
|
|
decoration: const InputDecoration(
|
|
labelText: "Login ID (Required)",
|
|
border: OutlineInputBorder(),
|
|
helperText: "Unique identifier (Email or Phone)",
|
|
),
|
|
validator: (value) => value == null || value.isEmpty
|
|
? 'Please enter Login ID'
|
|
: null,
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextFormField(
|
|
controller: _createNameController,
|
|
decoration: const InputDecoration(
|
|
labelText: "Display Name",
|
|
border: OutlineInputBorder(),
|
|
prefixIcon: Icon(Icons.person),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextFormField(
|
|
controller: _createEmailController,
|
|
decoration: const InputDecoration(
|
|
labelText: "Email",
|
|
border: OutlineInputBorder(),
|
|
prefixIcon: Icon(Icons.email),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextFormField(
|
|
controller: _createPhoneController,
|
|
decoration: const InputDecoration(
|
|
labelText: "Phone Number",
|
|
border: OutlineInputBorder(),
|
|
prefixIcon: Icon(Icons.phone),
|
|
helperText: "010-xxxx-xxxx",
|
|
),
|
|
),
|
|
const SizedBox(height: 32),
|
|
FilledButton(
|
|
onPressed: _isLoading ? null : _createUserSubmit,
|
|
style: FilledButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
),
|
|
child: const Text("Create User"),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|