All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
- Implemented Button component with various props for customization. - Created Modal component with header, content, and footer subcomponents. - Added Spinner component for loading indicators. - Developed Toast component for displaying notifications. - Introduced Tooltip component for contextual hints with keyboard shortcuts. - Added corresponding CSS modules for styling each component. - Updated index file to export new components. - Configured TypeScript settings for the UI package.
137 lines
3.7 KiB
TypeScript
137 lines
3.7 KiB
TypeScript
/**
|
|
* InviteAcceptPage — handles `/invite/:code#key=<secret>`.
|
|
*
|
|
* Pulls the encrypted payload from `api.invites.use`, decrypts it
|
|
* with the URL-fragment secret, stashes the decoded channel-key
|
|
* map in sessionStorage under `pendingInviteKeys` / `pendingInviteCode`,
|
|
* then forwards the user to the register page (or to `/channels/@me`
|
|
* if they're already logged in).
|
|
*
|
|
* The fragment secret never hits the server — browsers don't send
|
|
* `#…` to origin servers — so knowledge of it is the capability that
|
|
* unlocks the invite payload.
|
|
*/
|
|
import { useEffect, useState } from 'react';
|
|
import { useNavigate, useParams } from 'react-router-dom';
|
|
import { useConvex } from 'convex/react';
|
|
import { api } from '../../../../../convex/_generated/api';
|
|
import { usePlatform } from '../../platform';
|
|
import { AuthLayout } from './AuthLayout';
|
|
|
|
export function InviteAcceptPage() {
|
|
const { code } = useParams<{ code: string }>();
|
|
const navigate = useNavigate();
|
|
const convex = useConvex();
|
|
const { crypto } = usePlatform();
|
|
const [status, setStatus] = useState<'working' | 'error'>('working');
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
const run = async () => {
|
|
if (!code) {
|
|
setStatus('error');
|
|
setError('Missing invite code.');
|
|
return;
|
|
}
|
|
|
|
const hash =
|
|
typeof window !== 'undefined' ? window.location.hash : '';
|
|
// Fragment looks like "#key=abcd…"
|
|
const match = hash.match(/[#&]key=([^&]+)/);
|
|
const secret = match ? match[1] : null;
|
|
if (!secret) {
|
|
setStatus('error');
|
|
setError(
|
|
'Invite link is missing its secret. Ask the sender for a fresh link.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await convex.query(api.invites.use, { code });
|
|
if (cancelled) return;
|
|
if ('error' in result) {
|
|
setStatus('error');
|
|
setError(result.error);
|
|
return;
|
|
}
|
|
|
|
// Older invites used to send a placeholder empty payload.
|
|
// Treat that as "no channel keys to import" and continue
|
|
// so the user can still register with the code.
|
|
let keys: Record<string, string> = {};
|
|
if (result.encryptedPayload) {
|
|
try {
|
|
const blob = JSON.parse(result.encryptedPayload) as {
|
|
c: string;
|
|
i: string;
|
|
t: string;
|
|
};
|
|
const plaintext = await crypto.decryptData(
|
|
blob.c,
|
|
secret,
|
|
blob.i,
|
|
blob.t,
|
|
);
|
|
keys = JSON.parse(plaintext) as Record<string, string>;
|
|
} catch {
|
|
setStatus('error');
|
|
setError(
|
|
'Failed to decrypt invite payload. The secret may be wrong or the invite corrupted.',
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
sessionStorage.setItem('pendingInviteCode', code);
|
|
sessionStorage.setItem('pendingInviteKeys', JSON.stringify(keys));
|
|
} catch {
|
|
/* storage blocked — flow still works if same tab */
|
|
}
|
|
|
|
// If the user is already signed in, just take them home —
|
|
// the invite keys are already theirs in that case.
|
|
const loggedIn = !!sessionStorage.getItem('privateKey');
|
|
if (loggedIn) {
|
|
navigate('/channels/@me', { replace: true });
|
|
} else {
|
|
navigate('/register', { replace: true });
|
|
}
|
|
} catch (err: any) {
|
|
if (cancelled) return;
|
|
setStatus('error');
|
|
setError(err?.message ?? 'Failed to accept invite.');
|
|
}
|
|
};
|
|
|
|
void run();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [code, convex, crypto, navigate]);
|
|
|
|
return (
|
|
<AuthLayout>
|
|
<h1 style={{ color: 'var(--text-primary)' }}>
|
|
{status === 'working' ? 'Accepting invite…' : 'Invite Error'}
|
|
</h1>
|
|
{error && (
|
|
<p
|
|
style={{
|
|
color: 'var(--status-danger, #ed4245)',
|
|
marginTop: 12,
|
|
fontSize: 14,
|
|
}}
|
|
>
|
|
{error}
|
|
</p>
|
|
)}
|
|
</AuthLayout>
|
|
);
|
|
}
|
|
|
|
export default InviteAcceptPage;
|