첫 커밋: 로컬 프로젝트 업로드

This commit is contained in:
2026-06-10 15:51:34 +09:00
commit 6a8dbeb2e9
1211 changed files with 312864 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@@ -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;
}

View File

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

View File

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

View File

@@ -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;
}

View File

@@ -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();
}

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -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';
}

View File

@@ -0,0 +1,2 @@
export 'qr_scan_screen_stub.dart'
if (dart.library.js_interop) 'qr_scan_screen_web.dart';

View File

@@ -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: '승인 화면으로 이동'),
),
),
],
),
),
);
}
}

View File

@@ -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: '승인 화면으로 이동'),
),
),
],
),
),
);
}
}

View File

@@ -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

View File

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

View File

@@ -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;
}

View 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,
);
}
}

View File

@@ -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();
});

View File

@@ -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();
});

View File

@@ -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;
}
}

View File

@@ -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

View File

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

View File

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

View File

@@ -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

View File

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