diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-07-10 19:34:31 +0000 |
|---|---|---|
| committer | Mohamed Bassem <me@mbassem.com> | 2025-07-10 20:45:45 +0000 |
| commit | 333d1610fad10e70759545f223959503288a02c6 (patch) | |
| tree | 3354a21d4fa3b4dc75d03ba5f940bd3c213078fd /apps/web/components/invite | |
| parent | 93049e864ae6d281b60c23dee868bca3f585dd4a (diff) | |
| download | karakeep-333d1610fad10e70759545f223959503288a02c6.tar.zst | |
feat: Add invite user support
Diffstat (limited to 'apps/web/components/invite')
| -rw-r--r-- | apps/web/components/invite/InviteAcceptForm.tsx | 311 |
1 files changed, 311 insertions, 0 deletions
diff --git a/apps/web/components/invite/InviteAcceptForm.tsx b/apps/web/components/invite/InviteAcceptForm.tsx new file mode 100644 index 00000000..dcebed73 --- /dev/null +++ b/apps/web/components/invite/InviteAcceptForm.tsx @@ -0,0 +1,311 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { ActionButton } from "@/components/ui/action-button"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { api } from "@/lib/trpc"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { TRPCClientError } from "@trpc/client"; +import { AlertCircle, Clock, Loader2, Mail, UserPlus } from "lucide-react"; +import { signIn } from "next-auth/react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +const inviteAcceptSchema = z + .object({ + name: z.string().min(1, "Name is required"), + password: z.string().min(8, "Password must be at least 8 characters"), + confirmPassword: z + .string() + .min(8, "Password must be at least 8 characters"), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords don't match", + path: ["confirmPassword"], + }); + +interface InviteAcceptFormProps { + token: string; +} + +export default function InviteAcceptForm({ token }: InviteAcceptFormProps) { + const router = useRouter(); + + const form = useForm<z.infer<typeof inviteAcceptSchema>>({ + resolver: zodResolver(inviteAcceptSchema), + }); + + const [errorMessage, setErrorMessage] = useState<string | null>(null); + + const { + isPending: loading, + data: inviteData, + error, + } = api.invites.get.useQuery({ token }); + + useEffect(() => { + if (error) { + setErrorMessage(error.message); + } + }, [error]); + + const acceptInviteMutation = api.invites.accept.useMutation(); + + const handleBackToSignIn = () => { + router.push("/signin"); + }; + + if (loading) { + return ( + <Card className="w-full"> + <CardHeader className="text-center"> + <CardTitle className="text-2xl font-bold"> + Loading Invitation + </CardTitle> + <CardDescription> + Please wait while we verify your invitation... + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + <div className="flex items-center justify-center"> + <Loader2 className="h-8 w-8 animate-spin text-blue-600" /> + </div> + </CardContent> + </Card> + ); + } + + if (!inviteData) { + return ( + <Card className="w-full"> + <CardHeader className="text-center"> + <CardTitle className="text-2xl font-bold"> + Invalid Invitation + </CardTitle> + <CardDescription> + This invitation link is not valid or has been removed. + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + <div className="flex items-center justify-center"> + <AlertCircle className="h-12 w-12 text-red-500" /> + </div> + + {errorMessage && ( + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription>{errorMessage}</AlertDescription> + </Alert> + )} + + <Button onClick={handleBackToSignIn} className="w-full"> + Back to Sign In + </Button> + </CardContent> + </Card> + ); + } + + if (inviteData.expired) { + return ( + <Card className="w-full"> + <CardHeader className="text-center"> + <CardTitle className="text-2xl font-bold"> + Invitation Expired + </CardTitle> + <CardDescription> + This invitation link has expired and is no longer valid. + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + <div className="flex items-center justify-center"> + <Clock className="h-12 w-12 text-orange-500" /> + </div> + + <div className="space-y-2 text-center"> + <p className="text-sm text-gray-600"> + Please contact an administrator to request a new invitation. + </p> + </div> + + <Button + onClick={handleBackToSignIn} + variant="outline" + className="w-full" + > + Back to Sign In + </Button> + </CardContent> + </Card> + ); + } + + return ( + <Card className="w-full"> + <CardHeader className="text-center"> + <CardTitle className="text-2xl font-bold"> + Accept Your Invitation + </CardTitle> + <CardDescription> + Complete your account setup to join Karakeep + </CardDescription> + </CardHeader> + <CardContent className="space-y-6"> + <div className="flex items-center justify-center"> + <UserPlus className="h-12 w-12 text-blue-600" /> + </div> + + <div className="space-y-2 text-center"> + <div className="flex items-center justify-center space-x-2"> + <Mail className="h-4 w-4 text-gray-500" /> + <p className="text-sm text-gray-600">Invited email:</p> + </div> + <p className="font-medium text-gray-900">{inviteData.email}</p> + </div> + + <Form {...form}> + <form + onSubmit={form.handleSubmit(async (value) => { + try { + await acceptInviteMutation.mutateAsync({ + token, + name: value.name, + password: value.password, + }); + + // Sign in the user after successful account creation + const resp = await signIn("credentials", { + redirect: false, + email: inviteData.email, + password: value.password, + }); + + if (!resp || !resp.ok || resp.error) { + setErrorMessage( + resp?.error ?? + "Account created but sign in failed. Please try signing in manually.", + ); + return; + } + + router.replace("/"); + } catch (e) { + if (e instanceof TRPCClientError) { + setErrorMessage(e.message); + } else { + setErrorMessage("An unexpected error occurred"); + } + } + })} + className="space-y-4" + > + {errorMessage && ( + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription>{errorMessage}</AlertDescription> + </Alert> + )} + + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel>Full Name</FormLabel> + <FormControl> + <Input + type="text" + placeholder="Enter your full name" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="password" + render={({ field }) => ( + <FormItem> + <FormLabel>Password</FormLabel> + <FormControl> + <Input + type="password" + placeholder="Create a password" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="confirmPassword" + render={({ field }) => ( + <FormItem> + <FormLabel>Confirm Password</FormLabel> + <FormControl> + <Input + type="password" + placeholder="Confirm your password" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <ActionButton + type="submit" + loading={ + form.formState.isSubmitting || acceptInviteMutation.isPending + } + className="w-full" + > + {form.formState.isSubmitting || acceptInviteMutation.isPending ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + Creating Account... + </> + ) : ( + "Create Account & Sign In" + )} + </ActionButton> + + <Button + type="button" + variant="ghost" + onClick={handleBackToSignIn} + className="w-full" + > + Back to Sign In + </Button> + </form> + </Form> + </CardContent> + </Card> + ); +} |
