1
0
forked from baron/baron-sso

내정보 페이지 사용성개선, adminFront user 정보 연동.

This commit is contained in:
Lectom C Han
2026-01-30 13:42:41 +09:00
parent 1cb5115f2a
commit 35552943d7
29 changed files with 1586 additions and 472 deletions

View File

@@ -1,6 +1,7 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"formatter": {
"enabled": true,
"indentStyle": "space"
},
"linter": {

View File

@@ -7,6 +7,8 @@
"dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build",
"lint": "biome check .",
"lint:fix": "biome check . --write",
"format": "biome format . --write",
"preview": "vite preview",
"test": "playwright test",
"test:ui": "playwright test --ui"

View File

@@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import { ArrowLeft, ClipboardCopy, Loader2, Save } from "lucide-react";
import * as React from "react";
import { useForm } from "react-hook-form";
import { Link, useNavigate } from "react-router-dom";
@@ -14,12 +14,19 @@ import {
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import { createUser, type UserCreateRequest } from "../../lib/adminApi";
import {
createUser,
type UserCreateRequest,
type UserCreateResponse,
} from "../../lib/adminApi";
function UserCreatePage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [error, setError] = React.useState<string | null>(null);
const [generatedPassword, setGeneratedPassword] = React.useState<string | null>(null);
const [createdEmail, setCreatedEmail] = React.useState<string | null>(null);
const [autoPassword, setAutoPassword] = React.useState(true);
const {
register,
@@ -39,8 +46,13 @@ function UserCreatePage() {
const mutation = useMutation({
mutationFn: createUser,
onSuccess: () => {
onSuccess: (data: UserCreateResponse) => {
queryClient.invalidateQueries({ queryKey: ["users"] });
if (data.initialPassword) {
setGeneratedPassword(data.initialPassword);
setCreatedEmail(data.email);
return;
}
navigate("/users");
},
onError: (err: AxiosError<{ error?: string }>) => {
@@ -50,9 +62,31 @@ function UserCreatePage() {
const onSubmit = (data: UserCreateRequest) => {
setError(null);
setGeneratedPassword(null);
setCreatedEmail(null);
if (autoPassword) {
mutation.mutate({ ...data, password: "" });
return;
}
if (!data.password) {
setError("비밀번호를 입력하거나 자동 생성을 사용해 주세요.");
return;
}
mutation.mutate(data);
};
const onCopyPassword = async () => {
if (!generatedPassword) return;
try {
await navigator.clipboard.writeText(generatedPassword);
} catch (_) {
// ignore
}
};
return (
<div className="max-w-3xl space-y-8">
<header className="flex flex-wrap items-center justify-between gap-4">
@@ -74,12 +108,33 @@ function UserCreatePage() {
</Button>
</header>
{generatedPassword && (
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription>
{createdEmail ? `${createdEmail} 계정의 초기 비밀번호입니다.` : "초기 비밀번호가 생성되었습니다."}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-3 rounded-md border border-dashed px-4 py-3">
<span className="font-mono text-sm">{generatedPassword}</span>
<Button size="sm" variant="outline" onClick={onCopyPassword}>
<ClipboardCopy className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="flex justify-end">
<Button onClick={() => navigate("/users")}> </Button>
</div>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription>
.
</CardDescription>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
@@ -102,22 +157,29 @@ function UserCreatePage() {
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<div className="flex items-center justify-between">
<Label htmlFor="password"></Label>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={autoPassword}
onChange={(event) => setAutoPassword(event.target.checked)}
/>
</label>
</div>
<Input
id="password"
type="password"
placeholder="********"
{...register("password", {
required: "비밀번호는 필수입니다.",
minLength: { value: 6, message: "6자 이상 입력해주세요." },
})}
disabled={autoPassword}
{...register("password")}
/>
<p className="text-xs text-muted-foreground">
.
{autoPassword
? "비워두면 시스템이 초기 비밀번호를 자동 생성합니다."
: "초기 비밀번호를 직접 설정합니다."}
</p>
{errors.password && (
<p className="text-xs text-destructive">{errors.password.message}</p>
)}
</div>
<div className="grid gap-4 md:grid-cols-2">
@@ -203,4 +265,4 @@ function UserCreatePage() {
);
}
export default UserCreatePage;
export default UserCreatePage;

View File

@@ -215,6 +215,7 @@ function UserListPage() {
variant="ghost"
size="icon"
onClick={() => navigate(`/users/${user.id}`)}
aria-label={`사용자 수정: ${user.name}`}
>
<Pencil size={16} />
</Button>
@@ -224,6 +225,7 @@ function UserListPage() {
className="text-destructive hover:text-destructive"
onClick={() => handleDelete(user.id, user.name)}
disabled={deleteMutation.isPending}
aria-label={`사용자 삭제: ${user.name}`}
>
<Trash2 size={16} />
</Button>

View File

@@ -182,7 +182,7 @@ export type UserListResponse = {
export type UserCreateRequest = {
email: string;
password: string;
password?: string;
name: string;
phone?: string;
role?: string;
@@ -190,6 +190,10 @@ export type UserCreateRequest = {
department?: string;
};
export type UserCreateResponse = UserSummary & {
initialPassword?: string;
};
export type UserUpdateRequest = {
password?: string;
name?: string;
@@ -215,7 +219,7 @@ export async function fetchUser(userId: string) {
}
export async function createUser(payload: UserCreateRequest) {
const { data } = await apiClient.post<UserSummary>(
const { data } = await apiClient.post<UserCreateResponse>(
"/v1/admin/users",
payload,
);

View File

@@ -0,0 +1,139 @@
import { expect, test } from "@playwright/test";
type UserSummary = {
id: string;
email: string;
name: string;
phone?: string;
role: string;
status: string;
companyCode?: string;
department?: string;
createdAt: string;
updatedAt: string;
};
type UserCreatePayload = {
email: string;
password?: string;
name: string;
phone?: string;
role?: string;
companyCode?: string;
department?: string;
};
test("user create and delete flow", async ({ page }) => {
const users: UserSummary[] = [];
let idSeq = 1;
await page.route("**/api/v1/admin/users**", async (route) => {
const request = route.request();
const url = new URL(request.url());
const path = url.pathname;
const isCollection = path.endsWith("/api/v1/admin/users");
const isItem = path.includes("/api/v1/admin/users/");
if (request.method() === "GET" && isCollection) {
const search = url.searchParams.get("search")?.toLowerCase() ?? "";
const limit = Number(url.searchParams.get("limit") ?? "50");
const offset = Number(url.searchParams.get("offset") ?? "0");
const filtered = search
? users.filter(
(user) =>
user.name.toLowerCase().includes(search) ||
user.email.toLowerCase().includes(search),
)
: users;
const items = filtered.slice(offset, offset + limit);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items,
limit,
offset,
total: filtered.length,
}),
});
return;
}
if (request.method() === "POST" && isCollection) {
const payload = request.postDataJSON() as UserCreatePayload;
const now = new Date().toISOString();
const user: UserSummary = {
id: `user-${idSeq++}`,
email: payload.email,
name: payload.name,
phone: payload.phone,
role: payload.role ?? "user",
status: "active",
companyCode: payload.companyCode,
department: payload.department,
createdAt: now,
updatedAt: now,
};
users.unshift(user);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(user),
});
return;
}
if (request.method() === "DELETE" && isItem) {
const userId = path.split("/").pop();
const index = users.findIndex((user) => user.id === userId);
if (index === -1) {
await route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ error: "User not found" }),
});
return;
}
users.splice(index, 1);
await route.fulfill({ status: 204, body: "" });
return;
}
await route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ error: "Not found" }),
});
});
await page.goto("/users");
await page.getByRole("link", { name: "사용자 추가" }).click();
await expect(page).toHaveURL(/\/users\/new$/);
const uniqueEmail = `playwright-${Date.now()}@example.com`;
await page.getByRole("checkbox", { name: "자동 생성" }).setChecked(false);
await page.getByLabel("이메일").fill(uniqueEmail);
await page.getByLabel("비밀번호").fill("Test1234!");
await page.getByLabel("이름").fill("Playwright User");
await page.getByLabel("전화번호").fill("010-0000-0000");
await page.getByLabel("회사 코드").fill("E2E");
await page.getByLabel("부서").fill("QA");
await page.getByLabel("역할 (Role)").selectOption("admin");
await page.getByRole("button", { name: "사용자 생성" }).click();
await expect(page).toHaveURL(/\/users$/);
const createdRow = page.locator("tbody tr").filter({ hasText: uniqueEmail });
await expect(createdRow).toBeVisible();
page.once("dialog", (dialog) => dialog.accept());
await createdRow.getByRole("button", { name: /사용자 삭제/ }).click();
await expect(
page.locator("tbody tr").filter({ hasText: uniqueEmail }),
).toHaveCount(0);
});