1
0
forked from baron/baron-sso

Merge pull request 'dev/sso-from-b5aed4f' (#39) from dev/sso-from-b5aed4f into main

Reviewed-on: ai-team/baron-sso#39
This commit is contained in:
2026-01-16 13:49:04 +09:00
8 changed files with 1137 additions and 14 deletions

View File

@@ -3,7 +3,7 @@
# ==========================================
# --- General System ---
APP_ENV=development
APP_ENV=dev
TZ=Asia/Seoul
# --- Infrastructure Ports ---
@@ -33,3 +33,5 @@ NAVER_CLOUD_ACCESS_KEY=ncp_iam_...
NAVER_CLOUD_SECRET_KEY=ncp_iam_...
NAVER_CLOUD_SERVICE_ID=ncp:sms:kr:...:...
NAVER_SENDER_PHONE_NUMBER=...
ADMIN_PASSWORD=admin

View File

@@ -61,6 +61,7 @@ func main() {
// 2. Initialize Handlers
auditHandler := handler.NewAuditHandler(auditRepo)
authHandler := handler.NewAuthHandler()
adminHandler := handler.NewAdminHandler()
// 3. Initialize Fiber
app := fiber.New(fiber.Config{
@@ -132,6 +133,15 @@ func main() {
auth.Post("/sms", authHandler.SendSms)
auth.Post("/verify-sms", authHandler.VerifySms)
// Admin Routes
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)

View File

@@ -0,0 +1,281 @@
package handler
import (
"context"
"log"
"os"
"strings"
"net/url"
"github.com/descope/go-sdk/descope"
"github.com/descope/go-sdk/descope/client"
"github.com/gofiber/fiber/v2"
)
type AdminHandler struct {
DescopeClient *client.DescopeClient
}
func NewAdminHandler() *AdminHandler {
projectID := os.Getenv("DESCOPE_PROJECT_ID")
managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY")
var descopeClient *client.DescopeClient
var err error
if projectID != "" && managementKey != "" {
descopeClient, err = client.NewWithConfig(&client.Config{
ProjectID: projectID,
ManagementKey: managementKey,
})
if err != nil {
log.Printf("Warning: Failed to initialize Descope Client for Admin: %v", err)
}
} else {
log.Println("Warning: DESCOPE_PROJECT_ID or DESCOPE_MANAGEMENT_KEY missing. Admin functions will fail.")
}
return &AdminHandler{
DescopeClient: descopeClient,
}
}
func boolPtr(b bool) *bool {
return &b
}
// checkAuth Helper
func (h *AdminHandler) checkAuth(c *fiber.Ctx) error {
adminPass := os.Getenv("ADMIN_PASSWORD")
if adminPass == "" {
adminPass = "admin" // Default fallback
}
reqPass := c.Get("X-Admin-Password")
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"})
}
var req CreateUserRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
if req.LoginID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "LoginID is required"})
}
// Normalize Phone
normalizedPhone := req.Phone
if normalizedPhone != "" {
if strings.HasPrefix(normalizedPhone, "010") {
normalizedPhone = "+82" + normalizedPhone[1:]
} else if strings.HasPrefix(normalizedPhone, "82") {
normalizedPhone = "+" + normalizedPhone
}
}
userObj := &descope.UserRequest{
User: descope.User{
Email: req.Email,
Phone: normalizedPhone,
Name: req.DisplayName,
},
VerifiedEmail: boolPtr(req.Email != ""),
VerifiedPhone: boolPtr(normalizedPhone != ""),
}
// Add Roles if provided
if len(req.Roles) > 0 {
userObj.Roles = req.Roles
}
// Add Tenants if provided
if len(req.Tenants) > 0 {
// Convert map[string][]string to []*descope.AssociatedTenant
userTenants := []*descope.AssociatedTenant{}
for tenantID, roles := range req.Tenants {
userTenants = append(userTenants, &descope.AssociatedTenant{
TenantID: tenantID,
Roles: roles,
})
}
userObj.Tenants = userTenants
}
log.Printf("[Admin] Creating user: %s (Email: %s, Phone: %s)", req.LoginID, req.Email, normalizedPhone)
res, err := h.DescopeClient.Management.User().Create(context.Background(), req.LoginID, userObj)
if err != nil {
log.Printf("[Admin] Failed to create user: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"message": "User created successfully",
"user": res,
})
}

