1
0
forked from baron/baron-sso

마이페이지 구현

This commit is contained in:
2026-01-27 13:39:49 +09:00
parent 4c608c6c3c
commit 60035aad53
11 changed files with 844 additions and 1 deletions

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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})
}

View File

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

View File

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

View File

@@ -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<String?> _getToken() async {
final session = await Descope.sessionManager.session;
return session?.sessionToken.jwt;
}
Future<UserProfile> 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<void> 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<void> 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<void> 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}');
}
}
}

View File

@@ -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<UserProfile?> {
@override
FutureOr<UserProfile?> build() async {
// Initial data fetch
return _fetch();
}
Future<UserProfile?> _fetch() async {
return ref.read(profileRepositoryProvider).getMyProfile();
}
Future<void> loadProfile() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => _fetch());
}
Future<void> 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<ProfileNotifier, UserProfile?>(() {
return ProfileNotifier();
});

View File

@@ -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<EditProfilePage> createState() => _EditProfilePageState();
}
class _EditProfilePageState extends ConsumerState<EditProfilePage> {
final _formKey = GlobalKey<FormState>();
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<void> _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<void> _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<void> _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()),
],
),
),
),
);
}
}

View File

@@ -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<UserProfile?>
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('재시도'),
),
],
),
),
),
);
}
}

View File

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

View File

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