forked from baron/baron-sso
Merge pull request 'dev/mypage' (#68) from dev/mypage into main
Reviewed-on: ai-team/baron-sso#68
This commit is contained in:
@@ -262,14 +262,16 @@ func main() {
|
|||||||
signup.Post("/verify-code", authHandler.VerifySignupCode)
|
signup.Post("/verify-code", authHandler.VerifySignupCode)
|
||||||
signup.Post("/", authHandler.Signup)
|
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 Routes
|
||||||
admin := api.Group("/admin")
|
admin := api.Group("/admin")
|
||||||
admin.Post("/users", adminHandler.CreateUser)
|
|
||||||
admin.Get("/check", adminHandler.CheckAuth)
|
admin.Get("/check", adminHandler.CheckAuth)
|
||||||
admin.Get("/users", adminHandler.ListUsers)
|
|
||||||
admin.Patch("/users/:loginId", adminHandler.UpdateUser)
|
|
||||||
admin.Delete("/users/:loginId", adminHandler.DeleteUser)
|
|
||||||
admin.Patch("/users/:loginId/status", adminHandler.UpdateUserStatus)
|
|
||||||
|
|
||||||
// Webhook for Descope Generic SMS Gateway
|
// Webhook for Descope Generic SMS Gateway
|
||||||
auth.Post("/webhooks/descope-sms", authHandler.HandleDescopeSmsRelay)
|
auth.Post("/webhooks/descope-sms", authHandler.HandleDescopeSmsRelay)
|
||||||
|
|||||||
@@ -60,6 +60,25 @@ type SignupRequest struct {
|
|||||||
TermsAccepted bool `json:"termsAccepted"`
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// PasswordResetInitiateRequest is the request body for initiating a password reset.
|
// PasswordResetInitiateRequest is the request body for initiating a password reset.
|
||||||
type PasswordResetInitiateRequest struct {
|
type PasswordResetInitiateRequest struct {
|
||||||
LoginID string `json:"loginId"`
|
LoginID string `json:"loginId"`
|
||||||
@@ -69,4 +88,4 @@ type PasswordResetInitiateRequest struct {
|
|||||||
type PasswordResetCompleteRequest struct {
|
type PasswordResetCompleteRequest struct {
|
||||||
LoginID string `json:"loginId"`
|
LoginID string `json:"loginId"`
|
||||||
NewPassword string `json:"newPassword"`
|
NewPassword string `json:"newPassword"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/descope/go-sdk/descope"
|
|
||||||
"github.com/descope/go-sdk/descope/client"
|
"github.com/descope/go-sdk/descope/client"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
@@ -40,10 +36,6 @@ func NewAdminHandler() *AdminHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func boolPtr(b bool) *bool {
|
|
||||||
return &b
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkAuth Helper
|
// checkAuth Helper
|
||||||
func (h *AdminHandler) checkAuth(c *fiber.Ctx) error {
|
func (h *AdminHandler) checkAuth(c *fiber.Ctx) error {
|
||||||
adminPass := os.Getenv("ADMIN_PASSWORD")
|
adminPass := os.Getenv("ADMIN_PASSWORD")
|
||||||
@@ -58,234 +50,9 @@ func (h *AdminHandler) checkAuth(c *fiber.Ctx) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateUserRequest struct {
|
|
||||||
LoginID string `json:"loginId"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Phone string `json:"phone"`
|
|
||||||
DisplayName string `json:"displayName"`
|
|
||||||
Roles []string `json:"roles"`
|
|
||||||
Tenants map[string][]string `json:"tenants"` // tenantId -> roles
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {
|
func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {
|
||||||
if err := h.checkAuth(c); err != nil {
|
if err := h.checkAuth(c); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"})
|
return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListUsers - GET /api/v1/admin/users
|
|
||||||
func (h *AdminHandler) ListUsers(c *fiber.Ctx) error {
|
|
||||||
if err := h.checkAuth(c); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
text := c.Query("text")
|
|
||||||
// Limit is not directly supported in SearchAll options as a simple int in all SDK versions,
|
|
||||||
// but let's check the options struct.
|
|
||||||
// Based on previous inspection: SearchAll takes UserSearchOptions.
|
|
||||||
|
|
||||||
var users []*descope.UserResponse
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if text != "" {
|
|
||||||
options := &descope.UserSearchOptions{Text: text, Limit: 50}
|
|
||||||
users, _, err = h.DescopeClient.Management.User().SearchAll(context.Background(), options)
|
|
||||||
} else {
|
|
||||||
// Nil options means default search (usually returns all or default page)
|
|
||||||
users, _, err = h.DescopeClient.Management.User().SearchAll(context.Background(), nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("[Admin] ListUsers failed", "error", err)
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(fiber.Map{"users": users})
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteUser - DELETE /api/v1/admin/users/:loginId
|
|
||||||
func (h *AdminHandler) DeleteUser(c *fiber.Ctx) error {
|
|
||||||
if err := h.checkAuth(c); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
loginID := c.Params("loginId")
|
|
||||||
// Decode if necessary (Fiber usually decodes params, but let's be safe if it's double encoded)
|
|
||||||
if decoded, err := url.QueryUnescape(loginID); err == nil {
|
|
||||||
loginID = decoded
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("[Admin] Deleting user", "loginID", loginID)
|
|
||||||
if err := h.DescopeClient.Management.User().Delete(context.Background(), loginID); err != nil {
|
|
||||||
slog.Error("[Admin] DeleteUser failed", "error", err)
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(fiber.Map{"message": "User deleted successfully"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateUserStatus - PATCH /api/v1/admin/users/:loginId/status
|
|
||||||
func (h *AdminHandler) UpdateUserStatus(c *fiber.Ctx) error {
|
|
||||||
if err := h.checkAuth(c); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
loginID := c.Params("loginId")
|
|
||||||
if decoded, err := url.QueryUnescape(loginID); err == nil {
|
|
||||||
loginID = decoded
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Status string `json:"status"` // "enabled" or "disabled"
|
|
||||||
}
|
|
||||||
if err := c.BodyParser(&req); err != nil {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid body"})
|
|
||||||
}
|
|
||||||
|
|
||||||
var user *descope.UserResponse
|
|
||||||
var err error
|
|
||||||
|
|
||||||
slog.Info("[Admin] Updating status", "loginID", loginID, "status", req.Status)
|
|
||||||
|
|
||||||
if req.Status == "enabled" || req.Status == "active" {
|
|
||||||
user, err = h.DescopeClient.Management.User().Activate(context.Background(), loginID)
|
|
||||||
} else {
|
|
||||||
user, err = h.DescopeClient.Management.User().Deactivate(context.Background(), loginID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("[Admin] Status update failed", "error", err)
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(fiber.Map{
|
|
||||||
"message": "Status updated",
|
|
||||||
"user": user,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateUser - PATCH /api/v1/admin/users/:loginId
|
|
||||||
func (h *AdminHandler) UpdateUser(c *fiber.Ctx) error {
|
|
||||||
if err := h.checkAuth(c); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
loginID := c.Params("loginId")
|
|
||||||
if decoded, err := url.QueryUnescape(loginID); err == nil {
|
|
||||||
loginID = decoded
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Email *string `json:"email"`
|
|
||||||
Phone *string `json:"phone"`
|
|
||||||
DisplayName *string `json:"displayName"`
|
|
||||||
}
|
|
||||||
if err := c.BodyParser(&req); err != nil {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid body"})
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
var err error
|
|
||||||
|
|
||||||
// Update Display Name
|
|
||||||
if req.DisplayName != nil {
|
|
||||||
_, err = h.DescopeClient.Management.User().UpdateDisplayName(ctx, loginID, *req.DisplayName)
|
|
||||||
if err != nil {
|
|
||||||
return c.Status(500).JSON(fiber.Map{"error": "Failed to update name: " + err.Error()})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update Email
|
|
||||||
if req.Email != nil {
|
|
||||||
_, err = h.DescopeClient.Management.User().UpdateEmail(ctx, loginID, *req.Email, true, false) // verified=true, addToLoginIDs=false
|
|
||||||
if err != nil {
|
|
||||||
return c.Status(500).JSON(fiber.Map{"error": "Failed to update email: " + err.Error()})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update Phone
|
|
||||||
if req.Phone != nil {
|
|
||||||
phone := *req.Phone
|
|
||||||
// Normalize
|
|
||||||
if strings.HasPrefix(phone, "010") {
|
|
||||||
phone = "+82" + phone[1:]
|
|
||||||
}
|
|
||||||
_, err = h.DescopeClient.Management.User().UpdatePhone(ctx, loginID, phone, true, false) // verified=true, addToLoginIDs=false
|
|
||||||
if err != nil {
|
|
||||||
return c.Status(500).JSON(fiber.Map{"error": "Failed to update phone: " + err.Error()})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(fiber.Map{"message": "User updated successfully"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AdminHandler) CreateUser(c *fiber.Ctx) error {
|
|
||||||
if err := h.checkAuth(c); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if h.DescopeClient == nil {
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Descope Client not configured"})
|
|
||||||
}
|
|
||||||
|
|
||||||
var req CreateUserRequest
|
|
||||||
if err := c.BodyParser(&req); err != nil {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.LoginID == "" {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "LoginID is required"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize Phone
|
|
||||||
normalizedPhone := req.Phone
|
|
||||||
if normalizedPhone != "" {
|
|
||||||
if strings.HasPrefix(normalizedPhone, "010") {
|
|
||||||
normalizedPhone = "+82" + normalizedPhone[1:]
|
|
||||||
} else if strings.HasPrefix(normalizedPhone, "82") {
|
|
||||||
normalizedPhone = "+" + normalizedPhone
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
userObj := &descope.UserRequest{
|
|
||||||
User: descope.User{
|
|
||||||
Email: req.Email,
|
|
||||||
Phone: normalizedPhone,
|
|
||||||
Name: req.DisplayName,
|
|
||||||
},
|
|
||||||
VerifiedEmail: boolPtr(req.Email != ""),
|
|
||||||
VerifiedPhone: boolPtr(normalizedPhone != ""),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add Roles if provided
|
|
||||||
if len(req.Roles) > 0 {
|
|
||||||
userObj.Roles = req.Roles
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add Tenants if provided
|
|
||||||
if len(req.Tenants) > 0 {
|
|
||||||
// Convert map[string][]string to []*descope.AssociatedTenant
|
|
||||||
userTenants := []*descope.AssociatedTenant{}
|
|
||||||
for tenantID, roles := range req.Tenants {
|
|
||||||
userTenants = append(userTenants, &descope.AssociatedTenant{
|
|
||||||
TenantID: tenantID,
|
|
||||||
Roles: roles,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
userObj.Tenants = userTenants
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("[Admin] Creating user", "loginID", req.LoginID, "email", req.Email, "phone", normalizedPhone)
|
|
||||||
|
|
||||||
res, err := h.DescopeClient.Management.User().Create(context.Background(), req.LoginID, userObj)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("[Admin] Failed to create user", "error", err)
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(fiber.Map{
|
|
||||||
"message": "User created successfully",
|
|
||||||
"user": res,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -400,6 +400,18 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// --- Helpers ---
|
// --- 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) {
|
func (h *AuthHandler) getSignupState(key string) (*signupState, error) {
|
||||||
val, err := h.RedisService.Get(key)
|
val, err := h.RedisService.Get(key)
|
||||||
if err != nil || val == "" {
|
if err != nil || val == "" {
|
||||||
@@ -1292,3 +1304,213 @@ func (h *AuthHandler) HandleDescopeEmailRelay(c *fiber.Ctx) error {
|
|||||||
slog.Warn("[Email Webhook] Real email skipped (Not implemented)", "to", req.To)
|
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"})
|
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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -96,6 +96,19 @@ class DashboardScreen extends StatelessWidget {
|
|||||||
'PC 화면의 QR 코드를 스캔하여 로그인하세요.',
|
'PC 화면의 QR 코드를 스캔하여 로그인하세요.',
|
||||||
style: TextStyle(color: Colors.grey, fontSize: 13),
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('재시도'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ import 'features/auth/presentation/forgot_password_screen.dart';
|
|||||||
import 'features/auth/presentation/reset_password_screen.dart';
|
import 'features/auth/presentation/reset_password_screen.dart';
|
||||||
import 'features/dashboard/presentation/dashboard_screen.dart';
|
import 'features/dashboard/presentation/dashboard_screen.dart';
|
||||||
import 'features/admin/presentation/user_management_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/auth_proxy_service.dart';
|
||||||
import 'core/services/logger_service.dart';
|
import 'core/services/logger_service.dart';
|
||||||
import 'core/notifiers/auth_notifier.dart';
|
import 'core/notifiers/auth_notifier.dart';
|
||||||
@@ -79,6 +81,16 @@ final _router = GoRouter(
|
|||||||
return const DashboardScreen();
|
return const DashboardScreen();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/profile',
|
||||||
|
builder: (context, state) => const ProfilePage(),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: 'edit',
|
||||||
|
builder: (context, state) => const EditProfilePage(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/login',
|
path: '/login',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
@@ -196,4 +208,4 @@ class BaronSSOApp extends StatelessWidget {
|
|||||||
routerConfig: _router,
|
routerConfig: _router,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user