1
0
forked from baron/baron-sso

Merge pull request 'feature/1183-signup-personal-default' (#1187) from feature/1183-signup-personal-default into dev

Reviewed-on: baron/baron-sso#1187
This commit is contained in:
2026-06-16 18:39:35 +09:00
15 changed files with 201 additions and 175 deletions

View File

@@ -270,8 +270,7 @@ function AppLayout() {
if (item.to === "/permissions-direct") return false; if (item.to === "/permissions-direct") return false;
if (item.to === "/tenants") return permissions.tenants; if (item.to === "/tenants") return permissions.tenants;
if (item.to === orgfrontUrl) return permissions.org_chart; if (item.to === orgfrontUrl) return permissions.org_chart;
if (item.to === "/worksmobile") if (item.to === "/worksmobile") return permissions.worksmobile;
return permissions.worksmobile && showWorksmobile;
if (item.to === "/system/ory-ssot") return permissions.ory_ssot; if (item.to === "/system/ory-ssot") return permissions.ory_ssot;
if (item.to === "/system/data-integrity") if (item.to === "/system/data-integrity")
return permissions.data_integrity; return permissions.data_integrity;

View File

@@ -61,7 +61,7 @@ const users = [
id: "user-owner", id: "user-owner",
name: "Owner User", name: "Owner User",
email: "owner@example.com", email: "owner@example.com",
role: "tenant_admin", role: "super_admin",
status: "active", status: "active",
}, },
{ {

View File

@@ -13,7 +13,9 @@ export type TenantPermissionKey =
| "view_organization" | "view_organization"
| "manage_organization" | "manage_organization"
| "view_schema" | "view_schema"
| "manage_schema"; | "manage_schema"
| "view_worksmobile"
| "manage_worksmobile";
export function useTenantPermission(tenantId: string) { export function useTenantPermission(tenantId: string) {
const { data: profile } = useQuery({ const { data: profile } = useQuery({

View File

@@ -645,7 +645,9 @@ export function TenantFineGrainedPermissionsPage() {
{menu.label} {menu.label}
</span> </span>
{(menu.relation === "ory_ssot" || {(menu.relation === "ory_ssot" ||
menu.relation === "data_integrity") && ( menu.relation === "data_integrity" ||
menu.relation ===
"permissions_direct") && (
<Badge <Badge
variant="secondary" variant="secondary"
className="text-[10px] py-0.5 px-1.5 font-semibold text-destructive bg-destructive/10 border-destructive/20" className="text-[10px] py-0.5 px-1.5 font-semibold text-destructive bg-destructive/10 border-destructive/20"
@@ -667,7 +669,8 @@ export function TenantFineGrainedPermissionsPage() {
value={permissionValue} value={permissionValue}
disabled={ disabled={
menu.relation === "ory_ssot" || menu.relation === "ory_ssot" ||
menu.relation === "data_integrity" menu.relation === "data_integrity" ||
menu.relation === "permissions_direct"
} }
onChange={(e) => { onChange={(e) => {
const nextVal = e.target.value as const nextVal = e.target.value as

View File

@@ -31,13 +31,13 @@ import {
import { toast } from "../../../components/ui/use-toast"; import { toast } from "../../../components/ui/use-toast";
import { import {
addTenantRelation, addTenantRelation,
fetchMe,
fetchTenantRelations, fetchTenantRelations,
fetchUsers, fetchUsers,
removeTenantRelation, removeTenantRelation,
type TenantRelation, type TenantRelation,
} from "../../../lib/adminApi"; } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
import { useTenantPermission } from "../hooks/useTenantPermission";
interface TenantFineGrainedPermissionsTabProps { interface TenantFineGrainedPermissionsTabProps {
tenantIdProp?: string; tenantIdProp?: string;
@@ -48,8 +48,11 @@ export function TenantFineGrainedPermissionsTab({
}: TenantFineGrainedPermissionsTabProps = {}) { }: TenantFineGrainedPermissionsTabProps = {}) {
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>(); const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
const tenantId = tenantIdProp || tenantIdParam || ""; const tenantId = tenantIdProp || tenantIdParam || "";
const { hasPermission } = useTenantPermission(tenantId); const { data: profile } = useQuery({
const isWritable = hasPermission("manage_admins"); queryKey: ["me"],
queryFn: fetchMe,
});
const isWritable = profile?.role === "super_admin";
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
@@ -75,7 +78,13 @@ export function TenantFineGrainedPermissionsTab({
> = {}; > = {};
for (const user of relationsQuery.data) { for (const user of relationsQuery.data) {
initialMap[user.userId] = {}; initialMap[user.userId] = {};
const tabs = ["profile", "permissions", "organization", "schema"]; const tabs = [
"profile",
"permissions",
"organization",
"schema",
"worksmobile",
];
for (const tab of tabs) { for (const tab of tabs) {
const isWrite = user.relations.includes(`${tab}_managers`); const isWrite = user.relations.includes(`${tab}_managers`);
const isRead = user.relations.includes(`${tab}_viewers`); const isRead = user.relations.includes(`${tab}_viewers`);
@@ -204,7 +213,7 @@ export function TenantFineGrainedPermissionsTab({
const handleRelationChange = async ( const handleRelationChange = async (
userId: string, userId: string,
tab: "profile" | "permissions" | "organization" | "schema", tab: "profile" | "permissions" | "organization" | "schema" | "worksmobile",
currentVal: "none" | "read" | "write", currentVal: "none" | "read" | "write",
newVal: "none" | "read" | "write", newVal: "none" | "read" | "write",
) => { ) => {
@@ -318,6 +327,14 @@ export function TenantFineGrainedPermissionsTab({
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent className="pt-0"> <CardContent className="pt-0">
{!isWritable && (
<div className="mb-4 p-3 bg-amber-50 dark:bg-amber-950/20 text-amber-800 dark:text-amber-200 border border-amber-200 dark:border-amber-800/30 rounded-lg text-sm font-medium">
{t(
"msg.admin.tenants.relations.super_admin_only_desc",
"이 화면의 권한 설정은 시스템 최고 관리자(super_admin)만 수정할 수 있습니다.",
)}
</div>
)}
<div className="rounded-md border border-border overflow-hidden"> <div className="rounded-md border border-border overflow-hidden">
<Table> <Table>
<TableHeader className="bg-secondary/40"> <TableHeader className="bg-secondary/40">
@@ -337,6 +354,12 @@ export function TenantFineGrainedPermissionsTab({
<TableHead className="font-bold"> <TableHead className="font-bold">
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")} {t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
</TableHead> </TableHead>
<TableHead className="font-bold">
{t(
"ui.admin.tenants.detail.tab_worksmobile",
"네이버웍스 연동",
)}
</TableHead>
<TableHead className="font-bold text-center w-20"> <TableHead className="font-bold text-center w-20">
{t("ui.common.action", "작업")} {t("ui.common.action", "작업")}
</TableHead> </TableHead>
@@ -346,7 +369,7 @@ export function TenantFineGrainedPermissionsTab({
{relations.length === 0 ? ( {relations.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={6} colSpan={7}
className="text-center py-12 text-muted-foreground" className="text-center py-12 text-muted-foreground"
> >
{t( {t(
@@ -387,6 +410,14 @@ export function TenantFineGrainedPermissionsTab({
? "read" ? "read"
: "none"; : "none";
const worksmobileVal = user.relations.includes(
"worksmobile_managers",
)
? "write"
: user.relations.includes("worksmobile_viewers")
? "read"
: "none";
const curProfileVal = const curProfileVal =
localTenantPermissions[user.userId]?.profile ?? localTenantPermissions[user.userId]?.profile ??
profileVal; profileVal;
@@ -398,6 +429,9 @@ export function TenantFineGrainedPermissionsTab({
organizationVal; organizationVal;
const curSchemaVal = const curSchemaVal =
localTenantPermissions[user.userId]?.schema ?? schemaVal; localTenantPermissions[user.userId]?.schema ?? schemaVal;
const curWorksmobileVal =
localTenantPermissions[user.userId]?.worksmobile ??
worksmobileVal;
return ( return (
<TableRow <TableRow
@@ -562,6 +596,43 @@ export function TenantFineGrainedPermissionsTab({
</option> </option>
</select> </select>
</TableCell> </TableCell>
<TableCell>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={curWorksmobileVal}
disabled={!isWritable}
name={`tenant-fine-grained-worksmobile-${user.userId}`}
onChange={(e) => {
const nextVal = e.target.value as
| "none"
| "read"
| "write";
setLocalTenantPermissions((prev) => ({
...prev,
[user.userId]: {
...(prev[user.userId] ?? {}),
worksmobile: nextVal,
},
}));
handleRelationChange(
user.userId,
"worksmobile",
worksmobileVal,
nextVal,
);
}}
>
<option value="none">
{t("ui.common.none", "권한 없음")}
</option>
<option value="read">
{t("ui.common.read", "조회 가능 (Read)")}
</option>
<option value="write">
{t("ui.common.write", "수정 가능 (Write)")}
</option>
</select>
</TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<Button <Button
variant="ghost" variant="ghost"

View File

@@ -45,6 +45,8 @@ export type TenantSummary = {
manage_organization?: boolean; manage_organization?: boolean;
view_schema?: boolean; view_schema?: boolean;
manage_schema?: boolean; manage_schema?: boolean;
view_worksmobile?: boolean;
manage_worksmobile?: boolean;
}; };
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;

View File

@@ -712,69 +712,21 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
} }
// 소속이 비어 있는 일반 가입자는 PERSONAL tenant를 자동 생성해 대표소속을 보장합니다. // 소속이 비어 있는 일반 가입자는 PERSONAL tenant를 자동 생성해 대표소속을 보장합니다.
tenantSlug := strings.TrimSpace(req.TenantSlug) // 모든 온라인 가입자는 기본적으로 개인(Personal) 테넌트 소속으로 가입합니다.
// 기업/가족사 소속 연동은 별도 문의를 통해 처리되므로 온라인 가입 흐름에서는 제외합니다.
req.AffiliationType = "GENERAL"
slog.Info("[Signup] Forcing AffiliationType to GENERAL (Default personal tenant signup policy)", "email", req.Email)
var tenantID *string var tenantID *string
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), "personal")
parts := strings.Split(req.Email, "@") if err != nil || tenant == nil {
if len(parts) != 2 { // Fallback: 만약 시드된 personal 테넌트가 없을 경우 개인별 테넌트를 자동 생성합니다.
return errorJSON(c, fiber.StatusBadRequest, "Invalid email format") tenant, err = createPersonalTenantForUser(c.Context(), h.TenantService, req.Email)
}
domainName := parts[1]
// Check if this domain belongs to a predefined family affiliate
isInternal, _ := h.isAffiliateTenant(c.Context(), domainName)
// [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)
} else {
req.AffiliationType = "GENERAL"
slog.Info("[Signup] Forcing AffiliationType to GENERAL", "email", req.Email)
}
if tenantSlug != "" {
// [Security] Cross-check: If domain is NOT internal, they cannot provide a tenantSlug
if !isInternal {
slog.Warn("[Signup] Security violation: non-internal email providing tenantSlug", "email", req.Email)
return errorJSON(c, fiber.StatusForbidden, "Only affiliate members can join an organization.")
}
if !affiliateSlugs[strings.ToLower(tenantSlug)] {
return errorJSON(c, fiber.StatusForbidden, "The selected organization is not a valid family affiliate.")
}
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), tenantSlug)
if err == nil && tenant != nil {
if tenant.Status == domain.TenantStatusActive {
slog.Info("[Signup] Assigning tenant by manual slug", "email", req.Email, "tenant", tenant.Slug)
tenantSlug = tenant.Slug
tenantID = &tenant.ID
} else {
return errorJSON(c, fiber.StatusForbidden, "The specified organization is not active.")
}
} else {
slog.Warn("[Signup] Attempted to join non-existent organization", "slug", tenantSlug, "email", req.Email)
return errorJSON(c, fiber.StatusNotFound, "The specified organization code was not found.")
}
} else {
// 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 tenantID == nil && req.AffiliationType == "AFFILIATE" {
return errorJSON(c, fiber.StatusBadRequest, "We couldn't verify your organization affiliation. Please check your choice.")
}
if tenantID == nil && req.AffiliationType == "GENERAL" {
tenant, err := createPersonalTenantForUser(c.Context(), h.TenantService, req.Email)
if err != nil { if err != nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "failed to create personal tenant") return errorJSON(c, fiber.StatusServiceUnavailable, "failed to resolve personal tenant")
} }
tenantSlug = tenant.Slug
tenantID = &tenant.ID
} }
tenantID = &tenant.ID
// Normalize Phone (E.164 형태로 보관) // Normalize Phone (E.164 형태로 보관)
normalizedPhone := domain.NormalizePhoneNumber(req.Phone) normalizedPhone := domain.NormalizePhoneNumber(req.Phone)

View File

@@ -116,22 +116,19 @@ func TestSignup_TenantSlugValidation(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, resp.StatusCode) assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}) })
t.Run("Active Tenant Slug", func(t *testing.T) { t.Run("Success creates Personal Tenant", func(t *testing.T) {
reqBody := domain.SignupRequest{ reqBody := domain.SignupRequest{
Email: "user@hanmaceng.co.kr", Email: "user@hanmaceng.co.kr",
Password: "StrongPass123!", Password: "StrongPass123!",
Name: "Test User", Name: "Test User",
Phone: "010-1234-5678", Phone: "010-1234-5678",
TermsAccepted: true, TermsAccepted: true,
TenantSlug: "hanmac",
} }
body, _ := json.Marshal(reqBody) body, _ := json.Marshal(reqBody)
validTenant := &domain.Tenant{ID: "t1", Slug: "hanmac", Status: domain.TenantStatusActive} validTenant := &domain.Tenant{ID: "personal-t1", Slug: "personal-slug", Status: domain.TenantStatusActive}
mockTenantSvc.On("GetTenantByDomain", mock.Anything, "hanmaceng.co.kr").Return(&domain.Tenant{Slug: "hanmac"}, nil).Once() mockTenantSvc.On("GetTenantBySlug", mock.Anything, "personal").Return((*domain.Tenant)(nil), assert.AnError).Once()
mockTenantSvc.On("ProvisionTenantByDomain", mock.Anything, "hanmaceng.co.kr").Return(validTenant, nil).Maybe() mockTenantSvc.On("RegisterTenant", mock.Anything, "Personal - user@hanmaceng.co.kr", mock.Anything, domain.TenantTypePersonal, "Automatically provisioned personal tenant", []string(nil), (*string)(nil), "").Return(validTenant, nil).Once()
mockTenantSvc.On("GetTenantBySlug", mock.Anything, "hanmac").Return(validTenant, nil).Once()
mockTenantSvc.On("GetTenant", mock.Anything, "t1").Return(validTenant, nil).Once()
mockIdp.On("CreateUser", mock.Anything, mock.Anything).Return("user-id", nil).Once() mockIdp.On("CreateUser", mock.Anything, mock.Anything).Return("user-id", nil).Once()
mockRedis.On("Delete", mock.Anything).Return(nil) mockRedis.On("Delete", mock.Anything).Return(nil)

View File

@@ -135,6 +135,9 @@ func createPersonalTenantForUser(ctx context.Context, tenantService service.Tena
normalizedEmail = "user" normalizedEmail = "user"
} }
slug := "personal-" + strings.ReplaceAll(uuid.NewString(), "-", "") slug := "personal-" + strings.ReplaceAll(uuid.NewString(), "-", "")
if len(slug) > 32 {
slug = slug[:32]
}
tenant, err := tenantService.RegisterTenant( tenant, err := tenantService.RegisterTenant(
ctx, ctx,
fmt.Sprintf("Personal - %s", normalizedEmail), fmt.Sprintf("Personal - %s", normalizedEmail),

View File

@@ -0,0 +1,48 @@
package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/utils"
"context"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestCreatePersonalTenantForUser_SlugLength(t *testing.T) {
mockTenantService := &MockTenantService{}
ctx := context.Background()
var capturedSlug string
mockTenantService.On(
"RegisterTenant",
ctx,
"Personal - user@example.com",
mock.AnythingOfType("string"),
domain.TenantTypePersonal,
"Automatically provisioned personal tenant",
[]string(nil),
(*string)(nil),
"",
).Run(func(args mock.Arguments) {
capturedSlug = args.String(2)
}).Return(&domain.Tenant{
ID: "personal-tenant-id",
Slug: "personal-slug",
Name: "Personal - user@example.com",
}, nil)
tenant, err := createPersonalTenantForUser(ctx, mockTenantService, "user@example.com")
assert.NoError(t, err)
assert.NotNil(t, tenant)
// Ensure the generated slug is strictly 32 characters or less
assert.LessOrEqual(t, len(capturedSlug), 32)
assert.True(t, strings.HasPrefix(capturedSlug, "personal-"))
// Ensure that the captured slug actually passes ValidateSlug!
valid, msg := utils.ValidateSlug(capturedSlug)
assert.True(t, valid, "Slug must be valid: "+msg)
}

View File

@@ -97,6 +97,8 @@ type tenantPermissions struct {
ManageOrganization bool `json:"manage_organization"` ManageOrganization bool `json:"manage_organization"`
ViewSchema bool `json:"view_schema"` ViewSchema bool `json:"view_schema"`
ManageSchema bool `json:"manage_schema"` ManageSchema bool `json:"manage_schema"`
ViewWorksmobile bool `json:"view_worksmobile"`
ManageWorksmobile bool `json:"manage_worksmobile"`
} }
type tenantSummary struct { type tenantSummary struct {
@@ -1742,6 +1744,8 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
ManageOrganization: true, ManageOrganization: true,
ViewSchema: true, ViewSchema: true,
ManageSchema: true, ManageSchema: true,
ViewWorksmobile: true,
ManageWorksmobile: true,
} }
} else { } else {
// Query Keto in parallel for maximum performance // Query Keto in parallel for maximum performance
@@ -1751,13 +1755,14 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
allowed bool allowed bool
err error err error
} }
ch := make(chan checkResult, 11) ch := make(chan checkResult, 13)
relations := []string{ relations := []string{
"view", "manage", "manage_admins", "view", "manage", "manage_admins",
"view_profile", "manage_profile", "view_profile", "manage_profile",
"view_permissions", "manage_permissions", "view_permissions", "manage_permissions",
"view_organization", "manage_organization", "view_organization", "manage_organization",
"view_schema", "manage_schema", "view_schema", "manage_schema",
"view_worksmobile", "manage_worksmobile",
} }
for _, rel := range relations { for _, rel := range relations {
go func(r string) { go func(r string) {
@@ -1796,6 +1801,10 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
perms.ViewSchema = res.allowed perms.ViewSchema = res.allowed
case "manage_schema": case "manage_schema":
perms.ManageSchema = res.allowed perms.ManageSchema = res.allowed
case "view_worksmobile":
perms.ViewWorksmobile = res.allowed
case "manage_worksmobile":
perms.ManageWorksmobile = res.allowed
} }
} }
summary.UserPermissions = perms summary.UserPermissions = perms

View File

@@ -368,6 +368,7 @@ tree_hint = "Review parent-child relationships and subtree coverage in the hiera
[msg.admin.tenants.relations] [msg.admin.tenants.relations]
empty = "There are no users with designated fine-grained permissions. Please add a user to configure." empty = "There are no users with designated fine-grained permissions. Please add a user to configure."
remove_all_confirm = "Remove All Confirm" remove_all_confirm = "Remove All Confirm"
super_admin_only_desc = "The permission settings on this screen can only be modified by the system administrator (super_admin)."
subtitle = "Isolate and assign fine-grained view and edit permissions for each tab on a per-user basis. Parent inherited permissions are automatically preserved." subtitle = "Isolate and assign fine-grained view and edit permissions for each tab on a per-user basis. Parent inherited permissions are automatically preserved."
update_success = "Update Success" update_success = "Update Success"

View File

@@ -368,6 +368,7 @@ tree_hint = "계층 구조를 따라 부모-자식 관계와 하위 범위를
[msg.admin.tenants.relations] [msg.admin.tenants.relations]
empty = "세부 권한이 지정된 사용자가 없습니다. 사용자를 추가해 설정하세요." empty = "세부 권한이 지정된 사용자가 없습니다. 사용자를 추가해 설정하세요."
remove_all_confirm = "이 사용자의 모든 세부 권한을 삭제하시겠습니까?" remove_all_confirm = "이 사용자의 모든 세부 권한을 삭제하시겠습니까?"
super_admin_only_desc = "이 화면의 권한 설정은 시스템 최고 관리자(super_admin)만 수정할 수 있습니다."
subtitle = "사용자별로 각 탭의 세부 조회 및 수정 권한을 격리하여 할당합니다. 상위 상속 권한은 자동으로 보존됩니다." subtitle = "사용자별로 각 탭의 세부 조회 및 수정 권한을 격리하여 할당합니다. 상위 상속 권한은 자동으로 보존됩니다."
update_success = "세부 권한이 성공적으로 변경되었습니다." update_success = "세부 권한이 성공적으로 변경되었습니다."

View File

@@ -368,6 +368,7 @@ tree_hint = ""
[msg.admin.tenants.relations] [msg.admin.tenants.relations]
empty = "" empty = ""
remove_all_confirm = "" remove_all_confirm = ""
super_admin_only_desc = ""
subtitle = "" subtitle = ""
update_success = "" update_success = ""

View File

@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:userfront/i18n.dart'; import 'package:userfront/i18n.dart';
import '../../../core/i18n/locale_utils.dart'; import '../../../core/i18n/locale_utils.dart';
import '../../../core/services/auth_proxy_service.dart'; import '../../../core/services/auth_proxy_service.dart';
@@ -1739,105 +1740,49 @@ Matters not expressly provided in this Policy are governed by the Company's inte
), ),
), ),
), ),
const SizedBox(height: 18), const SizedBox(height: 24),
_buildProfileFieldGroup( Container(
title: tr('ui.userfront.signup.profile.affiliation_type'), padding: const EdgeInsets.all(16),
description: '소속 유형과 회사 정보를 입력합니다.', decoration: BoxDecoration(
isDesktop: isDesktop, color: _signupSurface,
trailing: null, borderRadius: BorderRadius.circular(12),
border: Border.all(color: _signupBorder),
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
DropdownButtonFormField<String>( Row(
key: ValueKey(_affiliationType), children: [
initialValue: _affiliationType, Icon(
decoration: InputDecoration( Icons.business,
labelText: tr( color: Theme.of(context).colorScheme.primary,
'ui.userfront.signup.profile.affiliation_type', size: 20,
), ),
border: const OutlineInputBorder(), const SizedBox(width: 8),
), Expanded(
items: [ child: Text(
DropdownMenuItem( '기업/가족사 소속이신가요?',
value: 'GENERAL', style: TextStyle(
child: Text(tr('domain.affiliation.general')), fontWeight: FontWeight.bold,
), fontSize: 14,
DropdownMenuItem( color: _signupInk,
value: 'AFFILIATE', ),
child: Text(tr('domain.affiliation.affiliate')), ),
), ),
], ],
onChanged: _isAffiliateLocked
? null
: (val) {
if (val == null) {
return;
}
setState(() {
_affiliationType = val;
if (_affiliationType == 'GENERAL') {
_companyCode = null;
}
});
},
), ),
AnimatedSize( const SizedBox(height: 8),
duration: const Duration(milliseconds: 180), Text(
curve: Curves.easeOut, '기업 및 가족사 임직원은 연동 문의가 필요합니다.\n\n해당하시는 경우, 사내 관리자 또는 담당자(baroncs@baroncs.co.kr)에게 문의해 주시기 바랍니다.',
child: Column( style: TextStyle(
crossAxisAlignment: CrossAxisAlignment.stretch, fontSize: 12,
children: [ height: 1.45,
if (_affiliationType == 'AFFILIATE') ...[ color: _signupInk.withValues(alpha: 0.7),
const SizedBox(height: 14),
DropdownButtonFormField<String>(
key: ValueKey(_companyCode ?? 'none'),
initialValue: _companyCode,
decoration: InputDecoration(
labelText: tr(
'ui.userfront.signup.profile.company',
),
border: const OutlineInputBorder(),
),
items: _tenants.map((t) {
return DropdownMenuItem<String>(
value: t['slug'],
child: Text(t['name'] ?? t['slug']),
);
}).toList(),
onChanged: (val) =>
setState(() => _companyCode = val),
),
],
],
), ),
), ),
], ],
), ),
), ),
const SizedBox(height: 18),
_buildProfileFieldGroup(
title: _affiliationType == 'AFFILIATE'
? tr('ui.userfront.signup.profile.department')
: tr(
'ui.userfront.signup.profile.department_optional',
),
description: _affiliationType == 'AFFILIATE'
? '가족사 사용자는 부서명을 입력해주세요.'
: '선택 입력 항목입니다.',
isDesktop: isDesktop,
child: TextFormField(
controller: _deptController,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
labelText: _affiliationType == 'AFFILIATE'
? tr('ui.userfront.signup.profile.department')
: tr(
'ui.userfront.signup.profile.department_optional',
),
border: const OutlineInputBorder(),
),
),
),
], ],
), ),
), ),
@@ -2313,15 +2258,7 @@ Matters not expressly provided in this Policy are governed by the Company's inte
canGoNext = true; canGoNext = true;
} }
if (_currentStep == 3) { if (_currentStep == 3) {
final nameOk = _nameController.text.trim().isNotEmpty; canGoNext = _nameController.text.trim().isNotEmpty;
if (_affiliationType == 'GENERAL') {
canGoNext = nameOk;
} else {
// AFFILIATE 필수: 이름 + 가족사 선택 + 부서명
final companyOk = _companyCode != null;
final deptOk = _deptController.text.trim().isNotEmpty;
canGoNext = nameOk && companyOk && deptOk;
}
} }
return Scaffold( return Scaffold(