diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 606545a0..fc09f2a7 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -20,6 +20,7 @@ import ( "github.com/gofiber/fiber/v2/middleware/encryptcookie" "github.com/gofiber/fiber/v2/middleware/recover" "github.com/gofiber/fiber/v2/middleware/requestid" + "github.com/joho/godotenv" ) func getEnv(key, fallback string) string { @@ -30,6 +31,16 @@ func getEnv(key, fallback string) string { } func main() { + // Load .env file from possible paths + // 1. .env (Current Directory) + // 2. ../.env (Project Root when running from backend/) + // 3. ../../.env (Project Root when running from backend/cmd/server/) + if err := godotenv.Load(".env"); err != nil { + if err := godotenv.Load("../.env"); err != nil { + godotenv.Load("../../.env") + } + } + // 0. Initialize Logger logger.Init(logger.Config{ ServiceName: "baron-sso", @@ -223,6 +234,14 @@ func main() { auth.Post("/qr/poll", authHandler.PollQRLogin) auth.Post("/qr/approve", authHandler.ScanQRLogin) + // Signup Routes + signup := auth.Group("/signup") + signup.Post("/check-email", authHandler.CheckEmail) + signup.Post("/send-email-code", authHandler.SendSignupEmailCode) + signup.Post("/send-sms-code", authHandler.SendSignupSmsCode) + signup.Post("/verify-code", authHandler.VerifySignupCode) + signup.Post("/", authHandler.Signup) + // Admin Routes admin := api.Group("/admin") admin.Post("/users", adminHandler.CreateUser) diff --git a/backend/go.mod b/backend/go.mod index bfcd6854..6e332a5f 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -35,6 +35,7 @@ require ( github.com/go-faster/errors v0.7.1 // indirect github.com/goccy/go-json v0.10.4 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/joho/godotenv v1.5.1 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/lestrrat-go/blackmagic v1.0.3 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 5b47a91c..40ee75e9 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -68,6 +68,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= diff --git a/backend/internal/domain/auth_models.go b/backend/internal/domain/auth_models.go index cc24e903..445e5ff6 100644 --- a/backend/internal/domain/auth_models.go +++ b/backend/internal/domain/auth_models.go @@ -31,3 +31,31 @@ type QRInitResponse struct { PendingRef string `json:"pendingRef"` ExpiresIn int `json:"expiresIn"` } + +// Signup Flow Models + +type CheckEmailRequest struct { + Email string `json:"email"` +} + +type SendSignupCodeRequest struct { + Target string `json:"target"` // Email or Phone + Type string `json:"type"` // "email" or "phone" +} + +type VerifySignupCodeRequest struct { + Target string `json:"target"` // Email or Phone + Type string `json:"type"` // "email" or "phone" + Code string `json:"code"` +} + +type SignupRequest struct { + Email string `json:"email"` + Password string `json:"password"` + Name string `json:"name"` + Phone string `json:"phone"` + AffiliationType string `json:"affiliationType"` // "AFFILIATE" or "GENERAL" + CompanyCode string `json:"companyCode,omitempty"` + Department string `json:"department"` + TermsAccepted bool `json:"termsAccepted"` +} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 58f9a730..18f61d7c 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -97,6 +97,11 @@ func (h *AuthHandler) CheckEmail(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request"}) } + // Email Format Validation + if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid email format"}) + } + if h.DescopeClient == nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"}) } @@ -366,7 +371,7 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { slog.Error("[Signup] Failed to set password", "error", err) // Rollback? Delete user? h.DescopeClient.Management.User().Delete(context.Background(), req.Email) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to set password"}) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": fmt.Sprintf("Failed to set password: %v", err)}) } // 4. Cleanup Redis diff --git a/backend/internal/service/redis_service.go b/backend/internal/service/redis_service.go index 1686e5bb..44268432 100644 --- a/backend/internal/service/redis_service.go +++ b/backend/internal/service/redis_service.go @@ -30,6 +30,10 @@ func NewRedisService() (*RedisService, error) { return nil, err } + // [DEV-FIX] Disable stop-writes-on-bgsave-error to allow writes even if persistence fails + // This is common in dev docker environments with permission issues. + rdb.ConfigSet(ctx, "stop-writes-on-bgsave-error", "no") + return &RedisService{Client: rdb}, nil } diff --git a/frontend/lib/core/services/auth_proxy_service.dart b/frontend/lib/core/services/auth_proxy_service.dart index 9f694b23..0d03eb84 100644 --- a/frontend/lib/core/services/auth_proxy_service.dart +++ b/frontend/lib/core/services/auth_proxy_service.dart @@ -300,4 +300,89 @@ class AuthProxyService { await sendLog('ERROR', message, data: data); } + + // --- Signup Methods --- + + static Future checkEmailAvailability(String email) async { + final url = Uri.parse('$_baseUrl/api/v1/auth/signup/check-email'); + final response = await http.post( + url, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'email': email}), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return data['available'] ?? false; + } + return false; + } + + static Future sendSignupCode(String target, String type) async { + final path = type == 'email' ? 'send-email-code' : 'send-sms-code'; + final url = Uri.parse('$_baseUrl/api/v1/auth/signup/$path'); + + final response = await http.post( + url, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'target': target}), + ); + + if (response.statusCode != 200) { + throw Exception('Failed to send code: ${response.body}'); + } + } + + static Future verifySignupCode(String target, String type, String code) async { + final url = Uri.parse('$_baseUrl/api/v1/auth/signup/verify-code'); + + final response = await http.post( + url, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'target': target, + 'type': type, + 'code': code, + }), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return data['success'] ?? false; + } + return false; + } + + static Future signup({ + required String email, + required String password, + required String name, + required String phone, + required String affiliationType, + String? companyCode, + required String department, + required bool termsAccepted, + }) async { + final url = Uri.parse('$_baseUrl/api/v1/auth/signup'); + + final response = await http.post( + url, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'email': email, + 'password': password, + 'name': name, + 'phone': phone, + 'affiliationType': affiliationType, + if (companyCode != null) 'companyCode': companyCode, + 'department': department, + 'termsAccepted': termsAccepted, + }), + ); + + if (response.statusCode != 200) { + final error = jsonDecode(response.body)['error'] ?? 'Signup failed'; + throw Exception(error); + } + } } diff --git a/frontend/lib/features/auth/presentation/login_screen.dart b/frontend/lib/features/auth/presentation/login_screen.dart index f2b5fe00..9e9eebf2 100644 --- a/frontend/lib/features/auth/presentation/login_screen.dart +++ b/frontend/lib/features/auth/presentation/login_screen.dart @@ -540,6 +540,17 @@ class _LoginScreenState extends ConsumerState child: const Text("로그인 링크 전송"), ), const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text("계정이 없으신가요?", style: TextStyle(color: Colors.grey, fontSize: 14)), + TextButton( + onPressed: () => context.push('/signup'), + child: const Text("회원가입"), + ), + ], + ), + const SizedBox(height: 16), const Text( "입력하신 정보로 로그인 링크를 전송합니다.", style: TextStyle(color: Colors.grey, fontSize: 12), diff --git a/frontend/lib/features/auth/presentation/signup_screen.dart b/frontend/lib/features/auth/presentation/signup_screen.dart new file mode 100644 index 00000000..02c9e65e --- /dev/null +++ b/frontend/lib/features/auth/presentation/signup_screen.dart @@ -0,0 +1,550 @@ +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 createState() => _SignupScreenState(); +} + +class _SignupScreenState extends State { + final _formKey = GlobalKey(); + int _currentStep = 1; // 1: 인증, 2: 정보, 3: 비밀번호 + + // 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 _termsAccepted = false; + bool _privacyAccepted = false; + bool _isLoading = false; + + // Inline Errors + String? _emailError; + String? _phoneError; + String? _passwordError; + String? _confirmPasswordError; + + // Timers + Timer? _emailTimer; + int _emailSeconds = 0; + Timer? _phoneTimer; + int _phoneSeconds = 0; + + @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(); + } + + // --- Logic Methods --- + + 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 _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 _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 _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 _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 _handleSignup() async { + if (_passwordController.text != _confirmPasswordController.text) { + setState(() => _confirmPasswordError = '비밀번호가 일치하지 않습니다.'); + return; + } + if (!_formKey.currentState!.validate()) return; + if (!_termsAccepted || !_privacyAccepted) 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('/login'), 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, '비밀번호'), + ], + ), + ); + } + + Widget _stepCircle(int step, String label) { + bool isDone = _currentStep > step; + bool isCurrent = _currentStep == step; + return Column( + children: [ + CircleAvatar( + radius: 15, + backgroundColor: isDone ? Colors.green : (isCurrent ? Colors.black : Colors.grey[300]), + child: isDone ? const Icon(Icons.check, size: 16, color: Colors.white) : Text('$step', style: TextStyle(color: isCurrent ? Colors.white : Colors.black54, fontSize: 12)), + ), + const SizedBox(height: 4), + Text(label, style: TextStyle(fontSize: 10, 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: 4, right: 4), + height: 2, + color: _currentStep > afterStep ? Colors.green : Colors.grey[300], + ), + ); + } + + Widget _buildStep1() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('이메일 인증', style: GoogleFonts.outfit(fontSize: 16, fontWeight: FontWeight.w600)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _emailController, + decoration: InputDecoration(labelText: '이메일 주소', border: const OutlineInputBorder(), errorText: _emailError), + readOnly: _isEmailVerified, + ), + ), + const SizedBox(width: 8), + SizedBox( + height: 55, + child: ElevatedButton( + onPressed: (_isEmailVerified || _isLoading) ? null : _sendEmailCode, + 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)), + ), + const SizedBox(height: 24), + Text('휴대폰 인증', style: GoogleFonts.outfit(fontSize: 16, fontWeight: FontWeight.w600)), + const SizedBox(height: 12), + 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, + 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)), + ), + ], + ); + } + + Widget _buildStep2() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('사용자 정보를 입력해주세요', style: GoogleFonts.outfit(fontSize: 16, fontWeight: FontWeight.w600)), + const SizedBox(height: 16), + TextFormField( + controller: _nameController, + decoration: const InputDecoration(labelText: '이름', border: OutlineInputBorder()), + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: _affiliationType, + decoration: const InputDecoration(labelText: '소속 유형', border: OutlineInputBorder()), + items: const [ + DropdownMenuItem(value: 'GENERAL', child: Text('일반 사용자')), + DropdownMenuItem(value: 'AFFILIATE', child: Text('가족사 임직원')), + ], + onChanged: (val) => setState(() { _affiliationType = val!; }), + ), + const SizedBox(height: 16), + if (_affiliationType == 'AFFILIATE') ...[ + DropdownButtonFormField( + 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: (val) => setState(() => _companyCode = val), + ), + const SizedBox(height: 16), + ], + TextFormField( + controller: _deptController, + decoration: InputDecoration( + labelText: _affiliationType == 'AFFILIATE' ? '부서명' : '소속 정보 (선택)', + border: const OutlineInputBorder() + ), + ), + ], + ); + } + + Widget _buildStep3() { + // 실시간 비밀번호 체크 로직 + String p = _passwordController.text; + bool hasUpper = p.contains(RegExp(r'[A-Z]')); + bool hasLower = p.contains(RegExp(r'[a-z]')); + bool hasDigit = p.contains(RegExp(r'[0-9]')); + bool hasSpecial = p.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]')); + bool hasLength = p.length >= 12; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('보안 설정', style: GoogleFonts.outfit(fontSize: 16, fontWeight: FontWeight.w600)), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + obscureText: true, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + labelText: '비밀번호', + border: const OutlineInputBorder(), + errorText: _passwordError, + ), + ), + const SizedBox(height: 8), + // 실시간 체크 표시 UI + Wrap( + spacing: 10, + children: [ + _cryptoCheck('12자 이상', hasLength), + _cryptoCheck('대문자', hasUpper), + _cryptoCheck('소문자', hasLower), + _cryptoCheck('숫자', hasDigit), + _cryptoCheck('특수문자', hasSpecial), + ], + ), + const SizedBox(height: 16), + TextFormField( + controller: _confirmPasswordController, + obscureText: true, + onChanged: (val) { + setState(() { + if (val != _passwordController.text) { + _confirmPasswordError = '비밀번호가 일치하지 않습니다.'; + } else { + _confirmPasswordError = null; + } + }); + }, + decoration: InputDecoration( + labelText: '비밀번호 확인', + border: const OutlineInputBorder(), + errorText: _confirmPasswordError, + ), + ), + const SizedBox(height: 24), + CheckboxListTile( + title: const Text('이용약관 및 개인정보 처리방침 동의', style: TextStyle(fontSize: 13)), + value: _termsAccepted && _privacyAccepted, + onChanged: (val) => setState(() { _termsAccepted = val!; _privacyAccepted = val; }), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ), + ], + ); + } + + 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 && _isEmailVerified && _isPhoneVerified) canGoNext = true; + if (_currentStep == 2 && _nameController.text.isNotEmpty && (_affiliationType == 'GENERAL' || _companyCode != null)) canGoNext = true; + + 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: 40), + child: _buildStepIndicator(), + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: _currentStep == 1 ? _buildStep1() : (_currentStep == 2 ? _buildStep2() : _buildStep3()), + ), + ), + ), + 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)), + child: const Text('이전'), + ), + ), + const SizedBox(width: 12), + ], + Expanded( + child: FilledButton( + onPressed: _currentStep < 3 + ? (canGoNext ? () => setState(() => _currentStep++) : null) + : (_isLoading || !_termsAccepted ? 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 < 3 ? '다음 단계' : '가입 완료'), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index 2550ba02..d657730f 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:flutter_web_plugins/url_strategy.dart'; import 'features/auth/presentation/login_screen.dart'; +import 'features/auth/presentation/signup_screen.dart'; import 'features/auth/presentation/approve_qr_screen.dart'; import 'features/auth/presentation/qr_scan_screen.dart'; import 'features/dashboard/presentation/dashboard_screen.dart'; @@ -83,6 +84,13 @@ final _router = GoRouter( return const LoginScreen(); } ), + GoRoute( + path: '/signup', + builder: (context, state) { + _routerLogger.info("Navigating to /signup"); + return const SignupScreen(); + }, + ), GoRoute( path: '/verify/:token', builder: (context, state) { @@ -121,6 +129,7 @@ final _router = GoRouter( // Public paths that don't require login final isPublicPath = path == '/login' || + path == '/signup' || path.startsWith('/verify/') || path == '/approve';