From 60035aad53385b37f93999e4ead6bd815e0ff8eb Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 27 Jan 2026 13:39:49 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A7=88=EC=9D=B4=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 7 + backend/internal/domain/auth_models.go | 19 ++ backend/internal/handler/auth_handler.go | 222 ++++++++++++++++ .../presentation/dashboard_screen.dart | 13 + .../data/models/user_profile_model.dart | 59 ++++ .../data/repositories/profile_repository.dart | 100 +++++++ .../domain/notifiers/profile_notifier.dart | 49 ++++ .../presentation/pages/edit_profile_page.dart | 251 ++++++++++++++++++ .../presentation/pages/profile_page.dart | 71 +++++ .../widgets/profile_info_row.dart | 40 +++ frontend/lib/main.dart | 14 +- 11 files changed, 844 insertions(+), 1 deletion(-) create mode 100644 frontend/lib/features/profile/data/models/user_profile_model.dart create mode 100644 frontend/lib/features/profile/data/repositories/profile_repository.dart create mode 100644 frontend/lib/features/profile/domain/notifiers/profile_notifier.dart create mode 100644 frontend/lib/features/profile/presentation/pages/edit_profile_page.dart create mode 100644 frontend/lib/features/profile/presentation/pages/profile_page.dart create mode 100644 frontend/lib/features/profile/presentation/widgets/profile_info_row.dart diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index fc09f2a7..bc132340 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -242,6 +242,13 @@ func main() { signup.Post("/verify-code", authHandler.VerifySignupCode) signup.Post("/", authHandler.Signup) + // User Routes (My Page) + user := api.Group("/user") + user.Get("/me", authHandler.GetMe) + user.Put("/me", authHandler.UpdateMe) + user.Post("/me/send-code", authHandler.SendUpdateCode) + user.Post("/me/verify-code", authHandler.VerifyUpdateCode) + // Admin Routes admin := api.Group("/admin") admin.Post("/users", adminHandler.CreateUser) diff --git a/backend/internal/domain/auth_models.go b/backend/internal/domain/auth_models.go index 445e5ff6..00bee9de 100644 --- a/backend/internal/domain/auth_models.go +++ b/backend/internal/domain/auth_models.go @@ -59,3 +59,22 @@ type SignupRequest struct { Department string `json:"department"` TermsAccepted bool `json:"termsAccepted"` } + +// User Profile Models + +type UserProfileResponse struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Phone string `json:"phone"` + Department string `json:"department"` + AffiliationType string `json:"affiliationType"` + CompanyCode string `json:"companyCode,omitempty"` +} + +type UpdateUserRequest struct { + Name string `json:"name"` + Phone string `json:"phone"` + Department string `json:"department"` + VerificationCode string `json:"verificationCode,omitempty"` // For phone change +} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 18f61d7c..df0887fc 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -385,6 +385,18 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { // --- Helpers --- +func (h *AuthHandler) getBearerToken(c *fiber.Ctx) string { + authHeader := c.Get("Authorization") + if authHeader == "" { + return "" + } + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { + return "" + } + return parts[1] +} + func (h *AuthHandler) getSignupState(key string) (*signupState, error) { val, err := h.RedisService.Get(key) if err != nil || val == "" { @@ -829,3 +841,213 @@ func (h *AuthHandler) HandleDescopeEmailRelay(c *fiber.Ctx) error { slog.Warn("[Email Webhook] Real email skipped (Not implemented)", "to", req.To) return c.Status(501).JSON(fiber.Map{"error": "Real email sending not implemented"}) } + + + +// --- User Profile Handlers --- + +func (h *AuthHandler) formatPhoneForDisplay(phone string) string { + if strings.HasPrefix(phone, "+8210") { + return "010" + phone[5:] + } + return phone +} + +func (h *AuthHandler) formatPhoneForStorage(phone string) string { + phone = strings.ReplaceAll(phone, "-", "") + if strings.HasPrefix(phone, "010") && len(phone) == 11 { + return "+8210" + phone[3:] + } + return phone +} + +// GetMe - Returns current user's profile with 010 phone format +func (h *AuthHandler) GetMe(c *fiber.Ctx) error { + token := h.getBearerToken(c) + if token == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing authorization token"}) + } + + authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) + if err != nil || !authorized { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) + } + + userResponse, err := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load user profile"}) + } + + dept, _ := userResponse.CustomAttributes["department"].(string) + affType, _ := userResponse.CustomAttributes["affiliationType"].(string) + compCode, _ := userResponse.CustomAttributes["companyCode"].(string) + + resp := domain.UserProfileResponse{ + ID: userResponse.UserID, + Email: userResponse.Email, + Name: userResponse.Name, + Phone: h.formatPhoneForDisplay(userResponse.Phone), + Department: dept, + AffiliationType: affType, + CompanyCode: compCode, + } + + return c.JSON(resp) +} + +// UpdateMe - Updates current user's profile with phone verification check +func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error { + token := h.getBearerToken(c) + if token == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing authorization token"}) + } + + authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) + if err != nil || !authorized { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) + } + + var req domain.UpdateUserRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + } + + // 1. Load current user to check changes + currentUser, err := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load current user"}) + } + + newPhoneStorage := h.formatPhoneForStorage(req.Phone) + oldPhoneStorage := currentUser.Phone + + slog.Info("[UpdateMe] Checking changes", "userID", userToken.ID, "oldPhone", oldPhoneStorage, "newPhone", newPhoneStorage, "newName", req.Name) + + // 2. Handle Phone Number Change + if newPhoneStorage != "" && newPhoneStorage != oldPhoneStorage { + // Check verification status in Redis + verifyKey := "verify_update_phone:" + userToken.ID + ":" + newPhoneStorage + val, _ := h.RedisService.Get(verifyKey) + if val != "verified" { + slog.Warn("[UpdateMe] Phone verification missing", "key", verifyKey) + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "휴대폰 번호 변경을 위해 SMS 인증이 필요합니다."}) + } + + // Update Phone in Descope and mark as verified + slog.Info("[UpdateMe] Updating phone number", "userID", userToken.ID, "newPhone", newPhoneStorage) + _, err = h.DescopeClient.Management.User().UpdatePhone(c.Context(), userToken.ID, newPhoneStorage, true, false) + if err != nil { + slog.Error("Failed to update phone in Descope", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "전화번호 업데이트에 실패했습니다."}) + } + + // If the old phone was used as a LoginID, replace it with the new one + for _, loginID := range currentUser.LoginIDs { + // Normalize for comparison + normID := strings.ReplaceAll(loginID, "+82", "0") + normOld := strings.ReplaceAll(oldPhoneStorage, "+82", "0") + + if loginID == oldPhoneStorage || (normOld != "" && normID == normOld) { + slog.Info("[UpdateMe] Updating LoginID", "old", loginID, "new", newPhoneStorage) + _, err = h.DescopeClient.Management.User().UpdateLoginID(c.Context(), loginID, newPhoneStorage) + if err != nil { + slog.Warn("Failed to update LoginID", "error", err) + } + break + } + } + + // Clear verification after successful update + h.RedisService.Delete(verifyKey) + } + + // 3. Update Name if changed + if req.Name != "" && req.Name != currentUser.Name { + slog.Info("[UpdateMe] Updating display name", "userID", userToken.ID, "newName", req.Name) + _, err = h.DescopeClient.Management.User().UpdateDisplayName(c.Context(), userToken.ID, req.Name) + if err != nil { + slog.Error("Failed to update user name", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "이름 업데이트에 실패했습니다."}) + } + } + + // 4. Update Custom Attributes (Department) + if req.Department != "" { + slog.Info("[UpdateMe] Updating department", "userID", userToken.ID, "dept", req.Department) + if _, err := h.DescopeClient.Management.User().UpdateCustomAttribute(c.Context(), userToken.ID, "department", req.Department); err != nil { + slog.Error("Failed to update department", "error", err) + } + } + + slog.Info("[UpdateMe] Profile update completed successfully", "userID", userToken.ID) + + return c.JSON(fiber.Map{ + "status": "success", + "updatedAt": time.Now().Format(time.RFC3339), + }) +} + +// SendUpdateCode - Sends OTP for phone number change +func (h *AuthHandler) SendUpdateCode(c *fiber.Ctx) error { + token := h.getBearerToken(c) + if token == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) + if err != nil || !authorized { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) + } + + var req struct { + Phone string `json:"phone"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid phone"}) + } + + phone := h.formatPhoneForStorage(req.Phone) + code := fmt.Sprintf("%06d", rand.Intn(1000000)) + + // Store code in Redis + key := "otp_update_phone:" + userToken.ID + ":" + phone + h.RedisService.Set(key, code, 5*time.Minute) + + // Send SMS + content := fmt.Sprintf("[Baron SSO] 정보 수정 인증번호: [%s]", code) + go h.SmsService.SendSms(phone, content) + + return c.JSON(fiber.Map{"message": "인증번호가 전송되었습니다."}) +} + +// VerifyUpdateCode - Verifies OTP for phone number change +func (h *AuthHandler) VerifyUpdateCode(c *fiber.Ctx) error { + token := h.getBearerToken(c) + authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) + if err != nil || !authorized { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) + } + + var req struct { + Phone string `json:"phone"` + Code string `json:"code"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request"}) + } + + phone := h.formatPhoneForStorage(req.Phone) + key := "otp_update_phone:" + userToken.ID + ":" + phone + storedCode, _ := h.RedisService.Get(key) + + if storedCode == "" || storedCode != req.Code { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증번호가 일치하지 않거나 만료되었습니다."}) + } + + // Mark as verified for 10 minutes + verifyKey := "verify_update_phone:" + userToken.ID + ":" + phone + h.RedisService.Set(verifyKey, "verified", 10*time.Minute) + h.RedisService.Delete(key) + + return c.JSON(fiber.Map{"success": true}) +} diff --git a/frontend/lib/features/dashboard/presentation/dashboard_screen.dart b/frontend/lib/features/dashboard/presentation/dashboard_screen.dart index fe6c56ff..3c5eb135 100644 --- a/frontend/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/frontend/lib/features/dashboard/presentation/dashboard_screen.dart @@ -96,6 +96,19 @@ class DashboardScreen extends StatelessWidget { 'PC 화면의 QR 코드를 스캔하여 로그인하세요.', style: TextStyle(color: Colors.grey, fontSize: 13), ), + const SizedBox(height: 32), + + // My Page Button + OutlinedButton.icon( + onPressed: () => context.push('/profile'), + icon: const Icon(Icons.person), + label: const Text('내 정보 보기'), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF1A1F2C), + side: const BorderSide(color: Color(0xFF1A1F2C)), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), ], ), ), diff --git a/frontend/lib/features/profile/data/models/user_profile_model.dart b/frontend/lib/features/profile/data/models/user_profile_model.dart new file mode 100644 index 00000000..5efe4bea --- /dev/null +++ b/frontend/lib/features/profile/data/models/user_profile_model.dart @@ -0,0 +1,59 @@ +class UserProfile { + final String id; + final String email; + final String name; + final String phone; + final String department; + final String affiliationType; + final String companyCode; + + UserProfile({ + required this.id, + required this.email, + required this.name, + required this.phone, + required this.department, + required this.affiliationType, + required this.companyCode, + }); + + factory UserProfile.fromJson(Map json) { + return UserProfile( + id: json['id'] ?? '', + email: json['email'] ?? '', + name: json['name'] ?? '', + phone: json['phone'] ?? '', + department: json['department'] ?? '', + affiliationType: json['affiliationType'] ?? '', + companyCode: json['companyCode'] ?? '', + ); + } + + Map toJson() { + return { + 'id': id, + 'email': email, + 'name': name, + 'phone': phone, + 'department': department, + 'affiliationType': affiliationType, + 'companyCode': companyCode, + }; + } + + UserProfile copyWith({ + String? name, + String? phone, + String? department, + }) { + return UserProfile( + id: id, + email: email, + name: name ?? this.name, + phone: phone ?? this.phone, + department: department ?? this.department, + affiliationType: affiliationType, + companyCode: companyCode, + ); + } +} diff --git a/frontend/lib/features/profile/data/repositories/profile_repository.dart b/frontend/lib/features/profile/data/repositories/profile_repository.dart new file mode 100644 index 00000000..911f11d7 --- /dev/null +++ b/frontend/lib/features/profile/data/repositories/profile_repository.dart @@ -0,0 +1,100 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import '../models/user_profile_model.dart'; +import 'package:descope/descope.dart'; + +class ProfileRepository { + static String get _baseUrl => dotenv.env['BACKEND_URL'] ?? 'https://sso.hmac.kr'; + + // Helper to get session token + static Future _getToken() async { + final session = await Descope.sessionManager.session; + return session?.sessionToken.jwt; + } + + Future getMyProfile() async { + final token = await _getToken(); + if (token == null) throw Exception('No active session'); + + final url = Uri.parse('$_baseUrl/api/v1/user/me'); + final response = await http.get( + url, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + ); + + if (response.statusCode == 200) { + return UserProfile.fromJson(jsonDecode(response.body)); + } else { + throw Exception('Failed to load profile: ${response.body}'); + } + } + + Future updateMyProfile({ + required String name, + required String phone, + required String department, + }) async { + final token = await _getToken(); + if (token == null) throw Exception('No active session'); + + final url = Uri.parse('$_baseUrl/api/v1/user/me'); + final response = await http.put( + url, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + body: jsonEncode({ + 'name': name, + 'phone': phone, + 'department': department, + }), + ); + + if (response.statusCode != 200) { + throw Exception('Failed to update profile: ${response.body}'); + } + } + + Future sendUpdateCode(String phone) async { + final token = await _getToken(); + if (token == null) throw Exception('No active session'); + + final url = Uri.parse('$_baseUrl/api/v1/user/me/send-code'); + final response = await http.post( + url, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + body: jsonEncode({'phone': phone}), + ); + + if (response.statusCode != 200) { + throw Exception('인증번호 전송 실패: ${response.body}'); + } + } + + Future verifyUpdateCode(String phone, String code) async { + final token = await _getToken(); + if (token == null) throw Exception('No active session'); + + final url = Uri.parse('$_baseUrl/api/v1/user/me/verify-code'); + final response = await http.post( + url, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + body: jsonEncode({'phone': phone, 'code': code}), + ); + + if (response.statusCode != 200) { + throw Exception('인증 실패: ${response.body}'); + } + } +} diff --git a/frontend/lib/features/profile/domain/notifiers/profile_notifier.dart b/frontend/lib/features/profile/domain/notifiers/profile_notifier.dart new file mode 100644 index 00000000..8b0a0fae --- /dev/null +++ b/frontend/lib/features/profile/domain/notifiers/profile_notifier.dart @@ -0,0 +1,49 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../data/models/user_profile_model.dart'; +import '../../data/repositories/profile_repository.dart'; + +// 1. Repository Provider +final profileRepositoryProvider = Provider((ref) => ProfileRepository()); + +// 2. AsyncNotifier implementation (Modern Riverpod) +class ProfileNotifier extends AsyncNotifier { + @override + FutureOr build() async { + // Initial data fetch + return _fetch(); + } + + Future _fetch() async { + return ref.read(profileRepositoryProvider).getMyProfile(); + } + + Future loadProfile() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() => _fetch()); + } + + Future updateProfile({ + required String name, + required String phone, + required String department, + }) async { + // Show loading state + state = const AsyncValue.loading(); + + // Perform update and then re-fetch profile + state = await AsyncValue.guard(() async { + await ref.read(profileRepositoryProvider).updateMyProfile( + name: name, + phone: phone, + department: department, + ); + return _fetch(); + }); + } +} + +// 3. Provider definition +final profileProvider = AsyncNotifierProvider(() { + return ProfileNotifier(); +}); diff --git a/frontend/lib/features/profile/presentation/pages/edit_profile_page.dart b/frontend/lib/features/profile/presentation/pages/edit_profile_page.dart new file mode 100644 index 00000000..ece0f193 --- /dev/null +++ b/frontend/lib/features/profile/presentation/pages/edit_profile_page.dart @@ -0,0 +1,251 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../domain/notifiers/profile_notifier.dart'; + +class EditProfilePage extends ConsumerStatefulWidget { + const EditProfilePage({super.key}); + + @override + ConsumerState createState() => _EditProfilePageState(); +} + +class _EditProfilePageState extends ConsumerState { + final _formKey = GlobalKey(); + late TextEditingController _nameController; + late TextEditingController _phoneController; + late TextEditingController _codeController; + late TextEditingController _departmentController; + + String? _initialPhone; + bool _isPhoneChanged = false; + bool _isPhoneVerified = false; + bool _isCodeSent = false; + bool _isVerifying = false; + + @override + void initState() { + super.initState(); + final profile = ref.read(profileProvider).value; + _initialPhone = profile?.phone ?? ''; + _nameController = TextEditingController(text: profile?.name ?? ''); + _phoneController = TextEditingController(text: _initialPhone); + _codeController = TextEditingController(); + _departmentController = TextEditingController(text: profile?.department ?? ''); + + _phoneController.addListener(() { + setState(() { + _isPhoneChanged = _phoneController.text != _initialPhone; + if (_isPhoneChanged) { + _isPhoneVerified = false; + } + }); + }); + } + + @override + void dispose() { + _nameController.dispose(); + _phoneController.dispose(); + _codeController.dispose(); + _departmentController.dispose(); + super.dispose(); + } + + Future _sendCode() async { + final phone = _phoneController.text; + if (phone.isEmpty) return; + + setState(() => _isVerifying = true); + try { + await ref.read(profileRepositoryProvider).sendUpdateCode(phone); + setState(() { + _isCodeSent = true; + _isVerifying = false; + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('인증번호가 전송되었습니다.')), + ); + } + } catch (e) { + setState(() => _isVerifying = false); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('전송 실패: $e')), + ); + } + } + } + + Future _verifyCode() async { + final phone = _phoneController.text; + final code = _codeController.text; + if (code.isEmpty) return; + + setState(() => _isVerifying = true); + try { + await ref.read(profileRepositoryProvider).verifyUpdateCode(phone, code); + setState(() { + _isPhoneVerified = true; + _isVerifying = false; + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('인증되었습니다.')), + ); + } + } catch (e) { + setState(() => _isVerifying = false); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('인증 실패: $e')), + ); + } + } + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + if (_isPhoneChanged && !_isPhoneVerified) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('휴대폰 번호 인증이 필요합니다.')), + ); + return; + } + + try { + await ref.read(profileProvider.notifier).updateProfile( + name: _nameController.text, + phone: _phoneController.text, + department: _departmentController.text, + ); + if (mounted) { + context.pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('정보가 수정되었습니다.')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('수정 실패: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final profileState = ref.watch(profileProvider); + final isUpdating = profileState.isLoading; + + return Scaffold( + appBar: AppBar( + title: const Text('내 정보 수정'), + actions: [ + TextButton( + onPressed: (isUpdating || (_isPhoneChanged && !_isPhoneVerified)) ? null : _save, + child: const Text('저장'), + ), + ], + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: ListView( + children: [ + TextFormField( + controller: _nameController, + decoration: const InputDecoration( + labelText: '이름', + prefixIcon: Icon(Icons.person_outline), + ), + validator: (value) => (value == null || value.isEmpty) ? '이름을 입력해주세요.' : null, + ), + const SizedBox(height: 24), + + // Phone Number Field + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextFormField( + controller: _phoneController, + decoration: InputDecoration( + labelText: '휴대폰 번호', + hintText: '01012345678', + prefixIcon: const Icon(Icons.phone_android), + suffixIcon: _isPhoneVerified + ? const Icon(Icons.check_circle, color: Colors.green) + : null, + ), + keyboardType: TextInputType.phone, + enabled: !_isPhoneVerified, + ), + ), + const SizedBox(width: 8), + if (_isPhoneChanged && !_isPhoneVerified) + ElevatedButton( + onPressed: _isVerifying ? null : _sendCode, + child: Text(_isCodeSent ? '재전송' : '인증요청'), + ), + ], + ), + + // OTP Code Field + if (_isCodeSent && !_isPhoneVerified) ...[ + const SizedBox(height: 16), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextFormField( + controller: _codeController, + decoration: const InputDecoration( + labelText: '인증번호', + hintText: '6자리 입력', + prefixIcon: Icon(Icons.security), + ), + keyboardType: TextInputType.number, + ), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: _isVerifying ? null : _verifyCode, + style: ElevatedButton.styleFrom(backgroundColor: Colors.blue[700], foregroundColor: Colors.white), + child: const Text('확인'), + ), + ], + ), + ], + + if (_isPhoneChanged && !_isPhoneVerified) + const Padding( + padding: EdgeInsets.only(top: 8.0, left: 4.0), + child: Text( + '휴대폰 번호를 변경하려면 SMS 인증이 필요합니다.', + style: TextStyle(color: Colors.orange, fontSize: 12), + ), + ), + + const SizedBox(height: 24), + TextFormField( + controller: _departmentController, + decoration: const InputDecoration( + labelText: '소속 (부서)', + prefixIcon: Icon(Icons.business), + ), + ), + + const SizedBox(height: 40), + if (isUpdating || _isVerifying) + const Center(child: CircularProgressIndicator()), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/lib/features/profile/presentation/pages/profile_page.dart b/frontend/lib/features/profile/presentation/pages/profile_page.dart new file mode 100644 index 00000000..145e06e2 --- /dev/null +++ b/frontend/lib/features/profile/presentation/pages/profile_page.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../domain/notifiers/profile_notifier.dart'; +import '../widgets/profile_info_row.dart'; + +class ProfilePage extends ConsumerWidget { + const ProfilePage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // profileState is AsyncValue + final profileState = ref.watch(profileProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('내 정보'), + actions: [ + IconButton( + icon: const Icon(Icons.edit), + onPressed: () => context.push('/profile/edit'), + ), + ], + ), + body: profileState.when( + data: (profile) { + if (profile == null) { + return const Center(child: Text('정보를 불러올 수 없습니다.')); + } + return RefreshIndicator( + onRefresh: () => ref.read(profileProvider.notifier).loadProfile(), + child: ListView( + padding: const EdgeInsets.all(16.0), + children: [ + const Center( + child: CircleAvatar( + radius: 40, + child: Icon(Icons.person, size: 40), + ), + ), + const SizedBox(height: 24), + ProfileInfoRow(label: '이름', value: profile.name), + ProfileInfoRow(label: '이메일', value: profile.email), + ProfileInfoRow(label: '전화번호', value: profile.phone), + const Divider(height: 32), + ProfileInfoRow(label: '소속', value: profile.department), + ProfileInfoRow(label: '구분', value: profile.affiliationType), + if (profile.companyCode.isNotEmpty) + ProfileInfoRow(label: '회사코드', value: profile.companyCode), + ], + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, stack) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('오류 발생: $err'), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => ref.read(profileProvider.notifier).loadProfile(), + child: const Text('재시도'), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/lib/features/profile/presentation/widgets/profile_info_row.dart b/frontend/lib/features/profile/presentation/widgets/profile_info_row.dart new file mode 100644 index 00000000..d1168dc4 --- /dev/null +++ b/frontend/lib/features/profile/presentation/widgets/profile_info_row.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +class ProfileInfoRow extends StatelessWidget { + final String label; + final String value; + + const ProfileInfoRow({ + super.key, + required this.label, + required this.value, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 80, + child: Text( + label, + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.grey[700], + ), + ), + ), + Expanded( + child: Text( + value.isEmpty ? '-' : value, + style: const TextStyle(fontSize: 16), + ), + ), + ], + ), + ); + } +} diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index d657730f..92353486 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -12,6 +12,8 @@ import 'features/auth/presentation/approve_qr_screen.dart'; import 'features/auth/presentation/qr_scan_screen.dart'; import 'features/dashboard/presentation/dashboard_screen.dart'; import 'features/admin/presentation/user_management_screen.dart'; +import 'features/profile/presentation/pages/profile_page.dart'; +import 'features/profile/presentation/pages/edit_profile_page.dart'; import 'core/services/auth_proxy_service.dart'; import 'core/services/logger_service.dart'; import 'core/notifiers/auth_notifier.dart'; @@ -77,6 +79,16 @@ final _router = GoRouter( return const DashboardScreen(); }, ), + GoRoute( + path: '/profile', + builder: (context, state) => const ProfilePage(), + routes: [ + GoRoute( + path: 'edit', + builder: (context, state) => const EditProfilePage(), + ), + ], + ), GoRoute( path: '/login', builder: (context, state) { @@ -174,4 +186,4 @@ class BaronSSOApp extends StatelessWidget { routerConfig: _router, ); } -} +} \ No newline at end of file