@micha.bigler/ui-core-micha 1.2.5 → 1.2.6

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,6 +1,61 @@
1
1
  import axios from 'axios';
2
2
  import { HEADLESS_BASE, USERS_BASE, ACCESS_CODES_BASE } from './authConfig';
3
3
  // -----------------------------
4
+ // WebAuthn Serialization Helpers
5
+ // -----------------------------
6
+ /**
7
+ * Converts an ArrayBuffer to a Base64URL encoded string.
8
+ * Required for manual credential serialization when .toJSON() is missing.
9
+ */
10
+ function bufferToBase64URL(buffer) {
11
+ const bytes = new Uint8Array(buffer);
12
+ let str = '';
13
+ for (const char of bytes) {
14
+ str += String.fromCharCode(char);
15
+ }
16
+ const base64 = btoa(str);
17
+ return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
18
+ }
19
+ /**
20
+ * Serializes a PublicKeyCredential into a JSON-compatible object.
21
+ * Acts as a polyfill for environments where credential.toJSON() is missing (e.g., Proton Pass).
22
+ */
23
+ function serializeCredential(credential) {
24
+ if (typeof credential.toJSON === 'function') {
25
+ return credential.toJSON();
26
+ }
27
+ // Manual serialization for extensions that return incomplete objects
28
+ const p = {
29
+ id: credential.id,
30
+ rawId: bufferToBase64URL(credential.rawId),
31
+ type: credential.type,
32
+ response: {
33
+ clientDataJSON: bufferToBase64URL(credential.response.clientDataJSON),
34
+ },
35
+ };
36
+ if (credential.response.attestationObject) {
37
+ // Registration specific
38
+ p.response.attestationObject = bufferToBase64URL(credential.response.attestationObject);
39
+ }
40
+ if (credential.response.authenticatorData) {
41
+ // Login specific
42
+ p.response.authenticatorData = bufferToBase64URL(credential.response.authenticatorData);
43
+ }
44
+ if (credential.response.signature) {
45
+ // Login specific
46
+ p.response.signature = bufferToBase64URL(credential.response.signature);
47
+ }
48
+ if (credential.response.userHandle) {
49
+ // Login specific
50
+ p.response.userHandle = bufferToBase64URL(credential.response.userHandle);
51
+ }
52
+ // Include clientExtensionResults if present
53
+ if (typeof credential.getClientExtensionResults === 'function') {
54
+ p.clientExtensionResults = credential.getClientExtensionResults();
55
+ }
56
+ return p;
57
+ }
58
+ // -----------------------------
4
59
  // Error helper
5
60
  // -----------------------------
