kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
2import {
3 AlertTriangle,
4 CheckCircle,
5 ExternalLink,
6 GitBranch,
7 Github,
8 Import,
9 Link,
10 RefreshCw,
11 Unlink,
12 XCircle,
13} from "lucide-react";
14import React from "react";
15import { useForm } from "react-hook-form";
16import { z } from "zod/v4";
17import { RepositoryBrowserModal } from "@/components/project/repository-browser-modal";
18import { Badge } from "@/components/ui/badge";
19import { Button } from "@/components/ui/button";
20import {
21 Form,
22 FormControl,
23 FormField,
24 FormItem,
25 FormLabel,
26 FormMessage,
27} from "@/components/ui/form";
28import { Input } from "@/components/ui/input";
29import { Separator } from "@/components/ui/separator";
30import type { VerifyGithubInstallationResponse } from "@/fetchers/github-integration/verify-github-installation";
31import {
32 useCreateGithubIntegration,
33 useDeleteGithubIntegration,
34 useVerifyGithubInstallation,
35} from "@/hooks/mutations/github-integration/use-create-github-integration";
36import useImportGithubIssues from "@/hooks/mutations/github-integration/use-import-github-issues";
37import useGetGithubIntegration from "@/hooks/queries/github-integration/use-get-github-integration";
38import { cn } from "@/lib/cn";
39import { toast } from "@/lib/toast";
40
41const githubIntegrationSchema = z.object({
42 repositoryOwner: z
43 .string()
44 .min(1, "Repository owner is required")
45 .regex(/^[a-zA-Z0-9-]+$/, "Invalid repository owner format"),
46 repositoryName: z
47 .string()
48 .min(1, "Repository name is required")
49 .regex(/^[a-zA-Z0-9._-]+$/, "Invalid repository name format"),
50});
51
52type GithubIntegrationFormValues = z.infer<typeof githubIntegrationSchema>;
53
54export function GitHubIntegrationSettings({
55 projectId,
56}: {
57 projectId: string;
58}) {
59 const { data: integration, isLoading } = useGetGithubIntegration(projectId);
60 const { mutateAsync: createIntegration, isPending: isCreating } =
61 useCreateGithubIntegration();
62 const { mutateAsync: deleteIntegration, isPending: isDeleting } =
63 useDeleteGithubIntegration();
64 const { mutateAsync: verifyInstallation, isPending: isVerifying } =
65 useVerifyGithubInstallation();
66 const { mutateAsync: importIssues, isPending: isImporting } =
67 useImportGithubIssues();
68
69 const [verificationResult, setVerificationResult] =
70 React.useState<VerifyGithubInstallationResponse | null>(null);
71 const [showRepositoryBrowser, setShowRepositoryBrowser] =
72 React.useState(false);
73
74 const form = useForm<GithubIntegrationFormValues>({
75 resolver: standardSchemaResolver(githubIntegrationSchema),
76 defaultValues: {
77 repositoryOwner: integration?.repositoryOwner || "",
78 repositoryName: integration?.repositoryName || "",
79 },
80 });
81
82 React.useEffect(() => {
83 if (integration) {
84 form.reset({
85 repositoryOwner: integration.repositoryOwner,
86 repositoryName: integration.repositoryName,
87 });
88 }
89 }, [integration, form]);
90
91 const repositoryOwner = form.watch("repositoryOwner");
92 const repositoryName = form.watch("repositoryName");
93
94 const handleVerifyInstallation = React.useCallback(
95 async (data: GithubIntegrationFormValues, showToast = true) => {
96 try {
97 const result = await verifyInstallation(data);
98 setVerificationResult(result);
99
100 if (showToast) {
101 if (result.isInstalled && result.hasRequiredPermissions) {
102 toast.success("GitHub App is properly installed!");
103 } else if (result.isInstalled) {
104 toast.warning(
105 "GitHub App is installed but missing required permissions",
106 );
107 } else if (result.repositoryExists) {
108 toast.warning(
109 "GitHub App needs to be installed on this repository",
110 );
111 } else {
112 toast.error("Repository not found or not accessible");
113 }
114 }
115 } catch (error) {
116 if (showToast) {
117 toast.error(
118 error instanceof Error
119 ? error.message
120 : "Failed to verify GitHub installation",
121 );
122 }
123 setVerificationResult(null);
124 }
125 },
126 [verifyInstallation],
127 );
128
129 React.useEffect(() => {
130 if (repositoryOwner && repositoryName && form.formState.isValid) {
131 handleVerifyInstallation({ repositoryOwner, repositoryName }, false);
132 }
133 }, [
134 repositoryOwner,
135 repositoryName,
136 form.formState.isValid,
137 handleVerifyInstallation,
138 ]);
139
140 const handleRepositorySelect = (repository: {
141 owner: string;
142 name: string;
143 }) => {
144 form.setValue("repositoryOwner", repository.owner, {
145 shouldValidate: true,
146 shouldDirty: true,
147 shouldTouch: true,
148 });
149 form.setValue("repositoryName", repository.name, {
150 shouldValidate: true,
151 shouldDirty: true,
152 shouldTouch: true,
153 });
154 setShowRepositoryBrowser(false);
155
156 setVerificationResult(null);
157 };
158
159 const onSubmit = async (data: GithubIntegrationFormValues) => {
160 try {
161 const verification = await verifyInstallation(data);
162
163 if (!verification.isInstalled) {
164 toast.error("Please install the GitHub App on this repository first");
165 return;
166 }
167
168 if (!verification.hasRequiredPermissions) {
169 toast.error(
170 `GitHub App is missing required permissions: ${verification.missingPermissions?.join(", ") || "issues"}. Please update the app permissions.`,
171 );
172 return;
173 }
174
175 await createIntegration({
176 projectId,
177 data,
178 });
179 toast.success("GitHub integration updated successfully");
180 } catch (error) {
181 toast.error(
182 error instanceof Error
183 ? error.message
184 : "Failed to update GitHub integration",
185 );
186 }
187 };
188
189 const handleDelete = async () => {
190 try {
191 await deleteIntegration(projectId);
192 form.reset({ repositoryOwner: "", repositoryName: "" });
193 setVerificationResult(null);
194 toast.success("GitHub integration removed successfully");
195 } catch (error) {
196 toast.error(
197 error instanceof Error
198 ? error.message
199 : "Failed to remove GitHub integration",
200 );
201 }
202 };
203
204 const handleImportIssues = async () => {
205 try {
206 await importIssues({ projectId });
207 toast.success("Issues imported successfully");
208 } catch (error) {
209 toast.error(
210 error instanceof Error ? error.message : "Failed to import issues",
211 );
212 }
213 };
214
215 if (isLoading) {
216 return (
217 <div className="space-y-4">
218 <div className="space-y-4 border border-border rounded-md p-4 bg-sidebar">
219 <div className="space-y-4">
220 <div className="h-4 bg-muted rounded animate-pulse w-40" />
221 <div className="h-4 bg-muted rounded animate-pulse w-full" />
222 <div className="h-10 bg-muted rounded animate-pulse w-full" />
223 </div>
224 </div>
225 <div className="space-y-4 border border-border rounded-md p-4 bg-sidebar">
226 <div className="space-y-4">
227 <div className="h-4 bg-muted rounded animate-pulse w-40" />
228 <div className="h-10 bg-muted rounded animate-pulse w-full" />
229 <div className="h-10 bg-muted rounded animate-pulse w-full" />
230 </div>
231 </div>
232 </div>
233 );
234 }
235
236 const isConnected = !!integration && integration.isActive;
237 const canImport =
238 isConnected &&
239 verificationResult?.isInstalled &&
240 verificationResult?.hasRequiredPermissions;
241
242 return (
243 <div className="space-y-4">
244 <div className="space-y-4 border border-border rounded-md p-4 bg-sidebar">
245 <div className="flex items-center justify-between">
246 <div className="space-y-0.5">
247 <p className="text-sm font-medium">Connection Status</p>
248 {isConnected ? (
249 <p className="text-xs text-muted-foreground">
250 Repository connected and active
251 </p>
252 ) : (
253 <p className="text-xs text-muted-foreground">
254 No repository connected
255 </p>
256 )}
257 </div>
258 <div className="flex items-center gap-3">
259 {isConnected ? (
260 <div className="flex items-center gap-2">
261 <Badge variant="secondary" className="gap-1">
262 <CheckCircle className="w-3 h-3" />
263 Connected
264 </Badge>
265 </div>
266 ) : (
267 <Badge variant="outline" className="gap-1">
268 <XCircle className="w-3 h-3" />
269 Not Connected
270 </Badge>
271 )}
272 </div>
273 </div>
274
275 {isConnected && (
276 <>
277 <Separator />
278 <div className="flex items-center justify-between">
279 <div className="space-y-0.5">
280 <p className="text-sm font-medium">Repository</p>
281 <p className="text-xs text-muted-foreground">
282 Connected GitHub repository
283 </p>
284 </div>
285 <div className="flex items-center gap-2 text-sm">
286 <Github className="w-4 h-4" />
287 <span className="font-medium">
288 {integration.repositoryOwner}/{integration.repositoryName}
289 </span>
290 <a
291 href={`https://github.com/${integration.repositoryOwner}/${integration.repositoryName}`}
292 target="_blank"
293 rel="noopener noreferrer"
294 className="text-primary hover:text-primary/80 transition-colors"
295 >
296 <ExternalLink className="w-3 h-3" />
297 </a>
298 </div>
299 </div>
300 </>
301 )}
302
303 {isConnected && verificationResult && (
304 <>
305 <Separator />
306 <div className="flex items-center justify-between">
307 <div className="space-y-0.5">
308 <p className="text-sm font-medium">GitHub App Status</p>
309 <p className="text-xs text-muted-foreground">
310 Installation and permissions status
311 </p>
312 </div>
313 <div className="flex items-center gap-2 text-sm">
314 {verificationResult.isInstalled &&
315 verificationResult.hasRequiredPermissions ? (
316 <>
317 <CheckCircle className="h-4 w-4 text-success-foreground" />
318 <span className="font-medium text-success-foreground">
319 Properly configured
320 </span>
321 </>
322 ) : verificationResult.isInstalled ? (
323 <>
324 <AlertTriangle className="h-4 w-4 text-warning-foreground" />
325 <span className="font-medium text-warning-foreground">
326 Missing permissions
327 </span>
328 </>
329 ) : (
330 <>
331 <XCircle className="h-4 w-4 text-destructive-foreground" />
332 <span className="font-medium text-destructive-foreground">
333 Not installed
334 </span>
335 </>
336 )}
337 </div>
338 </div>
339 </>
340 )}
341 </div>
342 <div className="space-y-4 border border-border rounded-md p-4 bg-sidebar">
343 <Form {...form}>
344 <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
345 <FormField
346 control={form.control}
347 name="repositoryOwner"
348 render={({ field }) => (
349 <FormItem>
350 <div className="flex items-center justify-between">
351 <div className="space-y-0.5">
352 <FormLabel className="text-sm font-medium">
353 Repository Owner
354 </FormLabel>
355 <p className="text-xs text-muted-foreground">
356 GitHub username or organization
357 </p>
358 </div>
359 <FormControl>
360 <Input
361 className="w-64"
362 placeholder="e.g., octocat"
363 {...field}
364 disabled={isCreating || isDeleting}
365 />
366 </FormControl>
367 </div>
368 <FormMessage />
369 </FormItem>
370 )}
371 />
372
373 <Separator />
374
375 <FormField
376 control={form.control}
377 name="repositoryName"
378 render={({ field }) => (
379 <FormItem>
380 <div className="flex items-center justify-between">
381 <div className="space-y-0.5">
382 <FormLabel className="text-sm font-medium">
383 Repository Name
384 </FormLabel>
385 <p className="text-xs text-muted-foreground">
386 The repository name
387 </p>
388 </div>
389 <FormControl>
390 <Input
391 className="w-64"
392 placeholder="e.g., my-project"
393 {...field}
394 disabled={isCreating || isDeleting}
395 />
396 </FormControl>
397 </div>
398 <FormMessage />
399 </FormItem>
400 )}
401 />
402
403 <Separator />
404
405 <div className="flex items-center justify-between">
406 <div className="space-y-0.5">
407 <p className="text-sm font-medium">Actions</p>
408 <p className="text-xs text-muted-foreground">
409 Manage your repository connection
410 </p>
411 </div>
412 <div className="flex flex-wrap gap-2">
413 <Button
414 type="button"
415 variant="outline"
416 size="sm"
417 onClick={() => setShowRepositoryBrowser(true)}
418 className="gap-2"
419 >
420 <GitBranch className="size-3" />
421 Browse
422 </Button>
423
424 <Button
425 type="button"
426 variant="outline"
427 size="sm"
428 onClick={() => handleVerifyInstallation(form.getValues())}
429 disabled={isVerifying || !form.formState.isValid}
430 className="gap-2"
431 >
432 <RefreshCw
433 className={cn("size-3", isVerifying && "animate-spin")}
434 />
435 Verify
436 </Button>
437
438 <Button
439 type="submit"
440 size="sm"
441 disabled={
442 isCreating ||
443 isDeleting ||
444 !form.formState.isValid ||
445 (verificationResult
446 ? !verificationResult.isInstalled ||
447 !verificationResult.hasRequiredPermissions
448 : false)
449 }
450 className="gap-2"
451 >
452 <Link className="size-3" />
453 {isConnected ? "Update" : "Connect"}
454 </Button>
455
456 {isConnected && (
457 <Button
458 type="button"
459 variant="destructive"
460 size="sm"
461 onClick={handleDelete}
462 disabled={isCreating || isDeleting}
463 className="gap-2"
464 >
465 <Unlink className="size-3" />
466 Disconnect
467 </Button>
468 )}
469 </div>
470 </div>
471 </form>
472 </Form>
473
474 {verificationResult && (
475 <>
476 <Separator />
477 <div className="space-y-2">
478 <div
479 className={cn(
480 "flex items-start gap-3 p-3 border rounded-md text-sm",
481 verificationResult.isInstalled &&
482 verificationResult.hasRequiredPermissions
483 ? "border-success/25 bg-success/10"
484 : verificationResult.isInstalled
485 ? "border-warning/25 bg-warning/10"
486 : verificationResult.repositoryExists
487 ? "border-warning/25 bg-warning/10"
488 : "border-destructive/25 bg-destructive/10",
489 )}
490 >
491 {verificationResult.isInstalled &&
492 verificationResult.hasRequiredPermissions ? (
493 <CheckCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-success-foreground" />
494 ) : verificationResult.isInstalled ||
495 verificationResult.repositoryExists ? (
496 <AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-warning-foreground" />
497 ) : (
498 <XCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-destructive-foreground" />
499 )}
500 <div className="flex-1">
501 <p className="font-medium">{verificationResult.message}</p>
502
503 {verificationResult.isInstalled &&
504 !verificationResult.hasRequiredPermissions &&
505 verificationResult.missingPermissions && (
506 <div className="mt-2">
507 <p className="text-xs mb-2">
508 Missing permissions:{" "}
509 <strong>
510 {verificationResult.missingPermissions.join(", ")}
511 </strong>
512 </p>
513 <div className="flex gap-2">
514 {verificationResult.settingsUrl && (
515 <Button
516 variant="outline"
517 size="sm"
518 onClick={() =>
519 window.open(
520 verificationResult.settingsUrl,
521 "_blank",
522 )
523 }
524 className="gap-2"
525 >
526 <ExternalLink className="w-3 h-3" />
527 Update Permissions
528 </Button>
529 )}
530 </div>
531 </div>
532 )}
533
534 {!verificationResult.isInstalled &&
535 verificationResult.repositoryExists && (
536 <div className="mt-2">
537 {verificationResult.installationUrl && (
538 <Button
539 variant="outline"
540 size="sm"
541 onClick={() =>
542 window.open(
543 verificationResult.installationUrl,
544 "_blank",
545 )
546 }
547 className="gap-2"
548 >
549 <ExternalLink className="w-3 h-3" />
550 Install GitHub App
551 </Button>
552 )}
553 </div>
554 )}
555 </div>
556 </div>
557 </div>
558 </>
559 )}
560 </div>
561
562 {isConnected && (
563 <div className="space-y-4 border border-border rounded-md p-4 bg-sidebar">
564 <div className="flex items-center justify-between">
565 <div className="space-y-0.5">
566 <p className="text-sm font-medium">Import GitHub Issues</p>
567 <p className="text-xs text-muted-foreground">
568 Import existing issues from your GitHub repository as tasks
569 </p>
570 </div>
571 <div className="flex items-center gap-2">
572 <Button
573 onClick={handleImportIssues}
574 disabled={isImporting || !canImport}
575 className="gap-2"
576 size="sm"
577 variant="outline"
578 >
579 {isImporting ? (
580 <RefreshCw className="size-3 animate-spin" />
581 ) : (
582 <Import className="size-3" />
583 )}
584 {isImporting ? "Importing..." : "Import Issues"}
585 </Button>
586 </div>
587 </div>
588 {!canImport && (
589 <>
590 <Separator />
591 <p className="text-xs text-muted-foreground">
592 Complete the repository configuration above to enable importing
593 </p>
594 </>
595 )}
596 </div>
597 )}
598
599 <RepositoryBrowserModal
600 open={showRepositoryBrowser}
601 onOpenChange={setShowRepositoryBrowser}
602 onSelectRepository={handleRepositorySelect}
603 selectedRepository={
604 repositoryOwner && repositoryName
605 ? `${repositoryOwner}/${repositoryName}`
606 : undefined
607 }
608 />
609 </div>
610 );
611}