@micha.bigler/ui-core-micha 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import axios from 'axios';
2
- import { HEADLESS_BASE, USERS_BASE } from './authConfig';
2
+ import { HEADLESS_BASE, USERS_BASE, ACCESS_CODES_BASE } from './authConfig';
3
3
  // Helper to normalise error messages from API responses
4
4
  function extractErrorMessage(error) {
5
5
  var _a;
@@ -22,6 +22,15 @@ function getCsrfToken() {
22
22
  const match = document.cookie.match(/csrftoken=([^;]+)/);
23
23
  return match ? match[1] : null;
24
24
  }
25
+ const hasJsonWebAuthn = typeof window !== 'undefined' &&
26
+ window.PublicKeyCredential &&
27
+ typeof window.PublicKeyCredential.parseCreationOptionsFromJSON === 'function' &&
28
+ typeof window.PublicKeyCredential.parseRequestOptionsFromJSON === 'function';
29
+ function ensureWebAuthnSupport() {
30
+ if (!window.PublicKeyCredential || !navigator.credentials) {
31
+ throw new Error('Passkeys are not supported in this browser.');
32
+ }
33
+ }
25
34
  /**
26
35
  * Fetches the current authenticated user from your own User API.
27
36
  */
@@ -52,14 +61,15 @@ export async function loginWithPassword(email, password) {
52
61
  }
53
62
  catch (error) {
54
63
  if (error.response && error.response.status === 409) {
55
- // Proceed normally if already logged in
64
+ // User is already logged in, continue and fetch current user
56
65
  }
57
66
  else {
58
67
  throw new Error(extractErrorMessage(error));
59
68
  }
60
69
  }
61
70
  const user = await fetchCurrentUser();
62
- return user;
71
+ // Normalise return shape so callers always get { user }
72
+ return { user };
63
73
  }
