diff --git a/frontend/src/pages/settings/settings-api-tokens.tsx b/frontend/src/pages/settings/settings-api-tokens.tsx index 6b3c78f2..b6f86974 100644 --- a/frontend/src/pages/settings/settings-api-tokens.tsx +++ b/frontend/src/pages/settings/settings-api-tokens.tsx @@ -1,5 +1,6 @@ import type { ColumnDef } from '@tanstack/react-table'; +import { zodResolver } from '@hookform/resolvers/zod'; import { format, isToday } from 'date-fns'; import { enUS } from 'date-fns/locale'; import { @@ -17,7 +18,9 @@ import { X, } from 'lucide-react'; import { useCallback, useId, useMemo, useState } from 'react'; +import { type Control, Controller, useForm, useFormState } from 'react-hook-form'; import { toast } from 'sonner'; +import * as z from 'zod'; import type { ApiTokenFragmentFragment } from '@/graphql/types'; @@ -58,15 +61,30 @@ import { baseUrl } from '@/models/api'; type APIToken = ApiTokenFragmentFragment; -interface CreateFormData { - expiresAt: Date | null; - name: string; -} +const tokenNameSchema = z.string().trim().max(255, 'Token name must be 255 characters or less').default(''); -interface EditFormData { - name: string; - status: TokenStatusEnum; -} +const createTokenFormSchema = z.object({ + // Nullable in the form state (the date picker starts empty) but required + // for submission — the refine drives `formState.isValid`, which gates the + // Create button without manual checks. Kept as `Date | null` in the + // inferred type so `defaultValues` can be `null` without a cast. + expiresAt: z + .date() + .nullable() + .refine((value) => value !== null, { message: 'Expiration date is required' }), + name: tokenNameSchema, +}); + +const editTokenFormSchema = z.object({ + name: tokenNameSchema, + status: z.nativeEnum(TokenStatusEnum), +}); + +type CreateTokenFormValues = z.infer; +type EditTokenFormValues = z.infer; + +const CREATE_TOKEN_DEFAULTS: CreateTokenFormValues = { expiresAt: null, name: '' }; +const EDIT_TOKEN_DEFAULTS: EditTokenFormValues = { name: '', status: TokenStatusEnum.Active }; const isTokenExpired = (token: APIToken): boolean => { const expiresAt = new Date(token.createdAt); @@ -191,6 +209,81 @@ const createNewTokenPlaceholder: APIToken = { userId: '0', }; +// Inline-row action buttons live in their own components so the validity +// subscription via `useFormState` re-renders only this small subtree on form +// changes, not the entire `SettingsAPITokens` parent / DataTable. +function CreateRowActions({ + control, + isLoading, + onCancel, + onSubmit, +}: { + control: Control; + isLoading: boolean; + onCancel: () => void; + onSubmit: () => void; +}) { + const { isValid } = useFormState({ control }); + + return ( +
+ + +
+ ); +} + +function EditRowActions({ + control, + isLoading, + onCancel, + onSubmit, +}: { + control: Control; + isLoading: boolean; + onCancel: () => void; + onSubmit: () => void; +}) { + const { isValid } = useFormState({ control }); + + return ( +
+ + +
+ ); +} + function SettingsAPITokens() { const { data, error, loading: isLoading } = useApiTokensQuery(); const [createAPIToken, { error: createError, loading: isCreateLoading }] = useCreateApiTokenMutation(); @@ -199,18 +292,31 @@ function SettingsAPITokens() { const [editingTokenId, setEditingTokenId] = useState(null); const [creatingToken, setCreatingToken] = useState(false); - const [editFormData, setEditFormData] = useState({ name: '', status: TokenStatusEnum.Active }); - const [createFormData, setCreateFormData] = useState({ expiresAt: null, name: '' }); const [tokenSecret, setTokenSecret] = useState(null); const [showTokenDialog, setShowTokenDialog] = useState(false); const [deleteErrorMessage, setDeleteErrorMessage] = useState(null); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [deletingToken, setDeletingToken] = useState(null); + // Stable per-instance ids — keep label/for and a11y warnings clean even when // the row re-mounts due to subscription-driven refetches. const createNameFieldId = useId(); const editNameFieldId = useId(); + // Form state lives in the parent so that subscription-driven DataTable + // re-renders and row remounts cannot drop user input. in each + // cell re-subscribes to this state on remount — no values are lost. + const createForm = useForm({ + defaultValues: CREATE_TOKEN_DEFAULTS, + mode: 'onChange', + resolver: zodResolver(createTokenFormSchema), + }); + const editForm = useForm({ + defaultValues: EDIT_TOKEN_DEFAULTS, + mode: 'onChange', + resolver: zodResolver(editTokenFormSchema), + }); + const { filter, pageIndex: currentPage, setFilter, setPage: handlePageChange } = useTableState(); useApiTokenCreatedSubscription({ @@ -231,68 +337,80 @@ function SettingsAPITokens() { }, }); - const handleEdit = useCallback((token: APIToken) => { - setEditingTokenId(token.tokenId); - setEditFormData({ - name: token.name ?? '', - status: token.status, - }); - }, []); + const handleEdit = useCallback( + (token: APIToken) => { + setEditingTokenId(token.tokenId); + editForm.reset({ name: token.name ?? '', status: token.status }); + }, + [editForm], + ); const handleCancelEdit = useCallback(() => { setEditingTokenId(null); - setEditFormData({ name: '', status: TokenStatusEnum.Active }); - }, []); + editForm.reset(EDIT_TOKEN_DEFAULTS); + }, [editForm]); const handleSave = useCallback( async (tokenId: string) => { - const name = editFormData.name.trim() || null; + const valid = await editForm.trigger(); + + if (!valid) { + return; + } + + const values = editForm.getValues(); try { await updateAPIToken({ refetchQueries: ['apiTokens'], variables: { input: { - name, - status: editFormData.status, + name: values.name.trim() || null, + status: values.status, }, tokenId, }, }); setEditingTokenId(null); - setEditFormData({ name: '', status: TokenStatusEnum.Active }); + editForm.reset(EDIT_TOKEN_DEFAULTS); } catch (error) { console.error('Failed to update token:', error); } }, - [editFormData.name, editFormData.status, updateAPIToken], + [editForm, updateAPIToken], ); const handleCreateNew = useCallback(() => { setCreatingToken(true); - setCreateFormData({ expiresAt: null, name: '' }); - }, []); + createForm.reset(CREATE_TOKEN_DEFAULTS); + }, [createForm]); const handleCancelCreate = useCallback(() => { setCreatingToken(false); - setCreateFormData({ expiresAt: null, name: '' }); - }, []); + createForm.reset(CREATE_TOKEN_DEFAULTS); + }, [createForm]); const handleCreate = useCallback(async () => { - if (!createFormData.expiresAt) { + const valid = await createForm.trigger(); + + if (!valid) { return; } - const name = createFormData.name.trim() || null; + const values = createForm.getValues(); + + if (!values.expiresAt) { + return; + } try { - const ttl = calculateTTL(createFormData.expiresAt); + const ttl = calculateTTL(values.expiresAt); const result = await createAPIToken({ refetchQueries: ['apiTokens'], variables: { input: { - name, + name: values.name.trim() || null, ttl, }, }, @@ -304,11 +422,11 @@ function SettingsAPITokens() { } setCreatingToken(false); - setCreateFormData({ expiresAt: null, name: '' }); + createForm.reset(CREATE_TOKEN_DEFAULTS); } catch (error) { console.error('Failed to create token:', error); } - }, [createAPIToken, createFormData.expiresAt, createFormData.name]); + }, [createAPIToken, createForm]); const handleDeleteDialogOpen = useCallback((token: APIToken) => { setDeletingToken(token); @@ -361,34 +479,38 @@ function SettingsAPITokens() { if (isCreating) { return ( - - setCreateFormData((prev) => ({ ...prev, name: event.target.value })) - } - placeholder="Token name (optional)" - value={createFormData.name} + ( + + )} /> ); } if (isEditing) { return ( - setEditFormData((prev) => ({ ...prev, name: event.target.value }))} - placeholder="Token name (optional)" - value={editFormData.name} + ( + + )} /> ); } @@ -464,22 +586,26 @@ function SettingsAPITokens() { } return ( - + ( + + )} + /> ); } @@ -507,37 +633,37 @@ function SettingsAPITokens() { tomorrow.setHours(0, 0, 0, 0); return ( - - - - - - { - setCreateFormData((prev) => ({ ...prev, expiresAt: date || null })); - }} - selected={createFormData.expiresAt || undefined} - /> - - + ( + + + + + + field.onChange(date ?? null)} + selected={field.value ?? undefined} + /> + + + )} + /> ); } @@ -615,49 +741,23 @@ function SettingsAPITokens() { if (isCreating) { return ( -
- - -
+ ); } if (isEditing) { return ( -
- - -
+ handleSave(token.tokenId)} + /> ); } @@ -712,12 +812,10 @@ function SettingsAPITokens() { }, ], [ - createFormData.expiresAt, - createFormData.name, + createForm.control, createNameFieldId, deletingToken, - editFormData.name, - editFormData.status, + editForm.control, editNameFieldId, editingTokenId, handleCancelCreate, @@ -902,4 +1000,7 @@ function SettingsAPITokens() { ); } +// Helper subcomponents so we can use useFormState/useWatch without subscribing +// the whole table to every keystroke. Each watches its own form's validity. + export default SettingsAPITokens;