6
61
  function extractErrorMessage(error) {
@@ -199,17 +254,17 @@ export async function registerPasskey(name = 'Passkey') {
199
254
  if (!hasJsonWebAuthn) {
200
255
  throw new Error('Passkey JSON helpers are not available in this browser.');
201
256
  }
202
- // Hier bekommst du bereits den inneren publicKey-Block mit challenge etc.
257
+ // ... (previous logic for registerPasskeyStart) ...
203
258
  const publicKeyJson = await registerPasskeyStart({ passwordless: true });
204
259
  let credential;
205
260
  try {
206
- // publicKeyJson hat challenge auf Top-Level
207
261
  const publicKeyOptions = window.PublicKeyCredential.parseCreationOptionsFromJSON(publicKeyJson);
208
262
  credential = await navigator.credentials.create({
209
263
  publicKey: publicKeyOptions,
210
264
  });
211
265
  }
212
266
  catch (err) {
267
+ // ... (error handling) ...
213
268
  if (err && err.name === 'NotAllowedError') {
214
269
  throw new Error('Passkey creation was cancelled by the user.');
215
270
  }
@@ -218,7 +273,8 @@ export async function registerPasskey(name = 'Passkey') {
218
273
  if (!credential) {
219
274
  throw new Error('Passkey creation was cancelled.');
220
275
  }
221
- const credentialJson = credential.toJSON();
276
+ // CHANGED: Use helper instead of direct .toJSON()
277
+ const credentialJson = serializeCredential(credential);
222
278
  return registerPasskeyComplete(credentialJson, name);
223
279
  }
224
280
  // -----------------------------
@@ -254,15 +310,13 @@ export async function loginWithPasskey() {
254
310
  });
255
311
  }
256
312
  catch (err) {
257
- if (err && err.name === 'NotAllowedError') {
258
- throw new Error('Passkey authentication was cancelled by the user.');
259
- }
313
+ // ... (error handling) ...
260
314
  throw err;
261
315
  }
262
316
  if (!assertion) {
263
317
  throw new Error('Passkey authentication was cancelled.');
264
318
  }
265
- const credentialJson = assertion.toJSON();
319
+ const credentialJson = serializeCredential(assertion);
266
320
  let postError = null;
267
321
  try {
268
322
  await loginWithPasskeyComplete(credentialJson);
@@ -282,6 +336,22 @@ export async function loginWithPasskey() {
282
336
  throw new Error(extractErrorMessage(err));
283
337
  }
284
338
  }
339
+ /**
340
+ * Loads all authenticators and filters for WebAuthn passkeys.
341
+ */
342
+ export async function fetchPasskeys() {
343
+ const res = await axios.get(`${HEADLESS_BASE}/account/authenticators/`, { withCredentials: true });
344
+ const items = Array.isArray(res.data) ? res.data : [];
345
+ // allauth usually returns objects like:
346
+ // { id, type, name, last_used_at, created_at, is_device_passkey, ... }
347
+ return items.filter((item) => item.type === 'webauthn');
348
+ }
349
+ /**
350
+ * Deletes a single passkey authenticator by id.
351
+ */
352
+ export async function deletePasskey(id) {
353
+ await axios.delete(`${HEADLESS_BASE}/account/authenticators/${id}/`, { withCredentials: true });
354
+ }
285
355
  // -----------------------------
286
356
  // Aggregated API object
287
357
  // -----------------------------
@@ -298,6 +368,8 @@ export const authApi = {
298
368
  setNewPassword,
299
369
  loginWithPasskey,
300
370
  registerPasskey,
371
+ fetchPasskeys,
372
+ deletePasskey,
301
373
  validateAccessCode,
302
374
  requestInviteWithCode,
303
375
  };
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { useState } from 'react';
3
- import { Box, Typography, Divider, Button, Stack, Alert, } from '@mui/material';
3
+ import { Box, Typography, Divider, Button, Stack, Alert, TextField, } from '@mui/material';
4
4
  import PasswordChangeForm from './PasswordChangeForm';
5
5
  import SocialLoginButtons from './SocialLoginButtons';
6
6
  import { authApi } from '../auth/authApi';
@@ -8,12 +8,13 @@ import { FEATURES } from '../auth/authConfig';
8
8
  const SecurityComponent = () => {
9
9
  const [message, setMessage] = useState('');
10
10
  const [error, setError] = useState('');
11
- const handleSocialClick = (provider) => {
11
+ const [passkeyName, setPasskeyName] = useState('');
12
+ const [passkeySubmitting, setPasskeySubmitting] = useState(false);
13
+ const handleSocialClick = async (provider) => {
12
14
  setMessage('');
13
15
  setError('');
14
16
  try {
15
- authApi.startSocialLogin(provider);
16
- // Ab hier verlässt der Browser normalerweise die Seite
17
+ await authApi.startSocialLogin(provider);
17
18
  }
18
19
  catch (e) {
19
20
  setError(e.message || 'Social login could not be started.');
@@ -34,16 +35,26 @@ const SecurityComponent = () => {
34
35
  setError(errorMsg);
35
36
  }
36
37
  };
37
- 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 () => {
38
- setMessage('');
39
- setError('');
40
- try {
41
- await authApi.registerPasskey('Default passkey');
42
- setMessage('Passkey added successfully.');
43
- }
44
- catch (e) {
45
- setError(e.message || 'Could not add passkey.');
46
- }
47
- }, children: FEATURES.passkeysEnabled ? 'Add passkey' : 'Passkeys disabled' }) })] }));
38
+ const handleRegisterPasskey = async () => {
39
+ setMessage('');
40
+ setError('');
41
+ setPasskeySubmitting(true);
42
+ try {
43
+ // Fallback-Name, wenn der User nichts einträgt
44
+ const fallbackName = (passkeyName === null || passkeyName === void 0 ? void 0 : passkeyName.trim()) ||
45
+ `Passkey on ${navigator.platform || 'this device'}`;
46
+ await authApi.registerPasskey(fallbackName);
47
+ setMessage('Passkey added successfully.');
48
+ setPasskeyName('');
49
+ }
50
+ catch (e) {
51
+ setError(e.message || 'Could not add passkey.');
52
+ }
53
+ finally {
54
+ setPasskeySubmitting(false);
55
+ }
56
+ };
57
+ const passkeysSupported = typeof window !== 'undefined' && !!window.PublicKeyCredential;
58
+ 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." }), !passkeysSupported && (_jsx(Typography, { variant: "body2", color: "text.secondary", sx: { mb: 1 }, children: "Passkeys are not supported in this browser." })), FEATURES.passkeysEnabled && passkeysSupported ? (_jsx(PasskeysComponent, {})) : (_jsx(Typography, { variant: "body2", color: "text.secondary", sx: { mt: 1 }, children: "Passkeys are disabled for this project." }))] }));
48
59
  };
