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

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