Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions app/note/join/[inviteKey]/page.tsx
Original file line number Diff line number Diff line change
@@ -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.'
);
}

Check warning on line 55 in app/note/join/[inviteKey]/page.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Handle this exception or don't catch it at all.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AZ67cJ9nmo_VJOImq3Z0&open=AZ67cJ9nmo_VJOImq3Z0&pullRequest=903
}, [acceptNoteInvite, inviteKey, note, openEditableNote]);

const handleAuthThenAccept = () => {
executeAuthenticatedAction(handleAcceptInvite);
};

if (status === 'loading' || isFetching) {
return (
<PageLayout rightSidebar={false} wideContent>
<div className="flex min-h-[60vh] flex-col items-center justify-center">
<Loader2 className="h-10 w-10 animate-spin text-primary-500" />
<p className="mt-4 text-sm text-gray-600">Loading note invitation...</p>
</div>
</PageLayout>
);
}

if (error || !note) {
return (
<PageLayout rightSidebar={false}>
<div className="mx-auto flex min-h-[60vh] max-w-md flex-col items-center justify-center px-4 text-center">
<div className="mb-4 rounded-full bg-red-50 p-4">
<LockKeyhole className="h-10 w-10 text-red-500" />
</div>
<h1 className="mb-2 text-2xl font-semibold text-gray-900">Invalid Invitation</h1>
<p className="mb-6 text-gray-600">
This invitation link is invalid or has expired. Please ask for a new note invitation.
</p>
<Button onClick={() => router.push('/')}>Return to Home</Button>
</div>
</PageLayout>
);
}

return (
<div className="min-h-screen bg-gray-50">
<PageLayout rightSidebar={false} wideContent>
<div className="mx-auto w-full max-w-4xl px-2 pb-10 pt-4 sm:px-4">
<div className="mb-4 flex flex-col gap-3 rounded-lg border border-gray-200 bg-white p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="mb-1 text-sm font-medium text-primary-600">Note invitation</p>
<h1 className="truncate text-xl font-semibold text-gray-900">{note.title}</h1>
<p className="mt-1 text-sm text-gray-600">
You can read this note now. Accept the invitation to edit it in your notebook.
</p>
</div>

{status === 'authenticated' ? (
<Button
onClick={handleAcceptInvite}
disabled={isAccepting || isPending}
className="w-full gap-2 sm:w-auto"
>
{isAccepting || isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Opening...
</>
) : (
<>
<PencilLine className="h-4 w-4" />
Accept Invite
</>
)}
</Button>
) : (
<Button onClick={handleAuthThenAccept} className="w-full gap-2 sm:w-auto">
<PencilLine className="h-4 w-4" />
Sign in or Create Account
</Button>
)}
</div>

<NotePaperWrapper canvas={false} className="min-h-[70vh] p-6 pl-6 lg:!p-12">
<BlockEditorClientWrapper
content={note.content || ''}
contentJson={note.contentJson}
editable={false}
/>
</NotePaperWrapper>
</div>
</PageLayout>
</div>
);
}
67 changes: 66 additions & 1 deletion hooks/useNote.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -10,6 +10,71 @@ export interface UseNoteOptions {
sendImmediately?: boolean;
}

interface UseNoteInviteState {
invite: NoteInvitePreview | null;
isLoading: boolean;
error: Error | null;
}

type FetchNoteInviteFn = (inviteKey: string) => Promise<NoteInvitePreview>;
type UseNoteInviteReturn = [UseNoteInviteState, FetchNoteInviteFn];

export function useNoteInvite(): UseNoteInviteReturn {
const [invite, setInvite] = useState<NoteInvitePreview | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(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<boolean>;
type UseAcceptNoteInviteReturn = [UseAcceptNoteInviteState, AcceptNoteInviteFn];

export function useAcceptNoteInvite(): UseAcceptNoteInviteReturn {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(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;
Expand Down
60 changes: 60 additions & 0 deletions services/note.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -74,6 +80,60 @@ export class NoteService {
}
}

/**
* Fetches a note by invitation key.
*/
static async getNoteByInviteKey(inviteKey: string): Promise<NoteInvitePreview> {
if (!inviteKey) {
throw new NoteError('Missing invitation key', 'INVALID_PARAMS');
}

try {
const response = await ApiClient.getPublic<any>(
`${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<boolean> {
if (!inviteKey) {
throw new NoteError('Missing invitation key', 'INVALID_PARAMS');
}

try {
await ApiClient.post<any>(`${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
Expand Down