feat(Users): add api_key support for user creation and updates

This commit is contained in:
Kyush 2026-03-27 02:15:44 +09:00
commit aa408841a5
8 changed files with 91 additions and 20 deletions

View file

@ -99,7 +99,7 @@ export const api = {
users: {
getAll: (): Promise<User[]> => fetchJson<User[]>(`${API_BASE}/admin/users`),
getById: (id: number): Promise<User> => fetchJson<User>(`${API_BASE}/admin/users/${id}`),
create: (data: { name: string; email?: string; detail_logging?: boolean }): Promise<User> =>
create: (data: { name: string; email?: string; api_key?: string; detail_logging?: boolean }): Promise<User> =>
fetchJson<User>(`${API_BASE}/admin/users`, { method: 'POST', body: JSON.stringify(data) }),
update: (id: number, data: Partial<User>): Promise<User> =>
fetchJson<User>(`${API_BASE}/admin/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),

View file

@ -35,6 +35,7 @@ type NoticeTone = 'success' | 'warning' | 'danger' | 'info';
interface UserFormState {
name: string;
email: string;
api_key: string;
is_active: boolean;
detail_logging: boolean;
}
@ -42,6 +43,7 @@ interface UserFormState {
const emptyForm = (): UserFormState => ({
name: '',
email: '',
api_key: '',
is_active: true,
detail_logging: false,
});
@ -124,6 +126,7 @@ export const Users: Component = () => {
setForm({
name: user.name,
email: user.email ?? '',
api_key: user.api_key,
is_active: user.is_active,
detail_logging: user.detail_logging,
});
@ -145,6 +148,7 @@ export const Users: Component = () => {
await api.users.update(editingUser()!.id, {
name: current.name.trim(),
email: current.email.trim() || undefined,
api_key: current.api_key.trim() || undefined,
is_active: current.is_active,
detail_logging: current.detail_logging,
});
@ -153,6 +157,7 @@ export const Users: Component = () => {
await api.users.create({
name: current.name.trim(),
email: current.email.trim() || undefined,
api_key: current.api_key.trim() || undefined,
detail_logging: current.detail_logging,
});
setNotice({ tone: 'success', message: 'User created.' });
@ -496,6 +501,17 @@ export const Users: Component = () => {
placeholder="ops@example.com"
onInput={(event) => setForm((current) => ({ ...current, email: event.currentTarget.value }))}
/>
<TextField
label="API Key"
value={form().api_key}
placeholder="Leave blank to auto-generate"
description={
editingUser()
? 'Set a replacement key for migrations or leave blank to keep the current key.'
: 'Optional. Paste a legacy key to preserve it during migration, or leave blank to auto-generate.'
}
onInput={(event) => setForm((current) => ({ ...current, api_key: event.currentTarget.value }))}
/>
<Show when={editingUser()}>
<Checkbox
label="User is active"

View file

@ -52,9 +52,9 @@
| Method | Path | Description |
|--------|------|-------------|
| GET | `/admin/users` | 전체 사용자 목록 |
| POST | `/admin/users` | 사용자 생성 (API 키 자동 발급) |
| POST | `/admin/users` | 사용자 생성 (`api_key` 생략 시 자동 발급, 지정 시 수동 등록) |
| GET | `/admin/users/:id` | 사용자 조회 |
| PUT | `/admin/users/:id` | 사용자 수정 (name, email, is_active, detail_logging) |
| PUT | `/admin/users/:id` | 사용자 수정 (name, email, api_key, is_active, detail_logging) |
| DELETE | `/admin/users/:id` | 사용자 삭제 |
| POST | `/admin/users/:id/regenerate-api-key` | API 키 재발급 |

View file

@ -15,7 +15,7 @@ client/src/
index.ts # TypeScript 타입 정의
routes/
Dashboard.tsx # 운영 요약, 최근 요청, 관리자 토큰 관리
Users.tsx # 사용자 CRUD / 권한 매핑 관리
Users.tsx # 사용자 CRUD / API 키 수동 지정 / 권한 매핑 관리
Backends.tsx # 백엔드 CRUD
Models.tsx # 모델 캐시/리라이트 규칙 관리
Analytics.tsx # 분석 화면

View file

@ -28,7 +28,7 @@ export class UserModel {
}
static create(data: CreateUserData): User {
const apiKey = `sk-${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
const apiKey = data.api_key ?? generateApiKey();
const timestamp = getUtcTimestamp();
const detailLogging = data.detail_logging ?? false;
const stmt = getDb().prepare(
@ -60,6 +60,10 @@ export class UserModel {
updates.push('email = ?');
values.push(data.email);
}
if (data.api_key !== undefined) {
updates.push('api_key = ?');
values.push(data.api_key);
}
if (data.is_active !== undefined) {
updates.push('is_active = ?');
values.push(data.is_active ? 1 : 0);

View file

@ -34,25 +34,29 @@ router.get('/users', (req: Request, res: Response) => {
});
router.post('/users', (req: Request, res: Response) => {
const { name, email, detail_logging } = req.body as CreateUserData;
const { name, email, api_key, detail_logging } = req.body as CreateUserData;
if (!name) {
if (!name?.trim()) {
res.status(400).json({ error: 'Name is required' });
return;
}
const user = UserModel.create({
name,
email,
detail_logging,
});
try {
const user = UserModel.create({
name: name.trim(),
email: email?.trim() || undefined,
api_key: api_key?.trim() || undefined,
detail_logging,
});
const updatedUser = UserModel.regenerateApiKey(user.id);
if (updatedUser) {
user.api_key = updatedUser;
res.status(201).json(user);
} catch (error) {
if (error instanceof Error && error.message.includes('UNIQUE')) {
res.status(409).json({ error: 'API key already exists' });
return;
}
res.status(500).json({ error: 'Failed to create user' });
}
res.status(201).json(user);
});
router.get('/users/:id', (req: Request, res: Response) => {
@ -76,10 +80,30 @@ router.put('/users/:id', (req: Request, res: Response) => {
return;
}
const { name, email, is_active, detail_logging } = req.body as UpdateUserData;
const updatedUser = UserModel.update(id, { name, email, is_active, detail_logging });
const { name, email, api_key, is_active, detail_logging } = req.body as UpdateUserData;
res.json(updatedUser);
if (typeof name === 'string' && !name.trim()) {
res.status(400).json({ error: 'Name cannot be empty' });
return;
}
try {
const updatedUser = UserModel.update(id, {
name: typeof name === 'string' ? name.trim() : undefined,
email: typeof email === 'string' ? email.trim() || undefined : undefined,
api_key: typeof api_key === 'string' ? api_key.trim() || undefined : undefined,
is_active,
detail_logging,
});
res.json(updatedUser);
} catch (error) {
if (error instanceof Error && error.message.includes('UNIQUE')) {
res.status(409).json({ error: 'API key already exists' });
return;
}
res.status(500).json({ error: 'Failed to update user' });
}
});
router.delete('/users/:id', (req: Request, res: Response) => {

View file

@ -36,6 +36,22 @@ describe('Admin API - User Management', () => {
expect(response.body.api_key).toMatch(/^sk-/);
});
it('should create a user with a manually supplied api key', async () => {
const userData = { name: 'Migrated User', api_key: 'legacy-user-key-001' };
const response = await admin.post('/admin/users').send(userData);
expect(response.status).toBe(201);
expect(response.body.api_key).toBe(userData.api_key);
});
it('should return 409 if a manually supplied api key already exists', async () => {
await admin.post('/admin/users').send({ name: 'Duplicate Key Source', api_key: 'legacy-duplicate-key' });
const response = await admin.post('/admin/users').send({ name: 'Duplicate Key Target', api_key: 'legacy-duplicate-key' });
expect(response.status).toBe(409);
expect(response.body.error).toBe('API key already exists');
});
it('should return 400 if name is missing', async () => {
const response = await admin.post('/admin/users').send({ email: 'test@example.com' });
@ -86,6 +102,15 @@ describe('Admin API - User Management', () => {
expect(response.body.email).toBe('updated@example.com');
});
it('should update user api key manually', async () => {
const response = await admin
.put(`/admin/users/${userId}`)
.send({ api_key: 'legacy-updated-key-001' });
expect(response.status).toBe(200);
expect(response.body.api_key).toBe('legacy-updated-key-001');
});
it('should return 404 for non-existent user', async () => {
const response = await admin.put('/admin/users/99999').send({ name: 'Test' });

View file

@ -100,6 +100,7 @@ export interface Permission {
export interface CreateUserData {
name: string;
email?: string;
api_key?: string;
detail_logging?: boolean;
}
@ -118,6 +119,7 @@ export interface CreatePermissionData {
export interface UpdateUserData {
name?: string;
email?: string;
api_key?: string;
is_active?: boolean;
detail_logging?: boolean;
}