@elvix.is/sdk 0.5.5 → 0.5.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.
package/dist/react.d.ts CHANGED
@@ -58,6 +58,8 @@ type ElvixCopy = {
58
58
  subtitle?: string;
59
59
  /** Google factor button. */
60
60
  googleButton?: string;
61
+ /** Passkey factor button. */
62
+ passkeyButton?: string;
61
63
  /** Email field placeholder. */
62
64
  emailPlaceholder?: string;
63
65
  /** Email submit button (identify step). */
package/dist/react.js CHANGED
@@ -204,6 +204,7 @@ import { useState as useState2 } from "react";
204
204
  var DEFAULT_COPY = {
205
205
  subtitle: "Pick how you want to continue.",
206
206
  googleButton: "Continue with Google",
207
+ passkeyButton: "Continue with passkey",
207
208
  emailPlaceholder: "you@example.com",
208
209
  sendCodeButton: "Send code",
209
210
  sendingLabel: "Sending\u2026",
@@ -272,6 +273,97 @@ function isSameOrigin(baseUrl) {
272
273
  }
273
274
  }
274
275
 
276
+ // src/react/passkey.ts
277
+ function b64urlToBuf(b64url) {
278
+ const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
279
+ const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - b64.length % 4);
280
+ const bin = atob(b64 + pad);
281
+ const bytes = new Uint8Array(bin.length);
282
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
283
+ return bytes.buffer;
284
+ }
285
+ function bufToB64url(buf) {
286
+ const bytes = new Uint8Array(buf);
287
+ let bin = "";
288
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
289
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
290
+ }
291
+ async function runPasskeySignIn(baseUrl, clientId) {
292
+ if (!clientId) return { ok: false, error: "missing_client_id", message: "ElvixProvider needs a clientId." };
293
+ if (typeof window === "undefined" || !window.PublicKeyCredential || !navigator.credentials?.get) {
294
+ return { ok: false, error: "passkey_unsupported", message: "This browser can't use passkeys." };
295
+ }
296
+ const credentials = isSameOrigin(baseUrl) ? "include" : "omit";
297
+ let options;
298
+ try {
299
+ const res = await fetch(`${baseUrl}/api/auth/passkey/sign-in/start`, {
300
+ method: "POST",
301
+ headers: { "content-type": "application/json" },
302
+ credentials,
303
+ body: JSON.stringify({ intent: "app", clientId })
304
+ });
305
+ const body = await res.json();
306
+ if (!res.ok || !body.success || !body.data?.options) {
307
+ return { ok: false, error: body.errorMessage ?? "passkey_start_failed" };
308
+ }
309
+ options = body.data.options;
310
+ } catch (e) {
311
+ return { ok: false, error: "network", message: e instanceof Error ? e.message : void 0 };
312
+ }
313
+ let assertion;
314
+ try {
315
+ const publicKey = {
316
+ challenge: b64urlToBuf(options.challenge),
317
+ timeout: options.timeout,
318
+ rpId: options.rpId,
319
+ userVerification: options.userVerification,
320
+ allowCredentials: options.allowCredentials?.map((c) => ({
321
+ id: b64urlToBuf(c.id),
322
+ type: c.type,
323
+ transports: c.transports
324
+ }))
325
+ };
326
+ const cred = await navigator.credentials.get({ publicKey });
327
+ if (!cred) return { ok: false, error: "passkey_cancelled" };
328
+ const resp = cred.response;
329
+ assertion = {
330
+ id: cred.id,
331
+ rawId: bufToB64url(cred.rawId),
332
+ type: "public-key",
333
+ clientExtensionResults: cred.getClientExtensionResults(),
334
+ authenticatorAttachment: cred.authenticatorAttachment ?? void 0,
335
+ response: {
336
+ clientDataJSON: bufToB64url(resp.clientDataJSON),
337
+ authenticatorData: bufToB64url(resp.authenticatorData),
338
+ signature: bufToB64url(resp.signature),
339
+ userHandle: resp.userHandle ? bufToB64url(resp.userHandle) : void 0
340
+ }
341
+ };
342
+ } catch (e) {
343
+ const name = e?.name;
344
+ if (name === "NotAllowedError" || name === "AbortError") {
345
+ return { ok: false, error: "passkey_cancelled" };
346
+ }
347
+ return { ok: false, error: "passkey_failed", message: e instanceof Error ? e.message : void 0 };
348
+ }
349
+ try {
350
+ const res = await fetch(`${baseUrl}/api/auth/passkey/sign-in/finish`, {
351
+ method: "POST",
352
+ headers: { "content-type": "application/json" },
353
+ credentials,
354
+ body: JSON.stringify({ intent: "app", clientId, ...assertion })
355
+ });
356
+ const body = await res.json();
357
+ if (!res.ok || !body.success) {
358
+ return { ok: false, error: body.errorMessage ?? "passkey_verify_failed" };
359
+ }
360
+ if (body.data?.token) setElvixToken(body.data.token);
361
+ return { ok: true, redirect: body.data?.redirect, token: body.data?.token };
362
+ } catch (e) {
363
+ return { ok: false, error: "network", message: e instanceof Error ? e.message : void 0 };
364
+ }
365
+ }
366
+
275
367
  // src/react/elvix-sign-in.tsx
