1
0
forked from baron/baron-sso

사용자 상태 세분화

This commit is contained in:
2026-05-20 10:17:15 +09:00
parent 9112c4fb36
commit 42b49674cc
33 changed files with 876 additions and 590 deletions

View File

@@ -44,6 +44,13 @@ import {
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../components/ui/select";
import { Switch } from "../../components/ui/switch";
import {
Tabs,
@@ -78,6 +85,11 @@ import {
parseOrgChartTenantSelection,
} from "./orgChartPicker";
import type { UserSchemaField } from "./userSchemaFields";
import {
normalizeUserStatusValue,
userStatusLabel,
userStatusValues,
} from "./userStatus";
import { resolvePersonalTenant } from "./utils/personalTenant";
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
@@ -603,7 +615,7 @@ function UserDetailPage() {
name: user.name,
phone: user.phone || "",
role: user.role,
status: user.status,
status: normalizeUserStatusValue(user.status),
tenantSlug:
user.tenantSlug ||
user.joinedTenants?.find(
@@ -1044,21 +1056,25 @@ function UserDetailPage() {
>
{t("ui.admin.users.detail.form.status", "상태")}
</Label>
<div className="flex h-11 items-center gap-3 rounded-md border border-input bg-background px-3">
<Switch
id="status"
checked={watchedStatus === "active"}
onCheckedChange={(checked) =>
setValue("status", checked ? "active" : "inactive")
}
/>
<span className="text-sm text-muted-foreground">
{t(
`ui.common.status.${watchedStatus}`,
watchedStatus || "inactive",
)}
</span>
</div>
<Select
value={normalizeUserStatusValue(watchedStatus || "")}
onValueChange={(value) =>
setValue("status", normalizeUserStatusValue(value), {
shouldDirty: true,
})
}
>
<SelectTrigger id="status" className="h-11 shadow-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{userStatusValues.map((status) => (
<SelectItem key={status} value={status}>
{userStatusLabel(status)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>

View File

@@ -67,7 +67,6 @@ import {
SelectTrigger,
SelectValue,
} from "../../components/ui/select";
import { Switch } from "../../components/ui/switch";
import {
Table,
TableBody,
@@ -93,6 +92,7 @@ import { t } from "../../lib/i18n";
import { isSuperAdminRole } from "../../lib/roles";
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
import {
normalizeUserStatusValue,
type UserStatusValue,
userStatusLabel,
userStatusValues,
@@ -776,30 +776,37 @@ function UserListPage() {
{user.id}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Switch
checked={user.status === "active"}
onCheckedChange={(checked) =>
statusMutation.mutate({
userId: user.id,
status: checked ? "active" : "inactive",
})
}
disabled={
statusMutation.isPending ||
user.id === profile?.id
}
<Select
value={normalizeUserStatusValue(user.status)}
onValueChange={(status) =>
statusMutation.mutate({
userId: user.id,
status,
})
}
disabled={
statusMutation.isPending || user.id === profile?.id
}
>
<SelectTrigger
className="h-8 w-[150px] border-none bg-transparent hover:bg-muted/50 transition-colors px-0 font-medium"
aria-label={t(
"ui.admin.users.list.toggle_status",
"{{name}} 활성 상태",
"ui.admin.users.list.change_status",
"{{name}} 상태 변경",
{ name: user.name },
)}
data-testid={`user-status-toggle-${user.id}`}
/>
<span className="text-sm text-muted-foreground">
{t(`ui.common.status.${user.status}`, user.status)}
</span>
</div>
data-testid={`user-status-select-${user.id}`}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{userStatusValues.map((status) => (
<SelectItem key={status} value={status}>
{userStatusLabel(status)}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Select
@@ -894,13 +901,11 @@ function UserListPage() {
/>
</SelectTrigger>
<SelectContent>
{userStatusValues
.filter((s) => s === "active" || s === "inactive")
.map((status) => (
<SelectItem key={status} value={status}>
{userStatusLabel(status)}
</SelectItem>
))}
{userStatusValues.map((status) => (
<SelectItem key={status} value={status}>
{userStatusLabel(status)}
</SelectItem>
))}
</SelectContent>
</Select>
{canPromoteSuperAdmin && (

View File

@@ -0,0 +1,38 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import {
normalizeUserStatusValue,
userStatusLabel,
userStatusValues,
} from "./userStatus";
vi.mock("../../lib/i18n", () => ({
t: (key: string, fallback?: string) => fallback ?? key,
}));
describe("userStatus", () => {
it("exposes canonical user status values", () => {
expect(userStatusValues).toEqual([
"active",
"temporary_leave",
"suspended",
"preboarding",
"baron_guest",
"extended_leave",
"archived",
]);
});
it("normalizes legacy status values", () => {
expect(normalizeUserStatusValue("inactive")).toBe("preboarding");
expect(normalizeUserStatusValue("leave_of_absence")).toBe(
"temporary_leave",
);
expect(normalizeUserStatusValue("baron_only")).toBe("baron_guest");
});
it("uses canonical labels for legacy status values", () => {
expect(userStatusLabel("baron_only")).toBe("baron_guest");
});
});

View File

@@ -2,13 +2,42 @@ import { t } from "../../lib/i18n";
export const userStatusValues = [
"active",
"inactive",
"temporary_leave",
"suspended",
"leave_of_absence",
"preboarding",
"baron_guest",
"extended_leave",
"archived",
] as const;
export type UserStatusValue = (typeof userStatusValues)[number];
export function userStatusLabel(status: string) {
return t(`ui.common.status.${status}`, status);
export function normalizeUserStatusValue(status: string): UserStatusValue {
switch (status.trim().toLowerCase()) {
case "active":
return "active";
case "temporary_leave":
case "leave_of_absence":
return "temporary_leave";
case "suspended":
case "blocked":
return "suspended";
case "preboarding":
case "inactive":
return "preboarding";
case "baron_guest":
case "baron_only":
return "baron_guest";
case "extended_leave":
return "extended_leave";
case "archived":
return "archived";
default:
return "preboarding";
}
}
export function userStatusLabel(status: string) {
const normalized = normalizeUserStatusValue(status);
return t(`ui.common.status.${normalized}`, normalized);
}

View File

@@ -1281,6 +1281,7 @@ title = "Affiliation & Organization Info"
add = "Add User"
add_to_tenant = "Add to Tenant"
bulk_import = "Bulk Import"
change_status = "Change {{name}} status"
empty = "No users found."
fetch_error = "Failed to fetch user list."
search_label = "Search Users"
@@ -1360,15 +1361,20 @@ user = "User"
[ui.common.status]
active = "Active"
archived = "Archived"
baron_guest = "Baron Guest"
blocked = "Blocked"
extended_leave = "Extended Leave"
failure = "Failure"
inactive = "Inactive"
leave_of_absence = "Leave of absence"
ok = "Ok"
pending = "Pending"
preboarding = "Preboarding"
status = "Status"
success = "Success"
suspended = "Suspended"
temporary_leave = "Temporary Leave"
[test]
key = "Test"

View File

@@ -1283,6 +1283,7 @@ title = "소속 및 조직 정보"
add = "사용자 추가"
add_to_tenant = "테넌트에 추가"
bulk_import = "일괄 임포트"
change_status = "{{name}} 상태 변경"
empty = "검색 결과가 없습니다."
fetch_error = "사용자 목록 조회에 실패했습니다."
search_label = "사용자 검색"
@@ -1362,15 +1363,20 @@ user = "User"
[ui.common.status]
active = "활성"
archived = "보관됨"
baron_guest = "Baron 게스트"
blocked = "차단됨"
extended_leave = "장기휴직"
failure = "실패"
inactive = "비활성"
leave_of_absence = "휴직"
ok = "정상"
pending = "준비 중"
preboarding = "입사대기"
status = "상태"
success = "성공"
suspended = "정지"
temporary_leave = "단기휴무"
[test]
key = "테스트"

View File

@@ -1295,6 +1295,7 @@ title = ""
[ui.admin.users.list]
add = ""
bulk_import = ""
change_status = ""
empty = ""
fetch_error = ""
search_placeholder = ""
@@ -1340,15 +1341,20 @@ user = ""
[ui.common.status]
active = ""
archived = ""
baron_guest = ""
blocked = ""
extended_leave = ""
failure = ""
inactive = ""
leave_of_absence = ""
ok = ""
pending = ""
preboarding = ""
status = ""
success = ""
suspended = ""
temporary_leave = ""
[test]
key = ""