forked from baron/baron-sso
admin page add
This commit is contained in:
@@ -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,11 @@ 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)
|
||||
|
||||
// Webhook for Descope Generic SMS Gateway
|
||||
auth.Post("/webhooks/descope-sms", authHandler.HandleDescopeSmsRelay)
|
||||
|
||||
|
||||
144
backend/internal/handler/admin_handler.go
Normal file
144
backend/internal/handler/admin_handler.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
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
|
||||
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"})
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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"})
|
||||
}
|
||||
@@ -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"})
|
||||
|
||||
@@ -99,6 +99,50 @@ 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<void> sendLog(String level, String message, {Map<String, dynamic>? data}) async {
|
||||
final url = Uri.parse('$_baseUrl/api/v1/client-log');
|
||||
try {
|
||||
|
||||
232
frontend/lib/features/admin/presentation/create_user_screen.dart
Normal file
232
frontend/lib/features/admin/presentation/create_user_screen.dart
Normal 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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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/create_user_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 CreateUserScreen();
|
||||
},
|
||||
),
|
||||
],
|
||||
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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user