From 7317a9b518b97e1325a0003aa83cc9daa90ce48d Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 11 Oct 2026 09:43:21 +0000 Subject: [PATCH 1/2] feat: group invite links, and make Add participants work - Group admins get one invite link per group (https://qrypt.chat/join/): create, copy, reset, turn off. /join/ previews the group and joins it; signed out, it keeps the code and /chat joins after any sign-in. - /api/conversations/join accepts { code } (a code or a link); the sidebar's new Join button opens the Join Group modal, which was never mounted. - POST /api/chat/conversations/[id]/participants did not exist, so Add participants silently 405'd; it now adds people (group admins only). - The chat header never knew the current conversation (always "Chat", Add participants never shown): derive it from the sidebar's list, with my_role. - qc join and qc invite [--reset|--off]. - Migration 20261011000000_conversation_invites (service role only). Co-Authored-By: Claude Opus 5.5 --- docs/INVITES.md | 45 ++++- .../chat/conversations/[id]/invite/route.js | 43 +++++ .../conversations/[id]/participants/route.js | 28 +++ src/app/api/chat/conversations/route.js | 3 +- src/app/api/chat/invites/[code]/route.js | 17 ++ src/app/api/conversations/join/route.js | 23 ++- src/app/api/conversations/load/route.js | 1 + src/app/chat/page.jsx | 28 ++- src/app/join/[code]/page.jsx | 89 +++++++++ src/cli/api.js | 12 ++ src/cli/commands.js | 22 +++ src/lib/chat/group-invites.js | 156 ++++++++++++++++ src/lib/chat/group-invites.test.js | 174 ++++++++++++++++++ src/lib/chat/pending-join.js | 34 ++++ src/lib/components/ChatSidebar.jsx | 25 ++- src/lib/components/JoinGroupModal.jsx | 4 +- .../components/chat/AddParticipantModal.jsx | 10 +- src/lib/components/chat/GroupInviteModal.jsx | 91 +++++++++ .../20261011000000_conversation_invites.sql | 20 ++ 19 files changed, 803 insertions(+), 22 deletions(-) create mode 100644 src/app/api/chat/conversations/[id]/invite/route.js create mode 100644 src/app/api/chat/invites/[code]/route.js create mode 100644 src/app/join/[code]/page.jsx create mode 100644 src/lib/chat/group-invites.js create mode 100644 src/lib/chat/group-invites.test.js create mode 100644 src/lib/chat/pending-join.js create mode 100644 src/lib/components/chat/GroupInviteModal.jsx create mode 100644 supabase/migrations/20261011000000_conversation_invites.sql diff --git a/docs/INVITES.md b/docs/INVITES.md index abbacb1e..b34acb69 100644 --- a/docs/INVITES.md +++ b/docs/INVITES.md @@ -1,12 +1,13 @@ # Inviting agents and people -There are three ways to bring someone into a chat on qrypt.chat: +There are four ways to bring someone into a chat on qrypt.chat: | Who | How | Account they get | | --- | --- | --- | | An AI agent (Claude Code, a bot, a script) | **Settings > AI agents**: link, email or text | an `agent` account with you as its operator, plus a DM with you | | A person with no account | **Settings > Invite Anonymously**: a one-time link | an anonymous account, no phone or email needed | -| A person who already has an account | **New Conversation**: search for their username | none needed; you start a DM or group | +| A person who already has an account | **New chat** (+): search for their username | none needed; you start a DM or group | +| Anyone, into a group you admin | **Invite link** in the group's header: `https://qrypt.chat/join/` | they sign in or sign up, then join | ## Invite an AI agent @@ -107,8 +108,42 @@ curl -X POST -H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: applicat No invite is needed for someone who already has an account: -- **New conversation:** open the **New chat** button (+) in the chat sidebar and search for the person's username. Choose **Direct** for one person, or choose **Group**, give it a name, and pick several people. -- **Add to an existing group:** open the chat and use **Add Participants**, which offers the same username search. +- **New conversation:** click the **New chat** button (+) in the chat sidebar and search for the person's username. Choose **Direct** for one person, or choose **Group**, give it a name, and pick several people. You are the new group's admin. +- **Add to an existing group:** group admins can open the chat and click **Add participants** (the person-plus icon in the header), which offers the same username search. - **Share your profile:** you can send someone your profile link, `https://qrypt.chat/u/`, so they can find you. -> **Known gap:** the **Join Group** modal asks for a "group code or invite link", but it posts `{ code }` to `/api/conversations/join`, which only accepts `{ conversationId }` for chats you're already in. Group invite codes don't work yet, so add people by username instead. +## Invite people to a group with a link + +A group admin can share one link that lets anyone join the group: + +1. Open the group and click **Invite link** (the chain icon in the header). +2. Click **Create invite link**, then **Copy**, and send the link however you like. It looks like `https://qrypt.chat/join/`. +3. Whoever opens the link sees the group's name and how many members it has, then clicks **Join group**. If they aren't signed in, the page sends them to sign in or sign up first, and they join the group right after. + +How group links work: + +- Each group has one link at a time, and it keeps working until an admin changes it. **Reset link** replaces it so the old one stops working, and **Turn off** disables it. The modal shows how many people have joined with it. +- Someone who joins reads messages sent from then on, not earlier ones. Each message is encrypted to whoever is in the group when it is sent. +- Someone who left the group can rejoin with the link. +- People can also paste a link or code into **Join** in the chat sidebar. +- Only group admins can make links or add people. Direct chats have neither. + +From a terminal: + +```sh +qc invite "" # print the group's link (made if there is none) +qc invite "" --reset # replace it; --off turns it off +qc join https://qrypt.chat/join/ +``` + +API: + +```sh +curl -X POST -H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \ + -d '{}' https://qrypt.chat/api/chat/conversations//invite # -> { invite: { url, code, uses } }; {"reset":true} replaces it +curl -X DELETE -H "Authorization: Bearer $ACCESS_TOKEN" https://qrypt.chat/api/chat/conversations//invite +curl -X POST -H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \ + -d '{"code":"https://qrypt.chat/join/"}' https://qrypt.chat/api/conversations/join # -> { conversation_id, joined } +curl -X POST -H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \ + -d '{"participant_ids":[""]}' https://qrypt.chat/api/chat/conversations//participants +``` diff --git a/src/app/api/chat/conversations/[id]/invite/route.js b/src/app/api/chat/conversations/[id]/invite/route.js new file mode 100644 index 00000000..6ce7b7a6 --- /dev/null +++ b/src/app/api/chat/conversations/[id]/invite/route.js @@ -0,0 +1,43 @@ +import { NextResponse } from 'next/server'; +import { withAuth } from '@/lib/api/middleware/auth.js'; +import { getServiceRoleClient } from '@/lib/supabase/service-role.js'; +import { getAppOrigin } from '@/lib/auth/coinpay.js'; +import { GroupInviteError, activeInvite, appUserId, ensureInvite, inviteUrl, requireGroupAdmin, revokeInvite } from '@/lib/chat/group-invites.js'; + +const fail = (error) => + error instanceof GroupInviteError + ? NextResponse.json({ error: error.message, code: error.code }, { status: error.status }) + : (console.error('[conversations/invite]', error), NextResponse.json({ error: 'Internal server error' }, { status: 500 })); + +/** Run as an admin of the group in the route: handler(...) returns the response body. */ +const asAdmin = (handler) => + withAuth(async ({ request, locals, context }) => { + try { + const { id } = (await context?.params) || {}; + const conversationId = String(id ?? '').trim(); + const db = getServiceRoleClient(); + const userId = await appUserId(db, locals.user.id); + await requireGroupAdmin(db, conversationId, userId); + const origin = getAppOrigin(new URL(request.url).origin); + return NextResponse.json(await handler({ db, request, conversationId, userId, origin })); + } catch (error) { + return fail(error); + } + }); + +const shape = (invite, origin) => (invite ? { code: invite.code, url: inviteUrl(invite.code, origin), created_at: invite.created_at, uses: invite.uses } : null); + +/** GET /api/chat/conversations/[id]/invite: the group's active link, or null. */ +export const GET = asAdmin(async ({ db, conversationId, origin }) => ({ invite: shape(await activeInvite(db, conversationId), origin) })); + +/** POST { reset? }: the active link, made if there is none; reset replaces it. */ +export const POST = asAdmin(async ({ db, request, conversationId, userId, origin }) => { + const body = await request.json().catch(() => ({})); + return { invite: shape(await ensureInvite(db, conversationId, userId, { reset: body?.reset === true }), origin) }; +}); + +/** DELETE: turn the link off. */ +export const DELETE = asAdmin(async ({ db, conversationId }) => { + await revokeInvite(db, conversationId); + return { success: true }; +}); diff --git a/src/app/api/chat/conversations/[id]/participants/route.js b/src/app/api/chat/conversations/[id]/participants/route.js index 4ca1a2db..769c3fbd 100644 --- a/src/app/api/chat/conversations/[id]/participants/route.js +++ b/src/app/api/chat/conversations/[id]/participants/route.js @@ -6,6 +6,7 @@ import { createClient } from '@supabase/supabase-js'; import { supabaseAuthCookieName } from '@/lib/supabase/auth-cookie.js'; import { createServiceRoleClient } from '@/lib/supabase/service-role.js'; import { bearerToken } from '@/lib/auth/bearer.js'; +import { GroupInviteError, addMembers, requireGroupAdmin } from '@/lib/chat/group-invites.js'; // Lazy service role client creation let supabaseServiceRole = null; @@ -189,3 +190,30 @@ export async function GET(request, { params } = {}) { return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } + +/** + * POST /api/chat/conversations/[id]/participants { participant_ids: [...] } + * A group admin adds people. They read what is sent from now on, not the history. + */ +export async function POST(request, { params } = {}) { + try { + const { user, error: authError } = await authenticateUser(request); + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + const { id } = await resolveRouteParams(params); + const conversationId = typeof id === 'string' ? id.trim() : ''; + const body = await request.json().catch(() => ({})); + if (!conversationId || !Array.isArray(body?.participant_ids) || !body.participant_ids.length) { + return NextResponse.json({ error: 'participant_ids is required' }, { status: 400 }); + } + const db = getServiceRoleClient(); + await requireGroupAdmin(db, conversationId, user.id); + const added = await addMembers(db, conversationId, body.participant_ids); + return NextResponse.json({ success: true, added }); + } catch (error) { + if (error instanceof GroupInviteError) return NextResponse.json({ error: error.message, code: error.code }, { status: error.status }); + console.error('Error in POST /api/chat/conversations/[id]/participants:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/chat/conversations/route.js b/src/app/api/chat/conversations/route.js index 65c12ee5..95678e2d 100644 --- a/src/app/api/chat/conversations/route.js +++ b/src/app/api/chat/conversations/route.js @@ -40,7 +40,7 @@ export async function GET(request, { params } = {}) { // Check archive status for this conversation const { data: participantData } = await supabase .from('conversation_participants') - .select('archived_at') + .select('archived_at, role') .eq('conversation_id', conv.conversation_id) .eq('user_id', internalUser?.id) .maybeSingle(); @@ -68,6 +68,7 @@ export async function GET(request, { params } = {}) { } return { id: p.user_id, username }; }) ?? [], + my_role: participantData?.role ?? null, is_archived: participantData?.archived_at !== null, archived_at: participantData?.archived_at }); diff --git a/src/app/api/chat/invites/[code]/route.js b/src/app/api/chat/invites/[code]/route.js new file mode 100644 index 00000000..4e1cc82d --- /dev/null +++ b/src/app/api/chat/invites/[code]/route.js @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server'; +import { withAuth } from '@/lib/api/middleware/auth.js'; +import { getServiceRoleClient } from '@/lib/supabase/service-role.js'; +import { GroupInviteError, appUserId, previewInvite } from '@/lib/chat/group-invites.js'; + +/** GET /api/chat/invites/[code]: the group a link leads to, shown on /join/. */ +export const GET = withAuth(async ({ locals, context }) => { + try { + const { code } = (await context?.params) || {}; + const db = getServiceRoleClient(); + return NextResponse.json(await previewInvite(db, code, await appUserId(db, locals.user.id))); + } catch (error) { + if (error instanceof GroupInviteError) return NextResponse.json({ error: error.message, code: error.code }, { status: error.status }); + console.error('[chat/invites]', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +}); diff --git a/src/app/api/conversations/join/route.js b/src/app/api/conversations/join/route.js index 570a1273..7861ca65 100644 --- a/src/app/api/conversations/join/route.js +++ b/src/app/api/conversations/join/route.js @@ -6,15 +6,34 @@ import { NextResponse } from 'next/server'; import { withAuth } from '@/lib/api/middleware/auth.js'; import { sseManager } from '@/lib/api/sse-manager.js'; +import { getServiceRoleClient } from '@/lib/supabase/service-role.js'; +import { GroupInviteError, appUserId, parseInviteCode, redeemInvite } from '@/lib/chat/group-invites.js'; function normalizeConversationId(conversationId) { return typeof conversationId === 'string' ? conversationId.trim() : ''; } +/** { code }: join a group from its invite code or https://qrypt.chat/join/ link. */ +async function joinByInvite(input, authUserId) { + const code = parseInviteCode(input); + if (!code) return NextResponse.json({ error: 'That is not a group invite code or link.' }, { status: 400 }); + try { + const db = getServiceRoleClient(); + const userId = await appUserId(db, authUserId); + const result = await redeemInvite(db, code, userId); + sseManager.joinRoom(userId, result.conversation_id); + return NextResponse.json({ success: true, ...result }); + } catch (error) { + if (error instanceof GroupInviteError) return NextResponse.json({ error: error.message, code: error.code }, { status: error.status }); + throw error; + } +} + export const POST = withAuth(async ({ request, locals }) => { try { - const { conversationId: rawConversationId } = await request.json(); - const conversationId = normalizeConversationId(rawConversationId); + const body = await request.json(); + if (typeof body?.code === 'string') return await joinByInvite(body.code, locals.user.id); + const conversationId = normalizeConversationId(body?.conversationId); if (!conversationId) { return NextResponse.json({ error: 'Missing conversationId' }, { status: 400 }); diff --git a/src/app/api/conversations/load/route.js b/src/app/api/conversations/load/route.js index a1a2ea41..a8aab850 100644 --- a/src/app/api/conversations/load/route.js +++ b/src/app/api/conversations/load/route.js @@ -44,6 +44,7 @@ export const POST = withAuth(async ({ locals }) => { *, participants:conversation_participants( user_id, + role, user:users!conversation_participants_user_id_fkey(id, username, display_name, avatar_url, is_online, last_seen) ) `) diff --git a/src/app/chat/page.jsx b/src/app/chat/page.jsx index 11252c41..9d721813 100644 --- a/src/app/chat/page.jsx +++ b/src/app/chat/page.jsx @@ -9,6 +9,8 @@ import ChatSidebar from '@/lib/components/ChatSidebar.jsx'; import MessageList from '@/lib/components/chat/MessageList.jsx'; import MessageInput from '@/lib/components/chat/MessageInput.jsx'; import AddParticipantModal from '@/lib/components/chat/AddParticipantModal.jsx'; +import GroupInviteModal from '@/lib/components/chat/GroupInviteModal.jsx'; +import { redeemPendingJoin } from '@/lib/chat/pending-join.js'; import EncryptionWarning from '@/lib/components/EncryptionWarning.jsx'; import MLKEMCallInterface from '@/lib/components/calls/MLKEMCallInterface.jsx'; import CallPanel from '@/lib/components/calls/CallPanel.jsx'; @@ -29,7 +31,12 @@ function ChatPageInner() { const [activeConversationId, setActiveConversationId] = useState(null); const [showSidebar, setShowSidebar] = useState(true); const [showAddParticipantModal, setShowAddParticipantModal] = useState(false); - const [currentConversation, setCurrentConversation] = useState(null); + const [showGroupInviteModal, setShowGroupInviteModal] = useState(false); + const [conversations, setConversations] = useState([]); + const currentConversation = conversations.find((c) => c.id === activeConversationId) ?? null; + const conversationTitle = currentConversation + ? currentConversation.name || currentConversation.participants?.find((p) => p.id !== user?.id)?.username || 'Chat' + : 'Chat'; const [showWelcome, setShowWelcome] = useState(false); const [mlkemCallManager, setMlkemCallManager] = useState(null); const [showCallInterface, setShowCallInterface] = useState(false); @@ -46,6 +53,8 @@ function ChatPageInner() { const convParam = searchParams.get('conversation'); if (convParam) handleConversationSelect(convParam); + // A group invite opened before signing in (/join/). + redeemPendingJoin().then((id) => { if (id) handleConversationSelect(id); }); }, [isAuthenticated]); async function initWebSocket() { @@ -120,7 +129,8 @@ function ChatPageInner() { }); } - const canAddParticipants = currentConversation && ['group', 'direct'].includes(currentConversation.type); + // Adding people and invite links are for group admins (the API checks this too). + const canAddParticipants = currentConversation?.type === 'group' && currentConversation.my_role === 'admin'; if (!isAuthenticated) return null; @@ -143,6 +153,7 @@ function ChatPageInner() { @@ -150,8 +161,8 @@ function ChatPageInner() { {activeConversationId ? (
- -
{currentConversation?.name || 'Chat'}
+ +
{conversationTitle}
)} + {canAddParticipants && ( + + )} @@ -193,6 +209,10 @@ function ChatPageInner() { /> )} + {showGroupInviteModal && activeConversationId && ( + setShowGroupInviteModal(false)} /> + )} +
diff --git a/src/lib/components/chat/GroupInviteModal.jsx b/src/lib/components/chat/GroupInviteModal.jsx new file mode 100644 index 00000000..89c183bb --- /dev/null +++ b/src/lib/components/chat/GroupInviteModal.jsx @@ -0,0 +1,91 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +/** + * A group admin's invite link: make it, copy it, reset it (the old link stops + * working) or turn it off. Whoever opens it joins the group and reads what is + * sent from then on. + */ +export default function GroupInviteModal({ conversationId, onClose }) { + const [invite, setInvite] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [copied, setCopied] = useState(false); + const base = `/api/chat/conversations/${encodeURIComponent(conversationId)}/invite`; + + async function call(method, body) { + setBusy(true); + setError(''); + try { + const res = await fetch(base, { + method, credentials: 'include', + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || 'Something went wrong'); + if (method === 'DELETE') setInvite(null); + else setInvite(data.invite ?? null); + } catch (e) { + setError(e.message); + } finally { + setBusy(false); + setLoading(false); + } + } + + useEffect(() => { call('GET'); }, [conversationId]); + + async function copy() { + try { + await navigator.clipboard.writeText(invite.url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + setError('Could not copy. Select the link and copy it.'); + } + } + + return ( +
+
e.stopPropagation()}> +

Invite link

+

Anyone signed in to qrypt.chat who opens this link joins the group. They can read messages sent after they join, not earlier ones.

+ {loading ? ( +

Loading…

+ ) : invite ? ( + <> +
+ e.target.select()} /> + +
+

{invite.uses === 1 ? '1 person has' : `${invite.uses} people have`} joined with this link.

+
+ + +
+ + ) : ( + + )} + {error &&
{error}
} + +
+
+ ); +} diff --git a/supabase/migrations/20261011000000_conversation_invites.sql b/supabase/migrations/20261011000000_conversation_invites.sql new file mode 100644 index 00000000..77467ec6 --- /dev/null +++ b/supabase/migrations/20261011000000_conversation_invites.sql @@ -0,0 +1,20 @@ +-- Group invite links: https://qrypt.chat/join/ adds whoever opens it to a +-- group chat. One active link per group; an admin can reset or revoke it. +-- Read and written only by the API with the service role. + +CREATE TABLE IF NOT EXISTS public.conversation_invites ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + conversation_id UUID NOT NULL REFERENCES public.conversations(id) ON DELETE CASCADE, + code TEXT NOT NULL UNIQUE, + created_by UUID REFERENCES public.users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + revoked_at TIMESTAMPTZ, + uses INTEGER NOT NULL DEFAULT 0 +); + +CREATE UNIQUE INDEX IF NOT EXISTS conversation_invites_one_active + ON public.conversation_invites (conversation_id) WHERE revoked_at IS NULL; + +ALTER TABLE public.conversation_invites ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.conversation_invites FROM anon, authenticated; +GRANT ALL ON public.conversation_invites TO service_role; From 88fdc3ec91353a7f649e18ad5611acafd188a67e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 11 Oct 2026 09:43:46 +0000 Subject: [PATCH 2/2] Release 0.8.0, qc 0.7.0 Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- packages/qryptchat/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index b7b8e6bc..0d20daab 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "qryptchat-web", "private": true, - "version": "0.7.2", + "version": "0.8.0", "type": "module", "bin": { "qc": "./bin/qc.js", diff --git a/packages/qryptchat/package.json b/packages/qryptchat/package.json index bb1453ac..1afa0f9b 100644 --- a/packages/qryptchat/package.json +++ b/packages/qryptchat/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/qryptchat", - "version": "0.6.4", + "version": "0.7.0", "description": "qc: qrypt.chat in your terminal. A full-screen end-to-end encrypted chat client (ML-KEM-1024), plus a scriptable CLI and an MCP server.", "type": "module", "bin": {