49
60
  export default SecurityComponent;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "1.2.5",
3
+ "version": "1.2.6",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "private": false,
@@ -1,6 +1,72 @@
1
1
  import axios from 'axios';
2
2
  import { HEADLESS_BASE, USERS_BASE, ACCESS_CODES_BASE } from './authConfig';
3
3
 
4
+ // -----------------------------
5
+ // WebAuthn Serialization Helpers
6
+ // -----------------------------
7
+
8
+ /**
9
+ * Converts an ArrayBuffer to a Base64URL encoded string.
10
+ * Required for manual credential serialization when .toJSON() is missing.
11
+ */
12
+ function bufferToBase64URL(buffer) {
13
+ const bytes = new Uint8Array(buffer);
14
+ let str = '';
15
+ for (const char of bytes) {
16
+ str += String.fromCharCode(char);
17
+ }
18
+ const base64 = btoa(str);
19
+ return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
20
+ }
21
+
22
+ /**
23
+ * Serializes a PublicKeyCredential into a JSON-compatible object.
24
+ * Acts as a polyfill for environments where credential.toJSON() is missing (e.g., Proton Pass).
25
+ */
26
+ function serializeCredential(credential) {
27
+ if (typeof credential.toJSON === 'function') {
28
+ return credential.toJSON();
29
+ }
30
+
31
+ // Manual serialization for extensions that return incomplete objects
32
+ const p = {
33
+ id: credential.id,
34
+ rawId: bufferToBase64URL(credential.rawId),
35
+ type: credential.type,
36
+ response: {
37
+ clientDataJSON: bufferToBase64URL(credential.response.clientDataJSON),
38
+ },
39
+ };
40
+
41
+ if (credential.response.attestationObject) {
42
+ // Registration specific
43
+ p.response.attestationObject = bufferToBase64URL(credential.response.attestationObject);
44
+ }
45
+
46
+ if (credential.response.authenticatorData) {
47
+ // Login specific
48
+ p.response.authenticatorData = bufferToBase64URL(credential.response.authenticatorData);
49
+ }
50
+
51
+ if (credential.response.signature) {
52
+ // Login specific
53
+ p.response.signature = bufferToBase64URL(credential.response.signature);
54
+ }
55
+
56
+ if (credential.response.userHandle) {
57
+ // Login specific
58
+ p.response.userHandle = bufferToBase64URL(credential.response.userHandle);
59
+ }
60
+
61
+ // Include clientExtensionResults if present
62
+ if (typeof credential.getClientExtensionResults === 'function') {
63
+ p.clientExtensionResults = credential.getClientExtensionResults();
64
+ }
65
+
66
+ return p;
67
+ }
68
+
69
+
4
70
  // -----------------------------
5
71
  // Error helper
6
72
  // -----------------------------
@@ -269,19 +335,19 @@ export async function registerPasskey(name = 'Passkey') {
269
335
  throw new Error('Passkey JSON helpers are not available in this browser.');
270
336
  }
271
337
 
272
- // Hier bekommst du bereits den inneren publicKey-Block mit challenge etc.
338
+ // ... (previous logic for registerPasskeyStart) ...
273
339
  const publicKeyJson = await registerPasskeyStart({ passwordless: true });
274
340
 
275
341
  let credential;
