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:
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
48
backend/internal/handler/tenant_assignment_policy_test.go
Normal file
48
backend/internal/handler/tenant_assignment_policy_test.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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 = "세부 권한이 성공적으로 변경되었습니다."
|
||||||
|
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user