Skip to content
Merged
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
45 changes: 40 additions & 5 deletions docs/INVITES.md
Original file line number Diff line number Diff line change
@@ -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/<code>` | they sign in or sign up, then join |

## Invite an AI agent

Expand Down Expand Up @@ -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/<username>`, 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/<code>`.
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 "<group>" # print the group's link (made if there is none)
qc invite "<group>" --reset # replace it; --off turns it off
qc join https://qrypt.chat/join/<code>
```

API:

```sh
curl -X POST -H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{}' https://qrypt.chat/api/chat/conversations/<id>/invite # -> { invite: { url, code, uses } }; {"reset":true} replaces it
curl -X DELETE -H "Authorization: Bearer $ACCESS_TOKEN" https://qrypt.chat/api/chat/conversations/<id>/invite
curl -X POST -H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{"code":"https://qrypt.chat/join/<code>"}' 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":["<user id>"]}' https://qrypt.chat/api/chat/conversations/<id>/participants
```
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "qryptchat-web",
"private": true,
"version": "0.7.2",
"version": "0.8.0",
"type": "module",
"bin": {
"qc": "./bin/qc.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/qryptchat/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
43 changes: 43 additions & 0 deletions src/app/api/chat/conversations/[id]/invite/route.js
Original file line number Diff line number Diff line change
@@ -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 };
});
28 changes: 28 additions & 0 deletions src/app/api/chat/conversations/[id]/participants/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
}
}
3 changes: 2 additions & 1 deletion src/app/api/chat/conversations/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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
});
Expand Down
17 changes: 17 additions & 0 deletions src/app/api/chat/invites/[code]/route.js
Original file line number Diff line number Diff line change
@@ -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/<code>. */
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 });
}
});
23 changes: 21 additions & 2 deletions src/app/api/conversations/join/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/<code> 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 });
Expand Down
1 change: 1 addition & 0 deletions src/app/api/conversations/load/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
`)
Expand Down
28 changes: 24 additions & 4 deletions src/app/chat/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand All @@ -46,6 +53,8 @@ function ChatPageInner() {

const convParam = searchParams.get('conversation');
if (convParam) handleConversationSelect(convParam);
// A group invite opened before signing in (/join/<code>).
redeemPendingJoin().then((id) => { if (id) handleConversationSelect(id); });
}, [isAuthenticated]);

async function initWebSocket() {
Expand Down Expand Up @@ -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;

Expand All @@ -143,15 +153,16 @@ function ChatPageInner() {
<ChatSidebar
activeConversationId={activeConversationId}
onConversationSelect={handleConversationSelect}
onConversationsLoaded={setConversations}
/>
</div>

<div className={`chat-main${(!showSidebar || !activeConversationId) ? ' show' : ''}`}>
{activeConversationId ? (
<div className="chat-interface">
<div className="chat-header">
<button className="back-button" onClick={() => { setShowSidebar(true); setActiveConversationId(null); setCurrentConversation(null); }}>← Back</button>
<div className="conversation-title">{currentConversation?.name || 'Chat'}</div>
<button className="back-button" onClick={() => { setShowSidebar(true); setActiveConversationId(null); }}>← Back</button>
<div className="conversation-title">{conversationTitle}</div>
<div className="header-actions">
<button className="header-action-btn voice-call-btn" onClick={handleMLKEMVoiceCall} title="Encrypted voice call">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
Expand All @@ -164,6 +175,11 @@ function ChatPageInner() {
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>
</button>
)}
{canAddParticipants && (
<button className="header-action-btn" onClick={() => setShowGroupInviteModal(true)} title="Invite link">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
</button>
)}
<button className="header-action-btn menu-button" onClick={() => setShowSidebar(!showSidebar)}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
Expand Down Expand Up @@ -193,6 +209,10 @@ function ChatPageInner() {
/>
)}

{showGroupInviteModal && activeConversationId && (
<GroupInviteModal conversationId={activeConversationId} onClose={() => setShowGroupInviteModal(false)} />
)}

<style>{`
.chat-container { height: calc(100vh - 4rem); height: calc(100dvh - 4rem); display: flex; flex-direction: column; }
.welcome-banner { background: linear-gradient(135deg, var(--color-brand-primary), var(--color-brand-secondary)); color: white; padding: 1rem; display: flex; align-items: center; justify-content: space-between; }
Expand Down
Loading
Loading