276
342
  try {
277
- // publicKeyJson hat challenge auf Top-Level
278
- const publicKeyOptions =
279
- window.PublicKeyCredential.parseCreationOptionsFromJSON(publicKeyJson);
343
+ const publicKeyOptions =
344
+ window.PublicKeyCredential.parseCreationOptionsFromJSON(publicKeyJson);
280
345
 
281
- credential = await navigator.credentials.create({
282
- publicKey: publicKeyOptions,
283
- });
346
+ credential = await navigator.credentials.create({
347
+ publicKey: publicKeyOptions,
348
+ });
284
349
  } catch (err) {
350
+ // ... (error handling) ...
285
351
  if (err && err.name === 'NotAllowedError') {
286
352
  throw new Error('Passkey creation was cancelled by the user.');
287
353
  }
@@ -292,7 +358,9 @@ export async function registerPasskey(name = 'Passkey') {
292
358
  throw new Error('Passkey creation was cancelled.');
293
359
  }
294
360
 
295
- const credentialJson = credential.toJSON();
361
+ // CHANGED: Use helper instead of direct .toJSON()
362
+ const credentialJson = serializeCredential(credential);
363
+
296
364
  return registerPasskeyComplete(credentialJson, name);
297
365
  }
298
366
 
@@ -348,9 +416,7 @@ export async function loginWithPasskey() {
348
416
  publicKey: publicKeyOptions,
349
417
  });
350
418
  } catch (err) {
351
- if (err && err.name === 'NotAllowedError') {
352
- throw new Error('Passkey authentication was cancelled by the user.');
353
- }
419
+ // ... (error handling) ...
354
420
  throw err;
355
421
  }
356
422
 
@@ -358,7 +424,7 @@ export async function loginWithPasskey() {
358
424
  throw new Error('Passkey authentication was cancelled.');
359
425
  }
360
426
 
361
- const credentialJson = assertion.toJSON();
427
+ const credentialJson = serializeCredential(assertion);
362
428
 
363
429
  let postError = null;
364
430
  try {
@@ -378,6 +444,30 @@ export async function loginWithPasskey() {
378
444
  throw new Error(extractErrorMessage(err));
379
445
  }
380
446
  }
447
+ /**
448
+ * Loads all authenticators and filters for WebAuthn passkeys.
449
+ */
450
+ export async function fetchPasskeys() {
451
+ const res = await axios.get(
452
+ `${HEADLESS_BASE}/account/authenticators/`,
453
+ { withCredentials: true },
454
+ );
455
+
456
+ const items = Array.isArray(res.data) ? res.data : [];
457
+ // allauth usually returns objects like:
458
+ // { id, type, name, last_used_at, created_at, is_device_passkey, ... }
459
+ return items.filter((item) => item.type === 'webauthn');
460
+ }
461
+
462
+ /**
463
+ * Deletes a single passkey authenticator by id.
464
+ */
465
+ export async function deletePasskey(id) {
466
+ await axios.delete(
467
+ `${HEADLESS_BASE}/account/authenticators/${id}/`,
468
+ { withCredentials: true },
469
+ );
470
+ }
381
471
 
382
472
  // -----------------------------
383
473
  // Aggregated API object
@@ -395,6 +485,8 @@ export const authApi = {
395
485
  setNewPassword,
396
486
  loginWithPasskey,
397
487
  registerPasskey,
488
+ fetchPasskeys,
489
+ deletePasskey,
398
490
  validateAccessCode,
399
491
  requestInviteWithCode,
400
492
  };
@@ -6,22 +6,24 @@ import {
6
6
  Button,
7
7
  Stack,
8
8
  Alert,
9
+ TextField,
9
10
  } from '@mui/material';
10
11
  import PasswordChangeForm from './PasswordChangeForm';
11
12
  import SocialLoginButtons from './SocialLoginButtons';
12
- import { authApi } from '../auth/authApi';
13
+ import { authApi } from '../auth/authApi';
13
14
  import { FEATURES } from '../auth/authConfig';
14
15
 
