LdapUserPicker.tsx 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. import { useEffect, useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
  4. import { Loader2, Search, Plus, CheckCircle2 } from 'lucide-react';
  5. import { Button } from './Button';
  6. import { api } from '../api/client';
  7. import type { LDAPSearchResult, UserResponse } from '../api/client';
  8. interface LdapUserPickerProps {
  9. onSuccess: (user: UserResponse) => void;
  10. }
  11. const SEARCH_DEBOUNCE_MS = 300;
  12. const MIN_QUERY_LENGTH = 2;
  13. export function LdapUserPicker({ onSuccess }: LdapUserPickerProps) {
  14. const { t } = useTranslation();
  15. const queryClient = useQueryClient();
  16. const [rawQuery, setRawQuery] = useState('');
  17. const [debouncedQuery, setDebouncedQuery] = useState('');
  18. const [selectedDn, setSelectedDn] = useState<string | null>(null);
  19. const [errorMessage, setErrorMessage] = useState<string | null>(null);
  20. // Debounce keystrokes — the search hits the directory and we don't want a
  21. // request per character. 300ms matches the debounce in other typeaheads in
  22. // this app (e.g. file manager).
  23. useEffect(() => {
  24. const trimmed = rawQuery.trim();
  25. if (trimmed.length < MIN_QUERY_LENGTH) {
  26. setDebouncedQuery('');
  27. return;
  28. }
  29. const id = setTimeout(() => setDebouncedQuery(trimmed), SEARCH_DEBOUNCE_MS);
  30. return () => clearTimeout(id);
  31. }, [rawQuery]);
  32. // Reset selection when the query changes so a stale selection from a previous
  33. // search can't be silently submitted.
  34. useEffect(() => {
  35. setSelectedDn(null);
  36. setErrorMessage(null);
  37. }, [debouncedQuery]);
  38. const searchQuery = useQuery({
  39. queryKey: ['ldap-search', debouncedQuery],
  40. queryFn: () => api.searchLDAPDirectory(debouncedQuery),
  41. enabled: debouncedQuery.length >= MIN_QUERY_LENGTH,
  42. staleTime: 30_000,
  43. });
  44. const provisionMutation = useMutation({
  45. mutationFn: (username: string) => api.provisionLDAPUser(username),
  46. onSuccess: (user) => {
  47. queryClient.invalidateQueries({ queryKey: ['users'] });
  48. onSuccess(user);
  49. },
  50. onError: (error: Error) => {
  51. setErrorMessage(error.message || t('users.modal.ldapErrorProvision'));
  52. },
  53. });
  54. const selectedResult = useMemo(
  55. () => searchQuery.data?.find((r) => r.dn === selectedDn) ?? null,
  56. [searchQuery.data, selectedDn]
  57. );
  58. const isShortQuery = rawQuery.trim().length > 0 && rawQuery.trim().length < MIN_QUERY_LENGTH;
  59. const isLoading = searchQuery.isFetching && debouncedQuery.length >= MIN_QUERY_LENGTH;
  60. const hasResults = !!searchQuery.data && searchQuery.data.length > 0;
  61. const showNoResults =
  62. !isLoading && !!searchQuery.data && searchQuery.data.length === 0 && debouncedQuery.length >= MIN_QUERY_LENGTH;
  63. const handleProvision = () => {
  64. if (!selectedResult || selectedResult.already_provisioned) return;
  65. setErrorMessage(null);
  66. provisionMutation.mutate(selectedResult.username);
  67. };
  68. return (
  69. <div className="space-y-4">
  70. {/* Search input */}
  71. <div>
  72. <label className="block text-sm font-medium text-white mb-2">
  73. {t('users.modal.ldapSearchLabel')}
  74. </label>
  75. <div className="relative">
  76. <Search className="w-4 h-4 text-bambu-gray absolute left-3 top-1/2 -translate-y-1/2" />
  77. <input
  78. type="text"
  79. value={rawQuery}
  80. onChange={(e) => setRawQuery(e.target.value)}
  81. className="w-full pl-9 pr-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
  82. placeholder={t('users.modal.ldapSearchPlaceholder')}
  83. autoComplete="off"
  84. />
  85. </div>
  86. {isShortQuery && (
  87. <p className="mt-1 text-xs text-bambu-gray">{t('users.modal.ldapMinChars')}</p>
  88. )}
  89. </div>
  90. {/* Results panel */}
  91. <div className="min-h-[8rem] max-h-64 overflow-y-auto bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
  92. {isLoading && (
  93. <div className="flex items-center justify-center py-8 text-bambu-gray">
  94. <Loader2 className="w-4 h-4 animate-spin mr-2" />
  95. <span>{t('users.modal.ldapSearching')}</span>
  96. </div>
  97. )}
  98. {showNoResults && (
  99. <div className="flex items-center justify-center py-8 text-bambu-gray text-sm">
  100. {t('users.modal.ldapNoResults')}
  101. </div>
  102. )}
  103. {searchQuery.isError && (
  104. <div className="px-3 py-4 text-sm text-red-400">
  105. {searchQuery.error instanceof Error ? searchQuery.error.message : t('users.modal.ldapSearchError')}
  106. </div>
  107. )}
  108. {!isLoading && hasResults && (
  109. <ul className="divide-y divide-bambu-dark-tertiary">
  110. {searchQuery.data!.map((result) => (
  111. <LdapResultRow
  112. key={result.dn}
  113. result={result}
  114. selected={selectedDn === result.dn}
  115. onSelect={() => setSelectedDn(result.dn)}
  116. />
  117. ))}
  118. </ul>
  119. )}
  120. {!isLoading && !searchQuery.data && !searchQuery.isError && (
  121. <div className="flex items-center justify-center py-8 text-bambu-gray text-sm">
  122. {t('users.modal.ldapTypeToSearch')}
  123. </div>
  124. )}
  125. </div>
  126. {/* Selected user summary */}
  127. {selectedResult && (
  128. <div className="bg-bambu-dark-secondary/50 border border-bambu-green/20 rounded-lg p-3 space-y-1">
  129. <p className="text-sm text-white">
  130. <span className="text-bambu-gray">{t('users.modal.ldapSelectedLabel')}: </span>
  131. <span className="font-medium">{selectedResult.username}</span>
  132. {selectedResult.display_name && (
  133. <span className="text-bambu-gray"> — {selectedResult.display_name}</span>
  134. )}
  135. </p>
  136. {selectedResult.email && (
  137. <p className="text-xs text-bambu-gray">{selectedResult.email}</p>
  138. )}
  139. <p className="text-xs text-bambu-gray break-all">{selectedResult.dn}</p>
  140. </div>
  141. )}
  142. {/* Error from the provision mutation */}
  143. {errorMessage && (
  144. <div className="bg-red-500/10 border border-red-500/30 rounded-lg p-3">
  145. <p className="text-sm text-red-400">{errorMessage}</p>
  146. </div>
  147. )}
  148. {/* Submit button */}
  149. <div className="flex justify-end">
  150. <Button
  151. onClick={handleProvision}
  152. disabled={
  153. !selectedResult || selectedResult.already_provisioned || provisionMutation.isPending
  154. }
  155. >
  156. {provisionMutation.isPending ? (
  157. <>
  158. <Loader2 className="w-4 h-4 animate-spin" />
  159. {t('users.modal.ldapProvisioning')}
  160. </>
  161. ) : (
  162. <>
  163. <Plus className="w-4 h-4" />
  164. {t('users.modal.ldapProvision')}
  165. </>
  166. )}
  167. </Button>
  168. </div>
  169. </div>
  170. );
  171. }
  172. interface LdapResultRowProps {
  173. result: LDAPSearchResult;
  174. selected: boolean;
  175. onSelect: () => void;
  176. }
  177. function LdapResultRow({ result, selected, onSelect }: LdapResultRowProps) {
  178. const { t } = useTranslation();
  179. const disabled = result.already_provisioned;
  180. return (
  181. <li>
  182. <button
  183. type="button"
  184. onClick={onSelect}
  185. disabled={disabled}
  186. className={`w-full text-left px-3 py-2 flex items-center gap-3 transition-colors ${
  187. disabled
  188. ? 'opacity-50 cursor-not-allowed'
  189. : selected
  190. ? 'bg-bambu-green/10'
  191. : 'hover:bg-bambu-dark-tertiary'
  192. }`}
  193. >
  194. <div className="flex-1 min-w-0">
  195. <p className="text-sm text-white truncate">
  196. <span className="font-medium">{result.username}</span>
  197. {result.display_name && (
  198. <span className="text-bambu-gray"> — {result.display_name}</span>
  199. )}
  200. </p>
  201. {result.email && (
  202. <p className="text-xs text-bambu-gray truncate">{result.email}</p>
  203. )}
  204. </div>
  205. {disabled && (
  206. <span className="flex items-center gap-1 text-xs text-bambu-gray whitespace-nowrap">
  207. <CheckCircle2 className="w-3.5 h-3.5" />
  208. {t('users.modal.ldapAlreadyProvisioned')}
  209. </span>
  210. )}
  211. </button>
  212. </li>
  213. );
  214. }