64
74
  /**
65
75
  * Requests a password reset email via allauth headless.
@@ -163,12 +173,6 @@ export async function fetchHeadlessSession() {
163
173
  });
164
174
  return res.data;
165
175
  }
166
- export async function loginWithPasskey() {
167
- throw new Error('Passkey login is not implemented yet.');
168
- }
169
- export async function registerPasskey() {
170
- throw new Error('Passkey registration is not implemented yet.');
171
- }
172
176
  export async function verifyResetToken(uid, token) {
173
177
  const res = await axios.get(`${USERS_BASE}/password-reset/${uid}/${token}/`, { withCredentials: true });
174
178
  return res.data;
@@ -177,6 +181,70 @@ export async function setNewPassword(uid, token, newPassword) {
177
181
  const res = await axios.post(`${USERS_BASE}/password-reset/${uid}/${token}/`, { new_password: newPassword }, { withCredentials: true });
178
182
  return res.data;
179
183
  }
184
+ // Start: Optionen für Credential-Erzeugung holen
185
+ async function registerPasskeyStart({ passwordless = true } = {}) {
186
+ ensureWebAuthnSupport();
187
+ const res = await axios.get(`${HEADLESS_BASE}/account/authenticators/webauthn`, {
188
+ params: passwordless ? { passwordless: true } : {},
189
+ withCredentials: true,
190
+ });
191
+ return res.data; // JSON-Options vom Server
192
+ }
193
+ // Complete: Credential an Server schicken
194
+ async function registerPasskeyComplete(credentialJson, name = 'Passkey') {
195
+ const res = await axios.post(`${HEADLESS_BASE}/account/authenticators/webauthn`, {
196
+ credential: credentialJson,
197
+ name,
198
+ }, { withCredentials: true });
199
+ return res.data;
200
+ }
201
+ // High-level Helper, den das UI aufruft
202
+ export async function registerPasskey(name = 'Passkey') {
203
+ ensureWebAuthnSupport();
204
+ const optionsJson = await registerPasskeyStart({ passwordless: true });
205
+ if (!hasJsonWebAuthn) {
206
+ throw new Error('Passkey JSON helpers not available in this browser.');
207
+ }
208
+ const publicKeyOptions = window.PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson);
209
+ const credential = await navigator.credentials.create({
210
+ publicKey: publicKeyOptions,
211
+ });
212
+ if (!credential) {
213
+ throw new Error('Passkey creation was cancelled.');
214
+ }
215
+ const credentialJson = credential.toJSON();
216
+ return registerPasskeyComplete(credentialJson, name);
217
+ }
218
+ async function loginWithPasskeyStart() {
219
+ ensureWebAuthnSupport();
220
+ const res = await axios.get(`${HEADLESS_BASE}/auth/webauthn/login`, { withCredentials: true });
221
+ return res.data;
222
+ }
223
+ async function loginWithPasskeyComplete(credentialJson) {
224
+ const res = await axios.post(`${HEADLESS_BASE}/auth/webauthn/login`, { credential: credentialJson }, { withCredentials: true });
225
+ return res.data;
226
+ }
227
+ // Public API, die du im UI verwendest
228
+ export async function loginWithPasskey() {
229
+ ensureWebAuthnSupport();
230
+ const optionsJson = await loginWithPasskeyStart();
231
+ if (!hasJsonWebAuthn) {
232
+ throw new Error('Passkey JSON helpers not available in this browser.');
233
+ }
234
+ const publicKeyOptions = window.PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson);
235
+ const assertion = await navigator.credentials.get({
236
+ publicKey: publicKeyOptions,
237
+ });
238
+ if (!assertion) {
239
+ throw new Error('Passkey authentication was cancelled.');
240
+ }
241
+ const credentialJson = assertion.toJSON();
242
+ // Allauth kann hier 200 (eingeloggt) oder 401 (z.B. Email noch nicht verifiziert) liefern :contentReference[oaicite:5]{index=5}
243
+ await loginWithPasskeyComplete(credentialJson);
244
+ // Danach wie bei Passwort-Login aktuellen User laden
245
+ const user = await fetchCurrentUser();
246
+ return user;
247
+ }
180
248
  export const authApi = {
181
249
  fetchCurrentUser,
182
250
  updateUserProfile,
@@ -20,6 +20,6 @@ export const SOCIAL_PROVIDERS = {
20
20
  };
21
21
  // Feature-Flags (falls du später Dinge toggeln willst)
22
22
  export const FEATURES = {
23
- passkeysEnabled: false, // kannst du später auf true setzen
23
+ passkeysEnabled: true, // kannst du später auf true setzen
24
24
  mfaEnabled: true,
25
25
  };
@@ -0,0 +1,9 @@
1
+ export async function registerPasskeyStart() {
2
+ const res = await axios.post(`${HEADLESS_BASE}/mfa/webauthn/register/start`, {}, { withCredentials: true });
3
+ return res.data; // enthält challenge, rpId etc.
4
+ }
5
+ export async function registerPasskeyComplete(publicKeyCredential) {
6
+ const payload = serializePublicKeyCredential(publicKeyCredential);
7
+ const res = await axios.post(`${HEADLESS_BASE}/mfa/webauthn/register/complete`, payload, { withCredentials: true });
8
+ return res.data;
9
+ }
@@ -50,12 +50,9 @@ export function AccessCodeManager() {
50
50
  // Generates a random code with the selected length
51
51
  const generateRandomCode = (len) => {
52
52
  const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
53
- let out = '';
54
- for (let i = 0; i < len; i += 1) {
55
- const idx = Math.floor(Math.random() * alphabet.length);
56
- out += alphabet[idx];
57
- }
58
- return out;
53
+ const array = new Uint32Array(len);
54
+ window.crypto.getRandomValues(array);
55
+ return Array.from(array, (x) => alphabet[x % alphabet.length]).join('');
59
56
  };
60
57
  const handleCreateCode = async (code) => {
61
58
  var _a, _b;
@@ -19,6 +19,6 @@ error, disabled = false, }) => {
19
19
  alignItems: 'center',
20
20
  mt: 1,
21
21
  gap: 1,
22
- }, children: [_jsxs(Box, { sx: { display: 'flex', gap: 1 }, children: [_jsx(Button, { type: "submit", variant: "contained", disabled: disabled, children: "Login" }), onSignUp && (_jsx(Button, { type: "button", variant: "outlined", onClick: onSignUp, disabled: disabled, children: "Sign up" }))] }), _jsx(Button, { type: "button", variant: "outlined", onClick: onForgotPassword, disabled: disabled, children: "Forgot password?" })] }), _jsx(Divider, { sx: { my: 2 }, children: "or" }), _jsx(SocialLoginButtons, { onProviderClick: onSocialLogin })] }));
22
+ }, children: [_jsxs(Box, { sx: { display: 'flex', gap: 1 }, children: [_jsx(Button, { type: "submit", variant: "contained", disabled: disabled, children: "Login" }), onSignUp && (_jsx(Button, { type: "button", variant: "outlined", onClick: onSignUp, disabled: disabled, children: "Sign up" }))] }), _jsx(Button, { type: "button", variant: "outlined", onClick: onForgotPassword, disabled: disabled, children: "Forgot password?" })] }), _jsx(Divider, { sx: { my: 2 }, children: "or" }), _jsx(SocialLoginButtons, { onProviderClick: onSocialLogin }), _jsx(Divider, { sx: { my: 2 }, children: "or" }), _jsx(Box, { sx: { mt: 2, textAlign: 'center' }, children: _jsx(Button, { variant: "text", onClick: handlePasskeyLogin, disabled: submitting || !window.PublicKeyCredential, children: "Use passkey" }) })] }));
23
23
  };
24
24
  export default LoginForm;
@@ -25,6 +25,6 @@ const PasswordSetForm = ({ onSubmit, submitting = false }) => {
25
25
  onSubmit(password1);
26
26
  }
27
27
  };
28
- return (_jsxs(Box, { component: "form", onSubmit: handleSubmit, sx: { display: 'flex', flexDirection: 'column', gap: 2, mt: 2 }, children: [localError && (_jsx(Box, { sx: { colour: 'error.main', fontSize: 14 }, children: localError })), _jsx(TextField, { label: "New password", type: "password", fullWidth: true, autoComplete: "new-password", value: password1, onChange: (e) => setPassword1(e.target.value), disabled: submitting }), _jsx(TextField, { label: "Confirm new password", type: "password", fullWidth: true, autoComplete: "new-password", value: password2, onChange: (e) => setPassword2(e.target.value), disabled: submitting }), _jsx(Button, { type: "submit", variant: "contained", disabled: submitting, children: "Set password" })] }));
28
+ return (_jsxs(Box, { component: "form", onSubmit: handleSubmit, sx: { display: 'flex', flexDirection: 'column', gap: 2, mt: 2 }, children: [localError && (_jsx(Box, { sx: { color: 'error.main', fontSize: 14 }, children: localError })), _jsx(TextField, { label: "New password", type: "password", fullWidth: true, autoComplete: "new-password", value: password1, onChange: (e) => setPassword1(e.target.value), disabled: submitting }), _jsx(TextField, { label: "Confirm new password", type: "password", fullWidth: true, autoComplete: "new-password", value: password2, onChange: (e) => setPassword2(e.target.value), disabled: submitting }), _jsx(Button, { type: "submit", variant: "contained", disabled: submitting, children: "Set password" })] }));
29
29
  };
30
30
  export default PasswordSetForm;
@@ -33,6 +33,16 @@ const SecurityComponent = () => {
33
33
  setError(errorMsg);
34
34
  }
35
35
  };
36
- return (_jsxs(Box, { children: [message && (_jsx(Alert, { severity: "success", sx: { mb: 2 }, children: message })), error && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error })), _jsx(Typography, { variant: "h6", gutterBottom: true, children: "Password" }), _jsx(PasswordChangeForm, { onSubmit: handlePasswordChange }), _jsx(Divider, { sx: { my: 3 } }), _jsx(Typography, { variant: "h6", gutterBottom: true, children: "Social logins" }), _jsx(Typography, { variant: "body2", sx: { mb: 1 }, children: "Sign in using a connected Google or Microsoft account." }), _jsx(SocialLoginButtons, { onProviderClick: handleSocialClick }), _jsx(Divider, { sx: { my: 3 } }), _jsx(Typography, { variant: "h6", gutterBottom: true, children: "Passkeys (coming soon)" }), _jsx(Typography, { variant: "body2", sx: { mb: 1 }, children: "Use passkeys for passwordless sign-in. This section will be active once WebAuthn endpoints are wired in the backend." }), _jsx(Stack, { direction: "row", spacing: 2, children: _jsx(Button, { variant: "outlined", disabled: true, children: "Add passkey" }) })] }));
36
+ return (_jsxs(Box, { children: [message && (_jsx(Alert, { severity: "success", sx: { mb: 2 }, children: message })), error && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error })), _jsx(Typography, { variant: "h6", gutterBottom: true, children: "Password" }), _jsx(PasswordChangeForm, { onSubmit: handlePasswordChange }), _jsx(Divider, { sx: { my: 3 } }), _jsx(Typography, { variant: "h6", gutterBottom: true, children: "Social logins" }), _jsx(Typography, { variant: "body2", sx: { mb: 1 }, children: "Sign in using a connected Google or Microsoft account." }), _jsx(SocialLoginButtons, { onProviderClick: handleSocialClick }), _jsx(Divider, { sx: { my: 3 } }), _jsx(Typography, { variant: "h6", gutterBottom: true, children: "Passkeys" }), _jsx(Typography, { variant: "body2", sx: { mb: 1 }, children: "Use passkeys for passwordless sign-in on this device." }), _jsx(Stack, { direction: "row", spacing: 2, children: _jsx(Button, { variant: "outlined", disabled: !FEATURES.passkeysEnabled, onClick: async () => {
37
+ setMessage('');
38
+ setError('');
39
+ try {
40
+ await authApi.registerPasskey('Default passkey');
41
+ setMessage('Passkey added successfully.');
42
+ }
43
+ catch (e) {
44
+ setError(e.message || 'Could not add passkey.');
45
+ }
46
+ }, children: FEATURES.passkeysEnabled ? 'Add passkey' : 'Passkeys disabled' }) })] }));
37
47
  };
38
48
  export default SecurityComponent;
@@ -6,6 +6,7 @@ import { Box, TextField, Button, Typography, Alert, } from '@mui/material';
6
6
  import { Helmet } from 'react-helmet';
7
7
  import { NarrowPage } from '../layout/PageLayout';
8
8
  import { authApi } from '../auth/authApi';
9
+ import { ACCESS_CODES_BASE } from '../auth/authConfig';
9
10
  export function SignUpPage() {
10
11
  const navigate = useNavigate();
11
12
  const [email, setEmail] = useState('');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "private": false,
@@ -1,5 +1,5 @@
1
1
  import axios from 'axios';
2
- import { HEADLESS_BASE, USERS_BASE } from './authConfig';
2
+ import { HEADLESS_BASE, USERS_BASE, ACCESS_CODES_BASE } from './authConfig';
3
3
 
4
4
  // Helper to normalise error messages from API responses
5
5
  function extractErrorMessage(error) {
@@ -23,6 +23,18 @@ function getCsrfToken() {
23
23
  return match ? match[1] : null;
24
24
  }
25
25
 
26
+ const hasJsonWebAuthn =
27
+ typeof window !== 'undefined' &&
28
+ window.PublicKeyCredential &&
29
+ typeof window.PublicKeyCredential.parseCreationOptionsFromJSON === 'function' &&
30
+ typeof window.PublicKeyCredential.parseRequestOptionsFromJSON === 'function';
31
+
32
+ function ensureWebAuthnSupport() {
33
+ if (!window.PublicKeyCredential || !navigator.credentials) {
34
+ throw new Error('Passkeys are not supported in this browser.');
35
+ }
36
+ }
37
+
26
38
  /**
27
39
  * Fetches the current authenticated user from your own User API.
28
40
  */
