Skip to content

Commit de43199

Browse files
committed
Merge anon invite UI (redeem page + anon-invites-anon minting)
2 parents 3eeb961 + 2f315de commit de43199

8 files changed

Lines changed: 939 additions & 0 deletions

File tree

‎.env.example‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ PUBLIC_APP_URL=http://localhost:8080
1818
# Public origin used to build OAuth redirect URIs (falls back to PUBLIC_APP_URL / request origin)
1919
NEXT_PUBLIC_APP_URL=http://localhost:8080
2020

21+
# Anonymous invites: Ed25519 issuer SEED (standard base64, raw 32-byte) for the
22+
# `qryptchat-web` issuer. Lets users mint /anon?invite=… links. Must match the
23+
# ed25519_public_key on the `qryptchat-web` row in invite_issuers. Generate with:
24+
# node -e "const{generateKeyPairSync}=require('crypto');const{publicKey,privateKey}=generateKeyPairSync('ed25519');console.log('seed',privateKey.export({type:'pkcs8',format:'der'}).subarray(-32).toString('base64'));console.log('pub',publicKey.export({type:'spki',format:'der'}).subarray(-32).toString('base64'))"
25+
QRYPT_WEB_ISSUER_SEED=your_ed25519_issuer_seed_here
26+
2127
# CoinPay OAuth2/OIDC ("Log in with CoinPay") — client is registered separately
2228
COINPAY_OAUTH_ISSUER=https://coinpayportal.com
2329
COINPAY_OAUTH_CLIENT_ID=your_coinpay_oauth_client_id_here

‎src/app/anon/page.jsx‎

