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