diff --git a/app/note/join/[inviteKey]/page.tsx b/app/note/join/[inviteKey]/page.tsx new file mode 100644 index 000000000..621660c3e --- /dev/null +++ b/app/note/join/[inviteKey]/page.tsx @@ -0,0 +1,140 @@ +'use client'; + +import { useCallback, useEffect, useTransition } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { useSession } from 'next-auth/react'; +import { Loader2, LockKeyhole, PencilLine } from 'lucide-react'; +import { toast } from 'react-hot-toast'; + +import { PageLayout } from '@/app/layouts/PageLayout'; +import { Button } from '@/components/ui/Button'; +import { BlockEditorClientWrapper } from '@/components/Editor/components/BlockEditor/components/BlockEditorClientWrapper'; +import { NotePaperWrapper } from '@/components/Notebook/NotePaperWrapper'; +import { useAuthenticatedAction } from '@/contexts/AuthModalContext'; +import { useAcceptNoteInvite, useNoteInvite } from '@/hooks/useNote'; + +export default function JoinNotePage() { + const params = useParams(); + const router = useRouter(); + const { status } = useSession(); + const { executeAuthenticatedAction } = useAuthenticatedAction(); + const [{ invite, isLoading: isFetching, error }, fetchInvite] = useNoteInvite(); + const [{ isLoading: isAccepting }, acceptNoteInvite] = useAcceptNoteInvite(); + const [isPending, startTransition] = useTransition(); + + const inviteKey = params?.inviteKey as string; + const note = invite?.note; + + useEffect(() => { + if (!inviteKey) return; + + fetchInvite(inviteKey).catch(() => { + console.log('Failed to fetch invited note. The invitation may be invalid or expired.'); + }); + }, [fetchInvite, inviteKey]); + + const openEditableNote = useCallback(() => { + if (!note?.organization?.slug || !note?.id) return; + + startTransition(() => { + router.push(`/notebook/${note.organization.slug}/${note.id}`); + }); + }, [note?.id, note?.organization?.slug, router]); + + const handleAcceptInvite = useCallback(async () => { + if (!inviteKey || !note) return; + + try { + await acceptNoteInvite(inviteKey); + toast.success('Note invite accepted'); + openEditableNote(); + } catch (error) { + toast.error( + 'Unable to accept this invite. Make sure you are signed in with the invited email.' + ); + } + }, [acceptNoteInvite, inviteKey, note, openEditableNote]); + + const handleAuthThenAccept = () => { + executeAuthenticatedAction(handleAcceptInvite); + }; + + if (status === 'loading' || isFetching) { + return ( + +
+ +

Loading note invitation...

+
+
+ ); + } + + if (error || !note) { + return ( + +
+
+ +
+

Invalid Invitation

+

+ This invitation link is invalid or has expired. Please ask for a new note invitation. +

+ +
+
+ ); + } + + return ( +
+ +
+
+
+

Note invitation

+

{note.title}

+

+ You can read this note now. Accept the invitation to edit it in your notebook. +

+
+ + {status === 'authenticated' ? ( + + ) : ( + + )} +
+ + + + +
+
+
+ ); +} diff --git a/hooks/useNote.ts b/hooks/useNote.ts index bb36f0a7b..e9408cac2 100644 --- a/hooks/useNote.ts +++ b/hooks/useNote.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback, useRef } from 'react'; -import { NoteService, NoteError } from '@/services/note.service'; +import { NoteService, NoteError, type NoteInvitePreview } from '@/services/note.service'; import type { NoteWithContent, Note, NoteAccess, NoteContent } from '@/types/note'; import { ID } from '@/types/root'; import { Editor } from '@tiptap/react'; @@ -10,6 +10,71 @@ export interface UseNoteOptions { sendImmediately?: boolean; } +interface UseNoteInviteState { + invite: NoteInvitePreview | null; + isLoading: boolean; + error: Error | null; +} + +type FetchNoteInviteFn = (inviteKey: string) => Promise; +type UseNoteInviteReturn = [UseNoteInviteState, FetchNoteInviteFn]; + +export function useNoteInvite(): UseNoteInviteReturn { + const [invite, setInvite] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchInvite = useCallback(async (inviteKey: string) => { + setIsLoading(true); + setError(null); + + try { + const inviteData = await NoteService.getNoteByInviteKey(inviteKey); + setInvite(inviteData); + return inviteData; + } catch (err) { + const error = err instanceof Error ? err : new Error('Failed to fetch invited note'); + setError(error); + setInvite(null); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + return [{ invite, isLoading, error }, fetchInvite]; +} + +interface UseAcceptNoteInviteState { + isLoading: boolean; + error: Error | null; +} + +type AcceptNoteInviteFn = (inviteKey: string) => Promise; +type UseAcceptNoteInviteReturn = [UseAcceptNoteInviteState, AcceptNoteInviteFn]; + +export function useAcceptNoteInvite(): UseAcceptNoteInviteReturn { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const acceptInvite = useCallback(async (inviteKey: string) => { + setIsLoading(true); + setError(null); + + try { + return await NoteService.acceptNoteInvite(inviteKey); + } catch (err) { + const error = err instanceof Error ? err : new Error('Failed to accept note invitation'); + setError(error); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + return [{ isLoading, error }, acceptInvite]; +} + interface UseNoteState { note: NoteWithContent | null; isLoading: boolean; diff --git a/services/note.service.ts b/services/note.service.ts index 7c496d53a..1cc4b4fa8 100644 --- a/services/note.service.ts +++ b/services/note.service.ts @@ -51,6 +51,12 @@ export interface GetOrganizationNotesParams { documentType?: 'PREREGISTRATION' | 'GRANT' | 'DISCUSSION'; } +export interface NoteInvitePreview { + inviteType: string; + recipientEmail?: string; + note: NoteWithContent; +} + export class NoteService { private static readonly BASE_PATH = '/api'; @@ -74,6 +80,60 @@ export class NoteService { } } + /** + * Fetches a note by invitation key. + */ + static async getNoteByInviteKey(inviteKey: string): Promise { + if (!inviteKey) { + throw new NoteError('Missing invitation key', 'INVALID_PARAMS'); + } + + try { + const response = await ApiClient.getPublic( + `${this.BASE_PATH}/note/${inviteKey}/get_note_by_key/` + ); + + if (!response?.note) { + throw new NoteError('Invalid invitation response', 'INVALID_RESPONSE'); + } + + return { + inviteType: response.invite_type, + recipientEmail: response.recipient_email, + note: transformNoteWithContent(response.note), + }; + } catch (error) { + if (error instanceof NoteError) { + throw error; + } + + const errorMsg = + error instanceof ApiError && error.status === 403 + ? 'This invitation link is invalid or has expired.' + : 'Failed to fetch invited note'; + throw new NoteError(errorMsg, error instanceof Error ? error.message : 'UNKNOWN_ERROR'); + } + } + + /** + * Accepts a note invitation and grants the matching user access. + */ + static async acceptNoteInvite(inviteKey: string): Promise { + if (!inviteKey) { + throw new NoteError('Missing invitation key', 'INVALID_PARAMS'); + } + + try { + await ApiClient.post(`${this.BASE_PATH}/invite/note/${inviteKey}/accept_invite/`); + return true; + } catch (error) { + throw new NoteError( + 'Failed to accept note invitation', + error instanceof Error ? error.message : 'UNKNOWN_ERROR' + ); + } + } + /** * Fetches notes for a specific organization * @param orgSlug - The slug of the organization