Lines changed: 380 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,380 @@
1+
'use client';
2+
3+
import { Suspense, useState, useEffect } from 'react';
4+
import { useRouter, useSearchParams } from 'next/navigation';
5+
import { useAuthStore } from '@/lib/stores/auth.js';
6+
import { useShallow } from 'zustand/react/shallow';
7+
import { useMessagesStore } from '@/lib/stores/messages.js';
8+
import { createSupabaseClient } from '@/lib/supabase.js';
9+
import { keyManager } from '@/lib/crypto/key-manager.js';
10+
import { privateKeyManager } from '@/lib/crypto/private-key-manager.js';
11+
import { postQuantumEncryption } from '@/lib/crypto/post-quantum-encryption.js';
12+
import Message from '@/lib/components/Message.jsx';
13+
14+
/** Map register-anon error codes to friendly copy. */
15+
const INVITE_ERRORS = {
16+
invite_used: 'This invite has already been used. Ask for a fresh one.',
17+
expired: 'This invite has expired. Ask for a fresh one.',
18+
bad_signature: 'This invite link is invalid or has been tampered with.',
19+
bad_format: 'This invite link is malformed.',
20+
unknown_issuer: 'This invite was issued by an unrecognized source.'
21+
};
22+
23+
function AnonRegister() {
24+
const router = useRouter();
25+
const searchParams = useSearchParams();
26+
const { authenticated } = useAuthStore(useShallow((s) => ({ authenticated: !!s.user })));
27+
const msgs = useMessagesStore((s) => s.messages);
28+
const removeMsg = useMessagesStore((s) => s.remove);
29+
const msgStore = useMessagesStore.getState();
30+
31+
const [invite, setInvite] = useState('');
32+
const [step, setStep] = useState('loading'); // loading | invalid | profile | backup
33+
const [username, setUsername] = useState('');
34+
const [displayName, setDisplayName] = useState('');
35+
const [submitting, setSubmitting] = useState(false);
36+
const [anonSession, setAnonSession] = useState(null);
37+
const [backupPin, setBackupPin] = useState('');
38+
const [confirmBackupPin, setConfirmBackupPin] = useState('');
39+
const [showBackupPin, setShowBackupPin] = useState(false);
40+
41+
useEffect(() => {
42+
if (authenticated) {
43+
router.replace('/chat');
44+
return;
45+
}
46+
const token = searchParams.get('invite');
47+
if (!token || !token.startsWith('qci1.')) {
48+
setStep('invalid');
49+
return;
50+
}
51+
setInvite(token);
52+
setStep('profile');
53+
}, [authenticated, searchParams, router]);
54+
55+
async function handleCreate(e) {
56+
e?.preventDefault();
57+
if (!username.trim() || submitting) return;
58+
setSubmitting(true);
59+
msgStore.clear();
60+
try {
61+
// 1. Anonymous Supabase session (no phone, no email).
62+
const supabase = createSupabaseClient();
63+
const { data: anonData, error: anonErr } = await supabase.auth.signInAnonymously();
64+
if (anonErr || !anonData?.session) {
65+
msgStore.error('Could not start an anonymous session. Please try again.');
66+
setSubmitting(false);
67+
return;
68+
}
69+
const session = anonData.session;
70+
71+
// 2. Generate on-device post-quantum keys; share the ML-KEM-1024 public key.
72+
await postQuantumEncryption.initialize();
73+
const publicKey = await postQuantumEncryption.getPublicKey();
74+
await keyManager.generateUserKeys();
75+
76+
// 3. Redeem the invite + create the anonymous account.
77+
const res = await fetch('/api/auth/register-anon', {
78+
method: 'POST',
79+
headers: {
80+
'Content-Type': 'application/json',
81+
Authorization: `Bearer ${session.access_token}`
82+
},
83+
body: JSON.stringify({
84+
inviteToken: invite,
85+
username: username.trim(),
86+
displayName: displayName.trim() || username.trim(),
87+
publicKey
88+
})
89+
});
90+
const data = await res.json().catch(() => ({}));
91+
92+
if (!res.ok || !data.success) {
93+
// Roll back the throwaway anon auth session so a retry is clean.
94+
await supabase.auth.signOut().catch(() => {});
95+
const friendly = data.code ? INVITE_ERRORS[data.code] : null;
96+
msgStore.error(friendly || data.error || 'Failed to create your account.');
97+
setSubmitting(false);
98+
return;
99+
}
100+
101+
// 4. Persist session + user the same way the phone path does.
102+
localStorage.setItem('qrypt_user', JSON.stringify(data.user));
103+
localStorage.setItem('qrypt_session', JSON.stringify(session));
104+
useAuthStore.setState({ user: data.user });
105+
106+
setAnonSession(session);
107+
setStep('backup');
108+
msgStore.success('Anonymous account created! Set a Backup PIN to protect your keys.');
109+
} catch {
110+
msgStore.error('Something went wrong creating your account. Please try again.');
111+
} finally {
112+
setSubmitting(false);
113+
}
114+
}
115+
116+
async function setBackupPinFn(e) {
117+
e?.preventDefault();
118+
if (!backupPin || !/^\d+$/.test(backupPin) || backupPin.length < 6 || backupPin.length > 12) {
119+
msgStore.error('PIN must be 6-12 digits');
120+
return;
121+
}
122+
if (backupPin !== confirmBackupPin) {
123+
msgStore.error('PINs do not match');
124+
return;
125+
}
126+
setSubmitting(true);
127+
try {
128+
const headers = { 'Content-Type': 'application/json' };
129+
if (anonSession?.access_token) headers['Authorization'] = `Bearer ${anonSession.access_token}`;
130+
const pinRes = await fetch('/api/auth/backup-pin', {
131+
method: 'POST',
132+
headers,
133+
body: JSON.stringify({ pin: backupPin })
134+
});
135+
if (!pinRes.ok) {
136+
const err = await pinRes.json().catch(() => ({}));
137+
throw new Error(err.error || 'Failed to save PIN');
138+
}
139+
const encryptedData = await privateKeyManager.exportPrivateKeys(backupPin);
140+
const backupRes = await fetch('/api/auth/key-backup', {
141+
method: 'PUT',
142+
headers,
143+
body: JSON.stringify({ encrypted_keys: encryptedData })
144+
});
145+
if (!backupRes.ok) {
146+
const err = await backupRes.json().catch(() => ({}));
147+
throw new Error(err.error || 'Key backup failed');
148+
}
149+
msgStore.success('Backup PIN set and keys backed up! Welcome to QryptChat!');
150+
router.push('/chat');
151+
} catch (err) {
152+
msgStore.error('Failed to set backup PIN: ' + (err.message || 'Unknown error'));
153+
} finally {
154+
setSubmitting(false);
155+
}
156+
}
157+
158+
return (
159+
<div className="auth-container">
160+
<div className="auth-card">
161+
<div className="auth-header">
162+
<div className="logo">
163+
<div className="logo-icon">🕶️</div>
164+
<h1>QryptChat</h1>
165+
</div>
166+
<p className="subtitle">Anonymous, invite-only access</p>
167+
</div>
168+
169+
<div className="messages-container">
170+
{msgs.map((m) => (
171+
<Message
172+
key={m.id}
173+
type={m.type}
174+
message={m.message}
175+
title={m.title}
176+
dismissible={m.dismissible}
177+
autoDismiss={m.autoDismiss}
178+
onDismiss={() => removeMsg(m.id)}
179+
/>
180+
))}
181+
</div>
182+
183+
{step === 'loading' && (
184+
<div className="auth-step">
185+
<p className="step-description">Checking your invite…</p>
186+
</div>
187+
)}
188+
189+
{step === 'invalid' && (
190+
<div className="auth-step">
191+
<h2>Invite required</h2>
192+
<p className="step-description">
193+
This page needs a valid anonymous invite link. Ask whoever invited you for a fresh
194+
<code> qrypt.chat/anon?invite=… </code> link, or sign up with a phone number instead.
195+
</p>
196+
<button className="primary-button" onClick={() => router.push('/auth')}>
197+
Sign up with phone
198+
</button>
199+
</div>
200+
)}
201+
202+
{step === 'profile' && (
203+
<div className="auth-step">
204+
<h2>Create your anonymous identity</h2>
205+
<p className="step-description">
206+
No phone, no email — just pick a username. Your account is end-to-end encrypted and
207+
not linked to whoever invited you.
208+
</p>
209+
<form onSubmit={handleCreate}>
210+
<div className="input-group">
211+
<label htmlFor="username">Username *</label>
212+
<input
213+
id="username"
214+
type="text"
215+
value={username}
216+
onChange={(e) => setUsername(e.target.value)}
217+
placeholder="ghost42"
218+
required
219+
disabled={submitting}
220+
pattern="[a-zA-Z0-9_]+"
221+
autoComplete="off"
222+
/>
223+
</div>
224+
<div className="input-group">
225+
<label htmlFor="displayName">Display Name</label>
226+
<input
227+
id="displayName"
228+
type="text"
229+
value={displayName}
230+
onChange={(e) => setDisplayName(e.target.value)}
231+
placeholder="Anonymous"
232+
disabled={submitting}
233+
autoComplete="off"
234+
/>
235+
</div>
236+
<button
237+
type="submit"
238+
disabled={submitting || !username.trim()}
239+
className="primary-button"
240+
>
241+
{submitting ? (
242+
<>
243+
<span className="loading-spinner" /> Creating…
244+
</>
245+
) : (
246+
'Create anonymous account'
247+
)}
248+
</button>
249+
</form>
250+
</div>
251+
)}
252+
253+
{step === 'backup' && (
254+
<div className="auth-step">
255+
<h2>Set Your Backup PIN</h2>
256+
<p className="step-description">This PIN protects your encryption keys.</p>
257+
<div className="backup-warning">
258+
<p>
259+
<strong>Important:</strong> There is no phone or email on this account. If you lose
260+
this PIN <em>and</em> this device, your account cannot be recovered.
261+
</p>
262+
</div>
263+
<form onSubmit={setBackupPinFn}>
264+
<div className="input-group">
265+
<label>Backup PIN (6-12 digits) *</label>
266+
<div className="password-input">
267+
<input
268+
type={showBackupPin ? 'text' : 'password'}
269+
inputMode="numeric"
270+
value={backupPin}
271+
onChange={(e) => setBackupPin(e.target.value.replace(/\D/g, '').slice(0, 12))}
272+
placeholder="Enter 6-12 digit PIN"
273+
required
274+
disabled={submitting}
275+
className="code-input"
276+
/>
277+
<button
278+
type="button"
279+
className="toggle-password"
280+
onClick={() => setShowBackupPin(!showBackupPin)}
281+
>
282+
{showBackupPin ? '👁️' : '👁️‍🗨️'}
283+
</button>
284+
</div>
285+
</div>
286+
<div className="input-group">
287+
<label>Confirm PIN *</label>
288+
<div className="password-input">
289+
<input
290+
type={showBackupPin ? 'text' : 'password'}
291+
inputMode="numeric"
292+
value={confirmBackupPin}
293+
onChange={(e) =>
294+
setConfirmBackupPin(e.target.value.replace(/\D/g, '').slice(0, 12))
295+
}
296+
placeholder="Confirm your PIN"
297+
required
298+
disabled={submitting}
299+
className="code-input"
300+
/>
301+
</div>
302+
</div>
303+
<div className="button-group">
304+
<button
305+
type="submit"
306+
disabled={submitting || !backupPin || !confirmBackupPin}
307+
className="primary-button"
308+
>
309+
{submitting ? (
310+
<>
311+
<span className="loading-spinner" /> Setting PIN…
312+
</>
313+
) : (
314+
'Set Backup PIN'
315+
)}
316+
</button>
317+
<button
318+
type="button"
319+
className="secondary-button"
320+
onClick={() => {
321+
msgStore.warning('Backup PIN skipped. You can set one later in Settings.');
322+
router.push('/chat');
323+
}}
324+
>
325+
Skip (Not Recommended)
326+
</button>
327+
</div>
328+
</form>
329+
</div>
330+
)}
331+
332+
<div className="auth-footer">
333+
<p className="privacy-note">Your data is encrypted end-to-end. We cannot read your messages.</p>
334+
</div>
335+
</div>
336+
337+
<style>{`
338+
.auth-container { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 1rem; background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%); }
339+
.auth-card { background: var(--color-bg-primary); border-radius: 1rem; padding: 2rem; width: 100%; max-width: 400px; box-shadow: var(--shadow-lg); border: 1px solid var(--color-border-primary); }
340+
.auth-header { text-align: center; margin-bottom: 2rem; }
341+
.logo { display: flex; align-items: center; justify-content: center; gap: .5rem; margin-bottom: .5rem; }
342+
.logo-icon { font-size: 2rem; }
343+
.logo h1 { font-size: 1.5rem; font-weight: 700; color: var(--color-brand-primary); margin: 0; }
344+
.subtitle { color: var(--color-text-secondary); margin: 0; }
345+
.auth-step h2 { font-size: 1.25rem; font-weight: 600; margin-bottom: .5rem; color: var(--color-text-primary); }
346+
.step-description { color: var(--color-text-secondary); margin-bottom: 1.5rem; line-height: 1.5; }
347+
.step-description code { font-family: monospace; font-size: .85em; color: var(--color-text-primary); }
348+
.input-group { margin-bottom: 1rem; }
349+
.input-group label { display: block; font-weight: 500; margin-bottom: .5rem; color: var(--color-text-primary); }
350+
.input-group input { width: 100%; padding: .75rem; border: 1px solid var(--color-border-primary); border-radius: .5rem; font-size: 1rem; background: var(--color-bg-primary); color: var(--color-text-primary); }
351+
.input-group input:focus { outline: none; border-color: var(--color-brand-primary); box-shadow: 0 0 0 3px rgb(99 102 241 / .1); }
352+
.code-input { font-family: monospace; font-size: 1.25rem; text-align: center; letter-spacing: .25em; }
353+
.primary-button { width: 100%; padding: .75rem; background: var(--color-brand-primary); color: white; border: none; border-radius: .5rem; font-size: 1rem; font-weight: 500; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: .5rem; }
354+
.primary-button:hover:not(:disabled) { background: var(--color-brand-secondary); }
355+
.primary-button:disabled { opacity: .6; cursor: not-allowed; }
356+
.secondary-button { width: 100%; padding: .75rem; background: var(--color-bg-secondary); color: var(--color-text-secondary); border: 1px solid var(--color-border-primary); border-radius: .5rem; font-size: .875rem; font-weight: 500; cursor: pointer; }
357+
.secondary-button:hover:not(:disabled) { background: var(--color-bg-tertiary); }
358+
.messages-container { margin-bottom: 1rem; }
359+
.loading-spinner { width: 1rem; height: 1rem; border: 2px solid transparent; border-top: 2px solid currentColor; border-radius: 50%; animation: spin 1s linear infinite; }
360+
@keyframes spin { to { transform: rotate(360deg); } }
361+
.auth-footer { margin-top: 2rem; text-align: center; }
362+
.privacy-note { font-size: .75rem; color: var(--color-text-secondary); }
363+
.backup-warning { background: rgba(239,68,68,.1); border: 1px solid #ef4444; border-radius: .375rem; padding: 1rem; margin: 1rem 0; }
364+
.backup-warning p { color: #dc2626; margin: 0; font-weight: 500; font-size: .875rem; }
365+
.button-group { display: flex; flex-direction: column; gap: .75rem; margin-top: 1.5rem; }
366+
.password-input { position: relative; display: flex; align-items: center; }
367+
.password-input input { padding-right: 3rem; }
368+
.toggle-password { position: absolute; right: .75rem; background: none; border: none; cursor: pointer; font-size: 1rem; }
369+
`}</style>
370+
</div>
371+
);
372+
}
373+
374+
export default function AnonPage() {
375+
return (
376+
<Suspense fallback={<div className="auth-container" />}>
377+
<AnonRegister />
378+
</Suspense>
379+
);
380+
}

0 commit comments

Comments
 (0)