Adds user management
This commit is contained in:
@@ -61,6 +61,25 @@ export interface InviteCompleteRequest {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface UserListItem extends User {
|
||||
createdAt: string;
|
||||
invitationPending: boolean;
|
||||
inviteLink: string | null;
|
||||
}
|
||||
|
||||
export interface InviteUserPayload {
|
||||
email: string;
|
||||
role: Exclude<UserRole, 'Owner'>;
|
||||
}
|
||||
|
||||
export interface InviteUserResponse {
|
||||
inviteLink: string;
|
||||
}
|
||||
|
||||
export interface ChangeRolePayload {
|
||||
newRole: UserRole;
|
||||
}
|
||||
|
||||
export type AvailabilityStatus = 'Available' | 'Maintenance' | 'Unavailable';
|
||||
|
||||
export interface AvailabilityResponse {
|
||||
|
||||
@@ -1,83 +1,21 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { api } from '../lib/api-client';
|
||||
|
||||
interface InvitationValidationResponse {
|
||||
valid: boolean;
|
||||
email?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface InvitationCompleteRequest {
|
||||
token: string;
|
||||
name: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface InvitationCompleteResponse {
|
||||
message: string;
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
};
|
||||
}
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api-client';
|
||||
import type { InvitationValidation, InviteCompleteRequest } from './types';
|
||||
|
||||
export function useValidateInvitation(token: string | null) {
|
||||
const [data, setData] = useState<InvitationValidationResponse | null>(null);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
|
||||
const fetchValidation = async () => {
|
||||
setIsPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const url = `/api/v1/Invitation/validate?token=${encodeURIComponent(token)}`;
|
||||
const response = await api.get(url);
|
||||
setData(response as InvitationValidationResponse);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err : new Error(String(err)));
|
||||
setData(null);
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchValidation();
|
||||
}, [token]);
|
||||
|
||||
return {
|
||||
data,
|
||||
isPending,
|
||||
error
|
||||
};
|
||||
return useQuery<InvitationValidation, Error>({
|
||||
queryKey: ['invitation', 'validate', token],
|
||||
queryFn: () =>
|
||||
api.get<InvitationValidation>(
|
||||
`/api/v1/Invitation/validate?token=${encodeURIComponent(token!)}`,
|
||||
),
|
||||
enabled: !!token,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCompleteInvitation() {
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const mutateAsync = useCallback(async (data: InvitationCompleteRequest): Promise<InvitationCompleteResponse> => {
|
||||
setIsPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.post('/api/v1/Invitation/complete', data);
|
||||
return response as InvitationCompleteResponse;
|
||||
} catch (err: unknown) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
setError(error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
mutateAsync,
|
||||
isPending,
|
||||
error
|
||||
};
|
||||
return useMutation<void, Error, InviteCompleteRequest>({
|
||||
mutationFn: (data) => api.post('/api/v1/Invitation/complete', data),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { renderHook, waitFor, act } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { createElement } from 'react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { server } from '@/mocks/server';
|
||||
import { API_BASE } from '@/mocks/auth/fixtures';
|
||||
import { getMockUsers } from '@/mocks/users/handlers';
|
||||
import { useUsers, useInviteUser, useChangeRole } from './useUsers';
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) =>
|
||||
createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
}
|
||||
|
||||
const USERS_URL = `${API_BASE}/api/v1/Users`;
|
||||
|
||||
describe('useUsers', () => {
|
||||
it('starts in loading state', () => {
|
||||
const { result } = renderHook(() => useUsers(), { wrapper: createWrapper() });
|
||||
expect(result.current.isPending).toBe(true);
|
||||
});
|
||||
|
||||
it('returns the users list on success', async () => {
|
||||
const { result } = renderHook(() => useUsers(), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.isPending).toBe(false));
|
||||
|
||||
expect(result.current.isError).toBe(false);
|
||||
expect(result.current.data).toHaveLength(getMockUsers().length);
|
||||
expect(result.current.data?.[0].email).toBe(getMockUsers()[0].email);
|
||||
});
|
||||
|
||||
it('enters error state on 5xx', async () => {
|
||||
server.use(http.get(USERS_URL, () => HttpResponse.json({}, { status: 500 })));
|
||||
|
||||
const { result } = renderHook(() => useUsers(), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.isPending).toBe(false));
|
||||
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useInviteUser', () => {
|
||||
it('returns inviteLink on successful mutation', async () => {
|
||||
const { result } = renderHook(() => useInviteUser(), { wrapper: createWrapper() });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({ email: 'new@example.com', role: 'User' });
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data?.inviteLink).toContain('/invite/complete');
|
||||
});
|
||||
|
||||
it('fails when server returns 400', async () => {
|
||||
server.use(
|
||||
http.post(`${USERS_URL}/invite`, () =>
|
||||
HttpResponse.json({ detail: 'already exists' }, { status: 400 }),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useInviteUser(), { wrapper: createWrapper() });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({ email: 'dup@example.com', role: 'User' }).catch(() => {});
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
});
|
||||
});
|
||||
|
||||
describe('useChangeRole', () => {
|
||||
it('succeeds when server returns 200', async () => {
|
||||
const { result } = renderHook(() => useChangeRole(), { wrapper: createWrapper() });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
userId: '22222222-2222-2222-2222-222222222222',
|
||||
newRole: 'User',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
});
|
||||
|
||||
it('fails when server returns 403', async () => {
|
||||
server.use(
|
||||
http.put(`${USERS_URL}/:userId/role`, () => new HttpResponse(null, { status: 403 })),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useChangeRole(), { wrapper: createWrapper() });
|
||||
|
||||
await act(async () => {
|
||||
await result.current
|
||||
.mutateAsync({ userId: 'any-id', newRole: 'Administrator' })
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api-client';
|
||||
import type { UserListItem, InviteUserPayload, InviteUserResponse, ChangeRolePayload, UserRole } from './types';
|
||||
|
||||
export function useUsers() {
|
||||
return useQuery<UserListItem[], Error>({
|
||||
queryKey: ['users'],
|
||||
queryFn: () => api.get<UserListItem[]>('/api/v1/Users'),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useInviteUser() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<InviteUserResponse, Error, InviteUserPayload>({
|
||||
mutationFn: (data) => api.post<InviteUserResponse>('/api/v1/Users/invite', data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useChangeRole() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, Error, { userId: string; newRole: UserRole }>({
|
||||
mutationFn: ({ userId, newRole }: { userId: string; newRole: UserRole }) =>
|
||||
api.put<void>(`/api/v1/Users/${userId}/role`, { newRole } satisfies ChangeRolePayload),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetUserActive() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, Error, { userId: string; isActive: boolean }>({
|
||||
mutationFn: ({ userId, isActive }) =>
|
||||
api.put<void>(`/api/v1/Users/${userId}/active`, { isActive }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteUser() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, Error, string>({
|
||||
mutationFn: (userId: string) => api.delete<void>(`/api/v1/Users/${userId}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user