1
0
forked from baron/baron-sso

userfront로 리펙토링 완료

This commit is contained in:
Lectom C Han
2026-01-28 08:28:25 +09:00
parent 6d88c81217
commit 1aaa772907
154 changed files with 339 additions and 314 deletions

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

View File

@@ -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("재설정 링크 전송"),
),
],
),
),
),
);
}
}

View 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("회원가입"),
),
],
),
],
),
],
),
),
),
),
);
},
),
);
}
}

View File

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

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

View File

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

View 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 ? '다음 단계' : '가입 완료'),
),
),
],
),
),
],
),
),
);
}
}