첫 커밋: 로컬 프로젝트 업로드
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/i18n/locale_utils.dart';
|
||||
import '../../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../../core/services/auth_token_store.dart';
|
||||
import '../../../../core/services/web_window.dart';
|
||||
|
||||
class ApproveQrScreen extends StatefulWidget {
|
||||
final String? pendingRef;
|
||||
const ApproveQrScreen({super.key, this.pendingRef});
|
||||
|
||||
@override
|
||||
State<ApproveQrScreen> createState() => _ApproveQrScreenState();
|
||||
}
|
||||
|
||||
class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
||||
bool _isLoading = false;
|
||||
String? _message;
|
||||
bool _success = false;
|
||||
bool _isCheckingSession = false;
|
||||
bool _redirectingToLogin = false;
|
||||
bool _autoApproveTriggered = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bootstrapCookieSession().then((_) {
|
||||
_redirectIfNotLoggedIn();
|
||||
_maybeAutoApprove();
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> _bootstrapCookieSession() async {
|
||||
if (AuthTokenStore.usesCookie()) {
|
||||
return true;
|
||||
}
|
||||
if (_isCheckingSession) {
|
||||
return false;
|
||||
}
|
||||
setState(() => _isCheckingSession = true);
|
||||
try {
|
||||
await AuthProxyService.checkCookieSession();
|
||||
AuthTokenStore.setCookieMode(provider: 'ory');
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isCheckingSession = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _redirectIfNotLoggedIn() {
|
||||
if (_redirectingToLogin || !mounted) return;
|
||||
final hasStoredToken = AuthTokenStore.getToken()?.isNotEmpty ?? false;
|
||||
final usesCookie = AuthTokenStore.usesCookie();
|
||||
final isLoggedIn = hasStoredToken || usesCookie;
|
||||
if (!isLoggedIn) {
|
||||
_redirectingToLogin = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final target = buildLocalizedSigninPath(Uri.base);
|
||||
webWindow.redirectTo('$target?notice=qr_login_required');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _maybeAutoApprove() {
|
||||
if (!mounted || _autoApproveTriggered) return;
|
||||
if (widget.pendingRef == null || widget.pendingRef!.trim().isEmpty) {
|
||||
if (_message == null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_message = 'Error: pendingRef is missing.';
|
||||
});
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final hasStoredToken = AuthTokenStore.getToken()?.isNotEmpty ?? false;
|
||||
final usesCookie = AuthTokenStore.usesCookie();
|
||||
final isLoggedIn = hasStoredToken || usesCookie || _isCheckingSession;
|
||||
if (!isLoggedIn || _isLoading || _success) {
|
||||
return;
|
||||
}
|
||||
|
||||
_autoApproveTriggered = true;
|
||||
_handleApprove();
|
||||
}
|
||||
|
||||
Future<void> _handleApprove() async {
|
||||
if (widget.pendingRef == null) return;
|
||||
|
||||
final storedToken = AuthTokenStore.getToken();
|
||||
final usesCookie = AuthTokenStore.usesCookie();
|
||||
var hasCookie = usesCookie;
|
||||
if (storedToken == null && !hasCookie) {
|
||||
hasCookie = await _bootstrapCookieSession();
|
||||
}
|
||||
if (storedToken == null && !hasCookie) {
|
||||
if (mounted) {
|
||||
final target = buildLocalizedSigninPath(Uri.base);
|
||||
webWindow.redirectTo('$target?notice=qr_login_required');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_message = null;
|
||||
});
|
||||
// jwt 유효성 확인
|
||||
try {
|
||||
final token = storedToken ?? '';
|
||||
await AuthProxyService.approveQrLogin(
|
||||
widget.pendingRef!,
|
||||
token: token,
|
||||
withCredentials: hasCookie,
|
||||
);
|
||||
setState(() {
|
||||
_success = true;
|
||||
_message = "Login Approved! Your browser should now be logged in.";
|
||||
});
|
||||
|
||||
// Automatically go to dashboard after a short delay
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
if (mounted) context.go(buildLocalizedHomePath(Uri.base));
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _message = "Error: $e");
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasStoredToken = AuthTokenStore.getToken()?.isNotEmpty ?? false;
|
||||
final usesCookie = AuthTokenStore.usesCookie();
|
||||
final isLoggedIn = hasStoredToken || usesCookie || _isCheckingSession;
|
||||
|
||||
if (!isLoggedIn && !_redirectingToLogin) {
|
||||
_redirectIfNotLoggedIn();
|
||||
}
|
||||
if (isLoggedIn && !_success && !_isLoading) {
|
||||
_maybeAutoApprove();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("QR Login Approval")),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.phonelink_lock, size: 80, color: Colors.blue),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
"Web Login Request",
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
"A computer is trying to log in using this QR code.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey.shade600),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
if (_message != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Text(
|
||||
_message!,
|
||||
style: TextStyle(
|
||||
color: _success ? Colors.green : Colors.red,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
|
||||
if (_isLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 16),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
|
||||
if (!_success && !_isLoading)
|
||||
Text(
|
||||
"Approving login request automatically...",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey.shade700),
|
||||
),
|
||||
|
||||
if (!isLoggedIn && !_success)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: TextButton(
|
||||
onPressed: () =>
|
||||
context.go(buildLocalizedSigninPath(Uri.base)),
|
||||
child: const Text("Login on this device first"),
|
||||
),
|
||||
),
|
||||
|
||||
if (!_success && !_isLoading && _message != null)
|
||||
FilledButton.icon(
|
||||
onPressed: !isLoggedIn
|
||||
? null
|
||||
: () {
|
||||
_autoApproveTriggered = false;
|
||||
_handleApprove();
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text("Retry Approval"),
|
||||
),
|
||||
|
||||
if (_success)
|
||||
FilledButton(
|
||||
onPressed: () => context.go(buildLocalizedHomePath(Uri.base)),
|
||||
child: const Text("Go to My Dashboard"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
import 'package:userfront/core/i18n/locale_utils.dart';
|
||||
import 'package:userfront/core/services/auth_proxy_service.dart';
|
||||
import 'package:userfront/core/services/web_window.dart';
|
||||
import 'package:userfront/core/ui/toast_service.dart';
|
||||
import 'package:userfront/features/auth/domain/consent_error_routing.dart';
|
||||
|
||||
class ConsentScreen extends StatefulWidget {
|
||||
final String consentChallenge;
|
||||
final Future<Map<String, dynamic>> Function(String consentChallenge)?
|
||||
consentInfoLoader;
|
||||
|
||||
const ConsentScreen({
|
||||
super.key,
|
||||
required this.consentChallenge,
|
||||
this.consentInfoLoader,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ConsentScreen> createState() => _ConsentScreenState();
|
||||
}
|
||||
|
||||
class _ConsentScreenState extends State<ConsentScreen> {
|
||||
Map<String, dynamic>? _consentInfo;
|
||||
bool _isLoading = true;
|
||||
bool _isSubmitting = false;
|
||||
String? _error;
|
||||
|
||||
final Set<String> _selectedScopes = {};
|
||||
final Map<String, String> _scopeDescriptions = {};
|
||||
final Set<String> _mandatoryScopes = {'openid'};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scopeDescriptions.addAll(_defaultScopeDescriptions());
|
||||
_fetchConsentInfo();
|
||||
}
|
||||
|
||||
Map<String, String> _defaultScopeDescriptions() {
|
||||
return {
|
||||
'openid': tr(
|
||||
'msg.userfront.consent.scope.openid',
|
||||
fallback: 'OpenID authentication information (signin session check)',
|
||||
),
|
||||
'profile': tr(
|
||||
'msg.userfront.consent.scope.profile',
|
||||
fallback: 'Basic profile information (name, user identifier)',
|
||||
),
|
||||
'email': tr(
|
||||
'msg.userfront.consent.scope.email',
|
||||
fallback: 'Email address (account identification and notifications)',
|
||||
),
|
||||
'offline_access': tr(
|
||||
'msg.userfront.consent.scope.offline_access',
|
||||
fallback: 'Offline access (keep signed in)',
|
||||
),
|
||||
'phone': tr(
|
||||
'msg.userfront.consent.scope.phone',
|
||||
fallback: 'Phone number (identity verification and notifications)',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
String _renderConsentText(String key, {String? fallback}) {
|
||||
return tr(
|
||||
key,
|
||||
fallback: fallback,
|
||||
).replaceAll(r'\\n', '\n').replaceAll(r'\n', '\n').replaceAll('\\\n', '\n');
|
||||
}
|
||||
|
||||
String _renderScopeCountLabel(int count) {
|
||||
return tr(
|
||||
'msg.userfront.consent.scope_count',
|
||||
fallback: 'Total {{count}}',
|
||||
params: {'count': '$count'},
|
||||
).replaceAll('{$count}', '$count');
|
||||
}
|
||||
|
||||
String _scopeDisplayLabel(String scope) {
|
||||
if (scope == 'offline_access') {
|
||||
return 'offline access';
|
||||
}
|
||||
return scope.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
String _renderClientIdLabel(String clientId) {
|
||||
final raw = tr(
|
||||
'msg.userfront.consent.client_id',
|
||||
fallback: 'Client ID: {{id}}',
|
||||
);
|
||||
final normalized = raw
|
||||
.replaceAll('{{id}}', '')
|
||||
.replaceAll('{id}', '')
|
||||
.trimRight();
|
||||
return '$normalized $clientId';
|
||||
}
|
||||
|
||||
Future<void> _fetchConsentInfo() async {
|
||||
try {
|
||||
final loader =
|
||||
widget.consentInfoLoader ?? AuthProxyService.getConsentInfo;
|
||||
final info = await loader(widget.consentChallenge);
|
||||
|
||||
// [Skip Logic] 백엔드에서 자동 승인되어 리다이렉트 URL이 온 경우 즉시 이동
|
||||
if (info['redirectTo'] != null) {
|
||||
webWindow.redirectTo(info['redirectTo']);
|
||||
return;
|
||||
}
|
||||
|
||||
// 백엔드에서 전달받은 커스텀 스코프 정보(scope_details) 적용
|
||||
if (info['scope_details'] != null) {
|
||||
final details = info['scope_details'] as Map<String, dynamic>;
|
||||
|
||||
details.forEach((scope, detail) {
|
||||
if (detail is Map<String, dynamic>) {
|
||||
// 설명 업데이트
|
||||
if (detail['description'] != null &&
|
||||
detail['description'].toString().isNotEmpty) {
|
||||
_scopeDescriptions[scope] = detail['description'].toString();
|
||||
}
|
||||
// 필수 여부 업데이트
|
||||
if (detail['mandatory'] == true) {
|
||||
_mandatoryScopes.add(scope);
|
||||
} else {
|
||||
// openid는 기본적으로 필수지만 설정에서 굳이 껐다면?
|
||||
// 안전을 위해 openid는 항상 필수로 유지하는 것이 좋지만,
|
||||
// 여기서는 서버 설정을 존중하되 openid는 예외처리 할 수도 있음.
|
||||
// 우선 서버 설정이 있으면 반영 (단, openid는 제거하지 않음)
|
||||
if (scope != 'openid') {
|
||||
_mandatoryScopes.remove(scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 초기 선택 상태 설정: 모든 요청된 스코프를 기본 선택
|
||||
final requestedScopes =
|
||||
(info['requested_scope'] as List<dynamic>?)?.cast<String>() ?? [];
|
||||
_selectedScopes.addAll(requestedScopes);
|
||||
|
||||
setState(() {
|
||||
_consentInfo = info;
|
||||
_isLoading = false;
|
||||
});
|
||||
} on AuthProxyException catch (e) {
|
||||
if (shouldRouteConsentErrorToErrorScreen(e)) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final target = buildTenantAccessErrorPath(e, Uri.base);
|
||||
context.go(target);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_error = tr(
|
||||
'msg.userfront.consent.load_error',
|
||||
fallback: 'Failed to load consent information: {{error}}',
|
||||
params: {'error': e.message},
|
||||
);
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = tr(
|
||||
'msg.userfront.consent.load_error',
|
||||
fallback: 'Failed to load consent information: {{error}}',
|
||||
params: {'error': '$e'},
|
||||
);
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _acceptConsent() async {
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
// 선택된 스코프만 리스트로 변환하여 전송
|
||||
final result = await AuthProxyService.acceptConsent(
|
||||
widget.consentChallenge,
|
||||
grantScope: _selectedScopes.toList(),
|
||||
);
|
||||
|
||||
if (result['redirectTo'] != null) {
|
||||
webWindow.redirectTo(result['redirectTo']);
|
||||
} else {
|
||||
setState(() {
|
||||
_error = tr(
|
||||
'msg.userfront.consent.missing_redirect',
|
||||
fallback:
|
||||
'Consent was processed, but the redirect URL was missing.',
|
||||
);
|
||||
_isSubmitting = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = tr(
|
||||
'msg.userfront.consent.accept_error',
|
||||
fallback: 'Failed to process consent: {{error}}',
|
||||
params: {'error': '$e'},
|
||||
);
|
||||
_isSubmitting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onCancel() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(tr('ui.userfront.consent.cancel.title')),
|
||||
content: Text(tr('msg.userfront.consent.cancel.confirm')),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(tr('ui.common.cancel')),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: Text(tr('ui.userfront.consent.cancel.confirm_button')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
setState(() => _isSubmitting = true);
|
||||
try {
|
||||
final resp = await AuthProxyService.rejectConsent(
|
||||
widget.consentChallenge,
|
||||
);
|
||||
final redirectTo = resp['redirectTo'];
|
||||
if (redirectTo != null) {
|
||||
webWindow.redirectTo(redirectTo);
|
||||
} else {
|
||||
if (mounted) context.go(buildLocalizedHomePath(Uri.base));
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _isSubmitting = false);
|
||||
if (mounted) {
|
||||
ToastService.error(
|
||||
tr(
|
||||
'msg.userfront.consent.cancel.error',
|
||||
fallback: 'An error occurred while cancelling consent: {{error}}',
|
||||
params: {'error': '$e'},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 배경색을 약간 어둡게 처리하거나, 전체적인 테마 색상을 사용
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.grey[100],
|
||||
body: Center(
|
||||
child: _isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: _error != null
|
||||
? _buildErrorCard()
|
||||
: _buildConsentCard(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorCard() {
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(24),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
Text(_error!, textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConsentCard(BuildContext context) {
|
||||
final clientRawName = _consentInfo?['client']?['client_name'] as String?;
|
||||
final clientId = _consentInfo?['client']?['client_id'] as String? ?? '-';
|
||||
final clientName = (clientRawName != null && clientRawName.isNotEmpty)
|
||||
? clientRawName
|
||||
: (clientId != '-'
|
||||
? clientId
|
||||
: tr('msg.userfront.consent.client_unknown'));
|
||||
final clientLogo = _consentInfo?['client']?['logo_uri'];
|
||||
final requestedScopes =
|
||||
(_consentInfo?['requested_scope'] as List<dynamic>?)?.cast<String>() ??
|
||||
[];
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
margin: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
elevation: 8,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 1. 헤더 영역
|
||||
Text(
|
||||
tr('ui.userfront.consent.title'),
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_renderConsentText('msg.userfront.consent.description'),
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 2. 서비스 정보 영역
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey[200]!),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (clientLogo != null &&
|
||||
clientLogo.toString().isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundImage: NetworkImage(clientLogo),
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
)
|
||||
else
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: CircleAvatar(
|
||||
radius: 24,
|
||||
child: Icon(Icons.apps),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
clientName,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_renderClientIdLabel(clientId),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 3. 권한 선택 영역
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
tr('ui.userfront.consent.requested_scopes'),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_renderScopeCountLabel(requestedScopes.length),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Divider(),
|
||||
...requestedScopes.map((scope) {
|
||||
final isMandatory = _mandatoryScopes.contains(scope);
|
||||
final description = _scopeDescriptions[scope] ?? scope;
|
||||
final isSelected = _selectedScopes.contains(scope);
|
||||
|
||||
return CheckboxListTile(
|
||||
title: Text(
|
||||
_scopeDisplayLabel(scope),
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(description),
|
||||
value: isSelected,
|
||||
onChanged: isMandatory
|
||||
? null // 필수 항목은 변경 불가 (비활성화 상태로 체크됨)
|
||||
: (bool? value) {
|
||||
setState(() {
|
||||
if (value == true) {
|
||||
_selectedScopes.add(scope);
|
||||
} else {
|
||||
_selectedScopes.remove(scope);
|
||||
}
|
||||
});
|
||||
},
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
activeColor: Theme.of(context).primaryColor,
|
||||
);
|
||||
}),
|
||||
const Divider(),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 4. 버튼 영역
|
||||
ElevatedButton(
|
||||
onPressed: _isSubmitting ? null : _acceptConsent,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
backgroundColor: const Color(0xFF1A1F2C), // 브랜드 컬러
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
child: _isSubmitting
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
tr('ui.userfront.consent.accept'),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(
|
||||
onPressed: _isSubmitting ? null : _onCancel,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Text(tr('ui.common.cancel')),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
tr('msg.userfront.consent.redirect_notice'),
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/constants/error_whitelist.dart';
|
||||
import '../../../core/i18n/locale_utils.dart';
|
||||
import '../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../core/services/logout_service.dart';
|
||||
import '../../../core/widgets/theme_toggle_button.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
|
||||
class ErrorScreen extends StatefulWidget {
|
||||
final String? errorId;
|
||||
final String? errorCode;
|
||||
final String? description;
|
||||
final bool? isProdOverride;
|
||||
final Future<Map<String, dynamic>> Function()? sessionProfileLoader;
|
||||
final Map<String, dynamic>? tenantAccessDetails;
|
||||
|
||||
const ErrorScreen({
|
||||
super.key,
|
||||
this.errorId,
|
||||
this.errorCode,
|
||||
this.description,
|
||||
this.isProdOverride,
|
||||
this.sessionProfileLoader,
|
||||
this.tenantAccessDetails,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ErrorScreen> createState() => _ErrorScreenState();
|
||||
}
|
||||
|
||||
class _ErrorScreenState extends State<ErrorScreen> {
|
||||
Map<String, dynamic>? _sessionProfile;
|
||||
bool _isLoadingSessionProfile = false;
|
||||
String? _sessionProfileError;
|
||||
|
||||
bool get _isTenantAccessBlocked =>
|
||||
(widget.errorCode ?? '').trim() == 'tenant_not_allowed';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (_isTenantAccessBlocked && _shouldLoadSessionProfile()) {
|
||||
unawaited(_loadSessionProfile());
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic>? get _tenantAccessDetails => widget.tenantAccessDetails;
|
||||
|
||||
bool _shouldLoadSessionProfile() {
|
||||
final details = _tenantAccessDetails;
|
||||
if (details == null) {
|
||||
return true;
|
||||
}
|
||||
final hasAccount = _extractAccountEmail(details).isNotEmpty;
|
||||
final hasTenant = _extractCurrentTenantLabel(details).isNotEmpty;
|
||||
return !hasAccount || !hasTenant;
|
||||
}
|
||||
|
||||
Future<void> _loadSessionProfile() async {
|
||||
if (_isLoadingSessionProfile) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isLoadingSessionProfile = true;
|
||||
_sessionProfileError = null;
|
||||
});
|
||||
try {
|
||||
final loader = widget.sessionProfileLoader ?? AuthProxyService.getMe;
|
||||
final profile = await loader();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_sessionProfile = profile;
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_sessionProfileError = error.toString();
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoadingSessionProfile = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _extractTenantLabel(Map<String, dynamic>? profile) {
|
||||
if (profile == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
final tenant = profile['tenant'];
|
||||
if (tenant is Map) {
|
||||
final name = tenant['name']?.toString().trim() ?? '';
|
||||
if (name.isNotEmpty) {
|
||||
return name;
|
||||
}
|
||||
final slug = tenant['slug']?.toString().trim() ?? '';
|
||||
if (slug.isNotEmpty) {
|
||||
return slug;
|
||||
}
|
||||
}
|
||||
|
||||
final joinedTenants = profile['joinedTenants'];
|
||||
if (joinedTenants is List) {
|
||||
for (final item in joinedTenants) {
|
||||
if (item is Map) {
|
||||
final name = item['name']?.toString().trim() ?? '';
|
||||
if (name.isNotEmpty) {
|
||||
return name;
|
||||
}
|
||||
final slug = item['slug']?.toString().trim() ?? '';
|
||||
if (slug.isNotEmpty) {
|
||||
return slug;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
String _extractCurrentTenantLabel(Map<String, dynamic>? details) {
|
||||
if (details == null) {
|
||||
return '';
|
||||
}
|
||||
final tenant = details['current_tenant'];
|
||||
if (tenant is! Map) {
|
||||
return '';
|
||||
}
|
||||
|
||||
final name = tenant['name']?.toString().trim() ?? '';
|
||||
if (name.isNotEmpty) {
|
||||
return name;
|
||||
}
|
||||
final slug = tenant['slug']?.toString().trim() ?? '';
|
||||
if (slug.isNotEmpty) {
|
||||
return slug;
|
||||
}
|
||||
final identifier = tenant['identifier']?.toString().trim() ?? '';
|
||||
if (identifier.isNotEmpty) {
|
||||
return identifier;
|
||||
}
|
||||
final id = tenant['id']?.toString().trim() ?? '';
|
||||
return id;
|
||||
}
|
||||
|
||||
String _extractAccountEmail(Map<String, dynamic>? details) {
|
||||
if (details == null) {
|
||||
return '';
|
||||
}
|
||||
final account = details['account'];
|
||||
if (account is! Map) {
|
||||
return '';
|
||||
}
|
||||
return account['email']?.toString().trim() ?? '';
|
||||
}
|
||||
|
||||
List<String> _extractAllowedTenantLabels(Map<String, dynamic>? details) {
|
||||
if (details == null) {
|
||||
return const [];
|
||||
}
|
||||
final raw = details['allowed_tenants'];
|
||||
if (raw is! List) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final labels = <String>[];
|
||||
for (final item in raw) {
|
||||
if (item is! Map) {
|
||||
continue;
|
||||
}
|
||||
final label =
|
||||
item['name']?.toString().trim() ??
|
||||
item['slug']?.toString().trim() ??
|
||||
item['identifier']?.toString().trim() ??
|
||||
item['id']?.toString().trim() ??
|
||||
'';
|
||||
if (label.isNotEmpty) {
|
||||
labels.add(label);
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
List<String> _extractAffiliatedTenantLabelsFromDetails(
|
||||
Map<String, dynamic>? details,
|
||||
) {
|
||||
if (details == null) {
|
||||
return const [];
|
||||
}
|
||||
final raw = details['affiliated_tenants'];
|
||||
if (raw is! List) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final labels = <String>[];
|
||||
for (final item in raw) {
|
||||
if (item is! Map) {
|
||||
continue;
|
||||
}
|
||||
final label =
|
||||
item['name']?.toString().trim() ??
|
||||
item['slug']?.toString().trim() ??
|
||||
item['identifier']?.toString().trim() ??
|
||||
item['id']?.toString().trim() ??
|
||||
'';
|
||||
if (label.isNotEmpty) {
|
||||
labels.add(label);
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
List<String> _extractAffiliatedTenantLabelsFromProfile(
|
||||
Map<String, dynamic>? profile,
|
||||
) {
|
||||
if (profile == null) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final labels = <String>[];
|
||||
final seen = <String>{};
|
||||
|
||||
void appendLabel(String value) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty || seen.contains(trimmed)) {
|
||||
return;
|
||||
}
|
||||
seen.add(trimmed);
|
||||
labels.add(trimmed);
|
||||
}
|
||||
|
||||
final joinedTenants = profile['joinedTenants'];
|
||||
if (joinedTenants is List) {
|
||||
for (final item in joinedTenants) {
|
||||
if (item is Map) {
|
||||
appendLabel(item['name']?.toString() ?? '');
|
||||
appendLabel(item['slug']?.toString() ?? '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final tenant = _extractTenantLabel(profile);
|
||||
if (tenant.isNotEmpty) {
|
||||
appendLabel(tenant);
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
|
||||
Future<void> _switchAccount() async {
|
||||
await LogoutService().logout();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
context.go(buildLocalizedSigninPath(Uri.base));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final isProd = widget.isProdOverride ?? AuthProxyService.isProdEnv;
|
||||
final normalizedCode = (widget.errorCode ?? '').trim();
|
||||
final hasCode = normalizedCode.isNotEmpty;
|
||||
final internalWhitelistKey =
|
||||
internalErrorWhitelistMessageKeys[normalizedCode];
|
||||
final isInternalWhitelisted = internalWhitelistKey != null;
|
||||
final isOryBypass = hasCode && oryBypassErrorCodes.contains(normalizedCode);
|
||||
final isKnownProdCode = hasCode && (isInternalWhitelisted || isOryBypass);
|
||||
final isTenantAccessBlocked = normalizedCode == 'tenant_not_allowed';
|
||||
final errorType = isProd
|
||||
? (isKnownProdCode ? normalizedCode : 'unknown_error')
|
||||
: (hasCode ? normalizedCode : 'unknown_error');
|
||||
final title = isTenantAccessBlocked
|
||||
? tr(
|
||||
'msg.userfront.error.tenant.page_title',
|
||||
fallback: 'Application access is restricted',
|
||||
)
|
||||
: isProd
|
||||
? tr('msg.userfront.error.title')
|
||||
: (hasCode
|
||||
? tr(
|
||||
'msg.userfront.error.title_with_code',
|
||||
params: {'code': normalizedCode},
|
||||
)
|
||||
: tr('msg.userfront.error.title_generic'));
|
||||
final tenantLabelFromDetails = _extractCurrentTenantLabel(
|
||||
_tenantAccessDetails,
|
||||
);
|
||||
final tenantLabel = tenantLabelFromDetails.isNotEmpty
|
||||
? tenantLabelFromDetails
|
||||
: _extractTenantLabel(_sessionProfile);
|
||||
final emailFromDetails = _extractAccountEmail(_tenantAccessDetails);
|
||||
final emailLabel = emailFromDetails.isNotEmpty
|
||||
? emailFromDetails
|
||||
: (_sessionProfile?['email']?.toString().trim() ?? '');
|
||||
final affiliatedTenantLabels =
|
||||
_extractAffiliatedTenantLabelsFromDetails(
|
||||
_tenantAccessDetails,
|
||||
).isNotEmpty
|
||||
? _extractAffiliatedTenantLabelsFromDetails(_tenantAccessDetails)
|
||||
: _extractAffiliatedTenantLabelsFromProfile(_sessionProfile);
|
||||
final allowedTenantLabels = _extractAllowedTenantLabels(
|
||||
_tenantAccessDetails,
|
||||
);
|
||||
final isLoadingTenantContext =
|
||||
_isLoadingSessionProfile && _tenantAccessDetails == null;
|
||||
final hasTenantLookupFailure =
|
||||
_sessionProfileError != null &&
|
||||
_sessionProfileError!.isNotEmpty &&
|
||||
_tenantAccessDetails == null;
|
||||
final showTenantLookupFallback =
|
||||
_tenantAccessDetails == null &&
|
||||
(emailLabel.isEmpty || tenantLabel.isEmpty);
|
||||
final internalWhitelistDetail = internalWhitelistKey == null
|
||||
? null
|
||||
: tr(internalWhitelistKey);
|
||||
final detail = isTenantAccessBlocked
|
||||
? tr(
|
||||
'msg.userfront.error.tenant.detail',
|
||||
fallback:
|
||||
'The current signed-in account cannot access this application.',
|
||||
)
|
||||
: isProd
|
||||
? (isInternalWhitelisted
|
||||
? internalWhitelistDetail!
|
||||
: (isOryBypass
|
||||
? tr(
|
||||
'msg.userfront.error.ory.$normalizedCode',
|
||||
fallback: (widget.description?.isNotEmpty == true)
|
||||
? widget.description
|
||||
: tr('msg.userfront.error.detail_request'),
|
||||
)
|
||||
: tr('msg.userfront.error.detail_contact')))
|
||||
: ((widget.description?.isNotEmpty == true)
|
||||
? widget.description!
|
||||
: (hasCode
|
||||
? tr('msg.userfront.error.detail_generic')
|
||||
: tr('msg.userfront.error.detail_request')));
|
||||
return Scaffold(
|
||||
backgroundColor: colorScheme.surfaceContainerLowest,
|
||||
body: SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) => SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: constraints.maxHeight - 48,
|
||||
),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 28, 28, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
const ThemeToggleButton(compact: true),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
detail,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
if (isTenantAccessBlocked) ...[
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.error.tenant.title',
|
||||
fallback: 'Access restriction details',
|
||||
),
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (isLoadingTenantContext)
|
||||
Row(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
child: Text(
|
||||
tr(
|
||||
'msg.userfront.error.tenant.loading',
|
||||
fallback:
|
||||
'Loading the current account details.',
|
||||
),
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else ...[
|
||||
_InfoRow(
|
||||
label: tr(
|
||||
'msg.userfront.error.tenant.account',
|
||||
fallback: 'Account',
|
||||
),
|
||||
value: emailLabel.isNotEmpty
|
||||
? emailLabel
|
||||
: tr(
|
||||
'msg.userfront.error.tenant.account_unknown',
|
||||
fallback: 'Unknown',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_InfoRow(
|
||||
label: tr(
|
||||
'msg.userfront.error.tenant.primary_tenant',
|
||||
fallback: 'Primary affiliated tenant',
|
||||
),
|
||||
value: tenantLabel.isNotEmpty
|
||||
? tenantLabel
|
||||
: tr(
|
||||
'msg.userfront.error.tenant.tenant_unknown',
|
||||
fallback: 'Unknown',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_InfoRow(
|
||||
label: tr(
|
||||
'msg.userfront.error.tenant.affiliated_tenants',
|
||||
fallback: 'All affiliated tenants',
|
||||
),
|
||||
value: affiliatedTenantLabels.isNotEmpty
|
||||
? affiliatedTenantLabels.join(', ')
|
||||
: tr(
|
||||
'msg.userfront.error.tenant.tenant_unknown',
|
||||
fallback: 'Unknown',
|
||||
),
|
||||
),
|
||||
if (showTenantLookupFallback) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.error.tenant.lookup_fallback',
|
||||
fallback:
|
||||
'Some fields may be unavailable because there is not enough profile information to display.',
|
||||
),
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color:
|
||||
colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (hasTenantLookupFailure) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.error.tenant.load_failed',
|
||||
fallback:
|
||||
'Failed to load account details. Please try again.',
|
||||
),
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color:
|
||||
colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.error.tenant.allowed_box_title',
|
||||
fallback: 'Allowed tenants',
|
||||
),
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (allowedTenantLabels.isNotEmpty) ...[
|
||||
_InfoRow(
|
||||
label: tr(
|
||||
'msg.userfront.error.tenant.allowed_tenants',
|
||||
fallback: 'Allowed tenants',
|
||||
),
|
||||
value: allowedTenantLabels.join(', '),
|
||||
),
|
||||
] else ...[
|
||||
_InfoRow(
|
||||
label: tr(
|
||||
'msg.userfront.error.tenant.allowed_tenants',
|
||||
fallback: 'Allowed tenants',
|
||||
),
|
||||
value: tr(
|
||||
'msg.userfront.error.tenant.tenant_unknown',
|
||||
fallback: 'Unknown',
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.error.type',
|
||||
params: {'type': errorType},
|
||||
),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (widget.errorId != null &&
|
||||
widget.errorId!.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.error.id',
|
||||
params: {'id': widget.errorId!},
|
||||
),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: isTenantAccessBlocked
|
||||
? _switchAccount
|
||||
: () => context.go('/login'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
isTenantAccessBlocked
|
||||
? tr('ui.userfront.error.switch_account')
|
||||
: tr('ui.userfront.error.go_login'),
|
||||
),
|
||||
),
|
||||
OutlinedButton(
|
||||
onPressed: () => context.go(
|
||||
buildLocalizedHomePath(Uri.base),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: colorScheme.onSurface,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
side: BorderSide(color: colorScheme.outline),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: Text(tr('ui.userfront.error.go_home')),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const _InfoRow({required this.label, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../core/ui/toast_service.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
|
||||
class ForgotPasswordScreen extends StatefulWidget {
|
||||
const ForgotPasswordScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ForgotPasswordScreen> createState() => _ForgotPasswordScreenState();
|
||||
}
|
||||
|
||||
class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
||||
final TextEditingController _loginIdController = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
bool _drySendEnabled = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_drySendEnabled =
|
||||
_parseBoolParam(Uri.base.queryParameters['drySend']) &&
|
||||
!AuthProxyService.isProdEnv;
|
||||
}
|
||||
|
||||
Future<void> _handlePasswordReset() async {
|
||||
final input = _loginIdController.text.trim();
|
||||
if (input.isEmpty) {
|
||||
_showError(tr('msg.userfront.forgot.input_required'));
|
||||
return;
|
||||
}
|
||||
|
||||
String loginId = input;
|
||||
if (!input.contains('@')) {
|
||||
// Format phone number if it's not an email
|
||||
loginId = input.replaceAll(RegExp(r'[-\s]'), '');
|
||||
if (loginId.startsWith('010')) {
|
||||
loginId = '+82${loginId.substring(1)}';
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
await AuthProxyService.initiatePasswordReset(
|
||||
loginId,
|
||||
drySend: _drySendEnabled,
|
||||
);
|
||||
if (mounted) {
|
||||
ToastService.success(tr('msg.userfront.forgot.sent'));
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
_showError(
|
||||
tr('msg.userfront.forgot.error', params: {'error': e.toString()}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
ToastService.error(message);
|
||||
}
|
||||
|
||||
bool _parseBoolParam(String? value) {
|
||||
if (value == null) {
|
||||
return false;
|
||||
}
|
||||
final normalized = value.toLowerCase();
|
||||
return normalized == 'true' || normalized == '1' || normalized == 'yes';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(tr('ui.userfront.forgot.title')),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
tr('ui.userfront.forgot.heading'),
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (_drySendEnabled) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF3CD),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFFFC107)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: Color(0xFF8A6D3B),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
tr('msg.userfront.forgot.dry_send'),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF8A6D3B),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
tr('msg.userfront.forgot.description'),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
TextField(
|
||||
controller: _loginIdController,
|
||||
decoration: InputDecoration(
|
||||
labelText: tr('ui.userfront.forgot.input_label'),
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.person_outline),
|
||||
),
|
||||
onSubmitted: (_) => _handlePasswordReset(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _isLoading ? null : _handlePasswordReset,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(tr('ui.userfront.forgot.submit')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
2427
baron-sso/userfront/lib/features/auth/presentation/login_screen.dart
Normal file
2427
baron-sso/userfront/lib/features/auth/presentation/login_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:userfront/core/i18n/locale_utils.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
|
||||
class LoginSuccessScreen extends StatelessWidget {
|
||||
const LoginSuccessScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.check_circle_outline,
|
||||
size: 80,
|
||||
color: Colors.green,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
tr('ui.userfront.login_success.title'),
|
||||
style: const TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
tr('msg.userfront.login_success.subtitle'),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
|
||||
// 이 버튼이 QR 카메라를 켜는 버튼입니다.
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
context.push('/scan');
|
||||
},
|
||||
icon: const Icon(Icons.camera_alt, size: 28),
|
||||
label: Text(tr('ui.userfront.login_success.qr')),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(80), // 버튼 높이를 더 크게
|
||||
backgroundColor: Colors.blue.shade700,
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.go(buildLocalizedHomePath(Uri.base));
|
||||
},
|
||||
child: Text(
|
||||
tr('ui.userfront.login_success.later'),
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
enum QrCameraBootstrapStatus {
|
||||
ready,
|
||||
detectorUnsupported,
|
||||
permissionError,
|
||||
cameraError,
|
||||
}
|
||||
|
||||
class QrCameraBootstrapResult {
|
||||
const QrCameraBootstrapResult(this.status, {this.errorDetail = ''});
|
||||
|
||||
final QrCameraBootstrapStatus status;
|
||||
final String errorDetail;
|
||||
|
||||
bool get isReady => status == QrCameraBootstrapStatus.ready;
|
||||
}
|
||||
|
||||
typedef QrOpenCameraAndPlay = Future<void> Function();
|
||||
typedef QrStopCamera = Future<void> Function();
|
||||
|
||||
bool isQrPermissionError(Object error) {
|
||||
final raw = error.toString();
|
||||
return raw.contains('NotAllowedError') ||
|
||||
raw.contains('PermissionDeniedError') ||
|
||||
raw.contains('SecurityError');
|
||||
}
|
||||
|
||||
Future<QrCameraBootstrapResult> bootstrapQrCamera({
|
||||
required bool hasBarcodeDetector,
|
||||
required QrOpenCameraAndPlay openCameraAndPlay,
|
||||
required QrStopCamera stopCamera,
|
||||
}) async {
|
||||
try {
|
||||
await openCameraAndPlay();
|
||||
if (!hasBarcodeDetector) {
|
||||
await stopCamera();
|
||||
return const QrCameraBootstrapResult(
|
||||
QrCameraBootstrapStatus.detectorUnsupported,
|
||||
errorDetail: 'BarcodeDetector is not supported in this browser.',
|
||||
);
|
||||
}
|
||||
return const QrCameraBootstrapResult(QrCameraBootstrapStatus.ready);
|
||||
} catch (e) {
|
||||
if (isQrPermissionError(e)) {
|
||||
return QrCameraBootstrapResult(
|
||||
QrCameraBootstrapStatus.permissionError,
|
||||
errorDetail: e.toString(),
|
||||
);
|
||||
}
|
||||
return QrCameraBootstrapResult(
|
||||
QrCameraBootstrapStatus.cameraError,
|
||||
errorDetail: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import '../../../../core/i18n/locale_utils.dart';
|
||||
|
||||
String buildQrApprovePath(
|
||||
String scannedValue, {
|
||||
String? localeCode,
|
||||
Uri? currentUri,
|
||||
}) {
|
||||
final value = scannedValue.trim();
|
||||
final explicitLocale = localeCode?.trim();
|
||||
final uri = currentUri ?? Uri.base;
|
||||
final resolvedLocale = explicitLocale != null && explicitLocale.isNotEmpty
|
||||
? explicitLocale.toLowerCase().replaceAll('_', '-')
|
||||
: normalizeLocaleCode(
|
||||
extractLocaleFromPath(uri) ?? resolvePreferredLocaleCode(),
|
||||
);
|
||||
return '/$resolvedLocale/approve?ref=${Uri.encodeQueryComponent(value)}';
|
||||
}
|
||||
|
||||
String buildQrBackFallbackPath({String? localeCode, Uri? currentUri}) {
|
||||
final explicitLocale = localeCode?.trim();
|
||||
final uri = currentUri ?? Uri.base;
|
||||
final resolvedLocale = explicitLocale != null && explicitLocale.isNotEmpty
|
||||
? explicitLocale.toLowerCase().replaceAll('_', '-')
|
||||
: normalizeLocaleCode(
|
||||
extractLocaleFromPath(uri) ?? resolvePreferredLocaleCode(),
|
||||
);
|
||||
return '/$resolvedLocale/dashboard';
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export 'qr_scan_screen_stub.dart'
|
||||
if (dart.library.js_interop) 'qr_scan_screen_web.dart';
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
import 'package:userfront/core/ui/toast_service.dart';
|
||||
|
||||
import 'qr_scan_route.dart';
|
||||
|
||||
class QRScanScreen extends StatefulWidget {
|
||||
const QRScanScreen({super.key});
|
||||
|
||||
@override
|
||||
State<QRScanScreen> createState() => _QRScanScreenState();
|
||||
}
|
||||
|
||||
class _QRScanScreenState extends State<QRScanScreen> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
final raw = _controller.text.trim();
|
||||
if (raw.isEmpty) {
|
||||
ToastService.info(
|
||||
tr('msg.userfront.qr.permission_required', fallback: '카메라 권한이 필요합니다.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.go(buildQrApprovePath(raw));
|
||||
}
|
||||
|
||||
void _handleBack() {
|
||||
final router = GoRouter.of(context);
|
||||
if (router.canPop()) {
|
||||
router.pop();
|
||||
return;
|
||||
}
|
||||
router.go(buildQrBackFallbackPath());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(tr('ui.userfront.qr.title', fallback: 'Scan QR Code')),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: _handleBack,
|
||||
),
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.qr.permission_error',
|
||||
fallback: '카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요.',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
key: const ValueKey('qr_scan_manual_input'),
|
||||
controller: _controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'QR Payload',
|
||||
hintText: 'https://.../ql/{ref} 또는 ref',
|
||||
),
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
key: const ValueKey('qr_scan_submit_button'),
|
||||
onPressed: _submit,
|
||||
icon: const Icon(Icons.check_circle),
|
||||
label: Text(
|
||||
tr('ui.userfront.qr.result_success', fallback: '승인 화면으로 이동'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
|
||||
import 'qr_scan_route.dart';
|
||||
|
||||
class QRScanScreen extends StatefulWidget {
|
||||
const QRScanScreen({super.key});
|
||||
|
||||
@override
|
||||
State<QRScanScreen> createState() => _QRScanScreenState();
|
||||
}
|
||||
|
||||
class _QRScanScreenState extends State<QRScanScreen> {
|
||||
final MobileScannerController _scannerController = MobileScannerController(
|
||||
autoStart: true,
|
||||
detectionSpeed: DetectionSpeed.noDuplicates,
|
||||
facing: CameraFacing.back,
|
||||
formats: const <BarcodeFormat>[BarcodeFormat.qrCode],
|
||||
);
|
||||
final TextEditingController _manualController = TextEditingController();
|
||||
|
||||
bool _isProcessing = false;
|
||||
String? _error;
|
||||
String? _status;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_status = tr(
|
||||
'msg.userfront.login.qr.scan_hint',
|
||||
fallback: 'QR 코드를 카메라 중앙에 맞춰주세요.',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_manualController.dispose();
|
||||
_scannerController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _navigateToApprove(String rawPayload) async {
|
||||
final payload = rawPayload.trim();
|
||||
if (payload.isEmpty || _isProcessing || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isProcessing = true;
|
||||
_error = null;
|
||||
_status = tr(
|
||||
'ui.userfront.qr.result_success',
|
||||
fallback: '승인 화면으로 이동 중...',
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
await _scannerController.stop();
|
||||
} catch (_) {}
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
context.go(buildQrApprovePath(payload));
|
||||
}
|
||||
|
||||
void _onDetect(BarcodeCapture capture) {
|
||||
for (final barcode in capture.barcodes) {
|
||||
final raw = barcode.rawValue?.trim();
|
||||
if (raw != null && raw.isNotEmpty) {
|
||||
unawaited(_navigateToApprove(raw));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _toScannerErrorMessage(MobileScannerException error) {
|
||||
switch (error.errorCode) {
|
||||
case MobileScannerErrorCode.permissionDenied:
|
||||
return tr(
|
||||
'msg.userfront.qr.permission_error',
|
||||
fallback: '카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요.',
|
||||
);
|
||||
case MobileScannerErrorCode.unsupported:
|
||||
return tr(
|
||||
'msg.userfront.qr.camera_error',
|
||||
fallback: '카메라 오류: {{error}}',
|
||||
params: {'error': 'QR scanner is not supported in this browser.'},
|
||||
);
|
||||
default:
|
||||
final detail = error.errorDetails?.message;
|
||||
return tr(
|
||||
'msg.userfront.qr.camera_error',
|
||||
fallback: '카메라 오류: {{error}}',
|
||||
params: {'error': detail ?? error.errorCode.message},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _submitManual() {
|
||||
unawaited(_navigateToApprove(_manualController.text));
|
||||
}
|
||||
|
||||
Future<void> _retry() async {
|
||||
setState(() {
|
||||
_isProcessing = false;
|
||||
_error = null;
|
||||
_status = tr(
|
||||
'msg.userfront.login.qr.scan_hint',
|
||||
fallback: 'QR 코드를 카메라 중앙에 맞춰주세요.',
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
await _scannerController.start();
|
||||
} catch (e) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_error = tr(
|
||||
'msg.userfront.qr.camera_error',
|
||||
fallback: '카메라 오류: {{error}}',
|
||||
params: {'error': '$e'},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleBack() {
|
||||
final router = GoRouter.of(context);
|
||||
if (router.canPop()) {
|
||||
router.pop();
|
||||
return;
|
||||
}
|
||||
router.go(buildQrBackFallbackPath());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(tr('ui.userfront.qr.title', fallback: 'Scan QR Code')),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: _handleBack,
|
||||
),
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 3 / 4,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
MobileScanner(
|
||||
controller: _scannerController,
|
||||
onDetect: _onDetect,
|
||||
errorBuilder: (context, error) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_error = _toScannerErrorMessage(error);
|
||||
});
|
||||
});
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
_toScannerErrorMessage(error),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (_isProcessing)
|
||||
Container(
|
||||
color: Colors.black45,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_status != null) Text(_status!, textAlign: TextAlign.center),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_error!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _isProcessing ? null : _retry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(tr('ui.userfront.qr.rescan', fallback: '다시 스캔')),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
key: const ValueKey('qr_scan_manual_input'),
|
||||
controller: _manualController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'QR Payload',
|
||||
hintText: 'https://.../ql/{ref} 또는 ref',
|
||||
),
|
||||
onSubmitted: (_) => _submitManual(),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.icon(
|
||||
key: const ValueKey('qr_scan_submit_button'),
|
||||
onPressed: _isProcessing ? null : _submitManual,
|
||||
icon: const Icon(Icons.check_circle),
|
||||
label: Text(
|
||||
tr('ui.userfront.qr.result_success', fallback: '승인 화면으로 이동'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/i18n/locale_utils.dart';
|
||||
import '../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../core/ui/toast_service.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
|
||||
class ResetPasswordScreen extends StatefulWidget {
|
||||
final String? loginId; // Now receiving loginId
|
||||
const ResetPasswordScreen({super.key, this.loginId});
|
||||
|
||||
@override
|
||||
State<ResetPasswordScreen> createState() => _ResetPasswordScreenState();
|
||||
}
|
||||
|
||||
class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
final TextEditingController _confirmPasswordController =
|
||||
TextEditingController();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isLoading = false;
|
||||
String? _loginId;
|
||||
String? _token;
|
||||
bool _isPasswordObscured = true;
|
||||
bool _isConfirmPasswordObscured = true;
|
||||
Map<String, dynamic>? _policy;
|
||||
bool _isPolicyLoading = false;
|
||||
|
||||
String _renderTranslatedText(
|
||||
String key, {
|
||||
String? fallback,
|
||||
Map<String, String> values = const {},
|
||||
}) {
|
||||
var text = tr(key, fallback: fallback);
|
||||
values.forEach((name, value) {
|
||||
text = text.replaceAll('{{$name}}', value).replaceAll('{$name}', value);
|
||||
});
|
||||
return text;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 1. Get loginId from GoRouter state if available
|
||||
_loginId = widget.loginId;
|
||||
|
||||
// 2. Fallback to URI query parameter if not available via router
|
||||
if (_loginId == null || _loginId!.isEmpty) {
|
||||
final uri = Uri.base;
|
||||
_loginId = uri.queryParameters['loginId'];
|
||||
}
|
||||
|
||||
// 토큰도 함께 읽어놓는다.
|
||||
final uri = Uri.base;
|
||||
_token = uri.queryParameters['token'];
|
||||
|
||||
_loadPolicy();
|
||||
}
|
||||
|
||||
Future<void> _loadPolicy() async {
|
||||
setState(() {
|
||||
_isPolicyLoading = true;
|
||||
});
|
||||
try {
|
||||
final policy = await AuthProxyService.fetchPasswordPolicy();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_policy = policy;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// 실패해도 기본 검증 로직 사용
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isPolicyLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handlePasswordReset() async {
|
||||
if (_isLoading) return;
|
||||
if (_formKey.currentState?.validate() != true) return;
|
||||
if ((_loginId == null || _loginId!.isEmpty) &&
|
||||
(_token == null || _token!.isEmpty)) {
|
||||
_showError(tr('msg.userfront.reset.invalid_link'));
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
bool isSuccess = false;
|
||||
|
||||
try {
|
||||
await AuthProxyService.completePasswordReset(
|
||||
loginId: _loginId,
|
||||
token: _token,
|
||||
newPassword: _passwordController.text,
|
||||
);
|
||||
|
||||
isSuccess = true;
|
||||
if (mounted) {
|
||||
ToastService.success(tr('msg.userfront.reset.success'));
|
||||
context.go(buildLocalizedSigninPath(Uri.base));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
_showError(
|
||||
tr(
|
||||
'msg.userfront.reset.error.generic',
|
||||
params: {'error': e.toString()},
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted && !isSuccess) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
ToastService.error(message);
|
||||
}
|
||||
|
||||
String _buildPolicyDescription() {
|
||||
if (_isPolicyLoading) {
|
||||
return tr('msg.userfront.reset.policy_loading');
|
||||
}
|
||||
final minLength = (_policy?['minLength'] as int?) ?? 12;
|
||||
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
|
||||
final requiresLower = _policy?['lowercase'] ?? true;
|
||||
final requiresUpper = _policy?['uppercase'] ?? false;
|
||||
final requiresNumber = _policy?['number'] ?? true;
|
||||
final requiresSymbol = _policy?['nonAlphanumeric'] ?? true;
|
||||
|
||||
final parts = <String>[
|
||||
_renderTranslatedText(
|
||||
'msg.userfront.reset.policy.min_length',
|
||||
values: {'count': '$minLength'},
|
||||
),
|
||||
];
|
||||
if (minTypes > 0) {
|
||||
parts.add(
|
||||
_renderTranslatedText(
|
||||
'msg.userfront.reset.policy.min_types',
|
||||
values: {'count': '$minTypes'},
|
||||
),
|
||||
);
|
||||
}
|
||||
if (requiresLower) {
|
||||
parts.add(tr('msg.userfront.reset.policy.lowercase'));
|
||||
}
|
||||
if (requiresUpper) {
|
||||
parts.add(tr('msg.userfront.reset.policy.uppercase'));
|
||||
}
|
||||
if (requiresNumber) {
|
||||
parts.add(tr('msg.userfront.reset.policy.number'));
|
||||
}
|
||||
if (requiresSymbol) {
|
||||
parts.add(tr('msg.userfront.reset.policy.symbol'));
|
||||
}
|
||||
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(tr('ui.userfront.reset.title')),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child:
|
||||
(_loginId == null || _loginId!.isEmpty) &&
|
||||
(_token == null || _token!.isEmpty)
|
||||
? _buildInvalidTokenView()
|
||||
: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
tr('ui.userfront.reset.subtitle'),
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_buildPolicyDescription(),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
TextFormField(
|
||||
key: const ValueKey('reset_password_new_input'),
|
||||
controller: _passwordController,
|
||||
obscureText: _isPasswordObscured,
|
||||
decoration: InputDecoration(
|
||||
labelText: tr('ui.userfront.reset.new_password'),
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_isPasswordObscured
|
||||
? Icons.visibility_off
|
||||
: Icons.visibility,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isPasswordObscured = !_isPasswordObscured;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
final val = value ?? "";
|
||||
if (val.isEmpty) {
|
||||
return tr(
|
||||
'msg.userfront.reset.error.empty_password',
|
||||
);
|
||||
}
|
||||
final minLength =
|
||||
(_policy?['minLength'] as int?) ?? 12;
|
||||
if (val.length < minLength) {
|
||||
return tr(
|
||||
'msg.userfront.reset.error.min_length',
|
||||
params: {'count': '$minLength'},
|
||||
);
|
||||
}
|
||||
final hasLower = RegExp(r'[a-z]').hasMatch(val);
|
||||
final hasUpper = RegExp(r'[A-Z]').hasMatch(val);
|
||||
final hasNumber = RegExp(r'[0-9]').hasMatch(val);
|
||||
final hasSymbol = RegExp(r'[\W_]').hasMatch(val);
|
||||
int typeCount = 0;
|
||||
if (hasLower) typeCount++;
|
||||
if (hasUpper) typeCount++;
|
||||
if (hasNumber) typeCount++;
|
||||
if (hasSymbol) typeCount++;
|
||||
|
||||
final minTypes =
|
||||
(_policy?['minCharacterTypes'] as int?) ?? 0;
|
||||
if (minTypes > 0 && typeCount < minTypes) {
|
||||
return tr(
|
||||
'msg.userfront.reset.error.min_types',
|
||||
params: {'count': '$minTypes'},
|
||||
);
|
||||
}
|
||||
|
||||
if ((_policy?['lowercase'] ?? true) && !hasLower) {
|
||||
return tr('msg.userfront.reset.error.lowercase');
|
||||
}
|
||||
if ((_policy?['uppercase'] ?? false) && !hasUpper) {
|
||||
return tr('msg.userfront.reset.error.uppercase');
|
||||
}
|
||||
if ((_policy?['number'] ?? true) && !hasNumber) {
|
||||
return tr('msg.userfront.reset.error.number');
|
||||
}
|
||||
if ((_policy?['nonAlphanumeric'] ?? true) &&
|
||||
!hasSymbol) {
|
||||
return tr('msg.userfront.reset.error.symbol');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
key: const ValueKey('reset_password_confirm_input'),
|
||||
controller: _confirmPasswordController,
|
||||
obscureText: _isConfirmPasswordObscured,
|
||||
decoration: InputDecoration(
|
||||
labelText: tr('ui.userfront.reset.confirm_password'),
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_isConfirmPasswordObscured
|
||||
? Icons.visibility_off
|
||||
: Icons.visibility,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isConfirmPasswordObscured =
|
||||
!_isConfirmPasswordObscured;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value != _passwordController.text) {
|
||||
return tr('msg.userfront.reset.error.mismatch');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
key: const ValueKey('reset_password_submit_button'),
|
||||
onPressed: _isLoading ? null : _handlePasswordReset,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(tr('ui.userfront.reset.submit')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInvalidTokenView() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 60),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
tr('msg.userfront.reset.invalid_title'),
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
tr('msg.userfront.reset.invalid_body'),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user