15
16
  const SecurityComponent = () => {
16
17
  const [message, setMessage] = useState('');
17
18
  const [error, setError] = useState('');
19
+ const [passkeyName, setPasskeyName] = useState('');
20
+ const [passkeySubmitting, setPasskeySubmitting] = useState(false);
18
21
 
19
- const handleSocialClick = (provider) => {
22
+ const handleSocialClick = async (provider) => {
20
23
  setMessage('');
21
24
  setError('');
22
25
  try {
23
- authApi.startSocialLogin(provider);
24
- // Ab hier verlässt der Browser normalerweise die Seite
26
+ await authApi.startSocialLogin(provider);
25
27
  } catch (e) {
26
28
  setError(e.message || 'Social login could not be started.');
27
29
  }
@@ -34,13 +36,38 @@ const SecurityComponent = () => {
34
36
  await authApi.changePassword(currentPassword, newPassword);
35
37
  setMessage('Password changed successfully.');
36
38
  } catch (err) {
37
- const errorMsg = err.response?.data?.detail ||
38
- err.message ||
39
- 'Could not change password.';
39
+ const errorMsg =
40
+ err.response?.data?.detail ||
41
+ err.message ||
42
+ 'Could not change password.';
40
43
  setError(errorMsg);
41
44
  }
42
45
  };
43
46
 
47
+ const handleRegisterPasskey = async () => {
48
+ setMessage('');
49
+ setError('');
50
+ setPasskeySubmitting(true);
51
+
52
+ try {
53
+ // Fallback-Name, wenn der User nichts einträgt
54
+ const fallbackName =
55
+ passkeyName?.trim() ||
56
+ `Passkey on ${navigator.platform || 'this device'}`;
57
+
58
+ await authApi.registerPasskey(fallbackName);
59
+ setMessage('Passkey added successfully.');
60
+ setPasskeyName('');
61
+ } catch (e) {
62
+ setError(e.message || 'Could not add passkey.');
63
+ } finally {
64
+ setPasskeySubmitting(false);
65
+ }
66
+ };
67
+
68
+ const passkeysSupported =
69
+ typeof window !== 'undefined' && !!window.PublicKeyCredential;
70
+
44
71
  return (
45
72
  <Box>
46
73
  {message && (
@@ -58,9 +85,8 @@ const SecurityComponent = () => {
58
85
  <Typography variant="h6" gutterBottom>
59
86
  Password
60
87
  </Typography>
61
-
62
88
  <PasswordChangeForm onSubmit={handlePasswordChange} />
63
-
89
+
64
90
  <Divider sx={{ my: 3 }} />
65
91
 
66
92
  {/* Social Logins Section */}
@@ -74,7 +100,7 @@ const SecurityComponent = () => {
74
100
 
75
101
  <Divider sx={{ my: 3 }} />
76
102
 
77
- {/* Passkeys Section (Placeholder) */}
103
+ {/* Passkeys Section */}
78
104
  <Typography variant="h6" gutterBottom>
79
105
  Passkeys
80
106
  </Typography>
@@ -82,26 +108,23 @@ const SecurityComponent = () => {
82
108
  Use passkeys for passwordless sign-in on this device.
83
109
  </Typography>
84
110
 
85
- <Stack direction="row" spacing={2}>
86
- <Button
87
- variant="outlined"
88
- disabled={!FEATURES.passkeysEnabled}
89
- onClick={async () => {
90
- setMessage('');
91
- setError('');
92
- try {
93
- await authApi.registerPasskey('Default passkey');
94
- setMessage('Passkey added successfully.');
95
- } catch (e) {
96
- setError(e.message || 'Could not add passkey.');
97
- }
98
- }}
99
- >
100
- {FEATURES.passkeysEnabled ? 'Add passkey' : 'Passkeys disabled'}
101
- </Button>
102
- </Stack>
111
+ {!passkeysSupported && (
112
+ <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
113
+ Passkeys are not supported in this browser.
114
+ </Typography>
115
+ )}
116
+
117
+ {FEATURES.passkeysEnabled && passkeysSupported ? (
118
+ <PasskeysComponent />
119
+ ) : (
120
+ <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
121
+ Passkeys are disabled for this project.
122
+ </Typography>
123
+ )}
124
+
125
+ {/* Hier später: Liste der vorhandenen Passkeys + Delete-Buttons */}
103
126
  </Box>
104
127
  );
105
128
  };
106
129
 
107
- export default SecurityComponent;
130
+ export default SecurityComponent;