276
368
  import { Fragment, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
277
369
  function ElvixSignIn({
@@ -310,6 +402,29 @@ function ElvixSignIn({
310
402
  `${ctx.baseUrl}/api/auth/google/start?intent=app&clientId=${encodeURIComponent(ctx.clientId)}`
311
403
  );
312
404
  }
405
+ async function startPasskey() {
406
+ setBusy(true);
407
+ setError(null);
408
+ try {
409
+ const result = await runPasskeySignIn(ctx.baseUrl, ctx.clientId);
410
+ if (!result.ok) {
411
+ if (result.error === "passkey_cancelled") {
412
+ onResult?.({ ok: false, error: result.error });
413
+ return;
414
+ }
415
+ return fail(result.error, result.message);
416
+ }
417
+ setStep("done");
418
+ onResult?.({
419
+ ok: true,
420
+ method: "passkey",
421
+ redirect: result.redirect ?? redirectAfterSignIn,
422
+ token: result.token
423
+ });
424
+ } finally {
425
+ setBusy(false);
426
+ }
427
+ }
313
428
  async function startOtp(e) {
314
429
  e.preventDefault();
315
430
  if (!email.trim()) return fail("invalid_input", copy.errorEnterEmail);
@@ -388,6 +503,17 @@ function ElvixSignIn({
388
503
  children: copy.googleButton
389
504
  }
390
505
  ),
506
+ app?.methodPasskey && /* @__PURE__ */ jsx3(
507
+ "button",
508
+ {
509
+ type: "button",
510
+ onClick: startPasskey,
511
+ disabled: busy,
512
+ className: "elvix-btn elvix-btn-passkey",
513
+ "data-elvix-method": "passkey",
514
+ children: copy.passkeyButton
515
+ }
516
+ ),
391
517
  app?.methodEmailOtp && /* @__PURE__ */ jsxs2("form", { onSubmit: startOtp, "data-elvix-method": "email_otp", className: "elvix-otp-form", children: [
392
518
  /* @__PURE__ */ jsx3(
393
519
  "input",
@@ -529,6 +655,28 @@ function ElvixSecuredBadge({
529
655
 
530
656
  // src/react/elvix-sign-in-form.tsx
531
657
  import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
658
+ function PasskeyIcon({ size = 18 }) {
659
+ return /* @__PURE__ */ jsxs5(
660
+ "svg",
661
+ {
662
+ width: size,
663
+ height: size,
664
+ viewBox: "0 0 24 24",
665
+ fill: "none",
666
+ stroke: "currentColor",
667
+ strokeWidth: 1.8,
668
+ strokeLinecap: "round",
669
+ strokeLinejoin: "round",
670
+ "aria-hidden": true,
671
+ style: { display: "block" },
672
+ children: [
673
+ /* @__PURE__ */ jsx6("circle", { cx: "9", cy: "8", r: "4" }),
674
+ /* @__PURE__ */ jsx6("path", { d: "M4 20c0-3 2.5-5 5-5 1 0 1.9.3 2.7.8" }),
675
+ /* @__PURE__ */ jsx6("path", { d: "M17 12.5a2.5 2.5 0 1 0-2.5 2.5v5l1.2-1.2 1.3 1.2v-5a2.5 2.5 0 0 0 0-2.5Z" })
676
+ ]
677
+ }
678
+ );
679
+ }
532
680
  function GoogleG({ size = 18 }) {
533
681
  return /* @__PURE__ */ jsxs5("svg", { width: size, height: size, viewBox: "0 0 18 18", "aria-hidden": true, style: { display: "block" }, children: [
534
682
  /* @__PURE__ */ jsx6(
@@ -590,8 +738,9 @@ function ElvixSignInForm({
590
738
  const title = copy.title ? fillCopy(copy.title, { app: app?.appName ?? "" }) : defaultTitle;
591
739
  const submitLabel = copy.submitButton ?? verb;
592
740
  const showGoogle = Boolean(app?.methodGoogle);
741
+ const showPasskey = Boolean(app?.methodPasskey);
593
742
  const showEmail = Boolean(app?.methodEmailOtp);
594
- const showDivider = showGoogle && showEmail;
743
+ const showDivider = (showGoogle || showPasskey) && showEmail;
595
744
  const logoSrc = app?.iconUrl || app?.logoUrl || null;
596
745
  const privacyUrl = app?.privacyPolicyUrl || null;
597
746
  const termsUrl = app?.termsOfServiceUrl || null;
@@ -629,6 +778,29 @@ function ElvixSignInForm({
629
778
  `${ctx.baseUrl}/api/auth/google/start?intent=app&clientId=${encodeURIComponent(ctx.clientId)}`
630
779
  );
631
780
  }
781
+ async function startPasskey() {
782
+ setBusy(true);
783
+ setError(null);
784
+ try {
785
+ const result = await runPasskeySignIn(ctx.baseUrl, ctx.clientId);
786
+ if (!result.ok) {
787
+ if (result.error === "passkey_cancelled") {
788
+ onResult?.({ ok: false, error: result.error });
789
+ return;
790
+ }
791
+ return fail(result.error, result.message);
792
+ }
793
+ setStep("done");
794
+ onResult?.({
795
+ ok: true,
796
+ method: "passkey",
797
+ redirect: result.redirect ?? redirectAfterSignIn,
798
+ token: result.token
799
+ });
800
+ } finally {
801
+ setBusy(false);
802
+ }
803
+ }
632
804
  async function startOtp(e) {
633
805
  e.preventDefault();
634
806
  if (!email.trim()) return fail("invalid_input", copy.errorEnterEmail);
@@ -792,6 +964,20 @@ function ElvixSignInForm({
792
964
  ]
793
965
  }
794
966
  ),
967
+ showPasskey && /* @__PURE__ */ jsxs5(
968
+ "button",
969
+ {
970
+ type: "button",
971
+ onClick: startPasskey,
972
+ disabled: busy,
973
+ style: googleBtnStyle,
974
+ "data-elvix-method": "passkey",
975
+ children: [
976
+ /* @__PURE__ */ jsx6(PasskeyIcon, {}),
977
+ /* @__PURE__ */ jsx6("span", { children: copy.passkeyButton })
978
+ ]
979
+ }
980
+ ),
795
981
  showDivider && /* @__PURE__ */ jsxs5(
796
982
  "div",
797
983
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elvix.is/sdk",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
4
4
  "description": "Official elvix SDK. Drop-in React components, server helpers, and an MCP server so AI coding agents integrate elvix on the first try.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://elvix.is",