Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ const CollaborativeDocContext =
interface CollaborativeDocProviderProps {
/** Unique document identifier for collaboration */
docId: string;
/** JWT token for Tiptap Cloud authentication */
token: string | null;
/** User's display name for collaboration cursors */
userName?: string;
/** Loading state to show while the collaboration provider initializes */
Expand All @@ -46,21 +48,23 @@ interface CollaborativeDocProviderProps {
*
* @example
* ```tsx
* <CollaborativeDocProvider docId="proposal-123" userName="Alice" fallback={<Skeleton />}>
* <CollaborativeDocProvider docId="proposal-123" token={collabToken} userName="Alice" fallback={<Skeleton />}>
* <CollaborativeTitleField />
* <CollaborativeEditor />
* </CollaborativeDocProvider>
* ```
*/
export function CollaborativeDocProvider({
docId,
token,
userName = 'Anonymous',
fallback = null,
children,
}: CollaborativeDocProviderProps) {
const { ydoc, provider, status, isSynced, user } = useTiptapCollab({
docId,
enabled: true,
token,
userName,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,13 +240,21 @@ export function ProposalEditor({
draftRef,
]);

// -- Collaboration token ---------------------------------------------------

const { data: collabTokenData } = trpc.decision.getCollabToken.useQuery(
{ proposalProfileId: proposal.profileId },
{ staleTime: 1000 * 60 * 60 },
);

// -- Render ----------------------------------------------------------------

const userName = user.profile?.name ?? t('Anonymous');

return (
<CollaborativeDocProvider
docId={collaborationDocId}
token={collabTokenData?.token ?? null}
userName={userName}
fallback={<ProposalEditorSkeleton />}
>
Expand Down
9 changes: 6 additions & 3 deletions apps/app/src/hooks/useTiptapCollab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface CollabUser {
export interface UseTiptapCollabOptions {
docId: string | null;
enabled?: boolean;
/** JWT token for Tiptap Cloud authentication */
token: string | null;
/** User's display name for the collaboration cursor */
userName?: string;
}
Expand All @@ -33,6 +35,7 @@ export interface UseTiptapCollabReturn {
export function useTiptapCollab({
docId,
enabled = true,
token,
userName = 'Anonymous',
}: UseTiptapCollabOptions): UseTiptapCollabReturn {
const [status, setStatus] = useState<CollabStatus>('connecting');
Expand All @@ -48,7 +51,7 @@ export function useTiptapCollab({
}, [userName]);

useEffect(() => {
if (!enabled || !docId) {
if (!enabled || !docId || !token) {
setStatus('disconnected');
return;
}
Expand All @@ -63,7 +66,7 @@ export function useTiptapCollab({
const newProvider = new TiptapCollabProvider({
name: docId,
appId,
token: 'notoken', // TODO: proper JWT auth
token,
document: ydoc,
onConnect: () => {
setStatus('connected');
Expand All @@ -82,7 +85,7 @@ export function useTiptapCollab({
newProvider.destroy();
setProvider(null);
};
}, [docId, enabled, ydoc]);
}, [docId, enabled, token, ydoc]);

// Update awareness when user info changes
useEffect(() => {
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

73 changes: 73 additions & 0 deletions services/api/src/routers/decision/proposals/getCollabToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { generateCollabToken } from '@op/collab/server';
import { getProfileAccessUser, parseProposalData } from '@op/common';
import { db } from '@op/db/client';
import { TRPCError } from '@trpc/server';
import { checkPermission, permission } from 'access-zones';
import { z } from 'zod';

import { commonAuthedProcedure, router } from '../../../trpcFactory';

export const getCollabTokenRouter = router({
getCollabToken: commonAuthedProcedure({
rateLimit: { windowSize: 60, maxRequests: 30 },
})
.input(
z.object({
proposalProfileId: z.uuid(),
}),
)
.output(z.object({ token: z.string() }))
.query(async ({ ctx, input }) => {
const { user } = ctx;
const { proposalProfileId } = input;

// Look up the proposal by its profile ID
const proposal = await db._query.proposals.findFirst({
where: { profileId: proposalProfileId },
columns: {
id: true,
proposalData: true,
profileId: true,
},
});

if (!proposal) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Proposal not found',
});
}

// Verify user has access to the proposal's profile
const profileAccessUser = await getProfileAccessUser({
user,
profileId: proposalProfileId,
});

const hasAccess = checkPermission(
{ profile: permission.READ },
profileAccessUser?.roles ?? [],
);

if (!hasAccess) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this proposal',
});
}

// Extract the collaboration doc ID from the proposal data
const { collaborationDocId } = parseProposalData(proposal.proposalData);

if (!collaborationDocId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Proposal does not have a collaboration document',
});
}

const token = generateCollabToken(user.id, [collaborationDocId]);

return { token };
}),
});
2 changes: 2 additions & 0 deletions services/api/src/routers/decision/proposals/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createProposalRouter } from './create';
import { deleteProposalRouter } from './delete';
import { exportProposalsRouter } from './export';
import { getProposalRouter } from './get';
import { getCollabTokenRouter } from './getCollabToken';
import { getExportStatusRouter } from './getExportStatus';
import { listProposalsRouter } from './list';
import { submitProposalRouter } from './submit';
Expand All @@ -13,6 +14,7 @@ export const proposalsRouter = mergeRouters(
acceptProposalInviteRouter,
createProposalRouter,
getProposalRouter,
getCollabTokenRouter,
listProposalsRouter,
submitProposalRouter,
updateProposalRouter,
Expand Down
5 changes: 4 additions & 1 deletion services/collab/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"type": "module",
"exports": {
".": "./src/index.ts",
"./server": "./src/server/index.ts",
"./testing": "./__mocks__/index.ts"
},
"main": "./src/index.ts",
Expand All @@ -14,9 +15,11 @@
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"jsonwebtoken": "^9.0.2",
"ky": "^1.8.1"
},
"devDependencies": {
"@op/typescript-config": "workspace:*"
"@op/typescript-config": "workspace:*",
"@types/jsonwebtoken": "^9.0.10"
}
}
1 change: 1 addition & 0 deletions services/collab/src/server/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { generateCollabToken } from './token';
41 changes: 41 additions & 0 deletions services/collab/src/server/token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import jwt from 'jsonwebtoken';

function getTiptapSecret(): string {
const secret = process.env.TIPTAP_SECRET;

if (!secret) {
throw new Error('Missing required environment variable: TIPTAP_SECRET');
}

return secret;
}

/**
* Generate a Tiptap Cloud collaboration JWT for a user.
*
* The token restricts the user to only the specified document names.
*
* @see https://tiptap.dev/docs/collaboration/getting-started/authenticate
*
* @param userId - The authenticated user's identifier
* @param allowedDocumentNames - Document names the user may access (supports trailing wildcards)
* @returns Signed JWT string
*/
export function generateCollabToken(
userId: string,
allowedDocumentNames: string[],
): string {
const secret = getTiptapSecret();

return jwt.sign(
{
sub: userId,
allowedDocumentNames,
},
secret,
{
algorithm: 'HS256',
expiresIn: 60 * 60 * 24, // 24 hours
},
);
}
Loading