forked from baron/baron-sso
내정보 페이지 사용성개선, adminFront user 정보 연동.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space"
|
||||
},
|
||||
"linter": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
139
adminfront/tests/user-management.spec.ts
Normal file
139
adminfront/tests/user-management.spec.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user