forked from baron/baron-sso
admin management 기능 추가
This commit is contained in:
@@ -137,6 +137,10 @@ func main() {
|
||||
admin := api.Group("/admin")
|
||||
admin.Post("/users", adminHandler.CreateUser)
|
||||
admin.Get("/check", adminHandler.CheckAuth)
|
||||
admin.Get("/users", adminHandler.ListUsers)
|
||||
admin.Patch("/users/:loginId", adminHandler.UpdateUser)
|
||||
admin.Delete("/users/:loginId", adminHandler.DeleteUser)
|
||||
admin.Patch("/users/:loginId/status", adminHandler.UpdateUserStatus)
|
||||
|
||||
// Webhook for Descope Generic SMS Gateway
|
||||
auth.Post("/webhooks/descope-sms", authHandler.HandleDescopeSmsRelay)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"net/url"
|
||||
|
||||
"github.com/descope/go-sdk/descope"
|
||||
"github.com/descope/go-sdk/descope/client"
|
||||
@@ -43,17 +44,8 @@ func boolPtr(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
type CreateUserRequest struct {
|
||||
LoginID string `json:"loginId"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Roles []string `json:"roles"`
|
||||
Tenants map[string][]string `json:"tenants"` // tenantId -> roles
|
||||
}
|
||||
|
||||
func (h *AdminHandler) CreateUser(c *fiber.Ctx) error {
|
||||
// 1. Simple Password Check
|
||||
// checkAuth Helper
|
||||
func (h *AdminHandler) checkAuth(c *fiber.Ctx) error {
|
||||
adminPass := os.Getenv("ADMIN_PASSWORD")
|
||||
if adminPass == "" {
|
||||
adminPass = "admin" // Default fallback
|
||||
@@ -63,6 +55,165 @@ func (h *AdminHandler) CreateUser(c *fiber.Ctx) error {
|
||||
if reqPass != adminPass {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid Admin Password"})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type CreateUserRequest struct {
|
||||
LoginID string `json:"loginId"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Roles []string `json:"roles"`
|
||||
Tenants map[string][]string `json:"tenants"` // tenantId -> roles
|
||||
}
|
||||
|
||||
func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {
|
||||
if err := h.checkAuth(c); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"})
|
||||
}
|
||||
|
||||
// ListUsers - GET /api/v1/admin/users
|
||||
func (h *AdminHandler) ListUsers(c *fiber.Ctx) error {
|
||||
if err := h.checkAuth(c); err != nil { return err }
|
||||
|
||||
text := c.Query("text")
|
||||
// Limit is not directly supported in SearchAll options as a simple int in all SDK versions,
|
||||
// but let's check the options struct.
|
||||
// Based on previous inspection: SearchAll takes UserSearchOptions.
|
||||
|
||||
var users []*descope.UserResponse
|
||||
var err error
|
||||
|
||||
if text != "" {
|
||||
options := &descope.UserSearchOptions{ Text: text, Limit: 50 }
|
||||
users, _, err = h.DescopeClient.Management.User().SearchAll(context.Background(), options)
|
||||
} else {
|
||||
// Nil options means default search (usually returns all or default page)
|
||||
users, _, err = h.DescopeClient.Management.User().SearchAll(context.Background(), nil)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[Admin] ListUsers failed: %v", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"users": users})
|
||||
}
|
||||
|
||||
// DeleteUser - DELETE /api/v1/admin/users/:loginId
|
||||
func (h *AdminHandler) DeleteUser(c *fiber.Ctx) error {
|
||||
if err := h.checkAuth(c); err != nil { return err }
|
||||
|
||||
loginID := c.Params("loginId")
|
||||
// Decode if necessary (Fiber usually decodes params, but let's be safe if it's double encoded)
|
||||
if decoded, err := url.QueryUnescape(loginID); err == nil {
|
||||
loginID = decoded
|
||||
}
|
||||
|
||||
log.Printf("[Admin] Deleting user: %s", loginID)
|
||||
if err := h.DescopeClient.Management.User().Delete(context.Background(), loginID); err != nil {
|
||||
log.Printf("[Admin] DeleteUser failed: %v", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"message": "User deleted successfully"})
|
||||
}
|
||||
|
||||
// UpdateUserStatus - PATCH /api/v1/admin/users/:loginId/status
|
||||
func (h *AdminHandler) UpdateUserStatus(c *fiber.Ctx) error {
|
||||
if err := h.checkAuth(c); err != nil { return err }
|
||||
|
||||
loginID := c.Params("loginId")
|
||||
if decoded, err := url.QueryUnescape(loginID); err == nil {
|
||||
loginID = decoded
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Status string `json:"status"` // "enabled" or "disabled"
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid body"})
|
||||
}
|
||||
|
||||
var user *descope.UserResponse
|
||||
var err error
|
||||
|
||||
log.Printf("[Admin] Updating status for %s to %s", loginID, req.Status)
|
||||
|
||||
if req.Status == "enabled" || req.Status == "active" {
|
||||
user, err = h.DescopeClient.Management.User().Activate(context.Background(), loginID)
|
||||
} else {
|
||||
user, err = h.DescopeClient.Management.User().Deactivate(context.Background(), loginID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[Admin] Status update failed: %v", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"message": "Status updated",
|
||||
"user": user,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateUser - PATCH /api/v1/admin/users/:loginId
|
||||
func (h *AdminHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
if err := h.checkAuth(c); err != nil { return err }
|
||||
|
||||
loginID := c.Params("loginId")
|
||||
if decoded, err := url.QueryUnescape(loginID); err == nil {
|
||||
loginID = decoded
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Email *string `json:"email"`
|
||||
Phone *string `json:"phone"`
|
||||
DisplayName *string `json:"displayName"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid body"})
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
var err error
|
||||
|
||||
// Update Display Name
|
||||
if req.DisplayName != nil {
|
||||
_, err = h.DescopeClient.Management.User().UpdateDisplayName(ctx, loginID, *req.DisplayName)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": "Failed to update name: " + err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
// Update Email
|
||||
if req.Email != nil {
|
||||
_, err = h.DescopeClient.Management.User().UpdateEmail(ctx, loginID, *req.Email, true, false) // verified=true, addToLoginIDs=false
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": "Failed to update email: " + err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
// Update Phone
|
||||
if req.Phone != nil {
|
||||
phone := *req.Phone
|
||||
// Normalize
|
||||
if strings.HasPrefix(phone, "010") {
|
||||
phone = "+82" + phone[1:]
|
||||
}
|
||||
_, err = h.DescopeClient.Management.User().UpdatePhone(ctx, loginID, phone, true, false) // verified=true, addToLoginIDs=false
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": "Failed to update phone: " + err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"message": "User updated successfully"})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) CreateUser(c *fiber.Ctx) error {
|
||||
if err := h.checkAuth(c); err != nil { return err }
|
||||
|
||||
if h.DescopeClient == nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Descope Client not configured"})
|
||||
@@ -127,18 +278,4 @@ func (h *AdminHandler) CreateUser(c *fiber.Ctx) error {
|
||||
"message": "User created successfully",
|
||||
"user": res,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {
|
||||
adminPass := os.Getenv("ADMIN_PASSWORD")
|
||||
if adminPass == "" {
|
||||
adminPass = "admin"
|
||||
}
|
||||
|
||||
reqPass := c.Get("X-Admin-Password")
|
||||
if reqPass != adminPass {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid Admin Password"})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"})
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,92 @@ class AuthProxyService {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<List<dynamic>> listUsers(String adminPassword, {String? query}) async {
|
||||
var uri = Uri.parse('$_baseUrl/api/v1/admin/users');
|
||||
if (query != null && query.isNotEmpty) {
|
||||
uri = uri.replace(queryParameters: {'text': query});
|
||||
}
|
||||
|
||||
final response = await http.get(
|
||||
uri,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Admin-Password': adminPassword,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
return data['users'] ?? [];
|
||||
} else {
|
||||
throw Exception('Failed to list users: ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> deleteUser(String adminPassword, String loginId) async {
|
||||
final encodedId = Uri.encodeComponent(loginId);
|
||||
final url = Uri.parse('$_baseUrl/api/v1/admin/users/$encodedId');
|
||||
|
||||
final response = await http.delete(
|
||||
url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Admin-Password': adminPassword,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to delete user: ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> updateUserStatus(String adminPassword, String loginId, String status) async {
|
||||
final encodedId = Uri.encodeComponent(loginId);
|
||||
final url = Uri.parse('$_baseUrl/api/v1/admin/users/$encodedId/status');
|
||||
|
||||
final response = await http.patch(
|
||||
url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Admin-Password': adminPassword,
|
||||
},
|
||||
body: jsonEncode({'status': status}),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to update status: ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> updateUserDetails({
|
||||
required String adminPassword,
|
||||
required String loginId,
|
||||
String? email,
|
||||
String? phone,
|
||||
String? displayName,
|
||||
}) async {
|
||||
final encodedId = Uri.encodeComponent(loginId);
|
||||
final url = Uri.parse('$_baseUrl/api/v1/admin/users/$encodedId');
|
||||
|
||||
final body = <String, dynamic>{};
|
||||
if (email != null) body['email'] = email;
|
||||
if (phone != null) body['phone'] = phone;
|
||||
if (displayName != null) body['displayName'] = displayName;
|
||||
|
||||
final response = await http.patch(
|
||||
url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Admin-Password': adminPassword,
|
||||
},
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to update user: ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> sendLog(String level, String message, {Map<String, dynamic>? data}) async {
|
||||
final url = Uri.parse('$_baseUrl/api/v1/client-log');
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'dart:async';
|
||||
import '../../../../core/services/auth_proxy_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('/dashboard');
|
||||
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) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Invalid Password'), backgroundColor: Colors.red));
|
||||
context.go('/dashboard');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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);
|
||||
try {
|
||||
await AuthProxyService.updateUserDetails(
|
||||
adminPassword: _verifiedAdminPassword!,
|
||||
loginId: loginId,
|
||||
displayName: nameController.text.trim(),
|
||||
email: emailController.text.trim(),
|
||||
phone: phoneController.text.trim(),
|
||||
);
|
||||
_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);
|
||||
try {
|
||||
await AuthProxyService.createUser(
|
||||
loginId: _createLoginIdController.text.trim(),
|
||||
adminPassword: _verifiedAdminPassword!,
|
||||
email: _createEmailController.text.trim().isEmpty ? null : _createEmailController.text.trim(),
|
||||
phone: _createPhoneController.text.trim().isEmpty ? null : _createPhoneController.text.trim(),
|
||||
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;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.red));
|
||||
}
|
||||
|
||||
void _showSuccess(String msg) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.green));
|
||||
}
|
||||
|
||||
@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('/dashboard'),
|
||||
),
|
||||
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: (_, __) => const Divider(),
|
||||
itemBuilder: (context, index) {
|
||||
final user = _users[index];
|
||||
final userObj = user['user'] ?? {}; // Descope struct structure might vary
|
||||
// Based on Descope API, user root might have fields directly or inside 'user'
|
||||
// Go SDK SearchAll returns UserResponse struct.
|
||||
|
||||
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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:flutter_web_plugins/url_strategy.dart';
|
||||
import 'features/auth/presentation/login_screen.dart';
|
||||
import 'features/dashboard/presentation/dashboard_screen.dart';
|
||||
import 'features/admin/presentation/create_user_screen.dart';
|
||||
import 'features/admin/presentation/user_management_screen.dart';
|
||||
import 'core/services/auth_proxy_service.dart';
|
||||
import 'core/services/logger_service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -90,7 +90,7 @@ final _router = GoRouter(
|
||||
path: '/admin/users',
|
||||
builder: (context, state) {
|
||||
_routerLogger.info("Navigating to /admin/users");
|
||||
return const CreateUserScreen();
|
||||
return const UserManagementScreen();
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user