@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><g><path d="m13.5 9 .00000017-.00021827c.00112736-1.46023-.532521-2.87033-1.50014-3.96395v-2.53584c0-1.38071-1.11929-2.5-2.5-2.5s-2.5 1.11929-2.5 2.5v.521l-.00000041.00000003c-3.30165.282474-5.74917 3.18798-5.4667 6.48963.200464 2.34309 1.75126 4.35306 3.96684 5.14137v.641l-.854.853.00000005-.00000005c-.0937141.0939506-.146237.221301-.146.354v.00000072c.0003071.132591.0527482.259742.146.354l.854.854v.586l-.854.854.00000003-.00000003c-.195191.19525-.195191.51175-.00000005.707l.854.853v1.292l.00000001-.00000402c-.00023806.132699.052284.26005.145997.354001l1.5 1.5-.00000004-.00000004c.195015.195509.511597.195909.707106.00089385.0002983-.00029755.00059623-.00059548.00089378-.00089378l1.5-1.5.00000001-.00000001c.0937141-.0939496.146238-.221299.146002-.353997v-7.348l-.00000037.00000013c2.39202-.851706 3.99229-3.11289 4-5.652zm-6-3h-.00000004c.552285-.00000002 1 .447715 1 1 .00000002.552285-.447715 1-1 1-.552285.00000002-1-.447715-1-1v.00000011c-.00000008-.552285.447715-1 1-1h.00000004zm2-5h-.00000007c.828427-.00000004 1.5.671573 1.5 1.5v1.63l-.0000001-.00000007c-.602574-.434703-1.28079-.753463-2-.94v1.81h-1v-2.5.00000023c-.00000013-.828427.671573-1.5 1.5-1.5z"/><path d="m22.354 18.026-5.2-5.2.00000012-.00000024c1.31269-2.70353.401635-5.96153-2.123-7.592-.230921-.151427-.540875-.0869843-.692302.143937-.0946713.144371-.108167.327367-.0356979.484063l-.00000011-.00000024c1.57633 3.39991.38992 7.44157-2.774 9.45l.00000003-.00000002c-.232512.148972-.300235.458226-.151263.690738.0917865.143258.250123.23001.420263.230262h.006-.0000002c.879855-.00837008 1.74719-.209417 2.541-.589l.65.577v1.279.00000008c.00000004.276142.223858.5.5.5h1.286l.214.224v1.276.00000008c.00000004.276142.223858.5.5.5h1.119l.917.864c.0928133.0874048.215509.136054.343.136h2.126-.00000002c.276142.00000001.5-.223858.5-.5v-2.121l.00000001.00010623c0-.132352-.052476-.259306-.145925-.353031z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="240" height="240" viewBox="0 0 240 240" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M239.257 120.417C239.257 54.4193 185.755 0.916504 119.757 0.916504C53.7601 0.916504 0.257324 54.4193 0.257324 120.417C0.257324 186.417 53.7601 239.917 119.757 239.917C185.755 239.917 239.257 186.417 239.257 120.417ZM98.0069 54.0276C97.0674 55.8714 97.0674 58.2851 97.0674 63.1126V90.4729C97.0674 91.6788 97.0674 92.2817 97.2196 92.8392C97.3545 93.3331 97.5763 93.799 97.8746 94.215C98.2113 94.6847 98.6792 95.0648 99.6152 95.8251L106.536 101.447C107.664 102.364 108.228 102.822 108.433 103.374C108.613 103.857 108.613 104.39 108.433 104.873C108.228 105.425 107.664 105.883 106.536 106.8L99.6152 112.422C98.6793 113.182 98.2113 113.562 97.8746 114.032C97.5763 114.448 97.3545 114.914 97.2196 115.408C97.0674 115.965 97.0674 116.568 97.0674 117.774V177.719C97.0674 182.547 97.0674 184.961 98.0069 186.805C98.8333 188.426 100.152 189.745 101.774 190.571C103.618 191.511 106.031 191.511 110.859 191.511H128.656C133.483 191.511 135.897 191.511 137.741 190.571C139.363 189.745 140.681 188.426 141.508 186.805C142.447 184.961 142.447 182.547 142.447 177.719V150.359C142.447 149.153 142.447 148.55 142.295 147.993C142.16 147.499 141.938 147.033 141.64 146.617C141.303 146.147 140.835 145.767 139.899 145.007L132.978 139.385C131.85 138.468 131.286 138.01 131.082 137.459C130.902 136.975 130.902 136.443 131.082 135.959C131.286 135.407 131.85 134.949 132.978 134.033L139.899 128.41C140.835 127.65 141.303 127.27 141.64 126.8C141.938 126.384 142.16 125.918 142.295 125.424C142.447 124.867 142.447 124.264 142.447 123.058V63.1126C142.447 58.2851 142.447 55.8714 141.508 54.0276C140.681 52.4057 139.363 51.087 137.741 50.2606C135.897 49.3211 133.483 49.3211 128.656 49.3211H110.859C106.031 49.3211 103.618 49.3211 101.774 50.2606C100.152 51.087 98.8333 52.4057 98.0069 54.0276Z" fill="#FFFEFB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="240" height="240" viewBox="0 0 240 240" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M239.116 120.417C239.116 54.4193 185.613 0.916504 119.616 0.916504C53.619 0.916504 0.116211 54.4193 0.116211 120.417C0.116211 186.417 53.619 239.917 119.616 239.917C185.613 239.917 239.116 186.417 239.116 120.417ZM97.8658 54.0276C96.9263 55.8714 96.9263 58.2851 96.9263 63.1126V90.4729C96.9263 91.6788 96.9263 92.2817 97.0785 92.8392C97.2134 93.3331 97.4352 93.799 97.7335 94.215C98.0702 94.6847 98.5381 95.0648 99.4741 95.8251L106.395 101.447C107.523 102.364 108.087 102.822 108.292 103.374C108.471 103.857 108.471 104.39 108.292 104.873C108.087 105.425 107.523 105.883 106.395 106.8L99.4741 112.422C98.5382 113.182 98.0702 113.562 97.7335 114.032C97.4352 114.448 97.2134 114.914 97.0785 115.408C96.9263 115.965 96.9263 116.568 96.9263 117.774V177.719C96.9263 182.547 96.9263 184.961 97.8658 186.805C98.6922 188.426 100.011 189.745 101.633 190.571C103.477 191.511 105.89 191.511 110.718 191.511H128.515C133.342 191.511 135.756 191.511 137.6 190.571C139.221 189.745 140.54 188.426 141.367 186.805C142.306 184.961 142.306 182.547 142.306 177.719V150.359C142.306 149.153 142.306 148.55 142.154 147.993C142.019 147.499 141.797 147.033 141.499 146.617C141.162 146.147 140.694 145.767 139.758 145.007L132.837 139.385C131.709 138.468 131.145 138.01 130.94 137.459C130.761 136.975 130.761 136.443 130.94 135.959C131.145 135.407 131.709 134.949 132.837 134.033L139.758 128.41C140.694 127.65 141.162 127.27 141.499 126.8C141.797 126.384 142.019 125.918 142.154 125.424C142.306 124.867 142.306 124.264 142.306 123.058V63.1126C142.306 58.2851 142.306 55.8714 141.367 54.0276C140.54 52.4057 139.221 51.087 137.6 50.2606C135.756 49.3211 133.342 49.3211 128.515 49.3211H110.718C105.89 49.3211 103.477 49.3211 101.633 50.2606C100.011 51.087 98.6922 52.4057 97.8658 54.0276Z" fill="#1A285F"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,38 @@
|
||||
<svg viewBox="0 0 322 322" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2">
|
||||
<path d="M321.347 72.106C321.347 32.31 289.037 0 249.241 0H72.106C32.31 0 0 32.31 0 72.106v177.135c0 39.796 32.31 72.106 72.106 72.106h177.135c39.796 0 72.106-32.31 72.106-72.106V72.106Z" style="fill:#fff"/>
|
||||
<path d="M126.106 266.574c.775.965.309 2.517.017 2.43l-7.773 7.741c-1.686 1.206-3.365 1.132-5.009.135l-18.424-19.268c-.452-.649-.762-1.328-.833-2.058v-94.735c-22.821-8.67-39.057-30.754-39.057-56.6 0-33.006 26.478-59.876 59.333-60.511-18.551 13.563-30.61 35.488-30.61 60.208 0 28.109 15.593 52.604 38.593 65.301v87.841h.006c-.004.137-.006.275-.006.413 0 3.551 1.699 6.535 3.763 9.103Z" style="fill:#ebebeb"/>
|
||||
<clipPath id="a">
|
||||
<path d="M126.106 266.574c.775.965.309 2.517.017 2.43l-7.773 7.741c-1.686 1.206-3.365 1.132-5.009.135l-18.424-19.268c-.452-.649-.762-1.328-.833-2.058v-94.735c-22.821-8.67-39.057-30.754-39.057-56.6 0-33.006 26.478-59.876 59.333-60.511-18.551 13.563-30.61 35.488-30.61 60.208 0 28.109 15.593 52.604 38.593 65.301v87.841h.006c-.004.137-.006.275-.006.413 0 3.551 1.699 6.535 3.763 9.103Z"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#a)">
|
||||
<path d="M126.531 277.545V43.603H55.027v233.942h71.504Z" style="fill:url(#b)"/>
|
||||
</g>
|
||||
<path d="M167.618 266.574c.775.965.309 2.517.017 2.43l-7.773 7.741c-1.686 1.206-3.365 1.132-5.009.135l-18.424-19.268c-.452-.649-.762-1.328-.833-2.058v-94.735c-22.82-8.67-39.056-30.754-39.056-56.6 0-33.006 26.477-59.876 59.333-60.511-18.552 13.563-30.611 35.488-30.611 60.208 0 28.109 15.593 52.604 38.593 65.301v87.841h.007c-.005.137-.007.275-.007.413 0 3.551 1.699 6.535 3.763 9.103Z" style="fill:#ebebeb"/>
|
||||
<clipPath id="c">
|
||||
<path d="M167.618 266.574c.775.965.309 2.517.017 2.43l-7.773 7.741c-1.686 1.206-3.365 1.132-5.009.135l-18.424-19.268c-.452-.649-.762-1.328-.833-2.058v-94.735c-22.82-8.67-39.056-30.754-39.056-56.6 0-33.006 26.477-59.876 59.333-60.511-18.552 13.563-30.611 35.488-30.611 60.208 0 28.109 15.593 52.604 38.593 65.301v87.841h.007c-.005.137-.007.275-.007.413 0 3.551 1.699 6.535 3.763 9.103Z"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#c)">
|
||||
<path d="M168.043 277.545V43.603H96.54v233.942h71.503Z" style="fill:url(#d)"/>
|
||||
</g>
|
||||
<path d="M177.014 160.725c-22.82-8.67-39.056-30.753-39.056-56.599 0-33.404 27.119-60.523 60.523-60.523 33.403 0 60.523 27.119 60.523 60.523 0 28.22-19.357 51.956-45.506 58.642l26.358 26.862c.994 1.581.943 3.037-.059 4.377l-25.881 26.464 18.777 18.979c1.571 2.132 1.323 4.182-.256 6.171l-31.157 31.03c-1.686 1.207-3.364 1.133-5.008.135l-18.424-19.268c-.453-.648-.763-1.327-.834-2.057v-94.736Zm21.662-93.481c8.924 0 16.169 7.245 16.169 16.169s-7.245 16.169-16.169 16.169-16.169-7.245-16.169-16.169 7.245-16.169 16.169-16.169Z" style="fill:none"/>
|
||||
<clipPath id="e">
|
||||
<path d="M177.014 160.725c-22.82-8.67-39.056-30.753-39.056-56.599 0-33.404 27.119-60.523 60.523-60.523 33.403 0 60.523 27.119 60.523 60.523 0 28.22-19.357 51.956-45.506 58.642l26.358 26.862c.994 1.581.943 3.037-.059 4.377l-25.881 26.464 18.777 18.979c1.571 2.132 1.323 4.182-.256 6.171l-31.157 31.03c-1.686 1.207-3.364 1.133-5.008.135l-18.424-19.268c-.453-.648-.763-1.327-.834-2.057v-94.736Zm21.662-93.481c8.924 0 16.169 7.245 16.169 16.169s-7.245 16.169-16.169 16.169-16.169-7.245-16.169-16.169 7.245-16.169 16.169-16.169Z"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#e)">
|
||||
<path d="M259.004 277.545V43.603H137.958v233.942h121.046Z" style="fill:url(#f)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="b" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 233.943 -71.5033 0 90.78 43.603)">
|
||||
<stop offset="0" style="stop-color:#ffdd53;stop-opacity:1"/>
|
||||
<stop offset="1" style="stop-color:#ffbf01;stop-opacity:1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="d" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 233.943 -71.5033 0 132.291 43.603)">
|
||||
<stop offset="0" style="stop-color:#51e376;stop-opacity:1"/>
|
||||
<stop offset="1" style="stop-color:#34c759;stop-opacity:1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="f" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 233.943 -121.046 0 198.481 43.603)">
|
||||
<stop offset="0" style="stop-color:#62ccf8;stop-opacity:1"/>
|
||||
<stop offset="1" style="stop-color:#0079ff;stop-opacity:1"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1,38 @@
|
||||
<svg viewBox="0 0 322 322" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2">
|
||||
<path d="M321.347 72.106C321.347 32.31 289.037 0 249.241 0H72.106C32.31 0 0 32.31 0 72.106v177.135c0 39.796 32.31 72.106 72.106 72.106h177.135c39.796 0 72.106-32.31 72.106-72.106V72.106Z" style="fill:#fff"/>
|
||||
<path d="M126.106 266.574c.775.965.309 2.517.017 2.43l-7.773 7.741c-1.686 1.206-3.365 1.132-5.009.135l-18.424-19.268c-.452-.649-.762-1.328-.833-2.058v-94.735c-22.821-8.67-39.057-30.754-39.057-56.6 0-33.006 26.478-59.876 59.333-60.511-18.551 13.563-30.61 35.488-30.61 60.208 0 28.109 15.593 52.604 38.593 65.301v87.841h.006c-.004.137-.006.275-.006.413 0 3.551 1.699 6.535 3.763 9.103Z" style="fill:#ebebeb"/>
|
||||
<clipPath id="a">
|
||||
<path d="M126.106 266.574c.775.965.309 2.517.017 2.43l-7.773 7.741c-1.686 1.206-3.365 1.132-5.009.135l-18.424-19.268c-.452-.649-.762-1.328-.833-2.058v-94.735c-22.821-8.67-39.057-30.754-39.057-56.6 0-33.006 26.478-59.876 59.333-60.511-18.551 13.563-30.61 35.488-30.61 60.208 0 28.109 15.593 52.604 38.593 65.301v87.841h.006c-.004.137-.006.275-.006.413 0 3.551 1.699 6.535 3.763 9.103Z"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#a)">
|
||||
<path d="M126.531 277.545V43.603H55.027v233.942h71.504Z" style="fill:url(#b)"/>
|
||||
</g>
|
||||
<path d="M167.618 266.574c.775.965.309 2.517.017 2.43l-7.773 7.741c-1.686 1.206-3.365 1.132-5.009.135l-18.424-19.268c-.452-.649-.762-1.328-.833-2.058v-94.735c-22.82-8.67-39.056-30.754-39.056-56.6 0-33.006 26.477-59.876 59.333-60.511-18.552 13.563-30.611 35.488-30.611 60.208 0 28.109 15.593 52.604 38.593 65.301v87.841h.007c-.005.137-.007.275-.007.413 0 3.551 1.699 6.535 3.763 9.103Z" style="fill:#ebebeb"/>
|
||||
<clipPath id="c">
|
||||
<path d="M167.618 266.574c.775.965.309 2.517.017 2.43l-7.773 7.741c-1.686 1.206-3.365 1.132-5.009.135l-18.424-19.268c-.452-.649-.762-1.328-.833-2.058v-94.735c-22.82-8.67-39.056-30.754-39.056-56.6 0-33.006 26.477-59.876 59.333-60.511-18.552 13.563-30.611 35.488-30.611 60.208 0 28.109 15.593 52.604 38.593 65.301v87.841h.007c-.005.137-.007.275-.007.413 0 3.551 1.699 6.535 3.763 9.103Z"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#c)">
|
||||
<path d="M168.043 277.545V43.603H96.54v233.942h71.503Z" style="fill:url(#d)"/>
|
||||
</g>
|
||||
<path d="M177.014 160.725c-22.82-8.67-39.056-30.753-39.056-56.599 0-33.404 27.119-60.523 60.523-60.523 33.403 0 60.523 27.119 60.523 60.523 0 28.22-19.357 51.956-45.506 58.642l26.358 26.862c.994 1.581.943 3.037-.059 4.377l-25.881 26.464 18.777 18.979c1.571 2.132 1.323 4.182-.256 6.171l-31.157 31.03c-1.686 1.207-3.364 1.133-5.008.135l-18.424-19.268c-.453-.648-.763-1.327-.834-2.057v-94.736Zm21.662-93.481c8.924 0 16.169 7.245 16.169 16.169s-7.245 16.169-16.169 16.169-16.169-7.245-16.169-16.169 7.245-16.169 16.169-16.169Z" style="fill:none"/>
|
||||
<clipPath id="e">
|
||||
<path d="M177.014 160.725c-22.82-8.67-39.056-30.753-39.056-56.599 0-33.404 27.119-60.523 60.523-60.523 33.403 0 60.523 27.119 60.523 60.523 0 28.22-19.357 51.956-45.506 58.642l26.358 26.862c.994 1.581.943 3.037-.059 4.377l-25.881 26.464 18.777 18.979c1.571 2.132 1.323 4.182-.256 6.171l-31.157 31.03c-1.686 1.207-3.364 1.133-5.008.135l-18.424-19.268c-.453-.648-.763-1.327-.834-2.057v-94.736Zm21.662-93.481c8.924 0 16.169 7.245 16.169 16.169s-7.245 16.169-16.169 16.169-16.169-7.245-16.169-16.169 7.245-16.169 16.169-16.169Z"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#e)">
|
||||
<path d="M259.004 277.545V43.603H137.958v233.942h121.046Z" style="fill:url(#f)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="b" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 233.943 -71.5033 0 90.78 43.603)">
|
||||
<stop offset="0" style="stop-color:#ffdd53;stop-opacity:1"/>
|
||||
<stop offset="1" style="stop-color:#ffbf01;stop-opacity:1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="d" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 233.943 -71.5033 0 132.291 43.603)">
|
||||
<stop offset="0" style="stop-color:#51e376;stop-opacity:1"/>
|
||||
<stop offset="1" style="stop-color:#34c759;stop-opacity:1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="f" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 233.943 -121.046 0 198.481 43.603)">
|
||||
<stop offset="0" style="stop-color:#62ccf8;stop-opacity:1"/>
|
||||
<stop offset="1" style="stop-color:#0079ff;stop-opacity:1"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_68_61)">
|
||||
<path d="M300 253.125C300 279.023 279.023 300 253.125 300H46.875C20.9766 300 0 279.023 0 253.125V46.875C0 20.9766 20.9766 0 46.875 0H253.125C279.023 0 300 20.9766 300 46.875V253.125Z" fill="#175DDC"/>
|
||||
<path d="M243.105 37.6758C241.201 35.7715 238.945 34.834 236.367 34.834H63.6328C61.0254 34.834 58.7988 35.7715 56.8945 37.6758C54.9902 39.5801 54.0527 41.8359 54.0527 44.4141V159.58C54.0527 168.164 55.7227 176.689 59.0625 185.156C62.4023 193.594 66.5625 201.094 71.5137 207.656C76.4648 214.189 82.3535 220.576 89.209 226.787C96.0645 232.998 102.393 238.125 108.164 242.227C113.965 246.328 120 250.195 126.299 253.857C132.598 257.52 137.08 259.98 139.717 261.27C142.354 262.559 144.492 263.584 146.074 264.258C147.275 264.844 148.564 265.166 149.971 265.166C151.377 265.166 152.666 264.873 153.867 264.258C155.479 263.555 157.588 262.559 160.254 261.27C162.891 259.98 167.373 257.49 173.672 253.857C179.971 250.195 186.006 246.328 191.807 242.227C197.607 238.125 203.936 232.969 210.791 226.787C217.646 220.576 223.535 214.219 228.486 207.656C233.438 201.094 237.568 193.623 240.938 185.156C244.277 176.719 245.947 168.193 245.947 159.58V44.4434C245.977 41.8359 245.01 39.5801 243.105 37.6758ZM220.84 160.664C220.84 202.354 150 238.271 150 238.271V59.502H220.84C220.84 59.502 220.84 118.975 220.84 160.664Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_68_61">
|
||||
<rect width="300" height="300" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_68_61)">
|
||||
<path d="M300 253.125C300 279.023 279.023 300 253.125 300H46.875C20.9766 300 0 279.023 0 253.125V46.875C0 20.9766 20.9766 0 46.875 0H253.125C279.023 0 300 20.9766 300 46.875V253.125Z" fill="#175DDC"/>
|
||||
<path d="M243.105 37.6758C241.201 35.7715 238.945 34.834 236.367 34.834H63.6328C61.0254 34.834 58.7988 35.7715 56.8945 37.6758C54.9902 39.5801 54.0527 41.8359 54.0527 44.4141V159.58C54.0527 168.164 55.7227 176.689 59.0625 185.156C62.4023 193.594 66.5625 201.094 71.5137 207.656C76.4648 214.189 82.3535 220.576 89.209 226.787C96.0645 232.998 102.393 238.125 108.164 242.227C113.965 246.328 120 250.195 126.299 253.857C132.598 257.52 137.08 259.98 139.717 261.27C142.354 262.559 144.492 263.584 146.074 264.258C147.275 264.844 148.564 265.166 149.971 265.166C151.377 265.166 152.666 264.873 153.867 264.258C155.479 263.555 157.588 262.559 160.254 261.27C162.891 259.98 167.373 257.49 173.672 253.857C179.971 250.195 186.006 246.328 191.807 242.227C197.607 238.125 203.936 232.969 210.791 226.787C217.646 220.576 223.535 214.219 228.486 207.656C233.438 201.094 237.568 193.623 240.938 185.156C244.277 176.719 245.947 168.193 245.947 159.58V44.4434C245.977 41.8359 245.01 39.5801 243.105 37.6758ZM220.84 160.664C220.84 202.354 150 238.271 150 238.271V59.502H220.84C220.84 59.502 220.84 118.975 220.84 160.664Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_68_61">
|
||||
<rect width="300" height="300" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M257.474 359.209C257.474 356.189 254.454 353.169 250.215 351.959L199.411 333.231C190.895 329.601 181.264 333.831 181.264 339.89V475.779C181.264 478.809 184.283 482.438 187.303 483.648L239.326 502.376C247.195 505.396 257.474 501.166 257.474 494.508V359.209Z" fill="white"/>
|
||||
<path d="M352.736 321.104C352.736 318.084 349.717 315.064 345.477 313.854L294.674 295.126C286.157 291.496 276.526 295.726 276.526 301.785V437.674C276.526 440.704 279.546 444.333 282.566 445.543L334.589 464.271C342.458 467.291 352.736 463.061 352.736 456.403V321.104Z" fill="white"/>
|
||||
<path d="M257.474 35.3211C257.474 32.3013 254.454 29.2815 250.215 28.0717L199.411 9.34343C190.895 5.71399 181.264 9.94358 181.264 16.0022V151.892C181.264 154.921 184.283 158.551 187.303 159.76L239.326 178.489C247.195 181.509 257.474 177.279 257.474 170.62V35.3211Z" fill="white"/>
|
||||
<path d="M352.736 92.4777C352.736 89.4579 349.717 86.4382 345.477 85.2283L294.674 66.5C286.157 62.8706 276.526 67.1002 276.526 73.1588V209.048C276.526 212.078 279.546 215.707 282.566 216.917L334.589 235.645C342.458 238.665 352.736 234.436 352.736 227.777V92.4777Z" fill="white"/>
|
||||
<path d="M448 168.687C448 165.667 444.98 162.647 440.741 161.437L389.937 142.709C381.421 139.079 371.79 143.309 371.79 149.368V361.466C371.79 364.495 374.81 368.125 377.829 369.335L429.852 388.063C437.721 391.083 448 386.853 448 380.194V168.687Z" fill="white"/>
|
||||
<path d="M162.21 35.3306C162.21 32.3108 159.19 29.2815 154.951 28.0717L104.148 9.34343C95.6787 5.71399 86 9.94358 86 16.0022V475.789C86 478.808 89.0198 482.438 92.0492 483.648L144.063 502.376C151.931 505.396 162.21 501.166 162.21 494.507V35.3306Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M257.474 359.209C257.474 356.189 254.454 353.169 250.215 351.959L199.411 333.231C190.895 329.601 181.264 333.831 181.264 339.89V475.779C181.264 478.809 184.283 482.438 187.303 483.648L239.326 502.376C247.195 505.396 257.474 501.166 257.474 494.508V359.209Z" fill="#09363F"/>
|
||||
<path d="M352.736 321.103C352.736 318.084 349.717 315.064 345.477 313.854L294.674 295.126C286.157 291.496 276.526 295.726 276.526 301.785V437.674C276.526 440.704 279.546 444.333 282.566 445.543L334.589 464.271C342.458 467.291 352.736 463.061 352.736 456.403V321.103Z" fill="#09363F"/>
|
||||
<path d="M257.474 35.3211C257.474 32.3013 254.454 29.2815 250.215 28.0717L199.411 9.34343C190.895 5.71399 181.264 9.94358 181.264 16.0022V151.892C181.264 154.921 184.283 158.551 187.303 159.76L239.326 178.489C247.195 181.508 257.474 177.279 257.474 170.62V35.3211Z" fill="#09363F"/>
|
||||
<path d="M352.736 92.4777C352.736 89.4579 349.717 86.4382 345.477 85.2283L294.674 66.5C286.157 62.8706 276.526 67.1002 276.526 73.1588V209.048C276.526 212.078 279.546 215.707 282.566 216.917L334.589 235.645C342.458 238.665 352.736 234.436 352.736 227.777V92.4777Z" fill="#09363F"/>
|
||||
<path d="M448 168.687C448 165.667 444.98 162.647 440.741 161.437L389.937 142.709C381.421 139.079 371.79 143.309 371.79 149.368V361.466C371.79 364.495 374.81 368.125 377.829 369.335L429.852 388.063C437.721 391.083 448 386.853 448 380.194V168.687Z" fill="#09363F"/>
|
||||
<path d="M162.21 35.3306C162.21 32.3108 159.19 29.2815 154.951 28.0717L104.148 9.34343C95.6787 5.71399 86 9.94358 86 16.0022V475.789C86 478.808 89.0198 482.438 92.0492 483.648L144.063 502.376C151.931 505.396 162.21 501.166 162.21 494.507V35.3306Z" fill="#09363F"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="none"><path d="M160 16a64 64 0 0 0-60.3 86.4L36 166.1V224h48v-32h32v-32h32l15.7-15.7A64 64 0 1 0 160 16Zm16 72a24 24 0 1 1 0-48 24 24 0 0 1 0 48Z" fill="#999"/></svg>
|
||||
|
After Width: | Height: | Size: 234 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="none"><path d="M160 16a64 64 0 0 0-60.3 86.4L36 166.1V224h48v-32h32v-32h32l15.7-15.7A64 64 0 1 0 160 16Zm16 72a24 24 0 1 1 0-48 24 24 0 0 1 0 48Z" fill="#888"/></svg>
|
||||
|
After Width: | Height: | Size: 234 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 192 192" height="24px" viewBox="0 0 192 192" width="24px"><rect fill="none" height="192" width="192" y="0"/><g><path d="M69.29,106c-3.46,5.97-9.91,10-17.29,10c-11.03,0-20-8.97-20-20s8.97-20,20-20 c7.38,0,13.83,4.03,17.29,10h25.55C90.3,66.54,72.82,52,52,52C27.74,52,8,71.74,8,96s19.74,44,44,44c20.82,0,38.3-14.54,42.84-34 H69.29z" fill="#4285F4"/><rect fill="#FBBC04" height="24" width="44" x="94" y="84"/><path d="M94.32,84H68v0.05c2.5,3.34,4,7.47,4,11.95s-1.5,8.61-4,11.95V108h26.32 c1.08-3.82,1.68-7.84,1.68-12S95.41,87.82,94.32,84z" fill="#EA4335"/><path d="M184,106v26h-16v-8c0-4.42-3.58-8-8-8s-8,3.58-8,8v8h-16v-26H184z" fill="#34A853"/><rect fill="#188038" height="24" width="48" x="136" y="84"/></g></svg>
|
||||
|
After Width: | Height: | Size: 779 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 192 192" height="24px" viewBox="0 0 192 192" width="24px"><rect fill="none" height="192" width="192" y="0"/><g><path d="M69.29,106c-3.46,5.97-9.91,10-17.29,10c-11.03,0-20-8.97-20-20s8.97-20,20-20 c7.38,0,13.83,4.03,17.29,10h25.55C90.3,66.54,72.82,52,52,52C27.74,52,8,71.74,8,96s19.74,44,44,44c20.82,0,38.3-14.54,42.84-34 H69.29z" fill="#4285F4"/><rect fill="#FBBC04" height="24" width="44" x="94" y="84"/><path d="M94.32,84H68v0.05c2.5,3.34,4,7.47,4,11.95s-1.5,8.61-4,11.95V108h26.32 c1.08-3.82,1.68-7.84,1.68-12S95.41,87.82,94.32,84z" fill="#EA4335"/><path d="M184,106v26h-16v-8c0-4.42-3.58-8-8-8s-8,3.58-8,8v8h-16v-26H184z" fill="#34A853"/><rect fill="#188038" height="24" width="48" x="136" y="84"/></g></svg>
|
||||
|
After Width: | Height: | Size: 779 B |
@@ -0,0 +1,13 @@
|
||||
<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="50" height="50" rx="7.84782" fill="url(#paint0_linear_284_5743)"/>
|
||||
<path d="M39.3518 17.6383C38.905 17.6383 38.5668 17.9747 38.5668 18.4287V31.5604C38.5668 32.0108 38.9014 32.3509 39.3518 32.3509C39.8022 32.3509 40.1368 32.0144 40.1368 31.5604V18.4287C40.1368 17.9784 39.8022 17.6383 39.3518 17.6383Z" fill="white"/>
|
||||
<path d="M21.8918 22.5449C20.3814 22.5449 19.0954 23.7857 19.0954 25.3069C19.0954 26.8281 20.3272 28.1232 21.8375 28.1232C23.3478 28.1232 24.6339 26.8824 24.6339 25.3612C24.6339 23.84 23.4021 22.5449 21.8918 22.5449Z" fill="white"/>
|
||||
<path d="M31.0694 22.5449C29.5591 22.5449 28.2731 23.7857 28.2731 25.3069C28.2731 26.8281 29.5048 28.1232 31.0152 28.1232C32.5255 28.1232 33.8115 26.8824 33.8115 25.3612C33.8658 23.84 32.634 22.5449 31.0694 22.5449Z" fill="white"/>
|
||||
<path d="M12.6596 22.5449C11.1493 22.5449 9.86328 23.7857 9.86328 25.3069C9.86328 26.8281 11.0951 28.1232 12.6054 28.1232C14.1157 28.1232 15.4017 26.8824 15.4017 25.3612C15.4017 23.84 14.17 22.5449 12.6596 22.5449Z" fill="white"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_284_5743" x1="25" y1="0" x2="25" y2="50" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#3C3D3D"/>
|
||||
<stop offset="1" stop-color="#262626"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="50" height="50" rx="7.84782" fill="url(#paint0_linear_284_5774)"/>
|
||||
<path d="M39.3517 17.6384C38.9049 17.6384 38.5667 17.9749 38.5667 18.4289V31.5605C38.5667 32.0108 38.9013 32.3509 39.3517 32.3509C39.8021 32.3509 40.1367 32.0145 40.1367 31.5605V18.4289C40.1367 17.9785 39.8021 17.6384 39.3517 17.6384Z" fill="white"/>
|
||||
<path d="M21.8918 22.545C20.3814 22.545 19.0954 23.7859 19.0954 25.307C19.0954 26.8282 20.3272 28.1233 21.8375 28.1233C23.3478 28.1233 24.6338 26.8825 24.6338 25.3613C24.6338 23.8401 23.4021 22.545 21.8918 22.545Z" fill="white"/>
|
||||
<path d="M31.0694 22.545C29.5591 22.545 28.273 23.7859 28.273 25.307C28.273 26.8282 29.5048 28.1233 31.0151 28.1233C32.5254 28.1233 33.8115 26.8825 33.8115 25.3613C33.8657 23.8401 32.634 22.545 31.0694 22.545Z" fill="white"/>
|
||||
<path d="M12.6597 22.545C11.1494 22.545 9.86339 23.7859 9.86339 25.307C9.86339 26.8282 11.0952 28.1233 12.6055 28.1233C14.1158 28.1233 15.4018 26.8825 15.4018 25.3613C15.4018 23.8401 14.17 22.545 12.6597 22.545Z" fill="white"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_284_5774" x1="25" y1="0" x2="25" y2="50" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#ED194A"/>
|
||||
<stop offset="1" stop-color="#C30833"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1 @@
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><defs><style>.cls-1{fill:#0078d4;stroke-width:0px;}</style></defs><rect class="cls-1" x="24.25" y="24.25" width="98.35" height="98.35"/><rect class="cls-1" x="133.4" y="24.25" width="98.35" height="98.35"/><rect class="cls-1" x="24.25" y="133.4" width="98.35" height="98.35"/><rect class="cls-1" x="133.4" y="133.4" width="98.35" height="98.35"/></svg>
|
||||
|
After Width: | Height: | Size: 427 B |
@@ -0,0 +1 @@
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><defs><style>.cls-1{fill:#0078d4;stroke-width:0px;}</style></defs><rect class="cls-1" x="24.25" y="24.25" width="98.35" height="98.35"/><rect class="cls-1" x="133.4" y="24.25" width="98.35" height="98.35"/><rect class="cls-1" x="24.25" y="133.4" width="98.35" height="98.35"/><rect class="cls-1" x="133.4" y="133.4" width="98.35" height="98.35"/></svg>
|
||||
|
After Width: | Height: | Size: 427 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="none"><circle cx="128" cy="128" r="120" fill="#9aca3c"/><path d="M128,160 L88,88 L108,88 L128,128 L148,88 L168,88 L140,148 L140,184 L116,184 L116,148 Z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 240 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="none"><circle cx="128" cy="128" r="120" fill="#9aca3c"/><path d="M128,160 L88,88 L108,88 L128,128 L148,88 L168,88 L140,148 L140,184 L116,184 L116,148 Z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 240 B |
@@ -1,19 +1,33 @@
|
||||
.access_tokens_table {
|
||||
.access-tokens {
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-small);
|
||||
inline-size: 100%;
|
||||
margin-block-end: 2lh;
|
||||
|
||||
td, th {
|
||||
border-block-end: 1px solid var(--color-ink-light);
|
||||
padding-inline: var(--inline-space);
|
||||
border-block-end: 1px solid var(--color-ink-lighter);
|
||||
text-align: start;
|
||||
|
||||
&:first-child {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
&:not(:first-child) {
|
||||
padding-inline-start: var(--inline-space);
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
padding-inline-end: var(--inline-space);
|
||||
}
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--color-ink-dark);
|
||||
font-size: var(--text-x-small);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
tr:nth-of-type(even) {
|
||||
background-color: var(--color-ink-lightest);
|
||||
td {
|
||||
padding-block: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
@layer components {
|
||||
.credential {
|
||||
border-block-start: var(--border);
|
||||
list-style: none;
|
||||
|
||||
&:last-child {
|
||||
border-block-end: var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
.credential__link {
|
||||
align-items: center;
|
||||
block-size: 1.75lh;
|
||||
color: currentcolor;
|
||||
display: flex;
|
||||
gap: 1ch;
|
||||
padding-inline: 1ch;
|
||||
|
||||
@media (any-hover: hover) {
|
||||
&:hover {
|
||||
background: var(--color-ink-lightest);
|
||||
|
||||
.credential__arrow {
|
||||
opacity: 0.66;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.credential__arrow {
|
||||
margin-inline-start: auto;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
[data-passkey-errors] [data-passkey-error] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-passkey-errors][data-passkey-error-state="error"] [data-passkey-error="error"],
|
||||
[data-passkey-errors][data-passkey-error-state="cancelled"] [data-passkey-error="cancelled"] {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
.icon--art { --svg: url("art.svg "); }
|
||||
.icon--assigned { --svg: url("assigned.svg "); }
|
||||
.icon--attachment { --svg: url("attachment.svg "); }
|
||||
.icon--authentication { --svg: url("authentication.svg "); }
|
||||
.icon--bell-alert { --svg: url("bell-alert.svg "); }
|
||||
.icon--bell-off { --svg: url("bell-off.svg "); }
|
||||
.icon--bell { --svg: url("bell.svg "); }
|
||||
|
||||
@@ -31,9 +31,10 @@
|
||||
.txt-capitalize-first-letter::first-letter { text-transform: capitalize; }
|
||||
.txt-link { color: var(--color-link); text-decoration: underline; }
|
||||
|
||||
.font-weight-normal { font-weight: normal; }
|
||||
.font-weight-normal { font-weight: 400; }
|
||||
.font-weight-medium { font-weight: 500; }
|
||||
.font-weight-semibold { font-weight: 600; }
|
||||
.font-weight-bold { font-weight: bold; }
|
||||
.font-weight-bold { font-weight: 700; }
|
||||
.font-weight-black { font-weight: 900; }
|
||||
|
||||
/* Flexbox and Grid */
|
||||
@@ -283,4 +284,28 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.hide-on-dark-mode {
|
||||
html[data-theme="dark"] & {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html:not([data-theme]) & {
|
||||
@media (prefers-color-scheme: dark) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hide-on-light-mode {
|
||||
html[data-theme="light"] & {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html:not([data-theme]) & {
|
||||
@media (prefers-color-scheme: light) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
class My::PasskeyChallengesController < ActionPack::Passkey::ChallengesController
|
||||
include Authentication
|
||||
include Authorization
|
||||
|
||||
allow_unauthenticated_access
|
||||
disallow_account_scope
|
||||
end
|
||||
@@ -0,0 +1,34 @@
|
||||
class My::PasskeysController < ApplicationController
|
||||
include ActionPack::Passkey::Request
|
||||
|
||||
before_action :set_passkey, only: %i[ edit update destroy ]
|
||||
|
||||
def index
|
||||
@passkeys = Current.identity.passkeys.order(name: :asc, created_at: :desc)
|
||||
@creation_options = passkey_creation_options(holder: Current.identity)
|
||||
end
|
||||
|
||||
def create
|
||||
passkey = Current.identity.passkeys.register(passkey_creation_params)
|
||||
|
||||
redirect_to edit_my_passkey_path(passkey, created: true)
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@passkey.update!(params.expect(passkey: [ :name ]))
|
||||
redirect_to my_passkeys_path
|
||||
end
|
||||
|
||||
def destroy
|
||||
@passkey.destroy!
|
||||
redirect_to my_passkeys_path
|
||||
end
|
||||
|
||||
private
|
||||
def set_passkey
|
||||
@passkey = Current.identity.passkeys.find(params[:id])
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,33 @@
|
||||
class Sessions::PasskeysController < ApplicationController
|
||||
include ActionPack::Passkey::Request
|
||||
|
||||
disallow_account_scope
|
||||
require_unauthenticated_access
|
||||
rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded
|
||||
|
||||
def create
|
||||
if credential = ActionPack::Passkey.authenticate(passkey_request_params)
|
||||
start_new_session_for credential.holder
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to after_authentication_url }
|
||||
format.json { render json: { session_token: session_token } }
|
||||
end
|
||||
else
|
||||
respond_to do |format|
|
||||
format.html { redirect_to new_session_path, alert: "That passkey didn't work. Try again." }
|
||||
format.json { render json: { message: "That passkey didn't work. Try again." }, status: :unauthorized }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def rate_limit_exceeded
|
||||
rate_limit_exceeded_message = "Try again later."
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to new_session_path, alert: rate_limit_exceeded_message }
|
||||
format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,6 @@
|
||||
class SessionsController < ApplicationController
|
||||
include ActionPack::Passkey::Request
|
||||
|
||||
disallow_account_scope
|
||||
require_unauthenticated_access except: :destroy
|
||||
rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded
|
||||
@@ -6,6 +8,7 @@ class SessionsController < ApplicationController
|
||||
layout "public"
|
||||
|
||||
def new
|
||||
@request_options = passkey_request_options
|
||||
end
|
||||
|
||||
def create
|
||||
|
||||
@@ -6,3 +6,4 @@ import "controllers"
|
||||
|
||||
import "lexxy"
|
||||
import "@rails/actiontext"
|
||||
import "lib/action_pack/passkey"
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
// JS companion for the ActionPack::Passkey Ruby helpers.
|
||||
//
|
||||
// Binds click handlers to passkey buttons and manages the WebAuthn ceremony
|
||||
// lifecycle (challenge refresh, credential creation/authentication, form submission).
|
||||
//
|
||||
// Expected data attributes:
|
||||
// [data-passkey="create"] — triggers the registration ceremony
|
||||
// [data-passkey="sign_in"] — triggers the authentication ceremony
|
||||
// [data-passkey-mediation="conditional"] — on a <form>, enables autofill-assisted sign in
|
||||
// [data-passkey-errors] — container whose data-passkey-error-state is set on failure
|
||||
// [data-passkey-error="error|cancelled"] — children shown/hidden via CSS based on error state
|
||||
// [data-passkey-field="..."] — hidden fields populated before form submission
|
||||
//
|
||||
// Custom events (all bubble):
|
||||
// passkey:start — ceremony begun
|
||||
// passkey:success — credential obtained, form about to submit
|
||||
// passkey:error — ceremony failed; detail: { error, cancelled }
|
||||
//
|
||||
// Meta tags (rendered by the Ruby form helpers):
|
||||
// <meta name="passkey-creation-options"> — JSON WebAuthn creation options
|
||||
// <meta name="passkey-request-options"> — JSON WebAuthn request options
|
||||
// <meta name="passkey-challenge-url"> — endpoint to refresh the challenge nonce
|
||||
|
||||
import { register, authenticate } from "lib/action_pack/webauthn"
|
||||
|
||||
let listeners
|
||||
let currentDocument
|
||||
|
||||
document.addEventListener("DOMContentLoaded", setup)
|
||||
document.addEventListener("turbo:load", setup)
|
||||
document.addEventListener("turbo:before-cache", teardown)
|
||||
|
||||
// Set error state on the nearest [data-passkey-errors] container.
|
||||
// The app's CSS is responsible for showing/hiding children based on
|
||||
// the data-passkey-error-state attribute ("error" or "cancelled").
|
||||
document.addEventListener("passkey:error", ({ target, detail: { cancelled } }) => {
|
||||
const container = target.closest("[data-passkey-errors]")
|
||||
|
||||
if (container) {
|
||||
container.dataset.passkeyErrorState = cancelled ? "cancelled" : "error"
|
||||
}
|
||||
})
|
||||
|
||||
// Bind click handlers to passkey buttons and attempt conditional mediation.
|
||||
// Guards against duplicate setup.
|
||||
function setup() {
|
||||
if (currentDocument !== document.documentElement) {
|
||||
currentDocument = document.documentElement
|
||||
|
||||
listeners?.abort()
|
||||
listeners = new AbortController()
|
||||
|
||||
for (const button of document.querySelectorAll('[data-passkey="create"]')) {
|
||||
button.addEventListener("click", () => createPasskey(button), { signal: listeners.signal })
|
||||
}
|
||||
|
||||
for (const button of document.querySelectorAll('[data-passkey="sign_in"]')) {
|
||||
button.addEventListener("click", () => signInWithPasskey(button), { signal: listeners.signal })
|
||||
}
|
||||
|
||||
attemptConditionalMediation()
|
||||
}
|
||||
}
|
||||
|
||||
// Reset transient DOM state and unbind event handlers to prevent leaks and duplicate handlers.
|
||||
function teardown() {
|
||||
currentDocument = null
|
||||
listeners?.abort()
|
||||
|
||||
for (const button of document.querySelectorAll('[data-passkey][disabled]')) {
|
||||
button.disabled = false
|
||||
}
|
||||
|
||||
for (const container of document.querySelectorAll("[data-passkey-errors]")) {
|
||||
delete container.dataset.passkeyErrorState
|
||||
}
|
||||
}
|
||||
|
||||
// Run the WebAuthn registration ceremony: refresh the challenge, prompt the
|
||||
// browser to create a credential, fill the form's hidden fields, and submit.
|
||||
async function createPasskey(button) {
|
||||
const form = button.closest("form")
|
||||
|
||||
if (form) {
|
||||
button.disabled = true
|
||||
button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true }))
|
||||
|
||||
try {
|
||||
if (!passkeysAvailable()) throw new Error("Passkeys are not supported by this browser")
|
||||
|
||||
const creationOptions = getCreationOptions()
|
||||
if (!creationOptions) throw new Error("Missing passkey creation options")
|
||||
|
||||
await refreshChallenge(creationOptions)
|
||||
const passkey = await register(creationOptions)
|
||||
|
||||
button.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true }))
|
||||
fillCreateForm(form, passkey)
|
||||
form.submit()
|
||||
} catch (error) {
|
||||
button.disabled = false
|
||||
|
||||
const cancelled = error.name === "AbortError" || error.name === "NotAllowedError"
|
||||
button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function passkeysAvailable() {
|
||||
return !!window.PublicKeyCredential
|
||||
}
|
||||
|
||||
// Read WebAuthn creation options from the <meta> tag rendered by
|
||||
// +passkey_creation_options_meta_tag+. Returns undefined if the tag is missing.
|
||||
function getCreationOptions() {
|
||||
return getOptions("passkey-creation-options")
|
||||
}
|
||||
|
||||
// Parse and return the JSON content of a <meta> tag by name.
|
||||
function getOptions(name) {
|
||||
const meta = document.querySelector(`meta[name="${name}"]`)
|
||||
|
||||
if (meta) {
|
||||
return JSON.parse(meta.content)
|
||||
}
|
||||
}
|
||||
|
||||
// POST to the challenge endpoint to get a fresh nonce, preventing replay attacks
|
||||
// when the page has been open for a while before the user initiates the ceremony.
|
||||
async function refreshChallenge(options) {
|
||||
const url = document.querySelector('meta[name="passkey-challenge-url"]')?.content
|
||||
if (!url) throw new Error("Missing passkey challenge URL")
|
||||
const token = document.querySelector('meta[name="csrf-token"]')?.content
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"X-CSRF-Token": token,
|
||||
"Accept": "application/json"
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error("Failed to refresh challenge")
|
||||
|
||||
const { challenge } = await response.json()
|
||||
options.challenge = challenge
|
||||
}
|
||||
|
||||
// Populate the registration form's hidden fields with the credential response.
|
||||
// Clones the transports template input for each reported transport.
|
||||
function fillCreateForm(form, passkey) {
|
||||
form.querySelector('[data-passkey-field="client_data_json"]').value = passkey.client_data_json
|
||||
form.querySelector('[data-passkey-field="attestation_object"]').value = passkey.attestation_object
|
||||
|
||||
const template = form.querySelector('[data-passkey-field="transports"]')
|
||||
for (const transport of passkey.transports) {
|
||||
const input = template.cloneNode()
|
||||
input.value = transport
|
||||
template.before(input)
|
||||
}
|
||||
template.remove()
|
||||
}
|
||||
|
||||
// Run the WebAuthn authentication ceremony: refresh the challenge, prompt the
|
||||
// browser to sign with an existing credential, fill the form, and submit.
|
||||
async function signInWithPasskey(button) {
|
||||
const form = button.closest("form")
|
||||
|
||||
if (form) {
|
||||
button.disabled = true
|
||||
button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true }))
|
||||
|
||||
try {
|
||||
if (!passkeysAvailable()) throw new Error("Passkeys are not supported by this browser")
|
||||
|
||||
const requestOptions = getRequestOptions()
|
||||
if (!requestOptions) throw new Error("Missing passkey request options")
|
||||
|
||||
await refreshChallenge(requestOptions)
|
||||
const passkey = await authenticate(requestOptions)
|
||||
|
||||
button.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true }))
|
||||
fillSignInForm(form, passkey)
|
||||
form.submit()
|
||||
} catch (error) {
|
||||
button.disabled = false
|
||||
|
||||
const cancelled = error.name === "AbortError" || error.name === "NotAllowedError"
|
||||
button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read WebAuthn request options from the <meta> tag rendered by
|
||||
// +passkey_request_options_meta_tag+. Returns undefined if the tag is missing.
|
||||
function getRequestOptions() {
|
||||
return getOptions("passkey-request-options")
|
||||
}
|
||||
|
||||
// Populate the authentication form's hidden fields with the assertion response.
|
||||
function fillSignInForm(form, passkey) {
|
||||
form.querySelector('[data-passkey-field="id"]').value = passkey.id
|
||||
form.querySelector('[data-passkey-field="client_data_json"]').value = passkey.client_data_json
|
||||
form.querySelector('[data-passkey-field="authenticator_data"]').value = passkey.authenticator_data
|
||||
form.querySelector('[data-passkey-field="signature"]').value = passkey.signature
|
||||
}
|
||||
|
||||
// Start the conditional mediation (autofill) ceremony if the page opts in with
|
||||
// a form[data-passkey-mediation="conditional"] and the browser supports it.
|
||||
// Unlike the button-driven ceremonies, this runs automatically on page load.
|
||||
async function attemptConditionalMediation() {
|
||||
if (await conditionalMediationAvailable()) {
|
||||
const form = document.querySelector('form[data-passkey-mediation="conditional"]')
|
||||
form.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true }))
|
||||
|
||||
const requestOptions = getRequestOptions()
|
||||
|
||||
try {
|
||||
await refreshChallenge(requestOptions)
|
||||
|
||||
const passkey = await authenticate(requestOptions, { mediation: "conditional" })
|
||||
|
||||
form.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true }))
|
||||
fillSignInForm(form, passkey)
|
||||
form.submit()
|
||||
} catch (error) {
|
||||
const cancelled = error.name === "AbortError" || error.name === "NotAllowedError"
|
||||
form.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check all preconditions for conditional mediation: the page has opted in,
|
||||
// request options are present, the browser supports passkeys, and the browser
|
||||
// supports the conditional mediation UI (autofill).
|
||||
async function conditionalMediationAvailable() {
|
||||
return isConditionalMediationFormPresent() &&
|
||||
getRequestOptions() &&
|
||||
passkeysAvailable() &&
|
||||
await window.PublicKeyCredential.isConditionalMediationAvailable?.()
|
||||
}
|
||||
|
||||
function isConditionalMediationFormPresent() {
|
||||
return !!document.querySelector('form[data-passkey-mediation="conditional"]')
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Thin wrapper around the browser WebAuthn API (navigator.credentials).
|
||||
//
|
||||
// Handles the base64url ↔ ArrayBuffer conversions required by the spec so
|
||||
// callers can work with plain JSON objects from the server-rendered meta tags.
|
||||
|
||||
// Call navigator.credentials.create() with the given creation options.
|
||||
// Returns { client_data_json, attestation_object, transports } with all
|
||||
// binary fields encoded as base64url strings ready for form submission.
|
||||
export async function register(options) {
|
||||
const publicKey = prepareCreationOptions(options)
|
||||
const credential = await navigator.credentials.create({ publicKey })
|
||||
|
||||
return {
|
||||
client_data_json: new TextDecoder().decode(credential.response.clientDataJSON),
|
||||
attestation_object: bufferToBase64url(credential.response.attestationObject),
|
||||
transports: credential.response.getTransports?.() || []
|
||||
}
|
||||
}
|
||||
|
||||
// Call navigator.credentials.get() with the given request options.
|
||||
// Accepts an optional signal (AbortSignal) and mediation hint ("conditional"
|
||||
// for autofill UI). Returns { id, client_data_json, authenticator_data, signature }
|
||||
// with binary fields encoded as base64url strings.
|
||||
export async function authenticate(options, { signal, mediation } = {}) {
|
||||
const publicKey = prepareRequestOptions(options)
|
||||
const credential = await navigator.credentials.get({ publicKey, signal, mediation })
|
||||
|
||||
return {
|
||||
id: credential.id,
|
||||
client_data_json: new TextDecoder().decode(credential.response.clientDataJSON),
|
||||
authenticator_data: bufferToBase64url(credential.response.authenticatorData),
|
||||
signature: bufferToBase64url(credential.response.signature)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert JSON creation options into the format expected by the browser:
|
||||
// decode base64url challenge, user.id, and excludeCredentials[].id into ArrayBuffers.
|
||||
function prepareCreationOptions(options) {
|
||||
return {
|
||||
...options,
|
||||
challenge: base64urlToBuffer(options.challenge),
|
||||
user: { ...options.user, id: base64urlToBuffer(options.user.id) },
|
||||
excludeCredentials: (options.excludeCredentials || []).map(cred => ({
|
||||
...cred,
|
||||
id: base64urlToBuffer(cred.id)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// Convert JSON request options into the format expected by the browser:
|
||||
// decode base64url challenge and allowCredentials[].id into ArrayBuffers.
|
||||
// Strips allowCredentials entirely when empty so the browser prompts for
|
||||
// any available credential (required for conditional mediation).
|
||||
function prepareRequestOptions(options) {
|
||||
const prepared = {
|
||||
...options,
|
||||
challenge: base64urlToBuffer(options.challenge)
|
||||
}
|
||||
|
||||
if (options.allowCredentials?.length) {
|
||||
prepared.allowCredentials = options.allowCredentials.map(cred => ({
|
||||
...cred,
|
||||
id: base64urlToBuffer(cred.id)
|
||||
}))
|
||||
} else {
|
||||
delete prepared.allowCredentials
|
||||
}
|
||||
|
||||
return prepared
|
||||
}
|
||||
|
||||
function base64urlToBuffer(base64url) {
|
||||
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/")
|
||||
const padding = "=".repeat((4 - base64.length % 4) % 4)
|
||||
const binary = atob(base64 + padding)
|
||||
return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer
|
||||
}
|
||||
|
||||
function bufferToBase64url(buffer) {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
const binary = String.fromCharCode(...bytes)
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
||||
}
|
||||
@@ -33,6 +33,12 @@ class Account::Seeder
|
||||
<action-text-attachment url="https://videos.37signals.com/fizzy/assets/videos/fizzyorientation-4k.mp4" caption="Fizzy orientation" content-type="video/mp4" filename="fizzyorientation-4k.mp4"></action-text-attachment>
|
||||
HTML
|
||||
|
||||
# TODO: Replace the video here with a screencap of creating a passkey
|
||||
playground.cards.create! creator: creator, title: "Then, set up a Passkey", status: "published", description: <<~HTML
|
||||
<p>Passkeys let you sign in securely without using passwords or email codes. To set one up, open the Fizzy menu and go to “<b><strong>My Profile > Manage Passkeys</b></strong>”. Using a passkey is optional, but recommended.</p>
|
||||
<action-text-attachment url="https://videos.37signals.com/fizzy/assets/videos/creating_a_passkey.mp4" alt="Demo of adding a passkey" caption="Create a passkey to sign in without passwords or email codes" content-type="video/mp4" filename="creating_a_passkey.mp4"></action-text-attachment>
|
||||
HTML
|
||||
|
||||
playground.cards.create! creator: creator, title: "Now, grab the invite link to invite someone else", status: "published", description: <<~HTML
|
||||
<p>Open the Fizzy menu, select “<b><strong>+ Add people</b></strong>”, then copy the invite link. You can give this link to someone else so they can make a login for themselves in your account.</p>
|
||||
<action-text-attachment url="https://videos.37signals.com/fizzy/assets/images/invite-link.gif" alt="Demo of copying invite link" caption="Get a link to invite co-workers" content-type="image/*" filename="invite-link.gif" presentation="gallery"></action-text-attachment>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
class Identity < ApplicationRecord
|
||||
include Joinable, Transferable
|
||||
|
||||
has_passkeys name: :email_address, display_name: -> { Current.user&.name || email_address }
|
||||
|
||||
has_many :access_tokens, dependent: :destroy
|
||||
has_many :magic_links, dependent: :destroy
|
||||
has_many :sessions, dependent: :destroy
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
class Passkey::Authenticator < Data.define(:aaguids, :name, :icon)
|
||||
class << self
|
||||
def find_by_aaguid(aaguid)
|
||||
registry[aaguid]
|
||||
end
|
||||
|
||||
def registry
|
||||
@registry ||= Hash.new.tap do |registry|
|
||||
all.each do |authenticator|
|
||||
authenticator.aaguids.each do |aaguid|
|
||||
registry[aaguid] = authenticator
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def all
|
||||
Rails.application.config_for(:passkey_aaguids).each_value.map do |attrs|
|
||||
new(aaguids: attrs[:aaguids], name: attrs[:name], icon: attrs[:icon])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -11,6 +11,10 @@
|
||||
|
||||
<p>This code will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.</p>
|
||||
|
||||
<% if account = @magic_link.identity.accounts.last %>
|
||||
<p>P.S. You can make your account more secure and sign-in faster with a <%= link_to "Passkey", my_passkeys_url(script_name: account.slug) %></p>
|
||||
<% end %>
|
||||
|
||||
<p class="footer">Need help? <%= mail_to "support@fizzy.do", "Send us an email"%>.
|
||||
</p>
|
||||
|
||||
|
||||
@@ -7,3 +7,7 @@ Please enter this 6-character verification code to finish creating your account:
|
||||
<%= @magic_link.code %>
|
||||
|
||||
This code will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.
|
||||
|
||||
<% if account = @magic_link.identity.accounts.last %>
|
||||
P.S. If you want to sign-in faster, and make your account more secure, add a passkey: <%= my_passkeys_url(script_name: account.slug) %>
|
||||
<% end %>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<section class="panel panel--wide shadow center webhooks">
|
||||
<% if @access_tokens.any? %>
|
||||
<p class="margin-none-block-start">Tokens you have generated that can be used to access the Fizzy API.</p>
|
||||
<table class="access_tokens_table margin-block-end-double max-width txt-small">
|
||||
<table class="access-tokens margin-block-end-double max-width txt-small">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<li class="credential" style="view-transition-name: <%= dom_id(passkey) %>">
|
||||
<%= link_to edit_my_passkey_path(passkey), class: "credential__link" do %>
|
||||
<% if icon = passkey.authenticator&.icon %>
|
||||
<%= image_tag icon[:light], size: 24, class: "flex-item-no-shrink hide-on-dark-mode", aria: { hidden: true } %>
|
||||
<%= image_tag icon[:dark], size: 24, class: "flex-item-no-shrink hide-on-light-mode", aria: { hidden: true } %>
|
||||
<% else %>
|
||||
<%= image_tag "passkeys/generic_light.svg", size: 24, class: "flex-item-no-shrink hide-on-dark-mode", aria: { hidden: true } %>
|
||||
<%= image_tag "passkeys/generic_dark.svg", size: 24, class: "flex-item-no-shrink hide-on-light-mode", aria: { hidden: true } %>
|
||||
<% end %>
|
||||
<strong class="overflow-ellipsis min-width"><%= passkey.name.presence || "Passkey" %></strong>
|
||||
<strong class="credential__arrow">→</strong>
|
||||
<% end %>
|
||||
</li>
|
||||
@@ -0,0 +1,33 @@
|
||||
<% @page_title = "Edit Passkey" %>
|
||||
|
||||
<% content_for :header do %>
|
||||
<div class="header__actions header__actions--start">
|
||||
<%= back_link_to "Passkeys", my_passkeys_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
|
||||
</div>
|
||||
|
||||
<h1 class="header__title" data-bridge--title-target="header"><%= @page_title %></h1>
|
||||
<% end %>
|
||||
|
||||
<section class="panel panel--wide shadow center txt-align-start flex flex-column gap">
|
||||
<% if params[:created] %>
|
||||
<p class="margin-none">Your passkey has been registered. Give it a name so you can identify it later.</p>
|
||||
<% end %>
|
||||
|
||||
<%= form_with model: @passkey, scope: :passkey, url: my_passkey_path(@passkey), html: { class: "flex flex-column gap" } do |form| %>
|
||||
<div class="flex flex-column gap-half">
|
||||
<strong><%= form.label "Name your passkey" %></strong>
|
||||
<%= form.text_field :name, autofocus: true, class: "input", placeholder: "e.g. MacBook Pro, iPhone", data: { "1p-ignore": "" }, autocomplete: "off" %>
|
||||
</div>
|
||||
|
||||
<%= form.submit "Save", class: "btn btn--link center" %>
|
||||
<% end %>
|
||||
|
||||
<div class="txt-align-center">
|
||||
<%= button_to my_passkey_path(@passkey), method: :delete,
|
||||
class: "btn txt-negative borderless txt-small",
|
||||
data: { turbo_confirm: "Are you sure you want to remove this passkey?" } do %>
|
||||
<%= icon_tag "trash" %>
|
||||
<span>Remove this passkey</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,43 @@
|
||||
<% @page_title = "Passkeys" %>
|
||||
|
||||
<% content_for :head do %>
|
||||
<%= passkey_creation_options_meta_tag(@creation_options) %>
|
||||
<% end %>
|
||||
|
||||
<% content_for :header do %>
|
||||
<div class="header__actions header__actions--start">
|
||||
<%= back_link_to "My profile", user_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
|
||||
</div>
|
||||
|
||||
<h1 class="header__title" data-bridge--title-target="header"><%= @page_title %></h1>
|
||||
<% end %>
|
||||
|
||||
<section class="panel panel--wide shadow center" data-passkey-errors>
|
||||
<p class="font-weight-medium margin-none-block-start">Passkeys let you sign in securely without a password or email code.</p>
|
||||
|
||||
<% if @passkeys.any? %>
|
||||
<ul class="margin-none-block-start margin-block-end-double unpad">
|
||||
<%= render partial: "my/passkeys/passkey", collection: @passkeys %>
|
||||
</ul>
|
||||
<% end %>
|
||||
|
||||
<footer>
|
||||
<%= passkey_creation_button my_passkeys_path, class: "btn btn--link center txt-medium" do %>
|
||||
<%= icon_tag "add" %>
|
||||
<span>Register a passkey</span>
|
||||
<% end %>
|
||||
|
||||
<p class="txt-small txt-subtle txt-balance margin-none-block-end">
|
||||
Your browser will prompt you to create a passkey using your device's biometrics, PIN, or security key
|
||||
</p>
|
||||
|
||||
<p data-passkey-error="error" class="txt-negative">
|
||||
Something went wrong while registering your passkey.
|
||||
</p>
|
||||
|
||||
<p data-passkey-error="cancelled" class="txt-subtle">
|
||||
Passkey registration was cancelled.
|
||||
Try again when you are ready.
|
||||
</p>
|
||||
</footer>
|
||||
</section>
|
||||
@@ -1,26 +1,33 @@
|
||||
<% @page_title = "Enter your email" %>
|
||||
|
||||
<% content_for :head do %>
|
||||
<%= passkey_request_options_meta_tag(@request_options) %>
|
||||
<% end %>
|
||||
|
||||
<div class="panel panel--centered flex flex-column gap-half">
|
||||
<h1 class="txt-x-large font-weight-black margin-block-end">Get into Fizzy</h1>
|
||||
|
||||
<%= form_with url: session_path, class: "flex flex-column gap-half txt-medium" do |form| %>
|
||||
<div class="flex align-center gap">
|
||||
<label class="flex align-center gap input input--actor">
|
||||
<%= form.email_field :email_address, required: true, class: "input txt-large full-width", autofocus: true, autocomplete: "username", placeholder: "Enter your email address…" %>
|
||||
<%= form.email_field :email_address, required: true, class: "input txt-large full-width", autofocus: true, autocomplete: "username webauthn", placeholder: "Enter your email address…" %>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<% if Account.accepting_signups? %>
|
||||
<p><strong>New here?</strong> <%= link_to "Sign up", new_signup_path %> to create an account. <strong>Already have an account?</strong> Enter your email and we’ll get you signed in.</p>
|
||||
<p><strong>New here?</strong> <%= link_to "Sign up", new_signup_path %> to create an account. <strong>Already have an account?</strong> Enter your email and we'll get you signed in.</p>
|
||||
<% else %>
|
||||
<p>Enter your email and we’ll get you signed in.</p>
|
||||
<p>Enter your email and we'll get you signed in.</p>
|
||||
<% end %>
|
||||
|
||||
<button type="submit" id="log_in" class="btn btn--link center txt-medium">
|
||||
<span>Let’s go</span>
|
||||
<span>Let's go</span>
|
||||
<%= icon_tag "arrow-right" %>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<%= passkey_sign_in_button "Sign in with a passkey", session_passkey_path, mediation: "conditional", class: "btn btn--link center txt-medium", hidden: true %>
|
||||
|
||||
</div>
|
||||
|
||||
<% content_for :footer do %>
|
||||
|
||||
@@ -41,6 +41,11 @@
|
||||
<% if Current.user == @user %>
|
||||
<hr class="separator--horizontal full-width flex-item-grow margin-block-start-double" style="--border-color: var(--color-ink-light);" aria-hidden="true">
|
||||
|
||||
<%= link_to my_passkeys_path, class: "btn txt-x-small center" do %>
|
||||
<%= icon_tag "authentication" %>
|
||||
<span>Manage passkeys</span>
|
||||
<% end %>
|
||||
|
||||
<%= button_to session_path(script_name: nil), method: :delete, class: "btn txt-x-small center", form: { data: { turbo: false, controller: "clear-offline-cache", action: "submit->clear-offline-cache#clearCache" } } do %>
|
||||
<span>Sign out of Fizzy on this device</span>
|
||||
<% end %>
|
||||
|
||||