첫 커밋: 로컬 프로젝트 업로드
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../../core/i18n/locale_utils.dart';
|
||||
import '../../../../core/ui/toast_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(buildLocalizedHomePath(Uri.base)); // 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) {
|
||||
ToastService.error('Invalid Password. Access Denied.');
|
||||
context.go(buildLocalizedHomePath(Uri.base)); // 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);
|
||||
|
||||
String loginId = _loginIdController.text.trim();
|
||||
if (!loginId.contains('@')) {
|
||||
loginId = loginId.replaceAll(RegExp(r'[-\s]'), '');
|
||||
if (loginId.startsWith('010')) {
|
||||
loginId = '+82${loginId.substring(1)}';
|
||||
}
|
||||
}
|
||||
|
||||
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.createUser(
|
||||
loginId: loginId,
|
||||
adminPassword: _verifiedAdminPassword!,
|
||||
email: _emailController.text.trim().isEmpty
|
||||
? null
|
||||
: _emailController.text.trim(),
|
||||
phone: phone,
|
||||
displayName: _nameController.text.trim().isEmpty
|
||||
? null
|
||||
: _nameController.text.trim(),
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
ToastService.success('User created successfully!');
|
||||
_formKey.currentState!.reset();
|
||||
_loginIdController.clear();
|
||||
_emailController.clear();
|
||||
_phoneController.clear();
|
||||
_nameController.clear();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ToastService.error('Error: $e');
|
||||
}
|
||||
} 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(buildLocalizedHomePath(Uri.base)),
|
||||
),
|
||||
),
|
||||
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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:userfront/core/i18n/locale_utils.dart';
|
||||
import 'package:userfront/core/services/auth_proxy_service.dart';
|
||||
|
||||
bool shouldRouteTenantAccessErrorToErrorScreen(Object error) {
|
||||
return error is AuthProxyException && error.errorCode == 'tenant_not_allowed';
|
||||
}
|
||||
|
||||
bool shouldRouteConsentErrorToErrorScreen(Object error) {
|
||||
return shouldRouteTenantAccessErrorToErrorScreen(error);
|
||||
}
|
||||
|
||||
String buildTenantAccessErrorPath(Object error, Uri baseUri) {
|
||||
final authError = error as AuthProxyException;
|
||||
final localeCode =
|
||||
extractLocaleFromPath(baseUri) ?? resolvePreferredLocaleCode();
|
||||
return buildLocalizedPath(
|
||||
localeCode,
|
||||
Uri(
|
||||
path: '/error',
|
||||
queryParameters: {
|
||||
'error': authError.errorCode,
|
||||
'error_description': authError.message,
|
||||
if (authError.details != null) 'details': jsonEncode(authError.details),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
bool shouldPromoteCookieSession({
|
||||
required String? currentToken,
|
||||
required String? loginChallenge,
|
||||
}) {
|
||||
final hasToken = currentToken != null && currentToken.trim().isNotEmpty;
|
||||
final hasChallenge =
|
||||
loginChallenge != null && loginChallenge.trim().isNotEmpty;
|
||||
|
||||
// 토큰 기반 세션이 이미 확보된 일반 로그인 흐름에서는
|
||||
// 뒤늦은 쿠키 세션 승격이 토큰을 덮어쓰지 않도록 차단합니다.
|
||||
if (hasToken && !hasChallenge) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
enum LoginChallengeSource { widget, uriQuery, rawSearch, rawHref, missing }
|
||||
|
||||
class LoginChallengeResolution {
|
||||
final String? value;
|
||||
final LoginChallengeSource source;
|
||||
final bool uriHasLoginChallenge;
|
||||
final bool rawSearchHasLoginChallenge;
|
||||
final bool rawHrefHasLoginChallenge;
|
||||
|
||||
const LoginChallengeResolution({
|
||||
required this.value,
|
||||
required this.source,
|
||||
required this.uriHasLoginChallenge,
|
||||
required this.rawSearchHasLoginChallenge,
|
||||
required this.rawHrefHasLoginChallenge,
|
||||
});
|
||||
|
||||
Map<String, Object?> toDiagnostics() {
|
||||
return {
|
||||
'resolved_value_len': value?.length ?? 0,
|
||||
'resolved_source': source.name,
|
||||
'uri_has_login_challenge': uriHasLoginChallenge,
|
||||
'raw_search_has_login_challenge': rawSearchHasLoginChallenge,
|
||||
'raw_href_has_login_challenge': rawHrefHasLoginChallenge,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
LoginChallengeResolution resolveLoginChallenge({
|
||||
String? widgetLoginChallenge,
|
||||
required Uri uri,
|
||||
String? rawSearch,
|
||||
String? rawHref,
|
||||
}) {
|
||||
final widgetValue = _normalizeChallenge(widgetLoginChallenge);
|
||||
if (widgetValue != null) {
|
||||
return const LoginChallengeResolution(
|
||||
value: null,
|
||||
source: LoginChallengeSource.widget,
|
||||
uriHasLoginChallenge: false,
|
||||
rawSearchHasLoginChallenge: false,
|
||||
rawHrefHasLoginChallenge: false,
|
||||
).copyWith(value: widgetValue);
|
||||
}
|
||||
|
||||
final uriValue = _normalizeChallenge(uri.queryParameters['login_challenge']);
|
||||
if (uriValue != null) {
|
||||
return const LoginChallengeResolution(
|
||||
value: null,
|
||||
source: LoginChallengeSource.uriQuery,
|
||||
uriHasLoginChallenge: true,
|
||||
rawSearchHasLoginChallenge: false,
|
||||
rawHrefHasLoginChallenge: false,
|
||||
).copyWith(value: uriValue);
|
||||
}
|
||||
|
||||
final rawSearchValue = _normalizeChallenge(
|
||||
_extractQueryParamFromRawQuery(rawSearch, 'login_challenge'),
|
||||
);
|
||||
if (rawSearchValue != null) {
|
||||
return const LoginChallengeResolution(
|
||||
value: null,
|
||||
source: LoginChallengeSource.rawSearch,
|
||||
uriHasLoginChallenge: false,
|
||||
rawSearchHasLoginChallenge: true,
|
||||
rawHrefHasLoginChallenge: false,
|
||||
).copyWith(value: rawSearchValue);
|
||||
}
|
||||
|
||||
final rawHrefValue = _normalizeChallenge(
|
||||
_extractQueryParamFromRawHref(rawHref, 'login_challenge'),
|
||||
);
|
||||
if (rawHrefValue != null) {
|
||||
return const LoginChallengeResolution(
|
||||
value: null,
|
||||
source: LoginChallengeSource.rawHref,
|
||||
uriHasLoginChallenge: false,
|
||||
rawSearchHasLoginChallenge: false,
|
||||
rawHrefHasLoginChallenge: true,
|
||||
).copyWith(value: rawHrefValue);
|
||||
}
|
||||
|
||||
return const LoginChallengeResolution(
|
||||
value: null,
|
||||
source: LoginChallengeSource.missing,
|
||||
uriHasLoginChallenge: false,
|
||||
rawSearchHasLoginChallenge: false,
|
||||
rawHrefHasLoginChallenge: false,
|
||||
);
|
||||
}
|
||||
|
||||
String? _normalizeChallenge(String? value) {
|
||||
final trimmed = value?.trim();
|
||||
if (trimmed == null || trimmed.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
String? _extractQueryParamFromRawHref(String? rawHref, String key) {
|
||||
final href = rawHref?.trim();
|
||||
if (href == null || href.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final parsed = Uri.tryParse(href);
|
||||
final fromParsed = parsed?.queryParameters[key];
|
||||
final normalizedParsed = _normalizeChallenge(fromParsed);
|
||||
if (normalizedParsed != null) {
|
||||
return normalizedParsed;
|
||||
}
|
||||
|
||||
final question = href.indexOf('?');
|
||||
if (question < 0) {
|
||||
return null;
|
||||
}
|
||||
final hash = href.indexOf('#', question + 1);
|
||||
final rawQuery = hash < 0
|
||||
? href.substring(question + 1)
|
||||
: href.substring(question + 1, hash);
|
||||
return _extractQueryParamFromRawQuery(rawQuery, key);
|
||||
}
|
||||
|
||||
String? _extractQueryParamFromRawQuery(String? rawQuery, String key) {
|
||||
final query = rawQuery?.trim();
|
||||
if (query == null || query.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final normalizedQuery = query.startsWith('?') ? query.substring(1) : query;
|
||||
if (normalizedQuery.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final parsed = Uri.splitQueryString(normalizedQuery);
|
||||
final value = _normalizeChallenge(parsed[key]);
|
||||
if (value != null) {
|
||||
return value;
|
||||
}
|
||||
} catch (_) {
|
||||
// URI 파싱이 실패하면 수동 파싱으로 보완합니다.
|
||||
}
|
||||
|
||||
for (final pair in normalizedQuery.split('&')) {
|
||||
if (pair.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
final equalIndex = pair.indexOf('=');
|
||||
final rawKey = equalIndex < 0 ? pair : pair.substring(0, equalIndex);
|
||||
final decodedKey = _decodeQueryComponentSafe(rawKey);
|
||||
if (decodedKey != key) {
|
||||
continue;
|
||||
}
|
||||
if (equalIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
final rawValue = pair.substring(equalIndex + 1);
|
||||
final decodedValue = _normalizeChallenge(
|
||||
_decodeQueryComponentSafe(rawValue),
|
||||
);
|
||||
if (decodedValue != null) {
|
||||
return decodedValue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _decodeQueryComponentSafe(String value) {
|
||||
try {
|
||||
return Uri.decodeQueryComponent(value);
|
||||
} catch (_) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
extension on LoginChallengeResolution {
|
||||
LoginChallengeResolution copyWith({
|
||||
String? value,
|
||||
LoginChallengeSource? source,
|
||||
bool? uriHasLoginChallenge,
|
||||
bool? rawSearchHasLoginChallenge,
|
||||
bool? rawHrefHasLoginChallenge,
|
||||
}) {
|
||||
return LoginChallengeResolution(
|
||||
value: value ?? this.value,
|
||||
source: source ?? this.source,
|
||||
uriHasLoginChallenge: uriHasLoginChallenge ?? this.uriHasLoginChallenge,
|
||||
rawSearchHasLoginChallenge:
|
||||
rawSearchHasLoginChallenge ?? this.rawSearchHasLoginChallenge,
|
||||
rawHrefHasLoginChallenge:
|
||||
rawHrefHasLoginChallenge ?? this.rawHrefHasLoginChallenge,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import '../../../core/i18n/locale_utils.dart';
|
||||
|
||||
bool isPublicAuthPath(String path, Uri uri) {
|
||||
return path == '/signin' ||
|
||||
path == '/signup' ||
|
||||
path == '/login' ||
|
||||
path == '/registration' ||
|
||||
path == '/verify' ||
|
||||
path == '/verification' ||
|
||||
path == '/verify-complete' ||
|
||||
path.startsWith('/verify/') ||
|
||||
path.startsWith('/l/') ||
|
||||
path == '/approve' ||
|
||||
path.startsWith('/ql/') ||
|
||||
path == '/forgot-password' ||
|
||||
path == '/recovery' ||
|
||||
path == '/reset-password' ||
|
||||
path == '/error' ||
|
||||
path == '/settings' ||
|
||||
path == '/consent' ||
|
||||
path.startsWith('/consent/') ||
|
||||
uri.path.contains('/consent');
|
||||
}
|
||||
|
||||
String? extractLoginShortCode(Uri uri) {
|
||||
final normalizedPath = stripLocalePath(uri);
|
||||
final segments = normalizedPath
|
||||
.split('/')
|
||||
.where((segment) => segment.isNotEmpty)
|
||||
.toList();
|
||||
if (segments.length < 2 || segments.first != 'l') {
|
||||
return null;
|
||||
}
|
||||
return segments[1];
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
enum PasswordLoginNextAction { redirectToOidc, acceptOidc, localLogin, invalid }
|
||||
|
||||
PasswordLoginNextAction decidePasswordLoginNextAction({
|
||||
required bool hasLoginChallenge,
|
||||
required String? redirectTo,
|
||||
required String? jwt,
|
||||
}) {
|
||||
final hasRedirectTo = redirectTo != null && redirectTo.isNotEmpty;
|
||||
if (hasRedirectTo) {
|
||||
return PasswordLoginNextAction.redirectToOidc;
|
||||
}
|
||||
|
||||
if (hasLoginChallenge) {
|
||||
return PasswordLoginNextAction.acceptOidc;
|
||||
}
|
||||
|
||||
final hasJwt = jwt != null && jwt.isNotEmpty;
|
||||
if (hasJwt) {
|
||||
return PasswordLoginNextAction.localLogin;
|
||||
}
|
||||
|
||||
return PasswordLoginNextAction.invalid;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import '../../../core/i18n/locale_utils.dart';
|
||||
|
||||
const verificationRoutePath = '/verify';
|
||||
const verificationCompletionRoutePath = '/verify-complete';
|
||||
const verificationCompletionRouteName = 'verify-complete';
|
||||
|
||||
String buildLocalizedVerificationCompletePath(String localeCode) {
|
||||
return '/$localeCode$verificationCompletionRoutePath';
|
||||
}
|
||||
|
||||
bool isDedicatedVerificationRoute(Uri uri) {
|
||||
final path = stripLocalePath(uri);
|
||||
return path == verificationRoutePath ||
|
||||
path == '/verification' ||
|
||||
path.startsWith('/verify/') ||
|
||||
path.startsWith('/l/');
|
||||
}
|
||||
|
||||
bool hasVerificationPayload(Uri uri) {
|
||||
final query = uri.queryParameters;
|
||||
final token = query['t'];
|
||||
final loginId = query['loginId'];
|
||||
final code = query['code'];
|
||||
return (token != null && token.isNotEmpty) ||
|
||||
(loginId != null &&
|
||||
loginId.isNotEmpty &&
|
||||
code != null &&
|
||||
code.isNotEmpty);
|
||||
}
|
||||
|
||||
String? buildDedicatedVerificationRedirect(
|
||||
Uri uri, {
|
||||
required String localeCode,
|
||||
}) {
|
||||
if (isDedicatedVerificationRoute(uri)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final query = uri.queryParameters;
|
||||
final token = query['t'];
|
||||
final loginId = query['loginId'];
|
||||
final code = query['code'];
|
||||
final pendingRef = query['pendingRef'];
|
||||
final sanitizedQuery = <String, String>{};
|
||||
|
||||
if (token != null && token.isNotEmpty) {
|
||||
sanitizedQuery['t'] = token;
|
||||
} else if (loginId != null &&
|
||||
loginId.isNotEmpty &&
|
||||
code != null &&
|
||||
code.isNotEmpty) {
|
||||
sanitizedQuery['loginId'] = loginId;
|
||||
sanitizedQuery['code'] = code;
|
||||
if (pendingRef != null && pendingRef.isNotEmpty) {
|
||||
sanitizedQuery['pendingRef'] = pendingRef;
|
||||
}
|
||||
}
|
||||
|
||||
if (sanitizedQuery.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Uri(
|
||||
path: '/$localeCode$verificationRoutePath',
|
||||
queryParameters: sanitizedQuery,
|
||||
).toString();
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/i18n/locale_utils.dart';
|
||||
import '../../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../../core/services/auth_token_store.dart';
|
||||
import '../../../../core/services/web_window.dart';
|
||||
|
||||
class ApproveQrScreen extends StatefulWidget {
|
||||
final String? pendingRef;
|
||||
const ApproveQrScreen({super.key, this.pendingRef});
|
||||
|
||||
@override
|
||||
State<ApproveQrScreen> createState() => _ApproveQrScreenState();
|
||||
}
|
||||
|
||||
class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
||||
bool _isLoading = false;
|
||||
String? _message;
|
||||
bool _success = false;
|
||||
bool _isCheckingSession = false;
|
||||
bool _redirectingToLogin = false;
|
||||
bool _autoApproveTriggered = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bootstrapCookieSession().then((_) {
|
||||
_redirectIfNotLoggedIn();
|
||||
_maybeAutoApprove();
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> _bootstrapCookieSession() async {
|
||||
if (AuthTokenStore.usesCookie()) {
|
||||
return true;
|
||||
}
|
||||
if (_isCheckingSession) {
|
||||
return false;
|
||||
}
|
||||
setState(() => _isCheckingSession = true);
|
||||
try {
|
||||
await AuthProxyService.checkCookieSession();
|
||||
AuthTokenStore.setCookieMode(provider: 'ory');
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isCheckingSession = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _redirectIfNotLoggedIn() {
|
||||
if (_redirectingToLogin || !mounted) return;
|
||||
final hasStoredToken = AuthTokenStore.getToken()?.isNotEmpty ?? false;
|
||||
final usesCookie = AuthTokenStore.usesCookie();
|
||||
final isLoggedIn = hasStoredToken || usesCookie;
|
||||
if (!isLoggedIn) {
|
||||
_redirectingToLogin = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final target = buildLocalizedSigninPath(Uri.base);
|
||||
webWindow.redirectTo('$target?notice=qr_login_required');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _maybeAutoApprove() {
|
||||
if (!mounted || _autoApproveTriggered) return;
|
||||
if (widget.pendingRef == null || widget.pendingRef!.trim().isEmpty) {
|
||||
if (_message == null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_message = 'Error: pendingRef is missing.';
|
||||
});
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final hasStoredToken = AuthTokenStore.getToken()?.isNotEmpty ?? false;
|
||||
final usesCookie = AuthTokenStore.usesCookie();
|
||||
final isLoggedIn = hasStoredToken || usesCookie || _isCheckingSession;
|
||||
if (!isLoggedIn || _isLoading || _success) {
|
||||
return;
|
||||
}
|
||||
|
||||
_autoApproveTriggered = true;
|
||||
_handleApprove();
|
||||
}
|
||||
|
||||
Future<void> _handleApprove() async {
|
||||
if (widget.pendingRef == null) return;
|
||||
|
||||
final storedToken = AuthTokenStore.getToken();
|
||||
final usesCookie = AuthTokenStore.usesCookie();
|
||||
var hasCookie = usesCookie;
|
||||
if (storedToken == null && !hasCookie) {
|
||||
hasCookie = await _bootstrapCookieSession();
|
||||
}
|
||||
if (storedToken == null && !hasCookie) {
|
||||
if (mounted) {
|
||||
final target = buildLocalizedSigninPath(Uri.base);
|
||||
webWindow.redirectTo('$target?notice=qr_login_required');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_message = null;
|
||||
});
|
||||
// jwt 유효성 확인
|
||||
try {
|
||||
final token = storedToken ?? '';
|
||||
await AuthProxyService.approveQrLogin(
|
||||
widget.pendingRef!,
|
||||
token: token,
|
||||
withCredentials: hasCookie,
|
||||
);
|
||||
setState(() {
|
||||
_success = true;
|
||||
_message = "Login Approved! Your browser should now be logged in.";
|
||||
});
|
||||
|
||||
// Automatically go to dashboard after a short delay
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
if (mounted) context.go(buildLocalizedHomePath(Uri.base));
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _message = "Error: $e");
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasStoredToken = AuthTokenStore.getToken()?.isNotEmpty ?? false;
|
||||
final usesCookie = AuthTokenStore.usesCookie();
|
||||
final isLoggedIn = hasStoredToken || usesCookie || _isCheckingSession;
|
||||
|
||||
if (!isLoggedIn && !_redirectingToLogin) {
|
||||
_redirectIfNotLoggedIn();
|
||||
}
|
||||
if (isLoggedIn && !_success && !_isLoading) {
|
||||
_maybeAutoApprove();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("QR Login Approval")),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.phonelink_lock, size: 80, color: Colors.blue),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
"Web Login Request",
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
"A computer is trying to log in using this QR code.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey.shade600),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
if (_message != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Text(
|
||||
_message!,
|
||||
style: TextStyle(
|
||||
color: _success ? Colors.green : Colors.red,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
|
||||
if (_isLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 16),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
|
||||
if (!_success && !_isLoading)
|
||||
Text(
|
||||
"Approving login request automatically...",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey.shade700),
|
||||
),
|
||||
|
||||
if (!isLoggedIn && !_success)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: TextButton(
|
||||
onPressed: () =>
|
||||
context.go(buildLocalizedSigninPath(Uri.base)),
|
||||
child: const Text("Login on this device first"),
|
||||
),
|
||||
),
|
||||
|
||||
if (!_success && !_isLoading && _message != null)
|
||||
FilledButton.icon(
|
||||
onPressed: !isLoggedIn
|
||||
? null
|
||||
: () {
|
||||
_autoApproveTriggered = false;
|
||||
_handleApprove();
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text("Retry Approval"),
|
||||
),
|
||||
|
||||
if (_success)
|
||||
FilledButton(
|
||||
onPressed: () => context.go(buildLocalizedHomePath(Uri.base)),
|
||||
child: const Text("Go to My Dashboard"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
import 'package:userfront/core/i18n/locale_utils.dart';
|
||||
import 'package:userfront/core/services/auth_proxy_service.dart';
|
||||
import 'package:userfront/core/services/web_window.dart';
|
||||
import 'package:userfront/core/ui/toast_service.dart';
|
||||
import 'package:userfront/features/auth/domain/consent_error_routing.dart';
|
||||
|
||||
class ConsentScreen extends StatefulWidget {
|
||||
final String consentChallenge;
|
||||
final Future<Map<String, dynamic>> Function(String consentChallenge)?
|
||||
consentInfoLoader;
|
||||
|
||||
const ConsentScreen({
|
||||
super.key,
|
||||
required this.consentChallenge,
|
||||
this.consentInfoLoader,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ConsentScreen> createState() => _ConsentScreenState();
|
||||
}
|
||||
|
||||
class _ConsentScreenState extends State<ConsentScreen> {
|
||||
Map<String, dynamic>? _consentInfo;
|
||||
bool _isLoading = true;
|
||||
bool _isSubmitting = false;
|
||||
String? _error;
|
||||
|
||||
final Set<String> _selectedScopes = {};
|
||||
final Map<String, String> _scopeDescriptions = {};
|
||||
final Set<String> _mandatoryScopes = {'openid'};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scopeDescriptions.addAll(_defaultScopeDescriptions());
|
||||
_fetchConsentInfo();
|
||||
}
|
||||
|
||||
Map<String, String> _defaultScopeDescriptions() {
|
||||
return {
|
||||
'openid': tr(
|
||||
'msg.userfront.consent.scope.openid',
|
||||
fallback: 'OpenID authentication information (signin session check)',
|
||||
),
|
||||
'profile': tr(
|
||||
'msg.userfront.consent.scope.profile',
|
||||
fallback: 'Basic profile information (name, user identifier)',
|
||||
),
|
||||
'email': tr(
|
||||
'msg.userfront.consent.scope.email',
|
||||
fallback: 'Email address (account identification and notifications)',
|
||||
),
|
||||
'offline_access': tr(
|
||||
'msg.userfront.consent.scope.offline_access',
|
||||
fallback: 'Offline access (keep signed in)',
|
||||
),
|
||||
'phone': tr(
|
||||
'msg.userfront.consent.scope.phone',
|
||||
fallback: 'Phone number (identity verification and notifications)',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
String _renderConsentText(String key, {String? fallback}) {
|
||||
return tr(
|
||||
key,
|
||||
fallback: fallback,
|
||||
).replaceAll(r'\\n', '\n').replaceAll(r'\n', '\n').replaceAll('\\\n', '\n');
|
||||
}
|
||||
|
||||
String _renderScopeCountLabel(int count) {
|
||||
return tr(
|
||||
'msg.userfront.consent.scope_count',
|
||||
fallback: 'Total {{count}}',
|
||||
params: {'count': '$count'},
|
||||
).replaceAll('{$count}', '$count');
|
||||
}
|
||||
|
||||
String _scopeDisplayLabel(String scope) {
|
||||
if (scope == 'offline_access') {
|
||||
return 'offline access';
|
||||
}
|
||||
return scope.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
String _renderClientIdLabel(String clientId) {
|
||||
final raw = tr(
|
||||
'msg.userfront.consent.client_id',
|
||||
fallback: 'Client ID: {{id}}',
|
||||
);
|
||||
final normalized = raw
|
||||
.replaceAll('{{id}}', '')
|
||||
.replaceAll('{id}', '')
|
||||
.trimRight();
|
||||
return '$normalized $clientId';
|
||||
}
|
||||
|
||||
Future<void> _fetchConsentInfo() async {
|
||||
try {
|
||||
final loader =
|
||||
widget.consentInfoLoader ?? AuthProxyService.getConsentInfo;
|
||||
final info = await loader(widget.consentChallenge);
|
||||
|
||||
// [Skip Logic] 백엔드에서 자동 승인되어 리다이렉트 URL이 온 경우 즉시 이동
|
||||
if (info['redirectTo'] != null) {
|
||||
webWindow.redirectTo(info['redirectTo']);
|
||||
return;
|
||||
}
|
||||
|
||||
// 백엔드에서 전달받은 커스텀 스코프 정보(scope_details) 적용
|
||||
if (info['scope_details'] != null) {
|
||||
final details = info['scope_details'] as Map<String, dynamic>;
|
||||
|
||||
details.forEach((scope, detail) {
|
||||
if (detail is Map<String, dynamic>) {
|
||||
// 설명 업데이트
|
||||
if (detail['description'] != null &&
|
||||
detail['description'].toString().isNotEmpty) {
|
||||
_scopeDescriptions[scope] = detail['description'].toString();
|
||||
}
|
||||
// 필수 여부 업데이트
|
||||
if (detail['mandatory'] == true) {
|
||||
_mandatoryScopes.add(scope);
|
||||
} else {
|
||||
// openid는 기본적으로 필수지만 설정에서 굳이 껐다면?
|
||||
// 안전을 위해 openid는 항상 필수로 유지하는 것이 좋지만,
|
||||
// 여기서는 서버 설정을 존중하되 openid는 예외처리 할 수도 있음.
|
||||
// 우선 서버 설정이 있으면 반영 (단, openid는 제거하지 않음)
|
||||
if (scope != 'openid') {
|
||||
_mandatoryScopes.remove(scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 초기 선택 상태 설정: 모든 요청된 스코프를 기본 선택
|
||||
final requestedScopes =
|
||||
(info['requested_scope'] as List<dynamic>?)?.cast<String>() ?? [];
|
||||
_selectedScopes.addAll(requestedScopes);
|
||||
|
||||
setState(() {
|
||||
_consentInfo = info;
|
||||
_isLoading = false;
|
||||
});
|
||||
} on AuthProxyException catch (e) {
|
||||
if (shouldRouteConsentErrorToErrorScreen(e)) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final target = buildTenantAccessErrorPath(e, Uri.base);
|
||||
context.go(target);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_error = tr(
|
||||
'msg.userfront.consent.load_error',
|
||||
fallback: 'Failed to load consent information: {{error}}',
|
||||
params: {'error': e.message},
|
||||
);
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = tr(
|
||||
'msg.userfront.consent.load_error',
|
||||
fallback: 'Failed to load consent information: {{error}}',
|
||||
params: {'error': '$e'},
|
||||
);
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _acceptConsent() async {
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
// 선택된 스코프만 리스트로 변환하여 전송
|
||||
final result = await AuthProxyService.acceptConsent(
|
||||
widget.consentChallenge,
|
||||
grantScope: _selectedScopes.toList(),
|
||||
);
|
||||
|
||||
if (result['redirectTo'] != null) {
|
||||
webWindow.redirectTo(result['redirectTo']);
|
||||
} else {
|
||||
setState(() {
|
||||
_error = tr(
|
||||
'msg.userfront.consent.missing_redirect',
|
||||
fallback:
|
||||
'Consent was processed, but the redirect URL was missing.',
|
||||
);
|
||||
_isSubmitting = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = tr(
|
||||
'msg.userfront.consent.accept_error',
|
||||
fallback: 'Failed to process consent: {{error}}',
|
||||
params: {'error': '$e'},
|
||||
);
|
||||
_isSubmitting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onCancel() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(tr('ui.userfront.consent.cancel.title')),
|
||||
content: Text(tr('msg.userfront.consent.cancel.confirm')),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(tr('ui.common.cancel')),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: Text(tr('ui.userfront.consent.cancel.confirm_button')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
setState(() => _isSubmitting = true);
|
||||
try {
|
||||
final resp = await AuthProxyService.rejectConsent(
|
||||
widget.consentChallenge,
|
||||
);
|
||||
final redirectTo = resp['redirectTo'];
|
||||
if (redirectTo != null) {
|
||||
webWindow.redirectTo(redirectTo);
|
||||
} else {
|
||||
if (mounted) context.go(buildLocalizedHomePath(Uri.base));
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _isSubmitting = false);
|
||||
if (mounted) {
|
||||
ToastService.error(
|
||||
tr(
|
||||
'msg.userfront.consent.cancel.error',
|
||||
fallback: 'An error occurred while cancelling consent: {{error}}',
|
||||
params: {'error': '$e'},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 배경색을 약간 어둡게 처리하거나, 전체적인 테마 색상을 사용
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.grey[100],
|
||||
body: Center(
|
||||
child: _isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: _error != null
|
||||
? _buildErrorCard()
|
||||
: _buildConsentCard(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorCard() {
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(24),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
Text(_error!, textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConsentCard(BuildContext context) {
|
||||
final clientRawName = _consentInfo?['client']?['client_name'] as String?;
|
||||
final clientId = _consentInfo?['client']?['client_id'] as String? ?? '-';
|
||||
final clientName = (clientRawName != null && clientRawName.isNotEmpty)
|
||||
? clientRawName
|
||||
: (clientId != '-'
|
||||
? clientId
|
||||
: tr('msg.userfront.consent.client_unknown'));
|
||||
final clientLogo = _consentInfo?['client']?['logo_uri'];
|
||||
final requestedScopes =
|
||||
(_consentInfo?['requested_scope'] as List<dynamic>?)?.cast<String>() ??
|
||||
[];
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
margin: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
elevation: 8,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 1. 헤더 영역
|
||||
Text(
|
||||
tr('ui.userfront.consent.title'),
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_renderConsentText('msg.userfront.consent.description'),
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 2. 서비스 정보 영역
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey[200]!),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (clientLogo != null &&
|
||||
clientLogo.toString().isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundImage: NetworkImage(clientLogo),
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
)
|
||||
else
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: CircleAvatar(
|
||||
radius: 24,
|
||||
child: Icon(Icons.apps),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
clientName,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_renderClientIdLabel(clientId),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 3. 권한 선택 영역
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
tr('ui.userfront.consent.requested_scopes'),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_renderScopeCountLabel(requestedScopes.length),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Divider(),
|
||||
...requestedScopes.map((scope) {
|
||||
final isMandatory = _mandatoryScopes.contains(scope);
|
||||
final description = _scopeDescriptions[scope] ?? scope;
|
||||
final isSelected = _selectedScopes.contains(scope);
|
||||
|
||||
return CheckboxListTile(
|
||||
title: Text(
|
||||
_scopeDisplayLabel(scope),
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(description),
|
||||
value: isSelected,
|
||||
onChanged: isMandatory
|
||||
? null // 필수 항목은 변경 불가 (비활성화 상태로 체크됨)
|
||||
: (bool? value) {
|
||||
setState(() {
|
||||
if (value == true) {
|
||||
_selectedScopes.add(scope);
|
||||
} else {
|
||||
_selectedScopes.remove(scope);
|
||||
}
|
||||
});
|
||||
},
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
activeColor: Theme.of(context).primaryColor,
|
||||
);
|
||||
}),
|
||||
const Divider(),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 4. 버튼 영역
|
||||
ElevatedButton(
|
||||
onPressed: _isSubmitting ? null : _acceptConsent,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
backgroundColor: const Color(0xFF1A1F2C), // 브랜드 컬러
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
child: _isSubmitting
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
tr('ui.userfront.consent.accept'),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(
|
||||
onPressed: _isSubmitting ? null : _onCancel,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Text(tr('ui.common.cancel')),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
tr('msg.userfront.consent.redirect_notice'),
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/constants/error_whitelist.dart';
|
||||
import '../../../core/i18n/locale_utils.dart';
|
||||
import '../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../core/services/logout_service.dart';
|
||||
import '../../../core/widgets/theme_toggle_button.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
|
||||
class ErrorScreen extends StatefulWidget {
|
||||
final String? errorId;
|
||||
final String? errorCode;
|
||||
final String? description;
|
||||
final bool? isProdOverride;
|
||||
final Future<Map<String, dynamic>> Function()? sessionProfileLoader;
|
||||
final Map<String, dynamic>? tenantAccessDetails;
|
||||
|
||||
const ErrorScreen({
|
||||
super.key,
|
||||
this.errorId,
|
||||
this.errorCode,
|
||||
this.description,
|
||||
this.isProdOverride,
|
||||
this.sessionProfileLoader,
|
||||
this.tenantAccessDetails,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ErrorScreen> createState() => _ErrorScreenState();
|
||||
}
|
||||
|
||||
class _ErrorScreenState extends State<ErrorScreen> {
|
||||
Map<String, dynamic>? _sessionProfile;
|
||||
bool _isLoadingSessionProfile = false;
|
||||
String? _sessionProfileError;
|
||||
|
||||
bool get _isTenantAccessBlocked =>
|
||||
(widget.errorCode ?? '').trim() == 'tenant_not_allowed';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (_isTenantAccessBlocked && _shouldLoadSessionProfile()) {
|
||||
unawaited(_loadSessionProfile());
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic>? get _tenantAccessDetails => widget.tenantAccessDetails;
|
||||
|
||||
bool _shouldLoadSessionProfile() {
|
||||
final details = _tenantAccessDetails;
|
||||
if (details == null) {
|
||||
return true;
|
||||
}
|
||||
final hasAccount = _extractAccountEmail(details).isNotEmpty;
|
||||
final hasTenant = _extractCurrentTenantLabel(details).isNotEmpty;
|
||||
return !hasAccount || !hasTenant;
|
||||
}
|
||||
|
||||
Future<void> _loadSessionProfile() async {
|
||||
if (_isLoadingSessionProfile) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isLoadingSessionProfile = true;
|
||||
_sessionProfileError = null;
|
||||
});
|
||||
try {
|
||||
final loader = widget.sessionProfileLoader ?? AuthProxyService.getMe;
|
||||
final profile = await loader();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_sessionProfile = profile;
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_sessionProfileError = error.toString();
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoadingSessionProfile = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _extractTenantLabel(Map<String, dynamic>? profile) {
|
||||
if (profile == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
final tenant = profile['tenant'];
|
||||
if (tenant is Map) {
|
||||
final name = tenant['name']?.toString().trim() ?? '';
|
||||
if (name.isNotEmpty) {
|
||||
return name;
|
||||
}
|
||||
final slug = tenant['slug']?.toString().trim() ?? '';
|
||||
if (slug.isNotEmpty) {
|
||||
return slug;
|
||||
}
|
||||
}
|
||||
|
||||
final joinedTenants = profile['joinedTenants'];
|
||||
if (joinedTenants is List) {
|
||||
for (final item in joinedTenants) {
|
||||
if (item is Map) {
|
||||
final name = item['name']?.toString().trim() ?? '';
|
||||
if (name.isNotEmpty) {
|
||||
return name;
|
||||
}
|
||||
final slug = item['slug']?.toString().trim() ?? '';
|
||||
if (slug.isNotEmpty) {
|
||||
return slug;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
String _extractCurrentTenantLabel(Map<String, dynamic>? details) {
|
||||
if (details == null) {
|
||||
return '';
|
||||
}
|
||||
final tenant = details['current_tenant'];
|
||||
if (tenant is! Map) {
|
||||
return '';
|
||||
}
|
||||
|
||||
final name = tenant['name']?.toString().trim() ?? '';
|
||||
if (name.isNotEmpty) {
|
||||
return name;
|
||||
}
|
||||
final slug = tenant['slug']?.toString().trim() ?? '';
|
||||
if (slug.isNotEmpty) {
|
||||
return slug;
|
||||
}
|
||||
final identifier = tenant['identifier']?.toString().trim() ?? '';
|
||||
if (identifier.isNotEmpty) {
|
||||
return identifier;
|
||||
}
|
||||
final id = tenant['id']?.toString().trim() ?? '';
|
||||
return id;
|
||||
}
|
||||
|
||||
String _extractAccountEmail(Map<String, dynamic>? details) {
|
||||
if (details == null) {
|
||||
return '';
|
||||
}
|
||||
final account = details['account'];
|
||||
if (account is! Map) {
|
||||
return '';
|
||||
}
|
||||
return account['email']?.toString().trim() ?? '';
|
||||
}
|
||||
|
||||
List<String> _extractAllowedTenantLabels(Map<String, dynamic>? details) {
|
||||
if (details == null) {
|
||||
return const [];
|
||||
}
|
||||
final raw = details['allowed_tenants'];
|
||||
if (raw is! List) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final labels = <String>[];
|
||||
for (final item in raw) {
|
||||
if (item is! Map) {
|
||||
continue;
|
||||
}
|
||||
final label =
|
||||
item['name']?.toString().trim() ??
|
||||
item['slug']?.toString().trim() ??
|
||||
item['identifier']?.toString().trim() ??
|
||||
item['id']?.toString().trim() ??
|
||||
'';
|
||||
if (label.isNotEmpty) {
|
||||
labels.add(label);
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
List<String> _extractAffiliatedTenantLabelsFromDetails(
|
||||
Map<String, dynamic>? details,
|
||||
) {
|
||||
if (details == null) {
|
||||
return const [];
|
||||
}
|
||||
final raw = details['affiliated_tenants'];
|
||||
if (raw is! List) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final labels = <String>[];
|
||||
for (final item in raw) {
|
||||
if (item is! Map) {
|
||||
continue;
|
||||
}
|
||||
final label =
|
||||
item['name']?.toString().trim() ??
|
||||
item['slug']?.toString().trim() ??
|
||||
item['identifier']?.toString().trim() ??
|
||||
item['id']?.toString().trim() ??
|
||||
'';
|
||||
if (label.isNotEmpty) {
|
||||
labels.add(label);
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
List<String> _extractAffiliatedTenantLabelsFromProfile(
|
||||
Map<String, dynamic>? profile,
|
||||
) {
|
||||
if (profile == null) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final labels = <String>[];
|
||||
final seen = <String>{};
|
||||
|
||||
void appendLabel(String value) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty || seen.contains(trimmed)) {
|
||||
return;
|
||||
}
|
||||
seen.add(trimmed);
|
||||
labels.add(trimmed);
|
||||
}
|
||||
|
||||
final joinedTenants = profile['joinedTenants'];
|
||||
if (joinedTenants is List) {
|
||||
for (final item in joinedTenants) {
|
||||
if (item is Map) {
|
||||
appendLabel(item['name']?.toString() ?? '');
|
||||
appendLabel(item['slug']?.toString() ?? '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final tenant = _extractTenantLabel(profile);
|
||||
if (tenant.isNotEmpty) {
|
||||
appendLabel(tenant);
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
|
||||
Future<void> _switchAccount() async {
|
||||
await LogoutService().logout();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
context.go(buildLocalizedSigninPath(Uri.base));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final isProd = widget.isProdOverride ?? AuthProxyService.isProdEnv;
|
||||
final normalizedCode = (widget.errorCode ?? '').trim();
|
||||
final hasCode = normalizedCode.isNotEmpty;
|
||||
final internalWhitelistKey =
|
||||
internalErrorWhitelistMessageKeys[normalizedCode];
|
||||
final isInternalWhitelisted = internalWhitelistKey != null;
|
||||
final isOryBypass = hasCode && oryBypassErrorCodes.contains(normalizedCode);
|
||||
final isKnownProdCode = hasCode && (isInternalWhitelisted || isOryBypass);
|
||||
final isTenantAccessBlocked = normalizedCode == 'tenant_not_allowed';
|
||||
final errorType = isProd
|
||||
? (isKnownProdCode ? normalizedCode : 'unknown_error')
|
||||
: (hasCode ? normalizedCode : 'unknown_error');
|
||||
final title = isTenantAccessBlocked
|
||||
? tr(
|
||||
'msg.userfront.error.tenant.page_title',
|
||||
fallback: 'Application access is restricted',
|
||||
)
|
||||
: isProd
|
||||
? tr('msg.userfront.error.title')
|
||||
: (hasCode
|
||||
? tr(
|
||||
'msg.userfront.error.title_with_code',
|
||||
params: {'code': normalizedCode},
|
||||
)
|
||||
: tr('msg.userfront.error.title_generic'));
|
||||
final tenantLabelFromDetails = _extractCurrentTenantLabel(
|
||||
_tenantAccessDetails,
|
||||
);
|
||||
final tenantLabel = tenantLabelFromDetails.isNotEmpty
|
||||
? tenantLabelFromDetails
|
||||
: _extractTenantLabel(_sessionProfile);
|
||||
final emailFromDetails = _extractAccountEmail(_tenantAccessDetails);
|
||||
final emailLabel = emailFromDetails.isNotEmpty
|
||||
? emailFromDetails
|
||||
: (_sessionProfile?['email']?.toString().trim() ?? '');
|
||||
final affiliatedTenantLabels =
|
||||
_extractAffiliatedTenantLabelsFromDetails(
|
||||
_tenantAccessDetails,
|
||||
).isNotEmpty
|
||||
? _extractAffiliatedTenantLabelsFromDetails(_tenantAccessDetails)
|
||||
: _extractAffiliatedTenantLabelsFromProfile(_sessionProfile);
|
||||
final allowedTenantLabels = _extractAllowedTenantLabels(
|
||||
_tenantAccessDetails,
|
||||
);
|
||||
final isLoadingTenantContext =
|
||||
_isLoadingSessionProfile && _tenantAccessDetails == null;
|
||||
final hasTenantLookupFailure =
|
||||
_sessionProfileError != null &&
|
||||
_sessionProfileError!.isNotEmpty &&
|
||||
_tenantAccessDetails == null;
|
||||
final showTenantLookupFallback =
|
||||
_tenantAccessDetails == null &&
|
||||
(emailLabel.isEmpty || tenantLabel.isEmpty);
|
||||
final internalWhitelistDetail = internalWhitelistKey == null
|
||||
? null
|
||||
: tr(internalWhitelistKey);
|
||||
final detail = isTenantAccessBlocked
|
||||
? tr(
|
||||
'msg.userfront.error.tenant.detail',
|
||||
fallback:
|
||||
'The current signed-in account cannot access this application.',
|
||||
)
|
||||
: isProd
|
||||
? (isInternalWhitelisted
|
||||
? internalWhitelistDetail!
|
||||
: (isOryBypass
|
||||
? tr(
|
||||
'msg.userfront.error.ory.$normalizedCode',
|
||||
fallback: (widget.description?.isNotEmpty == true)
|
||||
? widget.description
|
||||
: tr('msg.userfront.error.detail_request'),
|
||||
)
|
||||
: tr('msg.userfront.error.detail_contact')))
|
||||
: ((widget.description?.isNotEmpty == true)
|
||||
? widget.description!
|
||||
: (hasCode
|
||||
? tr('msg.userfront.error.detail_generic')
|
||||
: tr('msg.userfront.error.detail_request')));
|
||||
return Scaffold(
|
||||
backgroundColor: colorScheme.surfaceContainerLowest,
|
||||
body: SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) => SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: constraints.maxHeight - 48,
|
||||
),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 28, 28, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
const ThemeToggleButton(compact: true),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
detail,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
if (isTenantAccessBlocked) ...[
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.error.tenant.title',
|
||||
fallback: 'Access restriction details',
|
||||
),
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (isLoadingTenantContext)
|
||||
Row(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
child: Text(
|
||||
tr(
|
||||
'msg.userfront.error.tenant.loading',
|
||||
fallback:
|
||||
'Loading the current account details.',
|
||||
),
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else ...[
|
||||
_InfoRow(
|
||||
label: tr(
|
||||
'msg.userfront.error.tenant.account',
|
||||
fallback: 'Account',
|
||||
),
|
||||
value: emailLabel.isNotEmpty
|
||||
? emailLabel
|
||||
: tr(
|
||||
'msg.userfront.error.tenant.account_unknown',
|
||||
fallback: 'Unknown',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_InfoRow(
|
||||
label: tr(
|
||||
'msg.userfront.error.tenant.primary_tenant',
|
||||
fallback: 'Primary affiliated tenant',
|
||||
),
|
||||
value: tenantLabel.isNotEmpty
|
||||
? tenantLabel
|
||||
: tr(
|
||||
'msg.userfront.error.tenant.tenant_unknown',
|
||||
fallback: 'Unknown',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_InfoRow(
|
||||
label: tr(
|
||||
'msg.userfront.error.tenant.affiliated_tenants',
|
||||
fallback: 'All affiliated tenants',
|
||||
),
|
||||
value: affiliatedTenantLabels.isNotEmpty
|
||||
? affiliatedTenantLabels.join(', ')
|
||||
: tr(
|
||||
'msg.userfront.error.tenant.tenant_unknown',
|
||||
fallback: 'Unknown',
|
||||
),
|
||||
),
|
||||
if (showTenantLookupFallback) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.error.tenant.lookup_fallback',
|
||||
fallback:
|
||||
'Some fields may be unavailable because there is not enough profile information to display.',
|
||||
),
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color:
|
||||
colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (hasTenantLookupFailure) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.error.tenant.load_failed',
|
||||
fallback:
|
||||
'Failed to load account details. Please try again.',
|
||||
),
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color:
|
||||
colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.error.tenant.allowed_box_title',
|
||||
fallback: 'Allowed tenants',
|
||||
),
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (allowedTenantLabels.isNotEmpty) ...[
|
||||
_InfoRow(
|
||||
label: tr(
|
||||
'msg.userfront.error.tenant.allowed_tenants',
|
||||
fallback: 'Allowed tenants',
|
||||
),
|
||||
value: allowedTenantLabels.join(', '),
|
||||
),
|
||||
] else ...[
|
||||
_InfoRow(
|
||||
label: tr(
|
||||
'msg.userfront.error.tenant.allowed_tenants',
|
||||
fallback: 'Allowed tenants',
|
||||
),
|
||||
value: tr(
|
||||
'msg.userfront.error.tenant.tenant_unknown',
|
||||
fallback: 'Unknown',
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.error.type',
|
||||
params: {'type': errorType},
|
||||
),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (widget.errorId != null &&
|
||||
widget.errorId!.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.error.id',
|
||||
params: {'id': widget.errorId!},
|
||||
),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: isTenantAccessBlocked
|
||||
? _switchAccount
|
||||
: () => context.go('/login'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
isTenantAccessBlocked
|
||||
? tr('ui.userfront.error.switch_account')
|
||||
: tr('ui.userfront.error.go_login'),
|
||||
),
|
||||
),
|
||||
OutlinedButton(
|
||||
onPressed: () => context.go(
|
||||
buildLocalizedHomePath(Uri.base),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: colorScheme.onSurface,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
side: BorderSide(color: colorScheme.outline),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: Text(tr('ui.userfront.error.go_home')),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const _InfoRow({required this.label, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../core/ui/toast_service.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
|
||||
class ForgotPasswordScreen extends StatefulWidget {
|
||||
const ForgotPasswordScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ForgotPasswordScreen> createState() => _ForgotPasswordScreenState();
|
||||
}
|
||||
|
||||
class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
||||
final TextEditingController _loginIdController = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
bool _drySendEnabled = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_drySendEnabled =
|
||||
_parseBoolParam(Uri.base.queryParameters['drySend']) &&
|
||||
!AuthProxyService.isProdEnv;
|
||||
}
|
||||
|
||||
Future<void> _handlePasswordReset() async {
|
||||
final input = _loginIdController.text.trim();
|
||||
if (input.isEmpty) {
|
||||
_showError(tr('msg.userfront.forgot.input_required'));
|
||||
return;
|
||||
}
|
||||
|
||||
String loginId = input;
|
||||
if (!input.contains('@')) {
|
||||
// Format phone number if it's not an email
|
||||
loginId = input.replaceAll(RegExp(r'[-\s]'), '');
|
||||
if (loginId.startsWith('010')) {
|
||||
loginId = '+82${loginId.substring(1)}';
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
await AuthProxyService.initiatePasswordReset(
|
||||
loginId,
|
||||
drySend: _drySendEnabled,
|
||||
);
|
||||
if (mounted) {
|
||||
ToastService.success(tr('msg.userfront.forgot.sent'));
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
_showError(
|
||||
tr('msg.userfront.forgot.error', params: {'error': e.toString()}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
ToastService.error(message);
|
||||
}
|
||||
|
||||
bool _parseBoolParam(String? value) {
|
||||
if (value == null) {
|
||||
return false;
|
||||
}
|
||||
final normalized = value.toLowerCase();
|
||||
return normalized == 'true' || normalized == '1' || normalized == 'yes';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(tr('ui.userfront.forgot.title')),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
tr('ui.userfront.forgot.heading'),
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (_drySendEnabled) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF3CD),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFFFC107)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: Color(0xFF8A6D3B),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
tr('msg.userfront.forgot.dry_send'),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF8A6D3B),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
tr('msg.userfront.forgot.description'),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
TextField(
|
||||
controller: _loginIdController,
|
||||
decoration: InputDecoration(
|
||||
labelText: tr('ui.userfront.forgot.input_label'),
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.person_outline),
|
||||
),
|
||||
onSubmitted: (_) => _handlePasswordReset(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _isLoading ? null : _handlePasswordReset,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(tr('ui.userfront.forgot.submit')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
2427
baron-sso/userfront/lib/features/auth/presentation/login_screen.dart
Normal file
2427
baron-sso/userfront/lib/features/auth/presentation/login_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:userfront/core/i18n/locale_utils.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
|
||||
class LoginSuccessScreen extends StatelessWidget {
|
||||
const LoginSuccessScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.check_circle_outline,
|
||||
size: 80,
|
||||
color: Colors.green,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
tr('ui.userfront.login_success.title'),
|
||||
style: const TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
tr('msg.userfront.login_success.subtitle'),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
|
||||
// 이 버튼이 QR 카메라를 켜는 버튼입니다.
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
context.push('/scan');
|
||||
},
|
||||
icon: const Icon(Icons.camera_alt, size: 28),
|
||||
label: Text(tr('ui.userfront.login_success.qr')),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(80), // 버튼 높이를 더 크게
|
||||
backgroundColor: Colors.blue.shade700,
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.go(buildLocalizedHomePath(Uri.base));
|
||||
},
|
||||
child: Text(
|
||||
tr('ui.userfront.login_success.later'),
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
enum QrCameraBootstrapStatus {
|
||||
ready,
|
||||
detectorUnsupported,
|
||||
permissionError,
|
||||
cameraError,
|
||||
}
|
||||
|
||||
class QrCameraBootstrapResult {
|
||||
const QrCameraBootstrapResult(this.status, {this.errorDetail = ''});
|
||||
|
||||
final QrCameraBootstrapStatus status;
|
||||
final String errorDetail;
|
||||
|
||||
bool get isReady => status == QrCameraBootstrapStatus.ready;
|
||||
}
|
||||
|
||||
typedef QrOpenCameraAndPlay = Future<void> Function();
|
||||
typedef QrStopCamera = Future<void> Function();
|
||||
|
||||
bool isQrPermissionError(Object error) {
|
||||
final raw = error.toString();
|
||||
return raw.contains('NotAllowedError') ||
|
||||
raw.contains('PermissionDeniedError') ||
|
||||
raw.contains('SecurityError');
|
||||
}
|
||||
|
||||
Future<QrCameraBootstrapResult> bootstrapQrCamera({
|
||||
required bool hasBarcodeDetector,
|
||||
required QrOpenCameraAndPlay openCameraAndPlay,
|
||||
required QrStopCamera stopCamera,
|
||||
}) async {
|
||||
try {
|
||||
await openCameraAndPlay();
|
||||
if (!hasBarcodeDetector) {
|
||||
await stopCamera();
|
||||
return const QrCameraBootstrapResult(
|
||||
QrCameraBootstrapStatus.detectorUnsupported,
|
||||
errorDetail: 'BarcodeDetector is not supported in this browser.',
|
||||
);
|
||||
}
|
||||
return const QrCameraBootstrapResult(QrCameraBootstrapStatus.ready);
|
||||
} catch (e) {
|
||||
if (isQrPermissionError(e)) {
|
||||
return QrCameraBootstrapResult(
|
||||
QrCameraBootstrapStatus.permissionError,
|
||||
errorDetail: e.toString(),
|
||||
);
|
||||
}
|
||||
return QrCameraBootstrapResult(
|
||||
QrCameraBootstrapStatus.cameraError,
|
||||
errorDetail: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import '../../../../core/i18n/locale_utils.dart';
|
||||
|
||||
String buildQrApprovePath(
|
||||
String scannedValue, {
|
||||
String? localeCode,
|
||||
Uri? currentUri,
|
||||
}) {
|
||||
final value = scannedValue.trim();
|
||||
final explicitLocale = localeCode?.trim();
|
||||
final uri = currentUri ?? Uri.base;
|
||||
final resolvedLocale = explicitLocale != null && explicitLocale.isNotEmpty
|
||||
? explicitLocale.toLowerCase().replaceAll('_', '-')
|
||||
: normalizeLocaleCode(
|
||||
extractLocaleFromPath(uri) ?? resolvePreferredLocaleCode(),
|
||||
);
|
||||
return '/$resolvedLocale/approve?ref=${Uri.encodeQueryComponent(value)}';
|
||||
}
|
||||
|
||||
String buildQrBackFallbackPath({String? localeCode, Uri? currentUri}) {
|
||||
final explicitLocale = localeCode?.trim();
|
||||
final uri = currentUri ?? Uri.base;
|
||||
final resolvedLocale = explicitLocale != null && explicitLocale.isNotEmpty
|
||||
? explicitLocale.toLowerCase().replaceAll('_', '-')
|
||||
: normalizeLocaleCode(
|
||||
extractLocaleFromPath(uri) ?? resolvePreferredLocaleCode(),
|
||||
);
|
||||
return '/$resolvedLocale/dashboard';
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export 'qr_scan_screen_stub.dart'
|
||||
if (dart.library.js_interop) 'qr_scan_screen_web.dart';
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
import 'package:userfront/core/ui/toast_service.dart';
|
||||
|
||||
import 'qr_scan_route.dart';
|
||||
|
||||
class QRScanScreen extends StatefulWidget {
|
||||
const QRScanScreen({super.key});
|
||||
|
||||
@override
|
||||
State<QRScanScreen> createState() => _QRScanScreenState();
|
||||
}
|
||||
|
||||
class _QRScanScreenState extends State<QRScanScreen> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
final raw = _controller.text.trim();
|
||||
if (raw.isEmpty) {
|
||||
ToastService.info(
|
||||
tr('msg.userfront.qr.permission_required', fallback: '카메라 권한이 필요합니다.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.go(buildQrApprovePath(raw));
|
||||
}
|
||||
|
||||
void _handleBack() {
|
||||
final router = GoRouter.of(context);
|
||||
if (router.canPop()) {
|
||||
router.pop();
|
||||
return;
|
||||
}
|
||||
router.go(buildQrBackFallbackPath());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(tr('ui.userfront.qr.title', fallback: 'Scan QR Code')),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: _handleBack,
|
||||
),
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.qr.permission_error',
|
||||
fallback: '카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요.',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
key: const ValueKey('qr_scan_manual_input'),
|
||||
controller: _controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'QR Payload',
|
||||
hintText: 'https://.../ql/{ref} 또는 ref',
|
||||
),
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
key: const ValueKey('qr_scan_submit_button'),
|
||||
onPressed: _submit,
|
||||
icon: const Icon(Icons.check_circle),
|
||||
label: Text(
|
||||
tr('ui.userfront.qr.result_success', fallback: '승인 화면으로 이동'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
|
||||
import 'qr_scan_route.dart';
|
||||
|
||||
class QRScanScreen extends StatefulWidget {
|
||||
const QRScanScreen({super.key});
|
||||
|
||||
@override
|
||||
State<QRScanScreen> createState() => _QRScanScreenState();
|
||||
}
|
||||
|
||||
class _QRScanScreenState extends State<QRScanScreen> {
|
||||
final MobileScannerController _scannerController = MobileScannerController(
|
||||
autoStart: true,
|
||||
detectionSpeed: DetectionSpeed.noDuplicates,
|
||||
facing: CameraFacing.back,
|
||||
formats: const <BarcodeFormat>[BarcodeFormat.qrCode],
|
||||
);
|
||||
final TextEditingController _manualController = TextEditingController();
|
||||
|
||||
bool _isProcessing = false;
|
||||
String? _error;
|
||||
String? _status;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_status = tr(
|
||||
'msg.userfront.login.qr.scan_hint',
|
||||
fallback: 'QR 코드를 카메라 중앙에 맞춰주세요.',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_manualController.dispose();
|
||||
_scannerController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _navigateToApprove(String rawPayload) async {
|
||||
final payload = rawPayload.trim();
|
||||
if (payload.isEmpty || _isProcessing || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isProcessing = true;
|
||||
_error = null;
|
||||
_status = tr(
|
||||
'ui.userfront.qr.result_success',
|
||||
fallback: '승인 화면으로 이동 중...',
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
await _scannerController.stop();
|
||||
} catch (_) {}
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
context.go(buildQrApprovePath(payload));
|
||||
}
|
||||
|
||||
void _onDetect(BarcodeCapture capture) {
|
||||
for (final barcode in capture.barcodes) {
|
||||
final raw = barcode.rawValue?.trim();
|
||||
if (raw != null && raw.isNotEmpty) {
|
||||
unawaited(_navigateToApprove(raw));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _toScannerErrorMessage(MobileScannerException error) {
|
||||
switch (error.errorCode) {
|
||||
case MobileScannerErrorCode.permissionDenied:
|
||||
return tr(
|
||||
'msg.userfront.qr.permission_error',
|
||||
fallback: '카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요.',
|
||||
);
|
||||
case MobileScannerErrorCode.unsupported:
|
||||
return tr(
|
||||
'msg.userfront.qr.camera_error',
|
||||
fallback: '카메라 오류: {{error}}',
|
||||
params: {'error': 'QR scanner is not supported in this browser.'},
|
||||
);
|
||||
default:
|
||||
final detail = error.errorDetails?.message;
|
||||
return tr(
|
||||
'msg.userfront.qr.camera_error',
|
||||
fallback: '카메라 오류: {{error}}',
|
||||
params: {'error': detail ?? error.errorCode.message},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _submitManual() {
|
||||
unawaited(_navigateToApprove(_manualController.text));
|
||||
}
|
||||
|
||||
Future<void> _retry() async {
|
||||
setState(() {
|
||||
_isProcessing = false;
|
||||
_error = null;
|
||||
_status = tr(
|
||||
'msg.userfront.login.qr.scan_hint',
|
||||
fallback: 'QR 코드를 카메라 중앙에 맞춰주세요.',
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
await _scannerController.start();
|
||||
} catch (e) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_error = tr(
|
||||
'msg.userfront.qr.camera_error',
|
||||
fallback: '카메라 오류: {{error}}',
|
||||
params: {'error': '$e'},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleBack() {
|
||||
final router = GoRouter.of(context);
|
||||
if (router.canPop()) {
|
||||
router.pop();
|
||||
return;
|
||||
}
|
||||
router.go(buildQrBackFallbackPath());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(tr('ui.userfront.qr.title', fallback: 'Scan QR Code')),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: _handleBack,
|
||||
),
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 3 / 4,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
MobileScanner(
|
||||
controller: _scannerController,
|
||||
onDetect: _onDetect,
|
||||
errorBuilder: (context, error) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_error = _toScannerErrorMessage(error);
|
||||
});
|
||||
});
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
_toScannerErrorMessage(error),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (_isProcessing)
|
||||
Container(
|
||||
color: Colors.black45,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_status != null) Text(_status!, textAlign: TextAlign.center),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_error!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _isProcessing ? null : _retry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(tr('ui.userfront.qr.rescan', fallback: '다시 스캔')),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
key: const ValueKey('qr_scan_manual_input'),
|
||||
controller: _manualController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'QR Payload',
|
||||
hintText: 'https://.../ql/{ref} 또는 ref',
|
||||
),
|
||||
onSubmitted: (_) => _submitManual(),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.icon(
|
||||
key: const ValueKey('qr_scan_submit_button'),
|
||||
onPressed: _isProcessing ? null : _submitManual,
|
||||
icon: const Icon(Icons.check_circle),
|
||||
label: Text(
|
||||
tr('ui.userfront.qr.result_success', fallback: '승인 화면으로 이동'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/i18n/locale_utils.dart';
|
||||
import '../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../core/ui/toast_service.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
|
||||
class ResetPasswordScreen extends StatefulWidget {
|
||||
final String? loginId; // Now receiving loginId
|
||||
const ResetPasswordScreen({super.key, this.loginId});
|
||||
|
||||
@override
|
||||
State<ResetPasswordScreen> createState() => _ResetPasswordScreenState();
|
||||
}
|
||||
|
||||
class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
final TextEditingController _confirmPasswordController =
|
||||
TextEditingController();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isLoading = false;
|
||||
String? _loginId;
|
||||
String? _token;
|
||||
bool _isPasswordObscured = true;
|
||||
bool _isConfirmPasswordObscured = true;
|
||||
Map<String, dynamic>? _policy;
|
||||
bool _isPolicyLoading = false;
|
||||
|
||||
String _renderTranslatedText(
|
||||
String key, {
|
||||
String? fallback,
|
||||
Map<String, String> values = const {},
|
||||
}) {
|
||||
var text = tr(key, fallback: fallback);
|
||||
values.forEach((name, value) {
|
||||
text = text.replaceAll('{{$name}}', value).replaceAll('{$name}', value);
|
||||
});
|
||||
return text;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 1. Get loginId from GoRouter state if available
|
||||
_loginId = widget.loginId;
|
||||
|
||||
// 2. Fallback to URI query parameter if not available via router
|
||||
if (_loginId == null || _loginId!.isEmpty) {
|
||||
final uri = Uri.base;
|
||||
_loginId = uri.queryParameters['loginId'];
|
||||
}
|
||||
|
||||
// 토큰도 함께 읽어놓는다.
|
||||
final uri = Uri.base;
|
||||
_token = uri.queryParameters['token'];
|
||||
|
||||
_loadPolicy();
|
||||
}
|
||||
|
||||
Future<void> _loadPolicy() async {
|
||||
setState(() {
|
||||
_isPolicyLoading = true;
|
||||
});
|
||||
try {
|
||||
final policy = await AuthProxyService.fetchPasswordPolicy();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_policy = policy;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// 실패해도 기본 검증 로직 사용
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isPolicyLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handlePasswordReset() async {
|
||||
if (_isLoading) return;
|
||||
if (_formKey.currentState?.validate() != true) return;
|
||||
if ((_loginId == null || _loginId!.isEmpty) &&
|
||||
(_token == null || _token!.isEmpty)) {
|
||||
_showError(tr('msg.userfront.reset.invalid_link'));
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
bool isSuccess = false;
|
||||
|
||||
try {
|
||||
await AuthProxyService.completePasswordReset(
|
||||
loginId: _loginId,
|
||||
token: _token,
|
||||
newPassword: _passwordController.text,
|
||||
);
|
||||
|
||||
isSuccess = true;
|
||||
if (mounted) {
|
||||
ToastService.success(tr('msg.userfront.reset.success'));
|
||||
context.go(buildLocalizedSigninPath(Uri.base));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
_showError(
|
||||
tr(
|
||||
'msg.userfront.reset.error.generic',
|
||||
params: {'error': e.toString()},
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted && !isSuccess) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
ToastService.error(message);
|
||||
}
|
||||
|
||||
String _buildPolicyDescription() {
|
||||
if (_isPolicyLoading) {
|
||||
return tr('msg.userfront.reset.policy_loading');
|
||||
}
|
||||
final minLength = (_policy?['minLength'] as int?) ?? 12;
|
||||
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
|
||||
final requiresLower = _policy?['lowercase'] ?? true;
|
||||
final requiresUpper = _policy?['uppercase'] ?? false;
|
||||
final requiresNumber = _policy?['number'] ?? true;
|
||||
final requiresSymbol = _policy?['nonAlphanumeric'] ?? true;
|
||||
|
||||
final parts = <String>[
|
||||
_renderTranslatedText(
|
||||
'msg.userfront.reset.policy.min_length',
|
||||
values: {'count': '$minLength'},
|
||||
),
|
||||
];
|
||||
if (minTypes > 0) {
|
||||
parts.add(
|
||||
_renderTranslatedText(
|
||||
'msg.userfront.reset.policy.min_types',
|
||||
values: {'count': '$minTypes'},
|
||||
),
|
||||
);
|
||||
}
|
||||
if (requiresLower) {
|
||||
parts.add(tr('msg.userfront.reset.policy.lowercase'));
|
||||
}
|
||||
if (requiresUpper) {
|
||||
parts.add(tr('msg.userfront.reset.policy.uppercase'));
|
||||
}
|
||||
if (requiresNumber) {
|
||||
parts.add(tr('msg.userfront.reset.policy.number'));
|
||||
}
|
||||
if (requiresSymbol) {
|
||||
parts.add(tr('msg.userfront.reset.policy.symbol'));
|
||||
}
|
||||
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(tr('ui.userfront.reset.title')),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child:
|
||||
(_loginId == null || _loginId!.isEmpty) &&
|
||||
(_token == null || _token!.isEmpty)
|
||||
? _buildInvalidTokenView()
|
||||
: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
tr('ui.userfront.reset.subtitle'),
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_buildPolicyDescription(),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
TextFormField(
|
||||
key: const ValueKey('reset_password_new_input'),
|
||||
controller: _passwordController,
|
||||
obscureText: _isPasswordObscured,
|
||||
decoration: InputDecoration(
|
||||
labelText: tr('ui.userfront.reset.new_password'),
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_isPasswordObscured
|
||||
? Icons.visibility_off
|
||||
: Icons.visibility,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isPasswordObscured = !_isPasswordObscured;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
final val = value ?? "";
|
||||
if (val.isEmpty) {
|
||||
return tr(
|
||||
'msg.userfront.reset.error.empty_password',
|
||||
);
|
||||
}
|
||||
final minLength =
|
||||
(_policy?['minLength'] as int?) ?? 12;
|
||||
if (val.length < minLength) {
|
||||
return tr(
|
||||
'msg.userfront.reset.error.min_length',
|
||||
params: {'count': '$minLength'},
|
||||
);
|
||||
}
|
||||
final hasLower = RegExp(r'[a-z]').hasMatch(val);
|
||||
final hasUpper = RegExp(r'[A-Z]').hasMatch(val);
|
||||
final hasNumber = RegExp(r'[0-9]').hasMatch(val);
|
||||
final hasSymbol = RegExp(r'[\W_]').hasMatch(val);
|
||||
int typeCount = 0;
|
||||
if (hasLower) typeCount++;
|
||||
if (hasUpper) typeCount++;
|
||||
if (hasNumber) typeCount++;
|
||||
if (hasSymbol) typeCount++;
|
||||
|
||||
final minTypes =
|
||||
(_policy?['minCharacterTypes'] as int?) ?? 0;
|
||||
if (minTypes > 0 && typeCount < minTypes) {
|
||||
return tr(
|
||||
'msg.userfront.reset.error.min_types',
|
||||
params: {'count': '$minTypes'},
|
||||
);
|
||||
}
|
||||
|
||||
if ((_policy?['lowercase'] ?? true) && !hasLower) {
|
||||
return tr('msg.userfront.reset.error.lowercase');
|
||||
}
|
||||
if ((_policy?['uppercase'] ?? false) && !hasUpper) {
|
||||
return tr('msg.userfront.reset.error.uppercase');
|
||||
}
|
||||
if ((_policy?['number'] ?? true) && !hasNumber) {
|
||||
return tr('msg.userfront.reset.error.number');
|
||||
}
|
||||
if ((_policy?['nonAlphanumeric'] ?? true) &&
|
||||
!hasSymbol) {
|
||||
return tr('msg.userfront.reset.error.symbol');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
key: const ValueKey('reset_password_confirm_input'),
|
||||
controller: _confirmPasswordController,
|
||||
obscureText: _isConfirmPasswordObscured,
|
||||
decoration: InputDecoration(
|
||||
labelText: tr('ui.userfront.reset.confirm_password'),
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_isConfirmPasswordObscured
|
||||
? Icons.visibility_off
|
||||
: Icons.visibility,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isConfirmPasswordObscured =
|
||||
!_isConfirmPasswordObscured;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value != _passwordController.text) {
|
||||
return tr('msg.userfront.reset.error.mismatch');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
key: const ValueKey('reset_password_submit_button'),
|
||||
onPressed: _isLoading ? null : _handlePasswordReset,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(tr('ui.userfront.reset.submit')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInvalidTokenView() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 60),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
tr('msg.userfront.reset.invalid_title'),
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
tr('msg.userfront.reset.invalid_body'),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,178 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/services/auth_token_store.dart';
|
||||
import '../../../../core/services/http_client.dart';
|
||||
import '../../../../core/services/runtime_env.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
import 'models.dart';
|
||||
|
||||
String get _baseUrl => runtimeBackendUrl();
|
||||
|
||||
Future<AuditPage> _fetchAuthTimelinePage({String? cursor}) async {
|
||||
final queryParameters = <String, String>{'limit': '20'};
|
||||
if (cursor != null && cursor.isNotEmpty) {
|
||||
queryParameters['cursor'] = cursor;
|
||||
}
|
||||
|
||||
final url = Uri.parse(
|
||||
'$_baseUrl/api/v1/audit/auth/timeline',
|
||||
).replace(queryParameters: queryParameters);
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
final token = AuthTokenStore.getToken();
|
||||
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await client.get(url, headers: headers);
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to load audit logs');
|
||||
}
|
||||
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final items = (body['items'] as List?) ?? [];
|
||||
final nextCursor = body['next_cursor']?.toString();
|
||||
final logs = <AuditLogEntry>[];
|
||||
for (final item in items) {
|
||||
if (item is Map) {
|
||||
logs.add(AuditLogEntry.fromJson(Map<String, dynamic>.from(item)));
|
||||
}
|
||||
}
|
||||
|
||||
return AuditPage(items: logs, nextCursor: nextCursor);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
typedef AuthTimelineFetcher = Future<AuditPage> Function({String? cursor});
|
||||
|
||||
final authTimelineFetcherProvider = Provider<AuthTimelineFetcher>((ref) {
|
||||
return _fetchAuthTimelinePage;
|
||||
});
|
||||
|
||||
class AuthTimelineState {
|
||||
final List<AuditLogEntry> items;
|
||||
final String? nextCursor;
|
||||
final bool isLoading;
|
||||
final bool isLoadingMore;
|
||||
final String? error;
|
||||
|
||||
const AuthTimelineState({
|
||||
required this.items,
|
||||
this.nextCursor,
|
||||
this.isLoading = false,
|
||||
this.isLoadingMore = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
const AuthTimelineState.initial()
|
||||
: items = const [],
|
||||
nextCursor = null,
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
error = null;
|
||||
|
||||
AuthTimelineState copyWith({
|
||||
List<AuditLogEntry>? items,
|
||||
String? nextCursor,
|
||||
bool? isLoading,
|
||||
bool? isLoadingMore,
|
||||
String? error,
|
||||
}) {
|
||||
return AuthTimelineState(
|
||||
items: items ?? this.items,
|
||||
nextCursor: nextCursor ?? this.nextCursor,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AuthTimelineNotifier extends Notifier<AuthTimelineState> {
|
||||
late final AuthTimelineFetcher _fetchPage;
|
||||
bool _hasLoaded = false;
|
||||
|
||||
@override
|
||||
AuthTimelineState build() {
|
||||
_fetchPage = ref.watch(authTimelineFetcherProvider);
|
||||
if (!_hasLoaded) {
|
||||
_hasLoaded = true;
|
||||
Future.microtask(_loadInitial);
|
||||
}
|
||||
return const AuthTimelineState.initial();
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
if (state.isLoading) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(
|
||||
items: const [],
|
||||
nextCursor: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
);
|
||||
await _loadPage(reset: true);
|
||||
}
|
||||
|
||||
Future<void> loadMore() async {
|
||||
if (state.isLoading || state.isLoadingMore) {
|
||||
return;
|
||||
}
|
||||
final nextCursor = state.nextCursor;
|
||||
if (nextCursor == null || nextCursor.isEmpty) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(isLoadingMore: true, error: null);
|
||||
await _loadPage(reset: false);
|
||||
}
|
||||
|
||||
Future<void> _loadInitial() async {
|
||||
if (state.items.isNotEmpty || state.isLoading) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
await _loadPage(reset: true);
|
||||
}
|
||||
|
||||
Future<void> _loadPage({required bool reset}) async {
|
||||
try {
|
||||
final page = await _fetchPage(cursor: reset ? null : state.nextCursor);
|
||||
if (reset) {
|
||||
state = state.copyWith(
|
||||
items: page.items,
|
||||
nextCursor: page.nextCursor,
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
error: null,
|
||||
);
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
items: [...state.items, ...page.items],
|
||||
nextCursor: page.nextCursor,
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
error: null,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
error: tr('msg.userfront.dashboard.timeline.load_error'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final authTimelineProvider =
|
||||
NotifierProvider<AuthTimelineNotifier, AuthTimelineState>(
|
||||
AuthTimelineNotifier.new,
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'providers/linked_rps_provider.dart';
|
||||
|
||||
String? resolveLinkedRpLaunchUrl(LinkedRp rp) {
|
||||
final normalizedStatus = rp.status.trim().toLowerCase();
|
||||
final isActive = normalizedStatus.isEmpty || normalizedStatus == 'active';
|
||||
if (!isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (rp.autoLoginSupported) {
|
||||
final autoLoginUrl = rp.autoLoginUrl.trim();
|
||||
if (autoLoginUrl.isNotEmpty) {
|
||||
return autoLoginUrl;
|
||||
}
|
||||
final initUrl = rp.initUrl.trim();
|
||||
if (initUrl.isNotEmpty) {
|
||||
return initUrl;
|
||||
}
|
||||
}
|
||||
|
||||
final url = rp.url.trim();
|
||||
if (url.isNotEmpty) {
|
||||
return url;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
237
baron-sso/userfront/lib/features/dashboard/domain/models.dart
Normal file
237
baron-sso/userfront/lib/features/dashboard/domain/models.dart
Normal file
@@ -0,0 +1,237 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class AuditLogEntry {
|
||||
final String eventId;
|
||||
final DateTime timestamp;
|
||||
final String userId;
|
||||
final String eventType;
|
||||
final String status;
|
||||
final String authMethod;
|
||||
final String ipAddress;
|
||||
final String userAgent;
|
||||
final String sessionId;
|
||||
final String details;
|
||||
final String source;
|
||||
final String clientId;
|
||||
final String appName;
|
||||
final String parentSessionId;
|
||||
|
||||
AuditLogEntry({
|
||||
required this.eventId,
|
||||
required this.timestamp,
|
||||
required this.userId,
|
||||
required this.eventType,
|
||||
required this.status,
|
||||
required this.authMethod,
|
||||
required this.ipAddress,
|
||||
required this.userAgent,
|
||||
required this.sessionId,
|
||||
required this.details,
|
||||
required this.source,
|
||||
required this.clientId,
|
||||
required this.appName,
|
||||
required this.parentSessionId,
|
||||
});
|
||||
|
||||
factory AuditLogEntry.fromJson(Map<String, dynamic> json) {
|
||||
final timestampRaw = json['timestamp']?.toString() ?? '';
|
||||
DateTime parsedTimestamp;
|
||||
try {
|
||||
parsedTimestamp = DateTime.parse(timestampRaw).toLocal();
|
||||
} catch (_) {
|
||||
parsedTimestamp = DateTime.now();
|
||||
}
|
||||
|
||||
return AuditLogEntry(
|
||||
eventId: json['event_id'] ?? '',
|
||||
timestamp: parsedTimestamp,
|
||||
userId: json['user_id'] ?? '',
|
||||
eventType: json['event_type'] ?? '',
|
||||
status: json['status'] ?? '',
|
||||
authMethod: json['auth_method'] ?? '',
|
||||
ipAddress: json['ip_address'] ?? '',
|
||||
userAgent: json['user_agent'] ?? '',
|
||||
sessionId: json['session_id'] ?? '',
|
||||
details: json['details'] ?? '',
|
||||
source: json['source'] ?? '',
|
||||
clientId: json['client_id'] ?? '',
|
||||
appName: json['app_name'] ?? '',
|
||||
parentSessionId: json['parent_session_id'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> get detailMap {
|
||||
if (details.isEmpty) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return jsonDecode(details) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
String get path {
|
||||
final detailPath = detailMap['path']?.toString();
|
||||
if (detailPath != null && detailPath.isNotEmpty) {
|
||||
return detailPath;
|
||||
}
|
||||
final parts = eventType.split(' ');
|
||||
if (parts.length >= 2) {
|
||||
return parts.sublist(1).join(' ');
|
||||
}
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
class AuditPage {
|
||||
final List<AuditLogEntry> items;
|
||||
final String? nextCursor;
|
||||
|
||||
const AuditPage({required this.items, this.nextCursor});
|
||||
}
|
||||
|
||||
class LinkedRp {
|
||||
final String id;
|
||||
final String name;
|
||||
final String logo;
|
||||
final String url;
|
||||
final String initUrl;
|
||||
final bool autoLoginSupported;
|
||||
final String autoLoginUrl;
|
||||
final String status;
|
||||
final List<String> scopes;
|
||||
final DateTime? lastAuthenticatedAt;
|
||||
|
||||
LinkedRp({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.logo,
|
||||
required this.url,
|
||||
required this.initUrl,
|
||||
required this.autoLoginSupported,
|
||||
required this.autoLoginUrl,
|
||||
required this.status,
|
||||
required this.scopes,
|
||||
this.lastAuthenticatedAt,
|
||||
});
|
||||
|
||||
factory LinkedRp.fromJson(Map<String, dynamic> json) {
|
||||
DateTime? parsedLastAuth;
|
||||
final rawLastAuth = json['lastAuthenticatedAt']?.toString();
|
||||
if (rawLastAuth != null && rawLastAuth.isNotEmpty) {
|
||||
try {
|
||||
parsedLastAuth = DateTime.parse(rawLastAuth).toLocal();
|
||||
} catch (_) {
|
||||
parsedLastAuth = null;
|
||||
}
|
||||
}
|
||||
|
||||
return LinkedRp(
|
||||
id: json['id']?.toString() ?? '',
|
||||
name: json['name']?.toString() ?? '',
|
||||
logo: json['logo']?.toString() ?? '',
|
||||
url: json['url']?.toString() ?? '',
|
||||
initUrl: json['init_url']?.toString() ?? '',
|
||||
autoLoginSupported: json['auto_login_supported'] == true,
|
||||
autoLoginUrl: json['auto_login_url']?.toString() ?? '',
|
||||
status: json['status']?.toString() ?? '',
|
||||
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
|
||||
lastAuthenticatedAt: parsedLastAuth,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RpHistoryItem {
|
||||
final String clientId;
|
||||
final String clientName;
|
||||
final List<String> scopes;
|
||||
final DateTime? lastApprovedAt;
|
||||
final DateTime? lastRevokedAt;
|
||||
final String status;
|
||||
|
||||
RpHistoryItem({
|
||||
required this.clientId,
|
||||
required this.clientName,
|
||||
required this.scopes,
|
||||
this.lastApprovedAt,
|
||||
this.lastRevokedAt,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
factory RpHistoryItem.fromJson(Map<String, dynamic> json) {
|
||||
DateTime? parseDate(String? raw) {
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
try {
|
||||
return DateTime.parse(raw).toLocal();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return RpHistoryItem(
|
||||
clientId: json['client_id']?.toString() ?? '',
|
||||
clientName: json['client_name']?.toString() ?? '',
|
||||
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
|
||||
lastApprovedAt: parseDate(json['last_approved_at']?.toString()),
|
||||
lastRevokedAt: parseDate(json['last_revoked_at']?.toString()),
|
||||
status: json['status']?.toString() ?? 'unknown',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UserSessionSummary {
|
||||
final String sessionId;
|
||||
final DateTime? authenticatedAt;
|
||||
final DateTime? expiresAt;
|
||||
final DateTime? issuedAt;
|
||||
final DateTime? lastSeenAt;
|
||||
final String ipAddress;
|
||||
final String userAgent;
|
||||
final String clientId;
|
||||
final String appName;
|
||||
final bool isCurrent;
|
||||
final bool isActive;
|
||||
|
||||
UserSessionSummary({
|
||||
required this.sessionId,
|
||||
this.authenticatedAt,
|
||||
this.expiresAt,
|
||||
this.issuedAt,
|
||||
this.lastSeenAt,
|
||||
required this.ipAddress,
|
||||
required this.userAgent,
|
||||
required this.clientId,
|
||||
required this.appName,
|
||||
required this.isCurrent,
|
||||
required this.isActive,
|
||||
});
|
||||
|
||||
factory UserSessionSummary.fromJson(Map<String, dynamic> json) {
|
||||
DateTime? parseDate(dynamic raw) {
|
||||
final value = raw?.toString();
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return DateTime.parse(value).toLocal();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return UserSessionSummary(
|
||||
sessionId: json['session_id']?.toString() ?? '',
|
||||
authenticatedAt: parseDate(json['authenticated_at']),
|
||||
expiresAt: parseDate(json['expires_at']),
|
||||
issuedAt: parseDate(json['issued_at']),
|
||||
lastSeenAt: parseDate(json['last_seen_at']),
|
||||
ipAddress: json['ip_address']?.toString() ?? '',
|
||||
userAgent: json['user_agent']?.toString() ?? '',
|
||||
clientId: json['client_id']?.toString() ?? '',
|
||||
appName: json['app_name']?.toString() ?? '',
|
||||
isCurrent: json['is_current'] == true,
|
||||
isActive: json['is_active'] != false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:userfront/core/services/auth_proxy_service.dart';
|
||||
import 'package:userfront/core/services/auth_token_store.dart';
|
||||
import 'package:userfront/core/services/http_client.dart';
|
||||
import 'package:userfront/core/services/runtime_env.dart';
|
||||
|
||||
class LinkedRp {
|
||||
final String id;
|
||||
final String name;
|
||||
final String logo;
|
||||
final String url;
|
||||
final String initUrl;
|
||||
final bool autoLoginSupported;
|
||||
final String autoLoginUrl;
|
||||
final String status;
|
||||
final List<String> scopes;
|
||||
final DateTime? lastAuthenticatedAt;
|
||||
|
||||
LinkedRp({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.logo,
|
||||
required this.url,
|
||||
required this.initUrl,
|
||||
required this.autoLoginSupported,
|
||||
required this.autoLoginUrl,
|
||||
required this.status,
|
||||
required this.scopes,
|
||||
required this.lastAuthenticatedAt,
|
||||
});
|
||||
|
||||
factory LinkedRp.fromJson(Map<String, dynamic> json) {
|
||||
final rawLastAuth = json['lastAuthenticatedAt']?.toString() ?? '';
|
||||
DateTime? parsedLastAuth;
|
||||
if (rawLastAuth.isNotEmpty) {
|
||||
try {
|
||||
parsedLastAuth = DateTime.parse(rawLastAuth).toLocal();
|
||||
} catch (_) {
|
||||
parsedLastAuth = null;
|
||||
}
|
||||
}
|
||||
|
||||
return LinkedRp(
|
||||
id: json['id']?.toString() ?? '',
|
||||
name: json['name']?.toString() ?? '',
|
||||
logo: json['logo']?.toString() ?? '',
|
||||
url: json['url']?.toString() ?? '',
|
||||
initUrl: json['init_url']?.toString() ?? '',
|
||||
autoLoginSupported: json['auto_login_supported'] == true,
|
||||
autoLoginUrl: json['auto_login_url']?.toString() ?? '',
|
||||
status: json['status']?.toString() ?? 'unknown',
|
||||
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
|
||||
lastAuthenticatedAt: parsedLastAuth,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LinkedRpsNotifier extends AsyncNotifier<List<LinkedRp>> {
|
||||
@override
|
||||
Future<List<LinkedRp>> build() async {
|
||||
return _fetchLinkedRps();
|
||||
}
|
||||
|
||||
Future<List<LinkedRp>> _fetchLinkedRps() async {
|
||||
try {
|
||||
final baseUrl = runtimeBackendUrl();
|
||||
final url = Uri.parse('$baseUrl/api/v1/user/rp/linked');
|
||||
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
final token = AuthTokenStore.getToken();
|
||||
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
|
||||
final response = await client.get(url, headers: headers);
|
||||
client.close();
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to load linked rps: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final items = (body['items'] as List?) ?? [];
|
||||
|
||||
return items
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(LinkedRp.fromJson)
|
||||
.toList();
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() => _fetchLinkedRps());
|
||||
}
|
||||
|
||||
Future<void> revokeRp(String clientId) async {
|
||||
await AuthProxyService.revokeLinkedRp(clientId);
|
||||
await refresh();
|
||||
}
|
||||
}
|
||||
|
||||
final linkedRpsProvider =
|
||||
AsyncNotifierProvider<LinkedRpsNotifier, List<LinkedRp>>(() {
|
||||
return LinkedRpsNotifier();
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../../core/services/auth_token_store.dart';
|
||||
import '../../../../core/services/http_client.dart';
|
||||
import '../../../../core/services/runtime_env.dart';
|
||||
import '../models.dart';
|
||||
|
||||
class UserSessionsNotifier extends AsyncNotifier<List<UserSessionSummary>> {
|
||||
@override
|
||||
Future<List<UserSessionSummary>> build() async {
|
||||
return _fetchSessions();
|
||||
}
|
||||
|
||||
Future<List<UserSessionSummary>> _fetchSessions() async {
|
||||
final baseUrl = runtimeBackendUrl();
|
||||
final url = Uri.parse('$baseUrl/api/v1/user/sessions');
|
||||
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
final token = AuthTokenStore.getToken();
|
||||
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await client.get(url, headers: headers);
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to load sessions: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final items = (body['items'] as List?) ?? const [];
|
||||
return items
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(UserSessionSummary.fromJson)
|
||||
.toList();
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(_fetchSessions);
|
||||
}
|
||||
|
||||
Future<void> revokeSession(String sessionId) async {
|
||||
await AuthProxyService.revokeSession(sessionId);
|
||||
await refresh();
|
||||
}
|
||||
}
|
||||
|
||||
final userSessionsProvider =
|
||||
AsyncNotifierProvider<UserSessionsNotifier, List<UserSessionSummary>>(() {
|
||||
return UserSessionsNotifier();
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../profile/data/models/user_profile_model.dart';
|
||||
|
||||
DateTime? resolveDashboardSessionIssuedAt({
|
||||
String? token,
|
||||
UserProfile? profile,
|
||||
}) {
|
||||
final tokenIssuedAt = _getJwtIssuedAt(token);
|
||||
if (tokenIssuedAt != null) {
|
||||
return tokenIssuedAt;
|
||||
}
|
||||
return _parseSessionAuthenticatedAt(profile?.sessionAuthenticatedAt);
|
||||
}
|
||||
|
||||
DateTime? _getJwtIssuedAt(String? token) {
|
||||
if (token == null || token.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final parts = token.split('.');
|
||||
if (parts.length != 3) {
|
||||
return null;
|
||||
}
|
||||
final payload = utf8.decode(
|
||||
base64Url.decode(base64Url.normalize(parts[1])),
|
||||
);
|
||||
final data = json.decode(payload) as Map<String, dynamic>;
|
||||
final iatValue = data['iat'] ?? data['auth_time'];
|
||||
if (iatValue is num) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(
|
||||
iatValue.toInt() * 1000,
|
||||
).toLocal();
|
||||
}
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
DateTime? _parseSessionAuthenticatedAt(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return DateTime.parse(value).toLocal();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:userfront/features/dashboard/domain/models.dart';
|
||||
|
||||
const headlessServerUserAgentSentinel = '__headless_server__';
|
||||
|
||||
bool looksLikeInternalAuditUserAgent(String userAgent) {
|
||||
final lower = userAgent.trim().toLowerCase();
|
||||
return lower.startsWith('go-http-client/') ||
|
||||
lower.startsWith('fasthttp') ||
|
||||
lower.startsWith('fiber') ||
|
||||
lower.startsWith('undici') ||
|
||||
lower.startsWith('node');
|
||||
}
|
||||
|
||||
String preferredAuditLogUserAgent(AuditLogEntry log) {
|
||||
final userAgent = log.userAgent.trim();
|
||||
final path = log.path.toLowerCase();
|
||||
|
||||
final isHeadlessLinkLog =
|
||||
path.contains('/api/v1/auth/magic-link/verify') ||
|
||||
path.contains('/api/v1/auth/login/code/verify');
|
||||
final isHeadlessPasswordLog = path.contains(
|
||||
'/api/v1/auth/headless/password/login',
|
||||
);
|
||||
|
||||
if ((isHeadlessLinkLog || isHeadlessPasswordLog) &&
|
||||
looksLikeInternalAuditUserAgent(userAgent)) {
|
||||
return headlessServerUserAgentSentinel;
|
||||
}
|
||||
|
||||
return userAgent;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
||||
class Tenant {
|
||||
final String id;
|
||||
final String name;
|
||||
final String slug;
|
||||
final String description;
|
||||
|
||||
Tenant({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.slug,
|
||||
required this.description,
|
||||
});
|
||||
|
||||
factory Tenant.fromJson(Map<String, dynamic> json) {
|
||||
return Tenant(
|
||||
id: json['id'] ?? '',
|
||||
name: json['name'] ?? '',
|
||||
slug: json['slug'] ?? '',
|
||||
description: json['description'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {'id': id, 'name': name, 'slug': slug, 'description': description};
|
||||
}
|
||||
}
|
||||
|
||||
class UserProfile {
|
||||
final String id;
|
||||
final String email;
|
||||
final String name;
|
||||
final String phone;
|
||||
final String department;
|
||||
final String affiliationType;
|
||||
final String companyCode;
|
||||
final String? sessionAuthenticatedAt;
|
||||
final Map<String, dynamic>? metadata;
|
||||
final Tenant? tenant;
|
||||
|
||||
UserProfile({
|
||||
required this.id,
|
||||
required this.email,
|
||||
required this.name,
|
||||
required this.phone,
|
||||
required this.department,
|
||||
required this.affiliationType,
|
||||
required this.companyCode,
|
||||
this.sessionAuthenticatedAt,
|
||||
this.metadata,
|
||||
this.tenant,
|
||||
});
|
||||
|
||||
factory UserProfile.fromJson(Map<String, dynamic> json) {
|
||||
return UserProfile(
|
||||
id: json['id'] ?? '',
|
||||
email: json['email'] ?? '',
|
||||
name: json['name'] ?? '',
|
||||
phone: json['phone'] ?? '',
|
||||
department: json['department'] ?? '',
|
||||
affiliationType: json['affiliationType'] ?? '',
|
||||
companyCode: json['companyCode'] ?? '',
|
||||
sessionAuthenticatedAt: json['sessionAuthenticatedAt'] as String?,
|
||||
metadata: json['metadata'] != null
|
||||
? Map<String, dynamic>.from(json['metadata'])
|
||||
: null,
|
||||
tenant: json['tenant'] != null ? Tenant.fromJson(json['tenant']) : null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'email': email,
|
||||
'name': name,
|
||||
'phone': phone,
|
||||
'department': department,
|
||||
'affiliationType': affiliationType,
|
||||
'companyCode': companyCode,
|
||||
'sessionAuthenticatedAt': sessionAuthenticatedAt,
|
||||
'metadata': metadata,
|
||||
'tenant': tenant?.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
UserProfile copyWith({String? name, String? phone, String? department}) {
|
||||
return UserProfile(
|
||||
id: id,
|
||||
email: email,
|
||||
name: name ?? this.name,
|
||||
phone: phone ?? this.phone,
|
||||
department: department ?? this.department,
|
||||
affiliationType: affiliationType,
|
||||
companyCode: companyCode,
|
||||
sessionAuthenticatedAt: sessionAuthenticatedAt,
|
||||
tenant: tenant,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import 'dart:convert';
|
||||
import 'package:userfront/i18n.dart';
|
||||
import '../models/user_profile_model.dart';
|
||||
import '../../../../core/services/auth_token_store.dart';
|
||||
import '../../../../core/services/http_client.dart';
|
||||
import '../../../../core/services/runtime_env.dart';
|
||||
|
||||
class ProfileRepository {
|
||||
static String get _baseUrl => runtimeBackendUrl();
|
||||
|
||||
// Helper to get session token
|
||||
static Future<String?> _getToken() async {
|
||||
return AuthTokenStore.getToken();
|
||||
}
|
||||
|
||||
Future<UserProfile> getMyProfile() async {
|
||||
final token = await _getToken();
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
if (token == null && !useCookie) {
|
||||
throw Exception(tr('err.userfront.session.missing'));
|
||||
}
|
||||
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/me');
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
final response = await client.get(url, headers: headers);
|
||||
client.close();
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return UserProfile.fromJson(jsonDecode(response.body));
|
||||
} else {
|
||||
throw Exception(
|
||||
tr(
|
||||
'err.userfront.profile.load_failed',
|
||||
params: {'error': response.body},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateMyProfile({
|
||||
required String name,
|
||||
required String phone,
|
||||
required String department,
|
||||
}) async {
|
||||
final token = await _getToken();
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
if (token == null && !useCookie) {
|
||||
throw Exception(tr('err.userfront.session.missing'));
|
||||
}
|
||||
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/me');
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
final response = await client.put(
|
||||
url,
|
||||
headers: headers,
|
||||
body: jsonEncode({
|
||||
'name': name,
|
||||
'phone': phone,
|
||||
'department': department,
|
||||
}),
|
||||
);
|
||||
client.close();
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception(
|
||||
tr(
|
||||
'err.userfront.profile.update_failed',
|
||||
params: {'error': response.body},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendUpdateCode(String phone) async {
|
||||
final token = await _getToken();
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
if (token == null && !useCookie) {
|
||||
throw Exception(tr('err.userfront.session.missing'));
|
||||
}
|
||||
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/me/send-code');
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
final response = await client.post(
|
||||
url,
|
||||
headers: headers,
|
||||
body: jsonEncode({'phone': phone}),
|
||||
);
|
||||
client.close();
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception(
|
||||
tr(
|
||||
'err.userfront.profile.send_code_failed',
|
||||
params: {'error': response.body},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> changePassword({
|
||||
required String currentPassword,
|
||||
required String newPassword,
|
||||
}) async {
|
||||
final token = await _getToken();
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
if (token == null && !useCookie) {
|
||||
throw Exception(tr('err.userfront.session.missing'));
|
||||
}
|
||||
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/me/password');
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
final response = await client.post(
|
||||
url,
|
||||
headers: headers,
|
||||
body: jsonEncode({
|
||||
'currentPassword': currentPassword,
|
||||
'newPassword': newPassword,
|
||||
}),
|
||||
);
|
||||
client.close();
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception(
|
||||
tr(
|
||||
'err.userfront.profile.password_change_failed',
|
||||
params: {'error': response.body},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> verifyUpdateCode(String phone, String code) async {
|
||||
final token = await _getToken();
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
if (token == null && !useCookie) {
|
||||
throw Exception(tr('err.userfront.session.missing'));
|
||||
}
|
||||
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/me/verify-code');
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
final response = await client.post(
|
||||
url,
|
||||
headers: headers,
|
||||
body: jsonEncode({'phone': phone, 'code': code}),
|
||||
);
|
||||
client.close();
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception(
|
||||
tr(
|
||||
'err.userfront.profile.verify_code_failed',
|
||||
params: {'error': response.body},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../data/models/user_profile_model.dart';
|
||||
import '../../data/repositories/profile_repository.dart';
|
||||
|
||||
// 1. Repository Provider
|
||||
final profileRepositoryProvider = Provider((ref) => ProfileRepository());
|
||||
|
||||
// 2. AsyncNotifier implementation (Modern Riverpod)
|
||||
class ProfileNotifier extends AsyncNotifier<UserProfile?> {
|
||||
@override
|
||||
FutureOr<UserProfile?> build() async {
|
||||
// Initial data fetch
|
||||
return _fetch();
|
||||
}
|
||||
|
||||
Future<UserProfile?> _fetch() async {
|
||||
return ref.read(profileRepositoryProvider).getMyProfile();
|
||||
}
|
||||
|
||||
Future<UserProfile?> loadProfile() async {
|
||||
state = const AsyncValue.loading();
|
||||
final profile = await _fetch();
|
||||
state = AsyncValue.data(profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
Future<void> updateProfile({
|
||||
required String name,
|
||||
required String phone,
|
||||
required String department,
|
||||
}) async {
|
||||
// Show loading state
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
// Perform update and then re-fetch profile
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref
|
||||
.read(profileRepositoryProvider)
|
||||
.updateMyProfile(name: name, phone: phone, department: department);
|
||||
return _fetch();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Provider definition
|
||||
final profileProvider = AsyncNotifierProvider<ProfileNotifier, UserProfile?>(
|
||||
() {
|
||||
return ProfileNotifier();
|
||||
},
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ProfileInfoRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const ProfileInfoRow({super.key, required this.label, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value.isEmpty ? '-' : value,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user