feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
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.
This commit is contained in:
136
packages/shared/src/components/auth/InviteAcceptPage.tsx
Normal file
136
packages/shared/src/components/auth/InviteAcceptPage.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user