@@ -51,21 +63,22 @@ export async function loginWithPassword(email, password) {
51
63
  await axios.post(
52
64
  `${HEADLESS_BASE}/auth/login`,
53
65
  {
54
- email,
66
+ email,
55
67
  password,
56
68
  },
57
69
  { withCredentials: true },
58
70
  );
59
71
  } catch (error) {
60
72
  if (error.response && error.response.status === 409) {
61
- // Proceed normally if already logged in
73
+ // User is already logged in, continue and fetch current user
62
74
  } else {
63
75
  throw new Error(extractErrorMessage(error));
64
76
  }
65
77
  }
66
78
 
67
79
  const user = await fetchCurrentUser();
68
- return user;
80
+ // Normalise return shape so callers always get { user }
81
+ return { user };
69
82
  }
70
83
 
71
84
  /**
@@ -200,13 +213,7 @@ export async function fetchHeadlessSession() {
200
213
  return res.data;
201
214
  }
202
215
 
203
- export async function loginWithPasskey() {
204
- throw new Error('Passkey login is not implemented yet.');
205
- }
206
216
 
207
- export async function registerPasskey() {
208
- throw new Error('Passkey registration is not implemented yet.');
209
- }
210
217
 
211
218
  export async function verifyResetToken(uid, token) {
212
219
  const res = await axios.get(
@@ -225,6 +232,108 @@ export async function setNewPassword(uid, token, newPassword) {
225
232
  return res.data;
226
233
  }
227
234
 
235
+ // Start: Optionen für Credential-Erzeugung holen
236
+ async function registerPasskeyStart({ passwordless = true } = {}) {
237
+ ensureWebAuthnSupport();
238
+
239
+ const res = await axios.get(
240
+ `${HEADLESS_BASE}/account/authenticators/webauthn`,
241
+ {
242
+ params: passwordless ? { passwordless: true } : {},
243
+ withCredentials: true,
244
+ },
245
+ );
246
+ return res.data; // JSON-Options vom Server
247
+ }
248
+
249
+ // Complete: Credential an Server schicken
250
+ async function registerPasskeyComplete(credentialJson, name = 'Passkey') {
251
+ const res = await axios.post(
252
+ `${HEADLESS_BASE}/account/authenticators/webauthn`,
253
+ {
254
+ credential: credentialJson,
255
+ name,
256
+ },
257
+ { withCredentials: true },
258
+ );
259
+ return res.data;
260
+ }
261
+
262
+ // High-level Helper, den das UI aufruft
263
+ export async function registerPasskey(name = 'Passkey') {
264
+ ensureWebAuthnSupport();
265
+
266
+ const optionsJson = await registerPasskeyStart({ passwordless: true });
267
+
268
+ if (!hasJsonWebAuthn) {
269
+ throw new Error('Passkey JSON helpers not available in this browser.');
270
+ }
271
+
272
+ const publicKeyOptions =
273
+ window.PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson);
274
+
275
+ const credential = await navigator.credentials.create({
276
+ publicKey: publicKeyOptions,
277
+ });
278
+
279
+ if (!credential) {
280
+ throw new Error('Passkey creation was cancelled.');
281
+ }
282
+
283
+ const credentialJson = credential.toJSON();
284
+ return registerPasskeyComplete(credentialJson, name);
285
+ }
286
+
287
+ async function loginWithPasskeyStart() {
288
+ ensureWebAuthnSupport();
289
+
290
+ const res = await axios.get(
291
+ `${HEADLESS_BASE}/auth/webauthn/login`,
292
+ { withCredentials: true },
293
+ );
294
+ return res.data;
295
+ }
296
+
297
+ async function loginWithPasskeyComplete(credentialJson) {
298
+ const res = await axios.post(
299
+ `${HEADLESS_BASE}/auth/webauthn/login`,
300
+ { credential: credentialJson },
301
+ { withCredentials: true },
302
+ );
303
+ return res.data;
304
+ }
305
+
306
+ // Public API, die du im UI verwendest
307
+ export async function loginWithPasskey() {
308
+ ensureWebAuthnSupport();
309
+
310
+ const optionsJson = await loginWithPasskeyStart();
311
+
312
+ if (!hasJsonWebAuthn) {
313
+ throw new Error('Passkey JSON helpers not available in this browser.');
314
+ }
315
+
316
+ const publicKeyOptions =
317
+ window.PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson);
318
+
319
+ const assertion = await navigator.credentials.get({
320
+ publicKey: publicKeyOptions,
321
+ });
322
+
323
+ if (!assertion) {
324
+ throw new Error('Passkey authentication was cancelled.');
325
+ }
326
+
327
+ const credentialJson = assertion.toJSON();
328
+
329
+ // Allauth kann hier 200 (eingeloggt) oder 401 (z.B. Email noch nicht verifiziert) liefern :contentReference[oaicite:5]{index=5}
330
+ await loginWithPasskeyComplete(credentialJson);
331
+
332
+ // Danach wie bei Passwort-Login aktuellen User laden
333
+ const user = await fetchCurrentUser();
334
+ return user;
335
+ }
336
+
228
337
 
229
338
  export const authApi = {
230
339
  fetchCurrentUser,
@@ -29,6 +29,6 @@ export const SOCIAL_PROVIDERS = {
29
29
 
30
30
  // Feature-Flags (falls du später Dinge toggeln willst)
31
31
  export const FEATURES = {
32
- passkeysEnabled: false, // kannst du später auf true setzen
32
+ passkeysEnabled: true, // kannst du später auf true setzen
33
33
  mfaEnabled: true,
34
34
  };
@@ -0,0 +1,18 @@
1
+ export async function registerPasskeyStart() {
2
+ const res = await axios.post(
3
+ `${HEADLESS_BASE}/mfa/webauthn/register/start`,
4
+ {},
5
+ { withCredentials: true },
6
+ );
7
+ return res.data; // enthält challenge, rpId etc.
8
+ }
9
+
10
+ export async function registerPasskeyComplete(publicKeyCredential) {
11
+ const payload = serializePublicKeyCredential(publicKeyCredential);
12
+ const res = await axios.post(
13
+ `${HEADLESS_BASE}/mfa/webauthn/register/complete`,
14
+ payload,
15
+ { withCredentials: true },
16
+ );
17
+ return res.data;
18
+ }
@@ -63,14 +63,11 @@ export function AccessCodeManager() {
63
63
  }, []);
64
64
 
65
65
  // Generates a random code with the selected length
66
- const generateRandomCode = (len) => {
66
+ const generateRandomCode = (len) => {
67
67
  const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
68
- let out = '';
69
- for (let i = 0; i < len; i += 1) {
70
- const idx = Math.floor(Math.random() * alphabet.length);
71
- out += alphabet[idx];
72
- }
73
- return out;
68
+ const array = new Uint32Array(len);
69
+ window.crypto.getRandomValues(array);
70
+ return Array.from(array, (x) => alphabet[x % alphabet.length]).join('');
74
71
  };
75
72
 
76
73
  const handleCreateCode = async (code) => {
@@ -101,6 +101,18 @@ const LoginForm = ({
101
101
  <Divider sx={{ my: 2 }}>or</Divider>
102
102
 
103
103
  <SocialLoginButtons onProviderClick={onSocialLogin} />
104
+
105
+ <Divider sx={{ my: 2 }}>or</Divider>
106
+
107
+ <Box sx={{ mt: 2, textAlign: 'center' }}>
108
+ <Button
109
+ variant="text"
110
+ onClick={handlePasskeyLogin}
111
+ disabled={submitting || !window.PublicKeyCredential}
112
+ >
113
+ Use passkey
114
+ </Button>
115
+ </Box>
104
116
  </Box>
105
117
  );
106
118
  };
@@ -37,7 +37,7 @@ const PasswordSetForm = ({ onSubmit, submitting = false }) => {
37
37
  sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 2 }}
38
38
  >
39
39
  {localError && (
40
- <Box sx={{ colour: 'error.main', fontSize: 14 }}>
40
+ <Box sx={{ color: 'error.main', fontSize: 14 }}>
41
41
  {localError}
42
42
  </Box>
43
43
  )}
@@ -75,18 +75,28 @@ const SecurityComponent = () => {
75
75
 
76
76
  {/* Passkeys Section (Placeholder) */}
