diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 0911615a..887ec8ec 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -406,6 +406,24 @@ func (h *AuthHandler) SendSignupSmsCode(c *fiber.Ctx) error { return c.JSON(fiber.Map{"message": "Verification code sent"}) } +var affiliateSlugs = map[string]bool{ + "hanmac": true, + "saman": true, + "ptc": true, + "jangheon": true, + "baron": true, + "halla": true, +} + +func (h *AuthHandler) isAffiliateTenant(ctx context.Context, domainName string) (bool, *domain.Tenant) { + tenant, err := h.TenantService.GetTenantByDomain(ctx, domainName) + if err != nil || tenant == nil { + return false, nil + } + // [Strict] Check if the slug belongs to the predefined family company slugs + return affiliateSlugs[strings.ToLower(tenant.Slug)], tenant +} + // VerifySignupCode - Verifies the code for email or phone func (h *AuthHandler) VerifySignupCode(c *fiber.Ctx) error { var req domain.VerifySignupCodeRequest @@ -459,11 +477,11 @@ func (h *AuthHandler) VerifySignupCode(c *fiber.Ctx) error { state.Verified = true h.saveSignupState(key, state, signupStateExpiration) - // [New] Check if this is an affiliate domain to let frontend lock the choice + // [New] Check if this is a family affiliate domain to let frontend lock the choice isAffiliate := false parts := strings.Split(req.Target, "@") if req.Type == "email" && len(parts) == 2 { - isAffiliate, _ = h.TenantService.IsDomainAllowed(c.Context(), parts[1]) + isAffiliate, _ = h.isAffiliateTenant(c.Context(), parts[1]) } return c.JSON(fiber.Map{ @@ -498,7 +516,14 @@ func (h *AuthHandler) GetActiveTenants(c *fiber.Ctx) error { if len(parts) != 2 { return c.JSON([]interface{}{}) } - domainFilter := parts[1] + domainName := parts[1] + + // [Policy] Verify if the email belongs to any family affiliate domain + isInternal, _ := h.isAffiliateTenant(c.Context(), domainName) + if !isInternal { + // If not an affiliate email, do not show any tenants + return c.JSON([]interface{}{}) + } // 3. List and Filter Tenants tenants, _, err := h.TenantService.ListTenants(c.Context(), 1000, 0, "") @@ -516,19 +541,8 @@ func (h *AuthHandler) GetActiveTenants(c *fiber.Ctx) error { var results []tenantResp for _, t := range tenants { - if t.Status != domain.TenantStatusActive { - continue - } - - // Strictly filter by the domain of the verified email - match := false - for _, d := range t.Domains { - if strings.EqualFold(d.Domain, domainFilter) { - match = true - break - } - } - if !match { + // [Strict] Only allow choosing defined family company slugs + if t.Status != domain.TenantStatusActive || !affiliateSlugs[strings.ToLower(t.Slug)] { continue } @@ -599,10 +613,10 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { } domainName := parts[1] - // Check if this domain is registered to ANY company - isInternal, _ := h.TenantService.IsDomainAllowed(c.Context(), domainName) + // Check if this domain belongs to a predefined family affiliate + isInternal, _ := h.isAffiliateTenant(c.Context(), domainName) - // [Strict Policy] Force AffiliationType based on domain (User cannot choose) + // [Strict Policy] Force AffiliationType based on predefined family slugs (User cannot choose) if isInternal { req.AffiliationType = "AFFILIATE" slog.Info("[Signup] Forcing AffiliationType to AFFILIATE", "email", req.Email) @@ -611,7 +625,7 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { slog.Info("[Signup] Forcing AffiliationType to GENERAL", "email", req.Email) } - // If user provided a CompanyCode, verify it exists and is active + // If user provided a CompanyCode, verify it exists and is a family affiliate if req.CompanyCode != "" { // [Security] Cross-check: If domain is NOT internal, they cannot provide a CompanyCode if !isInternal { @@ -619,21 +633,16 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusForbidden, "Only affiliate members can join an organization.") } + // Verify the selected company code exists and is indeed a family company + if !affiliateSlugs[strings.ToLower(req.CompanyCode)] { + return errorJSON(c, fiber.StatusForbidden, "The selected organization is not a valid family affiliate.") + } + tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode) if err == nil && tenant != nil { if tenant.Status == domain.TenantStatusActive { - // [Security] Final domain cross-check for the selected tenant - match := false - for _, d := range tenant.Domains { - if strings.EqualFold(d.Domain, domainName) { - match = true - break - } - } - if !match { - slog.Warn("[Signup] Domain mismatch for selected organization", "email", req.Email, "targetTenant", tenant.Slug) - return errorJSON(c, fiber.StatusForbidden, "Your email domain does not match the selected organization.") - } + // We no longer strictly cross-check if the chosen tenant owns the email domain. + // Being an 'isInternal' (family) email is enough to join ANY family affiliate. slog.Info("[Signup] Assigning tenant by manual slug", "email", req.Email, "tenant", tenant.Slug) companyCode = tenant.Slug @@ -646,16 +655,12 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusNotFound, "The specified organization code was not found.") } } else { - // If no CompanyCode provided but it's an internal domain, they MUST select one - if isInternal && req.AffiliationType == "AFFILIATE" { + // If it's a family affiliate domain, they MUST select one of the family companies + if isInternal { return errorJSON(c, fiber.StatusBadRequest, "Please select your organization.") } - - // If they chose GENERAL even with internal email, or it's a completely external domain - // Here we might assign a "GENERAL" pool or a personal tenant. } - // [Strict] If no tenantID but trying to be an affiliate, it's an error if tenantID == nil && req.AffiliationType == "AFFILIATE" { return errorJSON(c, fiber.StatusBadRequest, "We couldn't verify your organization affiliation. Please check your choice.") } diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index 5867402d..790ce60e 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -923,13 +923,19 @@ class AuthProxyService { } } - static Future>> getActiveTenants() async { - final url = Uri.parse('$_baseUrl/api/v1/auth/signup/tenants'); - final response = await http.get(url); + static Future>> getActiveTenants({ + String? email, + }) async { + var uriString = '$_baseUrl/api/v1/auth/signup/tenants'; + if (email != null && email.isNotEmpty) { + uriString += '?email=${Uri.encodeComponent(email)}'; + } + final url = Uri.parse(uriString); + final response = await http.get(url); if (response.statusCode == 200) { - final List data = jsonDecode(response.body); - return data.cast>(); + final List list = jsonDecode(response.body); + return list.cast>(); } return []; } @@ -953,7 +959,7 @@ class AuthProxyService { } } - static Future verifySignupCode( + static Future> verifySignupCode( String target, String type, String code, @@ -967,10 +973,9 @@ class AuthProxyService { ); if (response.statusCode == 200) { - final data = jsonDecode(response.body); - return data['success'] ?? false; + return jsonDecode(response.body); } - return false; + throw Exception('Verification failed'); } static Future signup({ diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart index cbd0871a..20faea8b 100644 --- a/userfront/lib/features/auth/presentation/signup_screen.dart +++ b/userfront/lib/features/auth/presentation/signup_screen.dart @@ -44,6 +44,7 @@ class _SignupScreenState extends State { bool _isEmailVerified = false; bool _isPhoneVerified = false; String _affiliationType = 'GENERAL'; + bool _isAffiliateLocked = false; String? _companyCode; bool _termsAccepted = false; bool _privacyAccepted = false; @@ -72,15 +73,23 @@ class _SignupScreenState extends State { void initState() { super.initState(); _loadPolicy(); - _fetchTenants(); + // initState에서는 _fetchTenants() 호출 제외 } Future _fetchTenants() async { + if (!_isEmailVerified) return; + try { - final tenants = await AuthProxyService.getActiveTenants(); + final tenants = await AuthProxyService.getActiveTenants( + email: _emailController.text.trim(), + ); if (mounted) { setState(() { _tenants = tenants; + if (_tenants.isNotEmpty && _affiliationType == 'AFFILIATE') { + // 목록이 있는데 아직 아무것도 선택되지 않았다면 자동 할당 가능 + _companyCode ??= _tenants.first['slug']; + } }); } } catch (e) { @@ -195,18 +204,32 @@ class _SignupScreenState extends State { final code = _emailCodeController.text.trim(); if (code.length != 6) return; try { - final success = await AuthProxyService.verifySignupCode( + final res = await AuthProxyService.verifySignupCode( _emailController.text.trim(), 'email', code, ); - if (success) { + if (res['success'] == true) { setState(() { _isEmailVerified = true; _emailTimer?.cancel(); _emailSeconds = 0; _emailError = null; + + if (res['isAffiliate'] == true) { + _affiliationType = 'AFFILIATE'; + _isAffiliateLocked = true; + } else { + _affiliationType = 'GENERAL'; + _companyCode = null; + _isAffiliateLocked = true; + } }); + + // Only fetch tenants if it's an affiliate domain + if (res['isAffiliate'] == true) { + _fetchTenants(); + } } else { setState( () => _emailError = tr('msg.userfront.signup.email.code_mismatch'), @@ -248,12 +271,12 @@ class _SignupScreenState extends State { final code = _phoneCodeController.text.trim(); if (code.length != 6) return; try { - final success = await AuthProxyService.verifySignupCode( + final res = await AuthProxyService.verifySignupCode( _phoneController.text.trim(), 'phone', code, ); - if (success) { + if (res['success'] == true) { setState(() { _isPhoneVerified = true; _phoneTimer?.cancel(); @@ -1445,17 +1468,19 @@ class _SignupScreenState extends State { ), ), ], - onChanged: (val) { - if (val == null) { - return; - } - setState(() { - _affiliationType = val; - if (_affiliationType == 'GENERAL') { - _companyCode = null; - } - }); - }, + onChanged: _isAffiliateLocked + ? null + : (val) { + if (val == null) { + return; + } + setState(() { + _affiliationType = val; + if (_affiliationType == 'GENERAL') { + _companyCode = null; + } + }); + }, ), AnimatedSize( duration: const Duration(milliseconds: 180),