forked from baron/baron-sso
로그인 페이지 및 기능 구현
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -300,4 +300,89 @@ class AuthProxyService {
|
||||
|
||||
await sendLog('ERROR', message, data: data);
|
||||
}
|
||||
|
||||
// --- Signup Methods ---
|
||||
|
||||
static Future<bool> 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<void> 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<bool> 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<void> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,6 +540,17 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
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),
|
||||
|
||||
550
frontend/lib/features/auth/presentation/signup_screen.dart
Normal file
550
frontend/lib/features/auth/presentation/signup_screen.dart
Normal file
@@ -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<SignupScreen> createState() => _SignupScreenState();
|
||||
}
|
||||
|
||||
class _SignupScreenState extends State<SignupScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
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<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;
|
||||
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<String>(
|
||||
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<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: (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 ? '다음 단계' : '가입 완료'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user