77
77
  <Typography variant="h6" gutterBottom>
78
- Passkeys (coming soon)
78
+ Passkeys
79
79
  </Typography>
80
80
  <Typography variant="body2" sx={{ mb: 1 }}>
81
- Use passkeys for passwordless sign-in. This section will be active once
82
- WebAuthn endpoints are wired in the backend.
81
+ Use passkeys for passwordless sign-in on this device.
83
82
  </Typography>
83
+
84
84
  <Stack direction="row" spacing={2}>
85
85
  <Button
86
86
  variant="outlined"
87
- disabled
87
+ disabled={!FEATURES.passkeysEnabled}
88
+ onClick={async () => {
89
+ setMessage('');
90
+ setError('');
91
+ try {
92
+ await authApi.registerPasskey('Default passkey');
93
+ setMessage('Passkey added successfully.');
94
+ } catch (e) {
95
+ setError(e.message || 'Could not add passkey.');
96
+ }
97
+ }}
88
98
  >
89
- Add passkey
99
+ {FEATURES.passkeysEnabled ? 'Add passkey' : 'Passkeys disabled'}
90
100
  </Button>
91
101
  </Stack>
92
102
  </Box>
@@ -11,6 +11,7 @@ import {
11
11
  import { Helmet } from 'react-helmet';
12
12
  import { NarrowPage } from '../layout/PageLayout';
13
13
  import { authApi } from '../auth/authApi';
14
+ import { ACCESS_CODES_BASE } from '../auth/authConfig';
14
15
 
15
16
  export function SignUpPage() {
16
17
  const navigate = useNavigate();