Skip to content

Commit 2a8296f

Browse files
committed
feat: add modal for passkey registration
1 parent 1f9b9d0 commit 2a8296f

File tree

6 files changed

+481
-153
lines changed

6 files changed

+481
-153
lines changed

src/RegisterPassKey.tsx

Lines changed: 86 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -11,140 +11,146 @@ import { useNavigate } from 'react-router-dom';
1111
import styles from '@/styles/registerPasskey.module.css';
1212
import { isPasskeySupported, parseUserAgent } from './utils';
1313
import { createFetchWithAuth } from './fetchWithAuth';
14+
import DeviceNameModal from './components/DeviceNameModal';
1415

1516
const RegisterPasskey: React.FC = () => {
1617
const { apiHost, mode } = useAuth();
1718
const { validateToken } = useInternalAuth();
1819
const navigate = useNavigate();
20+
1921
const [status, setStatus] = useState<'idle' | 'success' | 'error' | 'loading'>('idle');
2022
const [message, setMessage] = useState('');
2123
const [passkeyAvailable, setPasskeyAvailable] = useState(false);
2224

25+
const [showDeviceModal, setShowDeviceModal] = useState(false);
26+
const [pendingMetadata, setPendingMetadata] = useState<{
27+
platform: string;
28+
browser: string;
29+
deviceInfo: string;
30+
} | null>(null);
31+
2332
const fetchWithAuth = createFetchWithAuth({
2433
authMode: mode,
2534
authHost: apiHost,
2635
});
2736

28-
const handlePasskeyRegister = async () => {
29-
setStatus('loading');
30-
const friendlyName = prompt(
31-
"Name this device (e.g., 'MacBook Pro', 'iPhone', 'YubiKey')"
32-
);
37+
useEffect(() => {
38+
async function checkSupport() {
39+
const supported = await isPasskeySupported();
40+
setPasskeyAvailable(supported);
41+
}
42+
43+
checkSupport();
44+
}, []);
3345

46+
const openDeviceModal = () => {
3447
const { platform, browser, deviceInfo } = parseUserAgent();
3548

49+
setPendingMetadata({ platform, browser, deviceInfo });
50+
setShowDeviceModal(true);
51+
};
52+
53+
const continueRegistration = async (friendlyName: string) => {
54+
if (!pendingMetadata) return;
55+
56+
const { platform, browser, deviceInfo } = pendingMetadata;
57+
58+
setStatus('loading');
59+
3660
try {
3761
const challengeRes = await fetchWithAuth(`/webAuthn/register/start`, {
3862
method: 'GET',
39-
headers: {
40-
'Content-Type': 'application/json',
41-
},
63+
headers: { 'Content-Type': 'application/json' },
4264
credentials: 'include',
4365
});
4466

4567
if (!challengeRes.ok) {
46-
setStatus('error');
47-
setMessage('Something went wrong registering passkey.');
48-
return;
68+
throw new Error('Failed to fetch challenge');
4969
}
5070

5171
const options = await challengeRes.json();
5272

5373
let attResp: RegistrationResponseJSON;
74+
5475
try {
5576
attResp = await startRegistration({ optionsJSON: options });
56-
57-
await verifyPassKey(attResp, { friendlyName, platform, browser, deviceInfo });
5877
} catch (error) {
5978
if (error instanceof WebAuthnError) {
60-
console.error(
61-
`Error occurred with webAuthn, ${error.name} - ${error.code}-${error.stack}`
62-
);
63-
setStatus('error');
64-
setMessage(`Error: ${error.name}`);
65-
} else {
66-
console.error('A problem happened.', error);
67-
setStatus('error');
68-
setMessage(`Error: ${error}`);
79+
throw new Error(error.name);
6980
}
70-
return;
81+
throw error;
7182
}
7283

84+
await verifyPassKey(attResp, {
85+
friendlyName,
86+
platform,
87+
browser,
88+
deviceInfo,
89+
});
90+
7391
setStatus('success');
7492
setMessage('Passkey registered successfully.');
7593
navigate('/');
76-
} catch (err) {
77-
console.error(err);
94+
} catch (error) {
95+
console.error(error);
7896
setStatus('error');
79-
setMessage('Something went wrong registering passkey.');
97+
setMessage('Error registering passkey.');
98+
} finally {
99+
setShowDeviceModal(false);
100+
setPendingMetadata(null);
80101
}
81102
};
82103

83104
const verifyPassKey = async (
84105
attResp: RegistrationResponseJSON,
85106
metadata: {
86-
friendlyName: string | null;
107+
friendlyName: string;
87108
platform: string;
88109
browser: string;
89110
deviceInfo: string;
90111
}
91112
) => {
92-
try {
93-
const verificationResp = await fetchWithAuth(`/webAuthn/register/finish`, {
94-
method: 'POST',
95-
headers: {
96-
'Content-Type': 'application/json',
97-
},
98-
body: JSON.stringify({
99-
attestationResponse: attResp,
100-
metadata,
101-
}),
102-
credentials: 'include',
103-
});
104-
105-
if (!verificationResp.ok) {
106-
setStatus('error');
107-
setMessage('Something went wrong registering passkey.');
108-
return;
109-
}
110-
111-
await validateToken();
112-
} catch (error) {
113-
console.error(`An error occurred: ${error}`);
113+
const verificationResp = await fetchWithAuth(`/webAuthn/register/finish`, {
114+
method: 'POST',
115+
headers: { 'Content-Type': 'application/json' },
116+
body: JSON.stringify({
117+
attestationResponse: attResp,
118+
metadata,
119+
}),
120+
credentials: 'include',
121+
});
122+
123+
if (!verificationResp.ok) {
124+
throw new Error('Verification failed');
114125
}
115-
};
116126

117-
useEffect(() => {
118-
async function checkSupport() {
119-
const supported = await isPasskeySupported();
120-
setPasskeyAvailable(supported);
121-
}
122-
123-
checkSupport();
124-
}, []);
127+
await validateToken();
128+
};
125129

126130
return (
127-
<div className={styles.container}>
128-
<div className={styles.card}>
129-
{!passkeyAvailable ? (
130-
<div className={styles.loading}>
131-
<div className={styles.spinner}></div>
132-
<span>Checking for Passkey Support...</span>
133-
</div>
134-
) : (
135-
passkeyAvailable && (
131+
<>
132+
<div className={styles.container}>
133+
<div className={styles.card}>
134+
{!passkeyAvailable ? (
135+
<div className={styles.loading}>
136+
<div className={styles.spinner}></div>
137+
<span>Checking for Passkey Support...</span>
138+
</div>
139+
) : (
136140
<div className={styles.supported}>
137141
<h2 className={styles.title}>Secure Your Account with a Passkey</h2>
138142
<p className={styles.description}>
139143
Your device supports passkeys! Register one to skip passwords forever.
140144
</p>
145+
141146
<button
142-
onClick={handlePasskeyRegister}
147+
onClick={openDeviceModal}
143148
disabled={status === 'loading'}
144149
className={styles.button}
145150
>
146151
{status === 'loading' ? 'Registering...' : 'Register Passkey'}
147152
</button>
153+
148154
{message && (
149155
<p
150156
className={`${styles.message} ${
@@ -155,10 +161,19 @@ const RegisterPasskey: React.FC = () => {
155161
</p>
156162
)}
157163
</div>
158-
)
159-
)}
164+
)}
165+
</div>
160166
</div>
161-
</div>
167+
168+
<DeviceNameModal
169+
isOpen={showDeviceModal}
170+
onCancel={() => {
171+
setShowDeviceModal(false);
172+
setPendingMetadata(null);
173+
}}
174+
onConfirm={continueRegistration}
175+
/>
176+
</>
162177
);
163178
};
164179

src/components/DeviceNameModal.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import React, { useState } from 'react';
2+
import styles from '@/styles/deviceNameModal.module.css';
3+
4+
interface DeviceNameModalProps {
5+
isOpen: boolean;
6+
onCancel: () => void;
7+
onConfirm: (name: string) => void;
8+
}
9+
10+
const DeviceNameModal: React.FC<DeviceNameModalProps> = ({
11+
isOpen,
12+
onCancel,
13+
onConfirm,
14+
}) => {
15+
const [value, setValue] = useState('');
16+
17+
if (!isOpen) return null;
18+
19+
return (
20+
<div className={styles.overlay}>
21+
<div className={styles.modal}>
22+
<h3>Name This Device</h3>
23+
<p className={styles.description}>
24+
Give this passkey a friendly name so you can recognize it later.
25+
</p>
26+
27+
<input
28+
type="text"
29+
placeholder="e.g., MacBook Pro, iPhone, YubiKey"
30+
value={value}
31+
onChange={e => setValue(e.target.value)}
32+
className={styles.input}
33+
autoFocus
34+
/>
35+
36+
<div className={styles.actions}>
37+
<button onClick={onCancel} className={styles.secondary}>
38+
Cancel
39+
</button>
40+
<button
41+
onClick={() => onConfirm(value.trim())}
42+
disabled={!value.trim()}
43+
className={styles.primary}
44+
>
45+
Continue
46+
</button>
47+
</div>
48+
</div>
49+
</div>
50+
);
51+
};
52+
53+
export default DeviceNameModal;
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
.overlay {
2+
position: fixed;
3+
inset: 0;
4+
background: rgba(0, 0, 0, 0.45);
5+
display: flex;
6+
align-items: center;
7+
justify-content: center;
8+
z-index: 1000;
9+
}
10+
11+
.modal {
12+
background: #1f2937;
13+
color: #fff;
14+
padding: 24px;
15+
border-radius: 12px;
16+
width: 100%;
17+
max-width: 420px;
18+
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
19+
}
20+
21+
.description {
22+
font-size: 14px;
23+
color: #fff;
24+
margin-bottom: 16px;
25+
}
26+
27+
.input {
28+
width: 100%;
29+
padding: 10px 12px;
30+
border-radius: 8px;
31+
border: 1px solid #ddd;
32+
margin-bottom: 20px;
33+
font-size: 14px;
34+
}
35+
36+
.actions {
37+
display: flex;
38+
justify-content: flex-end;
39+
gap: 10px;
40+
}
41+
42+
.primary {
43+
background: #2563eb;
44+
color: white;
45+
border: none;
46+
padding: 8px 14px;
47+
border-radius: 8px;
48+
cursor: pointer;
49+
}
50+
51+
.secondary {
52+
background: transparent;
53+
border: 1px solid #ccc;
54+
padding: 8px 14px;
55+
border-radius: 8px;
56+
cursor: pointer;
57+
}

0 commit comments

Comments
 (0)