View File

@@ -222,30 +222,64 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Descope Client not configured"})
}
log.Printf("[Verify] Generating embedded link for %s", loginID)
embeddedToken, err := h.DescopeClient.Management.User().GenerateEmbeddedLink(context.Background(), loginID, nil, 0)
// [Fix] Search for existing user by phone to prevent fragmentation
// Normalize Phone Number for Search (E.164)
searchPhone := loginID
if !strings.Contains(searchPhone, "@") {
// If it looks like a KR mobile number (010...), format to +8210...
if strings.HasPrefix(searchPhone, "010") {
searchPhone = "+82" + searchPhone[1:]
} else if strings.HasPrefix(searchPhone, "82") {
searchPhone = "+" + searchPhone
}
}
log.Printf("[Verify] Searching for user with phone: %s", searchPhone)
searchOptions := &descope.UserSearchOptions{
Phones: []string{searchPhone},
Limit: 1,
}
var targetLoginID string
users, _, errSearch := h.DescopeClient.Management.User().SearchAll(context.Background(), searchOptions)
if errSearch == nil && len(users) > 0 {
if len(users[0].LoginIDs) > 0 {
targetLoginID = users[0].LoginIDs[0]
log.Printf("[Verify] User found! Existing LoginID: %s", targetLoginID)
} else {
// Should not happen for a valid user, but fallback to UserID or searchPhone
log.Printf("[Verify] User found but no LoginIDs. Using UserID.")
targetLoginID = users[0].UserID
}
} else {
// Not found, or search error. Fallback to using the phone as LoginID.
// Use the normalized phone number to ensure consistency (+82...)
targetLoginID = searchPhone
log.Printf("[Verify] User not found by phone. Will use/create: %s", targetLoginID)
}
log.Printf("[Verify] Generating embedded link for %s", targetLoginID)
embeddedToken, err := h.DescopeClient.Management.User().GenerateEmbeddedLink(context.Background(), targetLoginID, nil, 0)
if err != nil {
if strings.Contains(err.Error(), "User not found") || strings.Contains(err.Error(), "E062108") {
log.Printf("[Verify] User %s not found. Creating...", loginID)
log.Printf("[Verify] User %s not found. Creating...", targetLoginID)
descopeLoginID := loginID
// Create User with Explicit Phone Attribute
userObj := &descope.UserRequest{}
if strings.Contains(loginID, "@") {
userObj.Email = loginID
if strings.Contains(targetLoginID, "@") {
userObj.Email = targetLoginID
} else {
if strings.HasPrefix(loginID, "010") {
descopeLoginID = "+82" + loginID[1:]
}
userObj.Phone = descopeLoginID
userObj.Phone = targetLoginID // Must be E.164
}
_, errCreate := h.DescopeClient.Management.User().Create(context.Background(), descopeLoginID, userObj)
_, errCreate := h.DescopeClient.Management.User().Create(context.Background(), targetLoginID, userObj)
if errCreate != nil {
log.Printf("[Verify] Failed to create user: %v", errCreate)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create new user"})
}
embeddedToken, err = h.DescopeClient.Management.User().GenerateEmbeddedLink(context.Background(), descopeLoginID, nil, 0)
embeddedToken, err = h.DescopeClient.Management.User().GenerateEmbeddedLink(context.Background(), targetLoginID, nil, 0)
if err != nil {
log.Printf("[Verify] Failed to generate token after creation: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate upstream token"})

View File

@@ -99,6 +99,136 @@ class AuthProxyService {
}
}
static Future<bool> checkAdminAuth(String adminPassword) async {
final url = Uri.parse('$_baseUrl/api/v1/admin/check');
try {
final response = await http.get(
url,
headers: {
'Content-Type': 'application/json',
'X-Admin-Password': adminPassword,
},
);
return response.statusCode == 200;
} catch (_) {
return false;
}
}
static Future<void> createUser({
required String loginId,
required String adminPassword,
String? email,
String? phone,
String? displayName,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/admin/users');
final response = await http.post(
url,
headers: {
'Content-Type': 'application/json',
'X-Admin-Password': adminPassword,
},
body: jsonEncode({
'loginId': loginId,
'email': email,
'phone': phone,
'displayName': displayName,
}),
);
if (response.statusCode != 200) {
throw Exception('Failed to create user: ${response.body}');
}
}
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 {

View File

@@ -0,0 +1,232 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/services/auth_proxy_service.dart';
class CreateUserScreen extends StatefulWidget {
const CreateUserScreen({super.key});
@override
State<CreateUserScreen> createState() => _CreateUserScreenState();
}
class _CreateUserScreenState extends State<CreateUserScreen> {
final _formKey = GlobalKey<FormState>();
final TextEditingController _loginIdController = TextEditingController();
final TextEditingController _emailController = TextEditingController();
final TextEditingController _phoneController = TextEditingController();
final TextEditingController _nameController = TextEditingController();
bool _isLoading = false;
bool _isAuthorized = false;
String? _verifiedAdminPassword;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _verifyAccess());
}
Future<void> _verifyAccess() async {
final passwordController = TextEditingController();
// Show blocking dialog
final String? inputPassword = await showDialog<String>(
context: context,
barrierDismissible: false, // User must enter password or leave
builder: (context) => AlertDialog(
title: const Text("Admin Authentication Required"),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text("Please enter the admin password to access this page."),
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), // Cancel
child: const Text("Cancel"),
),
FilledButton(
onPressed: () => Navigator.pop(context, passwordController.text),
child: const Text("Enter"),
),
],
),
);
// If cancelled or empty
if (inputPassword == null || inputPassword.isEmpty) {
if (mounted) context.go('/dashboard'); // Kick out
return;
}
// Verify against Backend
setState(() => _isLoading = true);
final isValid = await AuthProxyService.checkAdminAuth(inputPassword);
setState(() => _isLoading = false);
if (isValid) {
if (mounted) {
setState(() {
_isAuthorized = true;
_verifiedAdminPassword = inputPassword;
});
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid Password. Access Denied.'), backgroundColor: Colors.red),
);
context.go('/dashboard'); // Kick out
}
}
}
@override
void dispose() {
_loginIdController.dispose();
_emailController.dispose();
_phoneController.dispose();
_nameController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
if (_verifiedAdminPassword == null) return; // Should not happen
setState(() => _isLoading = true);
try {
await AuthProxyService.createUser(
loginId: _loginIdController.text.trim(),
adminPassword: _verifiedAdminPassword!,
email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(),
phone: _phoneController.text.trim().isEmpty ? null : _phoneController.text.trim(),
displayName: _nameController.text.trim().isEmpty ? null : _nameController.text.trim(),
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('User created successfully!'), backgroundColor: Colors.green),
);
_formKey.currentState!.reset();
_loginIdController.clear();
_emailController.clear();
_phoneController.clear();
_nameController.clear();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
// Hide content until authorized
if (!_isAuthorized) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('Create User'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/dashboard'),
),
),
body: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 600),
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
"Create New User",
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
TextFormField(
controller: _loginIdController,
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: _nameController,
decoration: const InputDecoration(
labelText: "Display Name",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person),
),
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: "Email",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.email),
),
),
const SizedBox(height: 16),
TextFormField(
controller: _phoneController,
decoration: const InputDecoration(
labelText: "Phone Number",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.phone),
helperText: "Start with 010 (e.g., 010-1234-5678)",
),
),
const SizedBox(height: 32),
FilledButton(
onPressed: _isLoading ? null : _submit,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: const Text("Create User"),
),
],
),
),
),
),
);
}
}

View File

@@ -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"),
),
],
),
),
),
),
);
}
}

View File

@@ -8,6 +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/user_management_screen.dart';
import 'core/services/auth_proxy_service.dart';
import 'core/services/logger_service.dart';
import 'package:logging/logging.dart';
@@ -85,12 +86,19 @@ final _router = GoRouter(
return const DashboardScreen();
},
),
GoRoute(
path: '/admin/users',
builder: (context, state) {
_routerLogger.info("Navigating to /admin/users");
return const UserManagementScreen();
},
),
],
redirect: (context, state) {
final isLoggedIn =
Descope.sessionManager.session?.refreshToken.isExpired == false;
final path = state.uri.path;
final isLoggingIn = path == '/' || path.startsWith('/verify/');
final isLoggingIn = path == '/' || path.startsWith('/verify/') || path.startsWith('/admin/');
_routerLogger.fine("Redirect check - Path: $path, IsLoggedIn: $isLoggedIn");