forked from baron/baron-sso
userfront로 리펙토링 완료
This commit is contained in:
122
userfront/lib/features/auth/presentation/approve_qr_screen.dart
Normal file
122
userfront/lib/features/auth/presentation/approve_qr_screen.dart
Normal file
@@ -0,0 +1,122 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:descope/descope.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/services/auth_proxy_service.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;
|
||||
|
||||
Future<void> _handleApprove() async {
|
||||
if (widget.pendingRef == null) return;
|
||||
|
||||
final session = Descope.sessionManager.session;
|
||||
if (session == null || session.refreshToken.isExpired) {
|
||||
setState(() => _message = "Please log in on your phone first.");
|
||||
context.go('/signin'); // Redirect to login
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_message = null;
|
||||
});
|
||||
// jwt 유효성 확인
|
||||
try {
|
||||
await AuthProxyService.approveQrLogin(
|
||||
widget.pendingRef!,
|
||||
session.sessionToken.jwt,
|
||||
);
|
||||
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('/');
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _message = "Error: $e");
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isLoggedIn = Descope.sessionManager.session?.refreshToken.isExpired == false;
|
||||
|
||||
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 (!_success)
|
||||
FilledButton.icon(
|
||||
onPressed: _isLoading || !isLoggedIn ? null : _handleApprove,
|
||||
icon: const Icon(Icons.check_circle),
|
||||
label: const Text("Approve Login"),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(60),
|
||||
backgroundColor: Colors.blue,
|
||||
),
|
||||
),
|
||||
|
||||
if (!isLoggedIn && !_success)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: TextButton(
|
||||
onPressed: () => context.go('/signin'),
|
||||
child: const Text("Login on this device first"),
|
||||
),
|
||||
),
|
||||
|
||||
if (_success)
|
||||
FilledButton(
|
||||
onPressed: () => context.go('/'),
|
||||
child: const Text("Go to My Dashboard"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../../../core/services/auth_proxy_service.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;
|
||||
|
||||
Future<void> _handlePasswordReset() async {
|
||||
final input = _loginIdController.text.trim();
|
||||
if (input.isEmpty) {
|
||||
_showError("이메일 또는 휴대폰 번호를 입력해주세요.");
|
||||
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);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("비밀번호 재설정 링크가 전송되었습니다. 이메일 또는 SMS를 확인해주세요."),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
_showError("전송에 실패했습니다: ${e.toString()}");
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("비밀번호 재설정"),
|
||||
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(
|
||||
"비밀번호를 잊으셨나요?",
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
"계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
TextField(
|
||||
controller: _loginIdController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "이메일 또는 휴대폰 번호",
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: 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),
|
||||
)
|
||||
: const Text("재설정 링크 전송"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
730
userfront/lib/features/auth/presentation/login_screen.dart
Normal file
730
userfront/lib/features/auth/presentation/login_screen.dart
Normal file
@@ -0,0 +1,730 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:descope/descope.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import '../../../core/services/audit_service.dart';
|
||||
import '../../../core/services/web_auth_integration.dart';
|
||||
import '../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../core/notifiers/auth_notifier.dart';
|
||||
import '../../profile/domain/notifiers/profile_notifier.dart';
|
||||
|
||||
class LoginScreen extends ConsumerStatefulWidget {
|
||||
final String? verificationToken;
|
||||
const LoginScreen({super.key, this.verificationToken});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
final TextEditingController _linkIdController = TextEditingController();
|
||||
final TextEditingController _passwordLoginIdController = TextEditingController();
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
String? _redirectUrl;
|
||||
|
||||
// QR Login Variables
|
||||
String? _qrImageBase64;
|
||||
String? _qrPendingRef;
|
||||
bool _isQrLoading = false;
|
||||
Timer? _qrPollingTimer;
|
||||
int _qrRemainingSeconds = 0;
|
||||
Timer? _qrCountdownTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 탭 컨트롤러: 3개 탭, 기본 선택은 두 번째 탭("로그인 링크")
|
||||
_tabController = TabController(length: 3, vsync: this, initialIndex: 1);
|
||||
_tabController.addListener(_handleTabSelection);
|
||||
|
||||
// Check for tokens (Path Parameter or Legacy Query Parameter)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (widget.verificationToken != null) {
|
||||
_verifyToken(widget.verificationToken!);
|
||||
} else {
|
||||
final uri = Uri.base;
|
||||
if (uri.queryParameters.containsKey('t')) {
|
||||
_verifyToken(uri.queryParameters['t']!);
|
||||
}
|
||||
}
|
||||
|
||||
final uri = Uri.base;
|
||||
if (uri.queryParameters.containsKey('redirect_url')) {
|
||||
_redirectUrl = uri.queryParameters['redirect_url'];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to decode JWT and get loginId
|
||||
String _getLoginIdFromJwt(String jwt) {
|
||||
try {
|
||||
final parts = jwt.split('.');
|
||||
if (parts.length != 3) return 'User';
|
||||
|
||||
final payload = utf8.decode(base64Url.decode(base64Url.normalize(parts[1])));
|
||||
final data = json.decode(payload);
|
||||
// Descope tokens usually have 'name', 'email', or 'sub'
|
||||
return data['name'] ?? data['email'] ?? data['sub'] ?? 'User';
|
||||
} catch (e) {
|
||||
debugPrint("[JWT] Decode error: $e");
|
||||
return 'User';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to decode JWT and get User ID (sub claim)
|
||||
String _getUserIdFromJwt(String jwt) {
|
||||
try {
|
||||
final parts = jwt.split('.');
|
||||
if (parts.length != 3) return 'unknown';
|
||||
final payload = utf8.decode(base64Url.decode(base64Url.normalize(parts[1])));
|
||||
final data = json.decode(payload) as Map<String, dynamic>;
|
||||
return data['sub'] as String? ?? 'unknown';
|
||||
} catch (e) {
|
||||
debugPrint("[JWT] Could not extract User ID (sub): $e");
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTabSelection() {
|
||||
// QR 탭 (세 번째 탭, index 2)이 선택되었을 때 QR 플로우 시작
|
||||
if (_tabController.index == 2 && _qrPendingRef == null) {
|
||||
_startQrFlow();
|
||||
} else if (_tabController.index != 2) {
|
||||
_stopQrPolling();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startQrFlow() async {
|
||||
if (_isQrLoading) return;
|
||||
setState(() {
|
||||
_isQrLoading = true;
|
||||
_qrImageBase64 = null;
|
||||
_qrRemainingSeconds = 0;
|
||||
});
|
||||
|
||||
try {
|
||||
final res = await AuthProxyService.initQrLogin();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_qrImageBase64 = res['qrCode'];
|
||||
_qrPendingRef = res['pendingRef'];
|
||||
_qrRemainingSeconds = res['expiresIn'] ?? 300;
|
||||
_isQrLoading = false;
|
||||
});
|
||||
_startQrPolling();
|
||||
_startCountdown();
|
||||
}
|
||||
} catch (e) {
|
||||
_showError("Failed to init QR: $e");
|
||||
if (mounted) setState(() => _isQrLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _startCountdown() {
|
||||
_qrCountdownTimer?.cancel();
|
||||
_qrCountdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (!mounted || _qrRemainingSeconds <= 0) {
|
||||
timer.cancel();
|
||||
if (_qrRemainingSeconds <= 0) _stopQrPolling();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_qrRemainingSeconds--;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _startQrPolling() {
|
||||
_qrPollingTimer?.cancel();
|
||||
_qrPollingTimer = Timer.periodic(const Duration(milliseconds: 1500), (timer) async {
|
||||
if (_qrPendingRef == null || !mounted || _qrRemainingSeconds <= 0) {
|
||||
timer.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final res = await AuthProxyService.pollQrStatus(_qrPendingRef!);
|
||||
if (res['status'] == 'ok' && res['sessionJwt'] != null) {
|
||||
timer.cancel();
|
||||
_qrCountdownTimer?.cancel();
|
||||
|
||||
final jwt = res['sessionJwt'];
|
||||
final displayName = _getLoginIdFromJwt(jwt);
|
||||
// Create User & Session for Descope SDK
|
||||
final dummyUser = DescopeUser(
|
||||
'unknown', // userId
|
||||
[], // loginIds
|
||||
0, // createdAt
|
||||
displayName, // name
|
||||
null, // picture (Uri?)
|
||||
'', // email
|
||||
false, // isVerifiedEmail
|
||||
'', // phone
|
||||
false, // isVerifiedPhone
|
||||
{}, // customAttributes
|
||||
'', // givenName
|
||||
'', // middleName
|
||||
'', // familyName
|
||||
false, // hasPassword
|
||||
'enabled', // status
|
||||
[], // roleNames
|
||||
[], // ssoAppIds
|
||||
[], // oauthProviders (List<String>)
|
||||
);
|
||||
final session = DescopeSession.fromJwt(jwt, jwt, dummyUser);
|
||||
Descope.sessionManager.manageSession(session);
|
||||
|
||||
_onLoginSuccess(jwt);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("[QR] Polling error: $e");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _stopQrPolling() {
|
||||
_qrPollingTimer?.cancel();
|
||||
_qrPollingTimer = null;
|
||||
_qrCountdownTimer?.cancel();
|
||||
_qrCountdownTimer = null;
|
||||
_qrPendingRef = null;
|
||||
}
|
||||
|
||||
String _formatTime(int seconds) {
|
||||
final m = seconds ~/ 60;
|
||||
final s = seconds % 60;
|
||||
return "${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}";
|
||||
}
|
||||
|
||||
Future<void> _verifyToken(String token) async {
|
||||
debugPrint("[Auth] Starting verification for token: $token");
|
||||
try {
|
||||
// Use Backend to verify the token (Backend-Driven Flow)
|
||||
final res = await AuthProxyService.verifyMagicLink(token);
|
||||
final jwt = res['token'];
|
||||
debugPrint("[Auth] Verification successful for token: $token");
|
||||
|
||||
if (jwt != null && mounted) {
|
||||
final displayName = _getLoginIdFromJwt(jwt);
|
||||
// Create User & Session for Descope SDK to log in this tab
|
||||
final dummyUser = DescopeUser(
|
||||
'unknown', [], 0, displayName, null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [],
|
||||
);
|
||||
final session = DescopeSession.fromJwt(jwt, jwt, dummyUser);
|
||||
// Refresh Token을 LocalStorage에 저장
|
||||
Descope.sessionManager.manageSession(session);
|
||||
|
||||
// Notify and Go to Dashboard
|
||||
_onLoginSuccess(jwt);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("[Auth] Verification FAILED for token: $token. Error: $e");
|
||||
if (mounted) {
|
||||
_showError("Verification failed: $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopQrPolling();
|
||||
_tabController.dispose();
|
||||
_linkIdController.dispose();
|
||||
_passwordLoginIdController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// 이메일/비밀번호 로그인 처리
|
||||
Future<void> _handlePasswordLogin() async {
|
||||
final input = _passwordLoginIdController.text.trim();
|
||||
final password = _passwordController.text.trim();
|
||||
if (input.isEmpty || password.isEmpty) {
|
||||
_showError("이메일(또는 전화번호)와 비밀번호를 모두 입력해주세요.");
|
||||
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)}';
|
||||
}
|
||||
}
|
||||
|
||||
// 로딩 인디케이터 표시
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
|
||||
try {
|
||||
final res = await AuthProxyService.loginWithPassword(loginId, password);
|
||||
final jwt = res['sessionJwt'];
|
||||
if (jwt != null && mounted) {
|
||||
Navigator.of(context).pop(); // 로딩 닫기
|
||||
_onLoginSuccess(jwt);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) Navigator.of(context).pop(); // 로딩 닫기
|
||||
if (e.toString().contains("User not registered")) {
|
||||
_showUnregisteredDialog();
|
||||
} else {
|
||||
_showError("로그인 실패: ${e.toString().replaceFirst("Exception: ", "")}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 로그인 링크 전송 처리
|
||||
Future<void> _handleLinkLogin() async {
|
||||
final input = _linkIdController.text.trim();
|
||||
if (input.isEmpty) 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)}';
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint("[Auth] Initiating Enchanted Link for: $loginId");
|
||||
|
||||
// 링크 전송 전 사용자 존재 여부 체크 (백엔드에서 이미 처리하지만 에러 핸들링을 위해)
|
||||
try {
|
||||
await _startEnchantedFlow(loginId, isEmail: input.contains('@'));
|
||||
} catch (e) {
|
||||
if (e.toString().contains("User not registered")) {
|
||||
_showUnregisteredDialog();
|
||||
} else {
|
||||
_showError("오류: $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startEnchantedFlow(String loginId, {required bool isEmail}) async {
|
||||
try {
|
||||
if (mounted) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Init via Backend API
|
||||
final initResponse = await AuthProxyService.initEnchantedLink(loginId);
|
||||
final pendingRef = initResponse['pendingRef'];
|
||||
debugPrint("[Auth] Link Sent. PendingRef: $pendingRef");
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop(); // Close Loading
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(isEmail ? "이메일 전송됨" : "SMS 전송됨"),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(isEmail
|
||||
? "입력하신 이메일로 로그인 링크를 보냈습니다."
|
||||
: "입력하신 번호로 로그인 링크를 보냈습니다."),
|
||||
const SizedBox(height: 16),
|
||||
const LinearProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
debugPrint("[Auth] Polling canceled by user");
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text("취소")
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 2. Poll Backend manually
|
||||
_pollForSession(pendingRef);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("[Auth] Initialization failed: $e");
|
||||
if (mounted && Navigator.canPop(context)) Navigator.of(context).pop();
|
||||
if (e.toString().contains("User not registered")) {
|
||||
_showUnregisteredDialog();
|
||||
} else {
|
||||
_showError("전송 실패: $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pollForSession(String pendingRef) async {
|
||||
int attempts = 0;
|
||||
const maxAttempts = 60; // 2 minutes
|
||||
debugPrint("[Auth] Starting poll for ref: $pendingRef");
|
||||
|
||||
while (attempts < maxAttempts && mounted) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
attempts++;
|
||||
|
||||
try {
|
||||
final result = await AuthProxyService.pollEnchantedLink(pendingRef);
|
||||
|
||||
if (result['status'] == 'ok') {
|
||||
final jwt = result['sessionJwt'];
|
||||
if (jwt != null) {
|
||||
debugPrint("[Auth] Polling SUCCESS. Token received.");
|
||||
|
||||
final displayName = _getLoginIdFromJwt(jwt);
|
||||
// Descope SDK 세션 강제 주입
|
||||
final dummyUser = DescopeUser(
|
||||
'unknown', [], 0, displayName, null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [],
|
||||
);
|
||||
final session = DescopeSession.fromJwt(jwt, jwt, dummyUser);
|
||||
Descope.sessionManager.manageSession(session);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop(); // Close Polling Dialog
|
||||
_onLoginSuccess(jwt);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("[Auth] Polling error (attempt $attempts): $e");
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
debugPrint("[Auth] Polling timed out for ref: $pendingRef");
|
||||
Navigator.of(context).pop(); // Close Polling Dialog
|
||||
_showError("Login timed out.");
|
||||
}
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), backgroundColor: Colors.red),
|
||||
);
|
||||
try {
|
||||
AuthProxyService.logError(message);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
void _logTokenDetails(String jwt) {
|
||||
try {
|
||||
final parts = jwt.split('.');
|
||||
if (parts.length != 3) return;
|
||||
|
||||
final decodedPayload = base64Url.decode(base64Url.normalize(parts[1]));
|
||||
final payloadJson = utf8.decode(decodedPayload);
|
||||
final data = json.decode(payloadJson) as Map<String, dynamic>;
|
||||
|
||||
final accessExpValue = data['exp'] as num?;
|
||||
final accessExp = accessExpValue != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(accessExpValue.toInt() * 1000)
|
||||
: 'N/A';
|
||||
final refreshExp = data['rexp'] ?? 'N/A';
|
||||
|
||||
debugPrint("""
|
||||
[Auth] Session Token Details ---
|
||||
- Access Token Expires: $accessExp
|
||||
- Refresh Token Expires: $refreshExp
|
||||
""");
|
||||
} catch (e) {
|
||||
debugPrint("[Auth] Failed to decode or log token details: $e");
|
||||
}
|
||||
}
|
||||
|
||||
void _onLoginSuccess(String token) async {
|
||||
if (!mounted) return;
|
||||
|
||||
_logTokenDetails(token);
|
||||
|
||||
final userId = _getUserIdFromJwt(token);
|
||||
|
||||
// [New] 로그인 성공 직후 백엔드에서 전체 프로필 정보를 가져와 세션 업데이트
|
||||
try {
|
||||
// 임시 세션 생성 (API 호출을 위해)
|
||||
final tempUser = DescopeUser(userId, [], 0, 'User', null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], []);
|
||||
final tempSession = DescopeSession.fromJwt(token, token, tempUser);
|
||||
Descope.sessionManager.manageSession(tempSession);
|
||||
|
||||
// 백엔드 GetMe 호출 (프로필 노티파이어 사용)
|
||||
final profile = await ref.read(profileProvider.notifier).loadProfile();
|
||||
if (profile != null) {
|
||||
// 실제 정보로 세션 유저 정보 교체
|
||||
final realUser = DescopeUser(
|
||||
userId, [], 0, profile.name, null, profile.email, false, profile.phone, false, {}, '', '', '', false, 'enabled', [], [], [],
|
||||
);
|
||||
final realSession = DescopeSession.fromJwt(token, token, realUser);
|
||||
Descope.sessionManager.manageSession(realSession);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("[Auth] Failed to pre-fetch profile: $e");
|
||||
}
|
||||
|
||||
// Record Audit Log
|
||||
AuditService.logEvent(
|
||||
userId: userId,
|
||||
eventType: "LOGIN_SUCCESS",
|
||||
status: "SUCCESS",
|
||||
details: "User logged in via Baron SSO",
|
||||
);
|
||||
|
||||
// 1. Handle Popup Flow
|
||||
if (WebAuthIntegration.isPopup()) {
|
||||
debugPrint("[Auth] Popup detected. Notifying opener and attempting to close.");
|
||||
WebAuthIntegration.sendLoginSuccess(token);
|
||||
} else {
|
||||
// 2. Handle Redirect Flow
|
||||
if (_redirectUrl != null && _redirectUrl!.isNotEmpty) {
|
||||
debugPrint("[Auth] Redirecting standalone window to: $_redirectUrl");
|
||||
final target = "$_redirectUrl?token=$token";
|
||||
launchUrlString(target, webOnlyWindowName: '_self');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Standalone mode / Fallback
|
||||
debugPrint("[Auth] Login success. Navigating to root.");
|
||||
AuthNotifier.instance.notify();
|
||||
if (mounted) {
|
||||
context.go('/');
|
||||
}
|
||||
}
|
||||
|
||||
// [New] 미등록 회원 안내 팝업
|
||||
void _showUnregisteredDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text("미등록 회원"),
|
||||
content: const Text("가입되지 않은 정보입니다.\n회원가입 후 이용해 주세요."),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text("취소"),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
context.push('/signup');
|
||||
},
|
||||
child: const Text("회원가입 하기"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
"Baron SSO",
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
TabBar(
|
||||
controller: _tabController,
|
||||
tabs: const [
|
||||
Tab(text: "비밀번호"),
|
||||
Tab(text: "로그인 링크"),
|
||||
Tab(text: "QR 코드"),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
SizedBox(
|
||||
height: 350,
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
// 1. 이메일/비밀번호 로그인 폼
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _passwordLoginIdController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "이메일 또는 휴대폰 번호",
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
),
|
||||
onSubmitted: (_) => _handlePasswordLogin(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "비밀번호",
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.lock_outline),
|
||||
),
|
||||
onSubmitted: (_) => _handlePasswordLogin(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _handlePasswordLogin,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
),
|
||||
child: const Text("로그인"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 2. 로그인 링크 전송 폼
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _linkIdController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "이메일 또는 휴대폰 번호",
|
||||
hintText: "",
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
),
|
||||
onSubmitted: (_) => _handleLinkLogin(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _handleLinkLogin,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
),
|
||||
child: const Text("로그인 링크 전송"),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
"입력하신 정보로 로그인 링크를 전송합니다.",
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 3. QR 로그인 뷰
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (_isQrLoading)
|
||||
const CircularProgressIndicator()
|
||||
else if (_qrImageBase64 != null)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: QrImageView(
|
||||
data: _qrImageBase64!,
|
||||
version: QrVersions.auto,
|
||||
size: 200.0,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_qrRemainingSeconds > 0
|
||||
? "남은 시간: ${_formatTime(_qrRemainingSeconds)}"
|
||||
: "QR 코드 만료됨",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: _qrRemainingSeconds > 30 ? Colors.blue : Colors.red,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
"모바일 앱으로 스캔하세요",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _startQrFlow,
|
||||
child: const Text("QR 코드 새로고침")
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
const Text("QR 코드를 불러오지 못했습니다.", textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Column(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => context.push('/forgot-password'),
|
||||
child: const Text("비밀번호를 잊으셨나요?"),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text("계정이 없으신가요?", style: TextStyle(color: Colors.grey, fontSize: 14)),
|
||||
TextButton(
|
||||
onPressed: () => context.push('/signup'),
|
||||
child: const Text("회원가입"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:google_fonts/google_fonts.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(
|
||||
"로그인 완료",
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
"성공적으로 로그인되었습니다.",
|
||||
textAlign: TextAlign.center,
|
||||
style: 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: const Text("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('/');
|
||||
},
|
||||
child: const Text("나중에 하기 (대시보드로 이동)", style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
122
userfront/lib/features/auth/presentation/qr_scan_screen.dart
Normal file
122
userfront/lib/features/auth/presentation/qr_scan_screen.dart
Normal file
@@ -0,0 +1,122 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:descope/descope.dart';
|
||||
import '../../../core/services/auth_proxy_service.dart';
|
||||
|
||||
class QRScanScreen extends StatefulWidget {
|
||||
const QRScanScreen({super.key});
|
||||
|
||||
@override
|
||||
State<QRScanScreen> createState() => _QRScanScreenState();
|
||||
}
|
||||
|
||||
class _QRScanScreenState extends State<QRScanScreen> {
|
||||
final _log = Logger('QRScanScreen');
|
||||
final MobileScannerController controller = MobileScannerController(
|
||||
detectionSpeed: DetectionSpeed.noDuplicates,
|
||||
);
|
||||
bool _isScanned = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _onDetect(BarcodeCapture capture) async {
|
||||
if (_isScanned) return;
|
||||
|
||||
final List<Barcode> barcodes = capture.barcodes;
|
||||
for (final barcode in barcodes) {
|
||||
if (barcode.rawValue != null) {
|
||||
_isScanned = true;
|
||||
String qrData = barcode.rawValue!;
|
||||
String pendingRef = qrData;
|
||||
|
||||
// URL 형식이라면 'ref' 파라미터 추출 시도
|
||||
if (qrData.startsWith('http')) {
|
||||
try {
|
||||
final uri = Uri.parse(qrData);
|
||||
if (uri.queryParameters.containsKey('ref')) {
|
||||
pendingRef = uri.queryParameters['ref']!;
|
||||
}
|
||||
} catch (e) {
|
||||
_log.warning('Failed to parse QR URL: $qrData', e);
|
||||
}
|
||||
}
|
||||
|
||||
_log.info('QR Code detected raw: $qrData, ref: $pendingRef');
|
||||
|
||||
final sessionToken = Descope.sessionManager.session?.sessionToken.jwt;
|
||||
if (sessionToken == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('로그인이 필요합니다.'), backgroundColor: Colors.red),
|
||||
);
|
||||
context.pop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Call backend API to approve login with clean ref
|
||||
await AuthProxyService.approveQrLogin(pendingRef, sessionToken);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('로그인 승인 완료!'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
// Wait a bit and go back
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
if (mounted) context.pop();
|
||||
}
|
||||
} catch (e) {
|
||||
_log.severe("QR Approval Failed", e);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('승인 실패: $e'), backgroundColor: Colors.red),
|
||||
);
|
||||
// Allow rescanning after a delay
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
_isScanned = false;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Scan QR Code'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
body: MobileScanner(
|
||||
controller: controller,
|
||||
onDetect: _onDetect,
|
||||
errorBuilder: (context, error, child) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error, color: Colors.red, size: 50),
|
||||
const SizedBox(height: 10),
|
||||
Text('Camera Error: ${error.errorCode}'),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/services/auth_proxy_service.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;
|
||||
|
||||
@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 (_formKey.currentState?.validate() != true) return;
|
||||
if ((_loginId == null || _loginId!.isEmpty) && (_token == null || _token!.isEmpty)) {
|
||||
_showError("유효하지 않은 재설정 링크입니다. (loginId/token 누락)");
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
await AuthProxyService.completePasswordReset(
|
||||
loginId: _loginId,
|
||||
token: _token,
|
||||
newPassword: _passwordController.text,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요."),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
context.go('/signin');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
_showError("비밀번호 변경에 실패했습니다: ${e.toString()}");
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
|
||||
String _buildPolicyDescription() {
|
||||
if (_isPolicyLoading) {
|
||||
return "비밀번호 정책을 불러오는 중입니다...";
|
||||
}
|
||||
final minLength = (_policy?['minLength'] as int?) ?? 8;
|
||||
final requiresLower = _policy?['lowercase'] ?? true;
|
||||
final requiresUpper = _policy?['uppercase'] ?? true;
|
||||
final requiresNumber = _policy?['number'] ?? true;
|
||||
final requiresSymbol = _policy?['nonAlphanumeric'] ?? true;
|
||||
|
||||
final parts = <String>["최소 ${minLength}자 이상"];
|
||||
if (requiresLower) parts.add("소문자 1개 이상");
|
||||
if (requiresUpper) parts.add("대문자 1개 이상");
|
||||
if (requiresNumber) parts.add("숫자 1개 이상");
|
||||
if (requiresSymbol) parts.add("특수문자 1개 이상");
|
||||
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("새 비밀번호 설정"),
|
||||
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(
|
||||
"새로운 비밀번호 설정",
|
||||
style: GoogleFonts.outfit(
|
||||
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(
|
||||
controller: _passwordController,
|
||||
obscureText: _isPasswordObscured,
|
||||
decoration: InputDecoration(
|
||||
labelText: "새 비밀번호",
|
||||
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 '비밀번호를 입력해주세요.';
|
||||
}
|
||||
final minLength = (_policy?['minLength'] as int?) ?? 8;
|
||||
if (val.length < minLength) {
|
||||
return '비밀번호는 최소 $minLength자 이상이어야 합니다.';
|
||||
}
|
||||
if ((_policy?['lowercase'] ?? true) && !RegExp(r'(?=.*[a-z])').hasMatch(val)) {
|
||||
return '최소 1개 이상의 소문자를 포함해야 합니다.';
|
||||
}
|
||||
if ((_policy?['uppercase'] ?? true) && !RegExp(r'(?=.*[A-Z])').hasMatch(val)) {
|
||||
return '최소 1개 이상의 대문자를 포함해야 합니다.';
|
||||
}
|
||||
if ((_policy?['number'] ?? true) && !RegExp(r'(?=.*\d)').hasMatch(val)) {
|
||||
return '최소 1개 이상의 숫자를 포함해야 합니다.';
|
||||
}
|
||||
if ((_policy?['nonAlphanumeric'] ?? true) && !RegExp(r'(?=.*[\W_])').hasMatch(val)) {
|
||||
return '최소 1개 이상의 특수문자를 포함해야 합니다.';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _confirmPasswordController,
|
||||
obscureText: _isConfirmPasswordObscured,
|
||||
decoration: InputDecoration(
|
||||
labelText: "새 비밀번호 확인",
|
||||
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 '비밀번호가 일치하지 않습니다.';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
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),
|
||||
)
|
||||
: const Text("비밀번호 변경"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInvalidTokenView() {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: Colors.red, size: 60),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
"유효하지 않은 링크입니다.",
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
"비밀번호 재설정 링크가 만료되었거나 잘못되었습니다. 다시 시도해주세요.",
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
970
userfront/lib/features/auth/presentation/signup_screen.dart
Normal file
970
userfront/lib/features/auth/presentation/signup_screen.dart
Normal file
@@ -0,0 +1,970 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/services/auth_proxy_service.dart';
|
||||
|
||||
class SignupScreen extends StatefulWidget {
|
||||
const SignupScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SignupScreen> createState() => _SignupScreenState();
|
||||
}
|
||||
|
||||
class _SignupScreenState extends State<SignupScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
int _currentStep = 1;
|
||||
|
||||
// Controllers
|
||||
final _emailController = TextEditingController();
|
||||
final _emailCodeController = TextEditingController();
|
||||
final _phoneController = TextEditingController();
|
||||
final _phoneCodeController = TextEditingController();
|
||||
final _nameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _confirmPasswordController = TextEditingController();
|
||||
final _deptController = TextEditingController();
|
||||
|
||||
// State
|
||||
bool _isEmailVerified = false;
|
||||
bool _isPhoneVerified = false;
|
||||
String _affiliationType = 'GENERAL';
|
||||
String? _companyCode;
|
||||
bool _isAffiliateEmail = false; // 가족사 이메일 여부
|
||||
bool _termsAccepted = false;
|
||||
bool _privacyAccepted = false;
|
||||
bool _isLoading = false;
|
||||
Map<String, dynamic>? _policy;
|
||||
bool _isPolicyLoading = false;
|
||||
|
||||
// Inline Errors
|
||||
String? _emailError;
|
||||
String? _phoneError;
|
||||
String? _passwordError;
|
||||
String? _confirmPasswordError;
|
||||
|
||||
// Timers
|
||||
Timer? _emailTimer;
|
||||
int _emailSeconds = 0;
|
||||
Timer? _phoneTimer;
|
||||
int _phoneSeconds = 0;
|
||||
|
||||
// 가족사 도메인 맵
|
||||
final Map<String, String> _affiliateDomains = {
|
||||
'hanmaceng.co.kr': 'HANMAC',
|
||||
'samaneng.com': 'SAMAN',
|
||||
'jangheon.co.kr': 'JANGHEON',
|
||||
'hallasanup.com': 'HALLA',
|
||||
'pre-cast.co.kr': 'PTC',
|
||||
'baroncs.co.kr': 'BARON',
|
||||
};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPolicy();
|
||||
}
|
||||
|
||||
Future<void> _loadPolicy() async {
|
||||
setState(() => _isPolicyLoading = true);
|
||||
try {
|
||||
final policy = await AuthProxyService.fetchPasswordPolicy();
|
||||
if (mounted) setState(() => _policy = policy);
|
||||
} catch (_) {
|
||||
// Ignore errors, will use defaults
|
||||
} finally {
|
||||
if (mounted) setState(() => _isPolicyLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailTimer?.cancel();
|
||||
_phoneTimer?.cancel();
|
||||
_emailController.dispose();
|
||||
_emailCodeController.dispose();
|
||||
_phoneController.dispose();
|
||||
_phoneCodeController.dispose();
|
||||
_nameController.dispose();
|
||||
_passwordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
_deptController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// 이메일 입력 시 도메인 체크 로직
|
||||
void _checkEmailAffiliation(String email) {
|
||||
if (!email.contains('@')) {
|
||||
if (_isAffiliateEmail) {
|
||||
setState(() {
|
||||
_isAffiliateEmail = false;
|
||||
_affiliationType = 'GENERAL';
|
||||
_companyCode = null;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final domain = email.split('@').last.toLowerCase();
|
||||
if (_affiliateDomains.containsKey(domain)) {
|
||||
setState(() {
|
||||
_isAffiliateEmail = true;
|
||||
_affiliationType = 'AFFILIATE';
|
||||
_companyCode = _affiliateDomains[domain];
|
||||
});
|
||||
} else {
|
||||
if (_isAffiliateEmail) {
|
||||
setState(() {
|
||||
_isAffiliateEmail = false;
|
||||
_affiliationType = 'GENERAL';
|
||||
_companyCode = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _startTimer(String type) {
|
||||
if (type == 'email') {
|
||||
_emailSeconds = 300;
|
||||
_emailTimer?.cancel();
|
||||
_emailTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
if (_emailSeconds > 0) _emailSeconds--;
|
||||
else timer.cancel();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
_phoneSeconds = 180;
|
||||
_phoneTimer?.cancel();
|
||||
_phoneTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
if (_phoneSeconds > 0) _phoneSeconds--;
|
||||
else timer.cancel();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String _formatTime(int seconds) {
|
||||
final m = seconds ~/ 60;
|
||||
final s = seconds % 60;
|
||||
return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
Future<void> _sendEmailCode() async {
|
||||
final email = _emailController.text.trim();
|
||||
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
|
||||
if (!emailRegex.hasMatch(email)) {
|
||||
setState(() => _emailError = '유효한 이메일 형식이 아닙니다.');
|
||||
return;
|
||||
}
|
||||
setState(() { _isLoading = true; _emailError = null; });
|
||||
try {
|
||||
final available = await AuthProxyService.checkEmailAvailability(email);
|
||||
if (!available) {
|
||||
setState(() => _emailError = '이미 가입된 이메일입니다.');
|
||||
return;
|
||||
}
|
||||
await AuthProxyService.sendSignupCode(email, 'email');
|
||||
_startTimer('email');
|
||||
} catch (e) {
|
||||
setState(() => _emailError = '발송 실패: $e');
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _verifyEmailCode() async {
|
||||
final code = _emailCodeController.text.trim();
|
||||
if (code.length != 6) return;
|
||||
try {
|
||||
final success = await AuthProxyService.verifySignupCode(_emailController.text.trim(), 'email', code);
|
||||
if (success) {
|
||||
setState(() {
|
||||
_isEmailVerified = true;
|
||||
_emailTimer?.cancel();
|
||||
_emailSeconds = 0;
|
||||
_emailError = null;
|
||||
});
|
||||
} else {
|
||||
setState(() => _emailError = '인증코드가 일치하지 않습니다.');
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _emailError = '인증 실패: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sendPhoneCode() async {
|
||||
final phone = _phoneController.text.trim();
|
||||
if (phone.isEmpty) return;
|
||||
setState(() { _isLoading = true; _phoneError = null; });
|
||||
try {
|
||||
await AuthProxyService.sendSignupCode(phone, 'phone');
|
||||
_startTimer('phone');
|
||||
} catch (e) {
|
||||
setState(() => _phoneError = '발송 실패: $e');
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _verifyPhoneCode() async {
|
||||
final code = _phoneCodeController.text.trim();
|
||||
if (code.length != 6) return;
|
||||
try {
|
||||
final success = await AuthProxyService.verifySignupCode(_phoneController.text.trim(), 'phone', code);
|
||||
if (success) {
|
||||
setState(() {
|
||||
_isPhoneVerified = true;
|
||||
_phoneTimer?.cancel();
|
||||
_phoneSeconds = 0;
|
||||
_phoneError = null;
|
||||
});
|
||||
} else {
|
||||
setState(() => _phoneError = '인증코드가 일치하지 않습니다.');
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _phoneError = '인증 실패: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleSignup() async {
|
||||
if (_passwordController.text != _confirmPasswordController.text) {
|
||||
setState(() => _confirmPasswordError = '비밀번호가 일치하지 않습니다.');
|
||||
return;
|
||||
}
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_passwordError = null;
|
||||
});
|
||||
|
||||
try {
|
||||
await AuthProxyService.signup(
|
||||
email: _emailController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
name: _nameController.text.trim(),
|
||||
phone: _phoneController.text.trim(),
|
||||
affiliationType: _affiliationType,
|
||||
companyCode: _affiliationType == 'AFFILIATE' ? _companyCode : null,
|
||||
department: _deptController.text.trim().isEmpty ? (_affiliationType == 'GENERAL' ? 'External' : '') : _deptController.text.trim(),
|
||||
termsAccepted: true,
|
||||
);
|
||||
if (mounted) _showSuccessDialog();
|
||||
} catch (e) {
|
||||
String eStr = e.toString().toLowerCase();
|
||||
setState(() {
|
||||
if (eStr.contains('uppercase')) _passwordError = '대문자가 최소 1개 이상 포함되어야 합니다.';
|
||||
else if (eStr.contains('lowercase')) _passwordError = '소문자가 최소 1개 이상 포함되어야 합니다.';
|
||||
else if (eStr.contains('digit') || eStr.contains('number')) _passwordError = '숫자가 최소 1개 이상 포함되어야 합니다.';
|
||||
else if (eStr.contains('symbol') || eStr.contains('special')) _passwordError = '특수문자가 최소 1개 이상 포함되어야 합니다.';
|
||||
else if (eStr.contains('length') || eStr.contains('12 characters')) _passwordError = '비밀번호는 최소 12자 이상이어야 합니다.';
|
||||
else _passwordError = '가입 실패: $e';
|
||||
});
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _showSuccessDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('회원가입 완료'),
|
||||
content: const Text('성공적으로 가입되었습니다.'),
|
||||
actions: [TextButton(onPressed: () => context.go('/signin'), child: const Text('로그인하기'))],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// --- UI Components ---
|
||||
|
||||
Widget _buildStepIndicator() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
_stepCircle(1, '약관동의'),
|
||||
_stepLine(1),
|
||||
_stepCircle(2, '본인인증'),
|
||||
_stepLine(2),
|
||||
_stepCircle(3, '정보입력'),
|
||||
_stepLine(3),
|
||||
_stepCircle(4, '비밀번호'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stepCircle(int step, String label) {
|
||||
bool isDone = _currentStep > step;
|
||||
bool isCurrent = _currentStep == step;
|
||||
return Column(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 12,
|
||||
backgroundColor: isDone ? Colors.green : (isCurrent ? Colors.black : Colors.grey[300]),
|
||||
child: isDone ? const Icon(Icons.check, size: 14, color: Colors.white) : Text('$step', style: TextStyle(color: isCurrent ? Colors.white : Colors.black54, fontSize: 10)),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(label, style: TextStyle(fontSize: 9, color: isCurrent ? Colors.black : Colors.grey, fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stepLine(int afterStep) {
|
||||
return Expanded(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 16, left: 2, right: 2),
|
||||
height: 1.5,
|
||||
color: _currentStep > afterStep ? Colors.green : Colors.grey[300],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStepAgreement() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('서비스 이용을 위해\n약관에 동의해주세요',
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 20, fontWeight: FontWeight.bold, height: 1.3)),
|
||||
const SizedBox(height: 24),
|
||||
// 모두 동의 버튼
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey[200]!),
|
||||
),
|
||||
child: CheckboxListTile(
|
||||
title: const Text('모두 동의합니다',
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
|
||||
value: _termsAccepted && _privacyAccepted,
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
_termsAccepted = val!;
|
||||
_privacyAccepted = val;
|
||||
});
|
||||
},
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
activeColor: Colors.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_agreementSection(
|
||||
title: '바론 소프트웨어 이용약관 (필수)',
|
||||
content: _tosText,
|
||||
value: _termsAccepted,
|
||||
onChanged: (val) => setState(() => _termsAccepted = val!),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_agreementSection(
|
||||
title: '개인정보 수집 및 이용 동의 (필수)',
|
||||
content: _privacyText,
|
||||
value: _privacyAccepted,
|
||||
onChanged: (val) => setState(() => _privacyAccepted = val!),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _agreementSection({
|
||||
required String title,
|
||||
required String content,
|
||||
required bool value,
|
||||
required ValueChanged<bool?> onChanged,
|
||||
}) {
|
||||
return Column(
|
||||
children: [
|
||||
CheckboxListTile(
|
||||
title: Text(title,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
activeColor: Colors.black,
|
||||
),
|
||||
Container(
|
||||
height: 120,
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: Colors.grey[300]!),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Text(
|
||||
content,
|
||||
style: const TextStyle(fontSize: 12, color: Colors.grey, height: 1.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static const String _tosText = """
|
||||
바론 소프트웨어 이용약관
|
||||
|
||||
제1장 총칙
|
||||
제1조 (목적)
|
||||
이 약관은 바론컨설턴트(이하 "회사"라 합니다)가 제공하는 바론소프트웨어(이하 "서비스"라 합니다)를 이용함에 있어 회사와 이용자 간의 권리, 의무 및 책임사항과 기타 필요한 사항을 정하는 것을 목적으로 합니다.
|
||||
제2조 (용어의 정의)
|
||||
① 본 약관에서 사용하는 용어의 정의는 다음과 같습니다:
|
||||
- “서비스”란 회사가 제공하는 소프트웨어 및 관련 제반 서비스를 의미합니다.
|
||||
- “이용자”란 회사의 서비스에 접속하여 본 약관에 따라 회사가 제공하는 서비스를 이용하는 회원 및 비회원을 말합니다.
|
||||
- “회원”이란 본 약관에 동의하고 회사와 이용계약을 체결한 자를 의미합니다.
|
||||
- “비회원”이란 회원가입을 하지 않고 회사가 제공하는 일부 서비스를 이용하는 자를 말합니다.
|
||||
제3조 (약관의 효력 및 변경)
|
||||
① 본 약관은 이용자가 본 약관에 동의하고, 회사가 이에 대한 승낙을 완료함으로써 효력이 발생합니다. ② 회사는 필요한 경우 본 약관을 변경할 수 있으며, 변경된 약관은 서비스 화면에 공지된 후 효력이 발생합니다.
|
||||
제4조 (약관 외 준칙)
|
||||
본 약관에 명시되지 않은 사항에 대해서는 대한민국의 관련 법령과 상관습에 따릅니다.
|
||||
제2장 서비스 이용계약
|
||||
제5조 (이용계약의 성립)
|
||||
이용계약은 이용자가 약관의 내용에 동의하고, 회사가 제공하는 소정의 회원가입 신청서를 작성하여 가입을 완료한 후, 회사가 이를 승인함으로써 성립합니다.
|
||||
제6조 (이용계약의 유보와 거절)
|
||||
① 회사는 다음 각 호에 해당하는 경우 이용계약의 성립을 유보하거나 거절할 수 있습니다: - 신청서의 내용이 허위로 판명된 경우 - 서비스 제공이 기술적으로 어려운 경우
|
||||
제7조 (계약사항의 변경)
|
||||
회원은 개인정보 관리 메뉴를 통해 언제든지 자신의 정보를 열람하고 수정할 수 있습니다. 회원의 정보가 변경된 경우 즉시 수정해야 하며, 수정하지 않아 발생하는 문제의 책임은 회원에게 있습니다.
|
||||
제3장 개인정보 보호
|
||||
제8조 (개인정보 보호의 원칙)
|
||||
① 회원의 개인정보는 관련 법령에 따라 보호됩니다. ② 회사는 개인정보 보호와 관련된 세부 사항을 별도로 마련한 개인정보처리방침에 따라 관리하며, 이용자는 언제든지 해당 방침을 통해 개인정보 관리에 대한 자세한 내용을 확인할 수 있습니다.
|
||||
제9조 (개인정보처리방침 준수)
|
||||
① 회사는 개인정보 보호와 관련된 구체적인 사항을 개인정보처리방침에 따라 관리합니다. ② 개인정보의 수집, 이용, 제공, 보관, 보호 등에 관한 사항은 회사의 개인정보처리방침을 따르며, 이용자는 회사 웹사이트에서 이를 확인할 수 있습니다. ③ 회사는 개인정보 보호를 위해 최선을 다하며, 관련 법령에 따라 이용자의 개인정보를 안전하게 관리합니다.
|
||||
제10조 (14세 미만 아동의 개인정보 보호)
|
||||
① 회사는 14세 미만 아동의 개인정보를 수집할 경우, 반드시 법정대리인의 동의를 받아야 합니다. ② 법정대리인은 아동의 개인정보 열람, 수정, 삭제를 요청할 수 있으며, 회사는 이를 신속하게 처리합니다. ③ 14세 미만 아동의 개인정보 보호와 관련된 구체적인 사항은 개인정보처리방침에 명시되어 있습니다.
|
||||
제4장 서비스 제공 및 이용
|
||||
제11조 (서비스 제공)
|
||||
회사는 회원의 이용 신청을 승인한 때부터 서비스를 개시합니다. 서비스 이용은 연중무휴 24시간을 원칙으로 합니다.
|
||||
제12조 (서비스의 변경 및 중단)
|
||||
회사는 서비스 제공이 어려운 경우 사전 고지 후 서비스를 변경하거나 중단할 수 있습니다.
|
||||
제5장 정보 제공 및 광고
|
||||
제13조 (정보 제공 및 광고)
|
||||
① 회사는 서비스 이용 중 필요하다고 인정되는 정보 및 광고를 제공할 수 있습니다. ② 회원은 원치 않는 정보를 수신 거부할 수 있습니다.
|
||||
제6장 게시물 관리
|
||||
제14조 (게시물의 관리)
|
||||
회사는 회원이 게시한 내용이 불법적이거나 약관에 위배될 경우 이를 삭제할 수 있습니다.
|
||||
제15조 (게시물의 저작권)
|
||||
게시물의 저작권은 회원에게 있으며, 회사는 이를 서비스 홍보 및 개선 목적으로 사용할 수 있습니다.
|
||||
제7장 계약 해지 및 이용 제한
|
||||
제16조 (계약 해지)
|
||||
회원은 언제든지 계약 해지를 요청할 수 있으며, 회사는 신속하게 처리합니다.
|
||||
제17조 (이용 제한)
|
||||
회사는 회원이 약관을 위반할 경우 서비스 이용을 제한할 수 있습니다.
|
||||
제8장 손해 배상 및 면책 조항
|
||||
제18조 (손해 배상)
|
||||
회사는 무료로 제공되는 서비스와 관련하여 회원에게 발생한 손해에 대해 책임을 지지 않습니다.
|
||||
제19조 (면책 조항)
|
||||
회사는 천재지변 등 불가항력적인 사유로 인해 서비스를 제공하지 못하는 경우 책임을 지지 않습니다.
|
||||
제9장 유료 서비스
|
||||
20조 (유료 서비스의 이용)
|
||||
① 회사는 회원에게 특정 서비스에 대해 유료로 제공할 수 있습니다. ② 유료 서비스의 이용 요금, 결제 방식, 환불 절차 등에 대한 상세 내용은 서비스 안내 페이지와 결제 화면에 명시합니다. ③ 유료 서비스 이용 요금은 회사가 정한 결제 방식에 따라 결제됩니다. 회원은 신용카드, 계좌이체, 휴대전화 결제 등 회사가 제공하는 다양한 결제 방식을 통해 요금을 납부할 수 있습니다. ④ 유료 서비스의 이용 요금은 선불 결제를 원칙으로 하며, 이용 기간 중 서비스 중지 및 해지 시 남은 이용 기간에 대한 환불은 회사의 환불 정책에 따라 처리됩니다. ⑤ 회사는 회원의 유료 서비스 이용과 관련하여 발생한 문제에 대해 최선을 다해 해결하도록 노력합니다. 다만, 회사의 고의 또는 중대한 과실이 없는 한 회원이 유료 서비스 이용 중 입은 손해에 대해서는 책임을 지지 않습니다.
|
||||
제21조(환불 정책)
|
||||
① 회원은 결제 후 7일 이내에 서비스 이용을 시작하지 않은 경우, 요금 전액을 환불받을 수 있습니다. ② 유료 서비스 이용 중 부득이한 사유로 서비스가 중지된 경우, 회사는 이용하지 않은 부분에 대해 환불 절차를 밟습니다. ③ 회원의 귀책사유로 인해 서비스 이용이 중지된 경우, 환불이 불가능합니다. ④ 환불은 회원이 지정한 계좌로 환불 절차를 거치며, 환불 요청 후 7일 이내에 처리됩니다.
|
||||
제22조 (유료 서비스의 중지 및 해지)
|
||||
① 회원이 유료 서비스를 해지하고자 하는 경우, 회사의 고객 지원 센터에 해지 신청을 해야 합니다. ② 회사는 회원이 약관을 위반하거나 부정한 방법으로 유료 서비스를 이용한 경우, 유료 서비스 이용을 즉시 중지하고 계약을 해지할 수 있습니다.
|
||||
제10장 양도 금지
|
||||
제23조 (양도 금지)
|
||||
회원은 서비스 이용권한, 기타 이용계약상의 지위를 제3자에게 양도, 증여할 수 없으며, 이를 담보로 제공할 수 없습니다.
|
||||
제11장 관할 법원
|
||||
제24조 (분쟁 해결)
|
||||
서비스 이용과 관련하여 분쟁이 발생한 경우, 회사와 회원은 성실히 협의하여 해결합니다.
|
||||
제25조 (관할 법원)
|
||||
본 약관에 따른 분쟁은 서울중앙지방법원을 관할 법원으로 합니다.
|
||||
부칙
|
||||
본 약관은 2024년 10월 1일부터 시행됩니다.
|
||||
""";
|
||||
|
||||
static const String _privacyText = """
|
||||
개인정보 수집 및 이용 동의
|
||||
|
||||
바론서비스 개인정보처리방침
|
||||
|
||||
제1조 (목적)
|
||||
바론컨설턴트(이하 "회사")는 바론서비스(이하 "서비스")를 이용하는 고객(이하 "이용자")의 개인정보를 보호하고, 「개인정보 보호법」에 따라 책임과 의무를 다하기 위해 본 개인정보처리방침을 마련했습니다. 본 방침은 이용자가 제공한 개인정보가 어떻게 수집, 이용, 보관, 보호되는지를 설명합니다.
|
||||
제2조 (개인정보의 처리목적)
|
||||
회사는 다음의 목적을 위해 개인정보를 처리합니다. 처리하고 있는 개인정보는 다음의 목적 이외의 용도로는 이용되지 않으며, 이용 목적이 변경되는 경우에는 「개인정보 보호법」 제18조에 따라 별도의 동의를 받는 등 필요한 조치를 이행할 예정입니다.
|
||||
- 본인확인: 회원가입 및 관리를 위한 본인 확인, 전화 또는 이메일을 통한 연락
|
||||
- 서비스 제공: 각종 통보 및 서비스 제공을 위한 업무 처리
|
||||
- 제품소개서 다운로드: 설명자료 전달
|
||||
- 상담 및 데모 신청: 상담 제공 및 데모 제공, 계약 처리자 정보 수집
|
||||
- 행사 참가 신청: 참석 안내 및 세미나/설명회/교육 제공
|
||||
- 보안가이드 제공: 안내자료 전달
|
||||
- 기술지원 문의: 서비스 사용 지원
|
||||
- 서비스 개선 의견 접수: 서비스 품질 개선
|
||||
- 마케팅 활동: 동의한 고객에 한해 뉴스레터 및 매거진 발송
|
||||
제3조 (개인정보의 처리 및 보유 기간)
|
||||
① 회사는 법령에 따른 개인정보 보유 및 이용기간 또는 정보주체로부터 개인정보를 수집 시 동의받은 개인정보 보유 및 이용기간 내에서 개인정보를 처리 및 보유합니다.
|
||||
② 각각의 개인정보 처리 및 보유 기간은 다음과 같습니다:
|
||||
- 회원정보: 회원가입일부터 회원탈퇴 후 1년까지
|
||||
- 홍보, 상담, 계약용 개인정보: 2년
|
||||
제4조 (개인정보의 제3자 제공)
|
||||
① 회사는 정보주체의 개인정보를 제2조에서 명시한 범위 내에서만 처리하며, 정보 주체의 동의, 법률의 특별한 규정 등 「개인정보 보호법」 제17조 및 제18조에 해당하는 경우에만 개인정보를 제3자에게 제공합니다.
|
||||
② 회사는 다음과 같이 개인정보를 제3자에게 제공하고 있습니다:
|
||||
- 제공받는 자: 수사기관 및 유관기관, 피신고업체
|
||||
- 이용 목적: 개인정보 침해 민원 처리
|
||||
- 제공하는 개인정보 항목: 성명, 연락처, 이메일
|
||||
- 보유 및 이용기간: 법령에서 정한 보존기간 및 제공목적 달성 시 파기
|
||||
제5조 (개인정보 처리 위탁)
|
||||
① 회사는 개인정보 처리업무를 외부 업체에 위탁하지 않으며, 자체적으로 처리하고 있습니다.
|
||||
② 회사가 특정 업무(예: 채용 업무)를 외부 업체에 위탁할 경우, 개인정보 처리방침 시행 전 회사 홈페이지에서 공지한 후 정보주체의 동의를 받은 후 위탁합니다.
|
||||
제6조 (정보주체의 권리·의무 및 행사 방법)
|
||||
① 정보주체는 회사에 대해 언제든지 개인정보 열람, 정정, 삭제, 처리정지 요구 등의 권리를 행사할 수 있습니다.
|
||||
② 권리 행사는 다음과 같은 방법으로 할 수 있습니다:
|
||||
- 서면: 회사 주소로 서면 제출
|
||||
- 전자우편: 회사 이메일로 요청
|
||||
- 모사전송(FAX): 회사 FAX로 요청
|
||||
③ 권리 행사는 정보주체의 법정대리인이나 위임을 받은 자를 통해 대리로도 가능합니다. 이 경우 “개인정보 처리 방법에 관한 고시” 별지 제11호 서식에 따른 위임장을 제출해야 합니다.
|
||||
④ 개인정보 열람 및 처리정지 요구는 「개인정보 보호법」 제35조 제4항, 제37조 제2항에 따라 제한될 수 있습니다.
|
||||
⑤ 개인정보의 정정 및 삭제 요구는 다른 법령에 따라 수집된 개인정보인 경우 제한될 수 있습니다.
|
||||
⑥ 회사는 권리 행사를 요청한 자가 본인 또는 정당한 대리인인지를 확인합니다.
|
||||
제7조 (처리하는 개인정보의 항목)
|
||||
회사는 다음의 개인정보 항목을 처리합니다:
|
||||
- 수집 항목:
|
||||
- 필수 항목: 성명, 휴대전화번호, 이메일
|
||||
- 선택 항목: 회사전화번호, 문의사항
|
||||
- 수집 방법:
|
||||
- 홈페이지, 전화, 이메일을 통해 수집
|
||||
제8조 (개인정보의 파기)
|
||||
① 회사는 개인정보 보유 기간의 경과, 처리 목적 달성 등 개인정보가 불필요하게 되었을 때 지체 없이 해당 개인정보를 파기합니다.
|
||||
② 정보주체로부터 동의받은 개인정보 보유 기간이 경과하거나 처리 목적이 달성된 경우에도 다른 법령에 따라 개인정보를 계속 보존해야 할 경우에는, 해당 개인정보를 별도의 데이터베이스(DB)로 옮기거나 보관 장소를 달리하여 보존합니다.
|
||||
③ 개인정보 파기의 절차 및 방법은 다음과 같습니다:
|
||||
- 파기 절차: 회사는 파기 사유가 발생한 개인정보를 선정하고, 개인정보 보호책임자의 승인을 받아 개인정보를 파기합니다.
|
||||
- 파기 방법: 전자적 파일 형태로 기록된 개인정보는 복구할 수 없도록 기술적 방법을 사용해 삭제하며, 종이 문서에 기록된 개인정보는 분쇄기로 분쇄하거나 소각하여 파기합니다.
|
||||
제9조 (개인정보의 안전성 확보 조치)
|
||||
회사는 개인정보의 안전성 확보를 위해 다음과 같은 조치를 취합니다:
|
||||
- 관리적 조치: 내부관리계획 수립·시행, 정기적 직원 교육
|
||||
- 기술적 조치: 개인정보처리시스템 접근 권한 관리, 접근통제시스템 설치, 고유식별정보 암호화, 보안 프로그램 설치
|
||||
- 물리적 조치: 전산실 및 자료보관실 접근 통제
|
||||
제10조 (개인정보 자동 수집 장치의 설치·운영 및 거부에 관한 사항)
|
||||
회사는 쿠키(Cookie)를 사용하지 않습니다. 쿠키는 이용자의 이용 정보를 저장하고 수시로 불러오는 작은 파일로, 바론서비스에서는 쿠키를 사용하지 않습니다.
|
||||
제11조 (개인정보 보호책임자)
|
||||
회사는 개인정보 처리에 관한 업무를 총괄하여 책임지고, 개인정보 처리와 관련된 정보주체의 불만처리 및 피해구제를 위해 개인정보 보호책임자를 지정하고 있습니다.
|
||||
개인정보 보호책임자:
|
||||
- 성명: 염승호
|
||||
- 직책: 수석연구원
|
||||
- 연락처: 02-2141-7448
|
||||
- 팩스번호: 02-2141-7599
|
||||
- 이메일: b23008@baroncs.co.kr
|
||||
제12조 (개인정보 열람청구)
|
||||
정보주체는 「개인정보 보호법」 제35조에 따른 개인정보 열람 청구를 아래 부서에 할 수 있습니다. 회사는 정보주체의 개인정보 열람청구가 신속하게 처리되도록 노력하겠습니다.
|
||||
개인정보 열람청구 접수·처리 부서:
|
||||
- 부서명: 총괄기획실
|
||||
- 담당자: 권혁진
|
||||
- 연락처: 02-2141-7465
|
||||
- 팩스번호: 02-2141-7599
|
||||
- 이메일: baroncs@baroncs.co.kr
|
||||
제13조 (권익침해 구제방법)
|
||||
정보주체는 개인정보 침해로 인한 구제를 위해 개인정보분쟁조정위원회, 한국인터넷진흥원 개인정보해신고센터 등에 분쟁 해결이나 상담을 신청할 수 있습니다.
|
||||
- 개인정보분쟁조정위원회: (국번없이) 1833-6972 (www.kopico.go.kr)
|
||||
- 개인정보침해신고센터: (국번없이) 118 (privacy.kisa.or.kr)
|
||||
- 대검찰청: (국번없이) 1301 (www.spo.go.kr)
|
||||
- 경찰청: (국번없이) 182 (www.police.go.kr)
|
||||
제14조 (개인정보 처리방침의 변경)
|
||||
본 개인정보처리방침은 법령, 정책 또는 보안 기술의 변경에 따라 내용의 추가, 삭제 및 수정이 있을 시, 개정 최소 7일 전에 홈페이지를 통해 사전 공지합니다.
|
||||
|
||||
부칙
|
||||
제1조 (시행일자)
|
||||
이 개인정보처리방침은 2024년 10월 1일부터 시행됩니다.
|
||||
제2조 (개정 및 고지의 의무)
|
||||
회사는 개인정보처리방침을 변경하는 경우, 변경사항을 시행일자 7일 전부터 서비스 내 공지사항 페이지를 통해 고지할 것입니다. 다만, 이용자의 권리나 의무에 중대한 변경이 발생하는 경우에는 시행일자 30일 전부터 고지합니다.
|
||||
제3조 (유효성)
|
||||
본 개인정보처리방침의 일부 조항이 법적 또는 기타 사유로 인해 무효화되거나 시행할 수 없는 경우, 나머지 조항들은 계속해서 유효합니다. 무효화된 조항은 관련 법령에 부합하는 방식으로 수정되어 효력을 지속합니다.
|
||||
제4조 (변경 통지의 방법)
|
||||
회사는 개인정보처리방침의 변경 시, 다음의 방법으로 이용자에게 고지합니다:
|
||||
- 서비스 초기화면 또는 팝업 공지
|
||||
- 이메일 발송
|
||||
- 회사 홈페이지 공지사항
|
||||
제5조 (비회원의 개인정보 보호)
|
||||
회사는 비회원의 개인정보도 회원과 동일한 수준으로 보호합니다. 비회원이 개인정보 제공을 거부할 경우 일부 서비스 이용에 제한이 있을 수 있습니다.
|
||||
제6조 (14세 미만 아동의 개인정보 보호)
|
||||
회사는 14세 미만 아동의 개인정보를 수집하지 않습니다. 만일 14세 미만 아동의 개인정보가 수집된 경우, 법정 대리인의 동의를 받아야 하며, 법정 대리인의 동의 없이 수집된 경우 이를 지체 없이 파기합니다.
|
||||
제7조 (개인정보의 국외 이전)
|
||||
회사는 이용자의 개인정보를 국외로 이전하지 않으며, 향후 필요한 경우, 사전에 이용자의 동의를 받습니다.
|
||||
제8조 (기타)
|
||||
본 방침에 명시되지 않은 사항은 회사의 내부 방침과 관련 법령에 따릅니다.
|
||||
""";
|
||||
|
||||
Widget _buildStepAuth() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('본인 확인을 위해\n인증을 진행해주세요', style: GoogleFonts.outfit(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 16),
|
||||
// 가족사 이메일 안내 문구
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(color: Colors.blue[50], borderRadius: BorderRadius.circular(6)),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 16, color: Colors.blue),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.blue, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('이메일 인증', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _emailController,
|
||||
onChanged: _checkEmailAffiliation, // 도메인 실시간 체크
|
||||
decoration: InputDecoration(
|
||||
labelText: '이메일 주소',
|
||||
border: const OutlineInputBorder(),
|
||||
errorText: _emailError,
|
||||
hintText: 'example@hanmaceng.co.kr',
|
||||
),
|
||||
readOnly: _isEmailVerified,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
height: 55,
|
||||
child: ElevatedButton(
|
||||
onPressed: (_isEmailVerified || _isLoading) ? null : _sendEmailCode,
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0),
|
||||
child: Text(_emailSeconds > 0 ? '재발송' : '인증요청'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_emailSeconds > 0 && !_isEmailVerified) ...[
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: _emailCodeController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '인증코드 6자리',
|
||||
suffixText: _formatTime(_emailSeconds),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)],
|
||||
onChanged: (val) { if(val.length == 6) _verifyEmailCode(); },
|
||||
),
|
||||
],
|
||||
if (_isEmailVerified) const Padding(
|
||||
padding: EdgeInsets.only(top: 8),
|
||||
child: Text('✅ 이메일 인증 완료', style: TextStyle(color: Colors.green, fontSize: 13, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('휴대폰 인증', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _phoneController,
|
||||
decoration: InputDecoration(labelText: '휴대폰 번호 (-없이)', border: const OutlineInputBorder(), errorText: _phoneError),
|
||||
readOnly: _isPhoneVerified,
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
height: 55,
|
||||
child: ElevatedButton(
|
||||
onPressed: (_isPhoneVerified || _isLoading) ? null : _sendPhoneCode,
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0),
|
||||
child: Text(_phoneSeconds > 0 ? '재발송' : '인증요청'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_phoneSeconds > 0 && !_isPhoneVerified) ...[
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: _phoneCodeController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '인증코드 6자리',
|
||||
suffixText: _formatTime(_phoneSeconds),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)],
|
||||
onChanged: (val) { if(val.length == 6) _verifyPhoneCode(); },
|
||||
),
|
||||
],
|
||||
if (_isPhoneVerified) const Padding(
|
||||
padding: EdgeInsets.only(top: 8),
|
||||
child: Text('✅ 휴대폰 인증 완료', style: TextStyle(color: Colors.green, fontSize: 13, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStepInfo() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('회원님의\n소속 정보를 알려주세요', style: GoogleFonts.outfit(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 24),
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: const InputDecoration(labelText: '이름', border: OutlineInputBorder()),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 소속 유형 선택 (가족사 메일일 경우 비활성화)
|
||||
AbsorbPointer(
|
||||
absorbing: _isAffiliateEmail,
|
||||
child: Opacity(
|
||||
opacity: _isAffiliateEmail ? 0.7 : 1.0,
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: _affiliationType,
|
||||
decoration: InputDecoration(
|
||||
labelText: '소속 유형',
|
||||
border: const OutlineInputBorder(),
|
||||
helperText: _isAffiliateEmail ? '가족사 이메일 사용 시 자동으로 선택됩니다.' : null,
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'GENERAL', child: Text('일반 사용자')),
|
||||
DropdownMenuItem(value: 'AFFILIATE', child: Text('가족사 임직원')),
|
||||
],
|
||||
onChanged: _isAffiliateEmail ? null : (val) => setState(() { _affiliationType = val!; }),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 가족사 선택 (가족사 메일일 경우 비활성화)
|
||||
if (_affiliationType == 'AFFILIATE') ...[
|
||||
AbsorbPointer(
|
||||
absorbing: _isAffiliateEmail,
|
||||
child: Opacity(
|
||||
opacity: _isAffiliateEmail ? 0.7 : 1.0,
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: _companyCode,
|
||||
decoration: const InputDecoration(labelText: '가족사 선택', border: OutlineInputBorder()),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'HANMAC', child: Text('한맥')),
|
||||
DropdownMenuItem(value: 'SAMAN', child: Text('삼안')),
|
||||
DropdownMenuItem(value: 'PTC', child: Text('PTC')),
|
||||
DropdownMenuItem(value: 'JANGHEON', child: Text('장헌')),
|
||||
DropdownMenuItem(value: 'BARON', child: Text('바론')),
|
||||
DropdownMenuItem(value: 'HALLA', child: Text('한라')),
|
||||
],
|
||||
onChanged: _isAffiliateEmail ? null : (val) => setState(() => _companyCode = val),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
TextFormField(
|
||||
controller: _deptController,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
labelText: _affiliationType == 'AFFILIATE' ? '부서명' : '소속 정보 (선택)',
|
||||
border: const OutlineInputBorder()
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _buildPolicyDescription() {
|
||||
if (_isPolicyLoading) {
|
||||
return "비밀번호 정책을 불러오는 중입니다...";
|
||||
}
|
||||
final minLength = (_policy?['minLength'] as int?) ?? 8;
|
||||
final requiresLower = _policy?['lowercase'] ?? true;
|
||||
final requiresUpper = _policy?['uppercase'] ?? true;
|
||||
final requiresNumber = _policy?['number'] ?? true;
|
||||
final requiresSymbol = _policy?['nonAlphanumeric'] ?? true;
|
||||
|
||||
final parts = <String>["최소 $minLength자 이상"];
|
||||
if (requiresUpper) parts.add("대문자");
|
||||
if (requiresLower) parts.add("소문자");
|
||||
if (requiresNumber) parts.add("숫자");
|
||||
if (requiresSymbol) parts.add("특수문자");
|
||||
|
||||
return "보안 정책: ${parts.join(', ')}를 각각 최소 1자 이상 포함해야 합니다.";
|
||||
}
|
||||
|
||||
Widget _buildStepPassword() {
|
||||
String p = _passwordController.text;
|
||||
|
||||
// Default Policy Fallback
|
||||
final minLength = (_policy?['minLength'] as int?) ?? 8;
|
||||
final requiresLower = _policy?['lowercase'] ?? true;
|
||||
final requiresUpper = _policy?['uppercase'] ?? true;
|
||||
final requiresNumber = _policy?['number'] ?? true;
|
||||
final requiresSymbol = _policy?['nonAlphanumeric'] ?? true;
|
||||
|
||||
bool hasLength = p.length >= minLength;
|
||||
bool hasUpper = !requiresUpper || p.contains(RegExp(r'[A-Z]'));
|
||||
bool hasLower = !requiresLower || p.contains(RegExp(r'[a-z]'));
|
||||
bool hasDigit = !requiresNumber || p.contains(RegExp(r'[0-9]'));
|
||||
bool hasSpecial = !requiresSymbol || p.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]'));
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('마지막으로\n비밀번호를 설정해주세요', style: GoogleFonts.outfit(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 16),
|
||||
// 비밀번호 정책 안내 박스
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(color: Colors.blue[50], borderRadius: BorderRadius.circular(8)),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.security, size: 18, color: Colors.blue),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_buildPolicyDescription(),
|
||||
style: TextStyle(fontSize: 12, color: Colors.blue[800], fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
labelText: '비밀번호',
|
||||
border: const OutlineInputBorder(),
|
||||
errorText: _passwordError,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
children: [
|
||||
_cryptoCheck('$minLength자 이상', hasLength),
|
||||
if (requiresUpper) _cryptoCheck('대문자', hasUpper),
|
||||
if (requiresLower) _cryptoCheck('소문자', hasLower),
|
||||
if (requiresNumber) _cryptoCheck('숫자', hasDigit),
|
||||
if (requiresSymbol) _cryptoCheck('특수문자', hasSpecial),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _confirmPasswordController,
|
||||
obscureText: true,
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
_confirmPasswordError = (val != _passwordController.text) ? '비밀번호가 일치하지 않습니다.' : null;
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
labelText: '비밀번호 확인',
|
||||
border: const OutlineInputBorder(),
|
||||
errorText: _confirmPasswordError,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _cryptoCheck(String label, bool isValid) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(isValid ? Icons.check_circle : Icons.circle_outlined, size: 14, color: isValid ? Colors.green : Colors.grey),
|
||||
const SizedBox(width: 4),
|
||||
Text(label, style: TextStyle(fontSize: 11, color: isValid ? Colors.green : Colors.grey)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
bool canGoNext = false;
|
||||
if (_currentStep == 1 && _termsAccepted && _privacyAccepted) canGoNext = true;
|
||||
if (_currentStep == 2 && _isEmailVerified && _isPhoneVerified) canGoNext = true;
|
||||
if (_currentStep == 3) {
|
||||
final nameOk = _nameController.text.trim().isNotEmpty;
|
||||
if (_affiliationType == 'GENERAL') {
|
||||
canGoNext = nameOk;
|
||||
} else {
|
||||
// AFFILIATE 필수: 이름 + 가족사 선택 + 부서명
|
||||
final companyOk = _companyCode != null;
|
||||
final deptOk = _deptController.text.trim().isNotEmpty;
|
||||
canGoNext = nameOk && companyOk && deptOk;
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
title: Text('회원가입', style: GoogleFonts.outfit(fontWeight: FontWeight.bold)),
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: _buildStepIndicator(),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: _currentStep == 1
|
||||
? _buildStepAgreement()
|
||||
: (_currentStep == 2
|
||||
? _buildStepAuth()
|
||||
: (_currentStep == 3 ? _buildStepInfo() : _buildStepPassword())),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Row(
|
||||
children: [
|
||||
if (_currentStep > 1) ...[
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => setState(() => _currentStep--),
|
||||
style: OutlinedButton.styleFrom(minimumSize: const Size.fromHeight(55), side: const BorderSide(color: Colors.black)),
|
||||
child: const Text('이전', style: TextStyle(color: Colors.black)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: _currentStep < 4
|
||||
? (canGoNext ? () => setState(() => _currentStep++) : null)
|
||||
: (_isLoading ? null : _handleSignup),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(55),
|
||||
backgroundColor: Colors.black,
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||
: Text(_currentStep < 4 ? '다음 단계' : '가입 완료'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user