Merge branch 'main' into delete-account-improvements

This commit is contained in:
Alexander Zaytsev
2026-03-19 15:26:31 +01:00
331 changed files with 8082 additions and 2471 deletions
+1
View File
@@ -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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

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

+20 -6
View File
@@ -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;
}
}
}
+2 -3
View File
@@ -3,8 +3,8 @@
block-size: auto;
display: block;
inline-size: fit-content;
position: relative;
max-inline-size: 100%;
position: relative;
progress {
inline-size: 100%;
@@ -35,8 +35,7 @@
@supports (field-sizing: content) {
field-sizing: content;
inline-size: auto;
min-inline-size: 20ch;
inline-size: 100%;
}
}
}
+69 -29
View File
@@ -34,16 +34,6 @@
}
}
@media (max-width: 639px) {
&:has(.card__closed) {
padding-block-start: 4.5rem;
.card-perma__bg {
box-shadow: 0 -2rem 0 0 var(--color-container);
}
}
}
@media (max-width: 799px) {
--half-btn-height: 1.25rem;
--padding-inline: 1.5ch;
@@ -156,37 +146,74 @@
@media (max-width: 639px) {
--meta-spacer-block: 0.75ch;
inline-size: 100%;
grid-template-areas:
"avatars-author text-added"
"avatars-author text-author"
"text-updated text-updated"
"text-assignees text-assignees"
"avatars-assignees avatars-assignees";
grid-template-columns: auto 1fr;
min-inline-size: 0;
gap: calc(var(--meta-spacer-block) / 2);
display: flex;
flex-wrap: wrap;
.card__meta-text {
border: 0;
padding: 0;
}
.card__meta-text + .card__meta-text {
.card__meta-avatars--author {
--btn-size: 1.5em;
display: initial;
margin-inline-end: unset;
order: 3;
}
.card__meta-text--added {
inline-size: 100%;
order: 1;
}
.card__meta-text--author {
order: 2;
}
.card__meta-text--updated {
border-block-start: var(--card-border);
margin-block-start: var(--meta-spacer-block);
inline-size: 100%;
margin-block-start: calc(var(--meta-spacer-block) * 0.5);
order: 4;
padding-block-start: var(--meta-spacer-block);
}
.card__meta-text--assignees {
margin-block-start: calc(var(--meta-spacer-block) * 3);
order: 6;
white-space: unset !important;
}
.card__meta-avatars--assignees {
margin-inline: 0;
margin-block-start: var(--meta-spacer-block);
margin-inline: 0 var(--meta-spacer-inline);
margin-block-start: calc(var(--meta-spacer-block) * 3);
order: 5;
.avatar {
display: grid;
}
}
.card__meta-avatars--author {
display: initial;
}
&:has(.card__meta-avatars--assignees .avatar) {
.card__meta-text--assignees {
order: 5;
}
.card__meta-avatars--assignees .avatar {
display: grid;
.card__meta-avatars--assignees {
margin-block-start: var(--meta-spacer-block);
order: 6;
}
}
}
}
&:has(.card__closed) .card__meta {
@media (max-width: 639px) {
.card__meta-avatars--assignees {
display: none;
}
}
}
@@ -225,8 +252,7 @@
.card__closed {
@media (max-width: 639px) {
inset: auto 0 100% auto;
translate: 0 30%;
inset: auto 0 3rem auto;
scale: 75%;
}
}
@@ -252,10 +278,16 @@
@media (max-width: 639px) {
display: grid;
font-size: var(--text-x-small);
gap: 1ch 0;
grid-template-columns: 1fr auto;
grid-template-areas:
"meta bg-zoom"
"meta reactions";
&:not(:has(.reaction)),
&:has(.card__background) {
column-gap: 2ch;
}
}
}
@@ -511,4 +543,12 @@
}
}
}
.card-perma__account-limit-message {
background-color: var(--color-canvas);
border: 2px solid var(--color-container);
border-radius: 4px;
margin-block-start: calc(var(--padding-block) / -2);
padding: 1ch 2ch;
}
}
+8
View File
@@ -220,4 +220,12 @@
display: none;
}
}
.comment-by-system--account-limit {
--stripe-color: oklch(var(--lch-blue-lightest));
.comment__content {
padding: 3ch;
}
}
}
+43
View File
@@ -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;
}
}
+1
View File
@@ -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 "); }
+29
View File
@@ -120,6 +120,10 @@
margin-block: 1em;
}
.lexxy-prompt-menu {
max-inline-size: min(35ch, calc(100% - var(--lexxy-prompt-offset-x)));
}
/* Content
/* ------------------------------------------------------------------------ */
@@ -188,4 +192,29 @@
.attachment {
margin-inline: 0;
}
.attachment-gallery {
.attachment {
display: inline-block;
inline-size: calc(33.333% - 0.8ch);
}
&.attachment-gallery--2,
&.attachment-gallery--4 {
.attachment {
inline-size: calc(50% - 0.8ch);
}
}
}
action-text-attachment[content-type^='application/vnd.actiontext'] {
lexxy-node-delete-button {
inset-inline-start: -0.25ch;
.lexxy-floating-controls__group {
background-color: oklch(var(--lch-blue-dark));
border-radius: 50%;
}
}
}
}
+20 -15
View File
@@ -59,16 +59,26 @@
/* ------------------------------------------------------------------------ */
.card-perma {
margin-block-start: 0;
margin-block-start: var(--block-space-half);
&:not(:has(.card-perma__notch-new-card-buttons)) .card-perma__bg {
padding-block: clamp(0.25rem, 2vw, var(--padding-block));
padding-block-end: clamp(0.25rem, 2vw, var(--padding-block));
}
.card {
background: linear-gradient(to bottom, var(--color-canvas), var(--card-bg-color));
box-shadow: unset;
}
.card__board {
border-radius: 0 var(--border-radius) var(--border-radius) 0;
}
}
.card-perma__bg {
border-start-start-radius: calc(0.2em + clamp(0.25rem, 2vw, var(--padding-block)));
border-start-end-radius: calc(0.2em + clamp(0.25rem, 2vw, var(--padding-block)));
padding-inline: 0;
padding-block-start: 0;
background-color: unset;
}
.card-perma__closure-message {
@@ -77,21 +87,10 @@
}
.card-perma--draft {
.card-perma__bg {
padding-inline: 0;
padding-block-start: 0;
background-color: unset;
}
.card {
background: linear-gradient(to bottom, var(--color-canvas), var(--card-bg-color));
box-shadow: 0 101vh 0 100vh var(--card-bg-color);
}
.card__board {
border-radius: 0 var(--border-radius) var(--border-radius) 0;
}
.card-perma__notch--bottom {
z-index: 1;
}
@@ -131,3 +130,9 @@
display: none;
}
}
[data-bridge-components~=stamp] {
[data-controller~=bridge--stamp] {
display: none;
}
}
+27 -2
View File
@@ -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 */
@@ -287,4 +288,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;
}
}
}
}
@@ -1,13 +1,22 @@
class Account::EntropiesController < ApplicationController
wrap_parameters :entropy, include: [ :auto_postpone_period_in_days ]
before_action :ensure_admin
def update
Current.account.entropy.update!(entropy_params)
redirect_to account_settings_path, notice: "Account updated"
@account = Current.account
@account.entropy.update!(entropy_params)
respond_to do |format|
format.html { redirect_to account_settings_path, notice: "Account updated" }
format.json { render "account/settings/show", status: :ok }
end
rescue ActiveRecord::RecordInvalid
head :unprocessable_entity
end
private
def entropy_params
params.expect(entropy: [ :auto_postpone_period ])
params.expect(entropy: [ :auto_postpone_period_in_days ])
end
end
+13 -3
View File
@@ -6,11 +6,20 @@ class Account::ExportsController < ApplicationController
CURRENT_EXPORT_LIMIT = 10
def show
respond_to do |format|
format.html
format.json { @export ? render(:show) : head(:not_found) }
end
end
def create
Current.account.exports.create!(user: Current.user).build_later
redirect_to account_settings_path, notice: "Export started. You'll receive an email when it's ready."
@export = Current.account.exports.create!(user: Current.user)
@export.build_later
respond_to do |format|
format.html { redirect_to account_settings_path, notice: "Export started. You'll receive an email when it's ready." }
format.json { render :show, status: :created }
end
end
private
@@ -23,6 +32,7 @@ class Account::ExportsController < ApplicationController
end
def set_export
@export = Current.account.exports.completed.find_by(id: params[:id], user: Current.user)
scope = request.format.json? ? Current.account.exports : Current.account.exports.completed
@export = scope.find_by(id: params[:id], user: Current.user)
end
end
@@ -1,4 +1,6 @@
class Account::JoinCodesController < ApplicationController
wrap_parameters :account_join_code, include: %i[ usage_limit ]
before_action :set_join_code
before_action :ensure_admin, only: %i[ update destroy ]
@@ -10,15 +12,25 @@ class Account::JoinCodesController < ApplicationController
def update
if @join_code.update(join_code_params)
redirect_to account_join_code_path
respond_to do |format|
format.html { redirect_to account_join_code_path }
format.json { head :no_content }
end
else
render :edit, status: :unprocessable_entity
respond_to do |format|
format.html { render :edit, status: :unprocessable_entity }
format.json { render json: @join_code.errors, status: :unprocessable_entity }
end
end
end
def destroy
@join_code.reset
redirect_to account_join_code_path
respond_to do |format|
format.html { redirect_to account_join_code_path }
format.json { head :no_content }
end
end
private
+11 -2
View File
@@ -1,14 +1,23 @@
class Account::SettingsController < ApplicationController
wrap_parameters :account, include: %i[ name ]
before_action :ensure_admin, only: :update
before_action :set_account
def show
@users = @account.users.active.alphabetically.includes(:identity)
respond_to do |format|
format.html { @users = @account.users.active.alphabetically.includes(:identity) }
format.json
end
end
def update
@account.update!(account_params)
redirect_to account_settings_path
respond_to do |format|
format.html { redirect_to account_settings_path }
format.json { head :no_content }
end
end
private
+3 -1
View File
@@ -1,4 +1,6 @@
class Boards::ColumnsController < ApplicationController
wrap_parameters :column, include: %i[ name color ]
include BoardScoped
before_action :set_column, only: %i[ show update destroy ]
@@ -18,7 +20,7 @@ class Boards::ColumnsController < ApplicationController
respond_to do |format|
format.turbo_stream
format.json { head :created, location: board_column_path(@board, @column, format: :json) }
format.json { render :show, status: :created, location: board_column_path(@board, @column, format: :json) }
end
end
+10 -1
View File
@@ -1,14 +1,23 @@
class Boards::EntropiesController < ApplicationController
wrap_parameters :board, include: [ :auto_postpone_period_in_days ]
include BoardScoped
before_action :ensure_permission_to_admin_board
def update
@board.update!(entropy_params)
respond_to do |format|
format.turbo_stream
format.json { render "boards/show", status: :ok }
end
rescue ActiveRecord::RecordInvalid
head :unprocessable_entity
end
private
def entropy_params
params.expect(board: [ :auto_postpone_period ])
params.expect(board: [ :auto_postpone_period_in_days ])
end
end
@@ -3,5 +3,11 @@ class Boards::InvolvementsController < ApplicationController
def update
@board.access_for(Current.user).update!(involvement: params[:involvement])
respond_to do |format|
format.html
format.turbo_stream
format.json { head :no_content }
end
end
end
+4 -2
View File
@@ -1,4 +1,6 @@
class BoardsController < ApplicationController
wrap_parameters :board, include: %i[ name all_access auto_postpone_period_in_days public_description ]
include FilterScoped
before_action :set_board, except: %i[ index new create ]
@@ -26,7 +28,7 @@ class BoardsController < ApplicationController
respond_to do |format|
format.html { redirect_to board_path(@board) }
format.json { head :created, location: board_path(@board, format: :json) }
format.json { render :show, status: :created, location: board_path(@board, format: :json) }
end
end
@@ -88,7 +90,7 @@ class BoardsController < ApplicationController
end
def board_params
params.expect(board: [ :name, :all_access, :auto_postpone_period, :public_description ])
params.expect(board: [ :name, :all_access, :auto_postpone_period_in_days, :public_description ])
end
def grantees
@@ -1,4 +1,6 @@
class Cards::Comments::ReactionsController < ApplicationController
wrap_parameters :reaction, include: %i[ content ]
include CardScoped
before_action :set_comment
@@ -22,7 +24,7 @@ class Cards::Comments::ReactionsController < ApplicationController
respond_to do |format|
format.turbo_stream { render "reactions/create" }
format.json { head :created }
format.json { render "reactions/show", status: :created }
end
end
+2 -1
View File
@@ -1,4 +1,5 @@
class Cards::CommentsController < ApplicationController
wrap_parameters :comment, include: %i[ body created_at ]
include CardScoped
before_action :set_comment, only: %i[ show edit update destroy ]
@@ -14,7 +15,7 @@ class Cards::CommentsController < ApplicationController
respond_to do |format|
format.turbo_stream
format.json { head :created, location: card_comment_path(@card, @comment, format: :json) }
format.json { render :show, status: :created, location: card_comment_path(@card, @comment, format: :json) }
end
end
+11 -5
View File
@@ -4,11 +4,17 @@ class Cards::PublishesController < ApplicationController
def create
@card.publish
if add_another_param?
card = @board.cards.create!(status: :drafted)
redirect_to card_draft_path(card), notice: "Card added"
else
redirect_to @card.board
respond_to do |format|
format.html do
if add_another_param?
card = @board.cards.create!(status: :drafted)
redirect_to card_draft_path(card), notice: "Card added"
else
redirect_to @card.board
end
end
format.json { head :created }
end
end
@@ -1,4 +1,6 @@
class Cards::ReactionsController < ApplicationController
wrap_parameters :reaction, include: %i[ content ]
include CardScoped
before_action :set_reactable
@@ -21,7 +23,7 @@ class Cards::ReactionsController < ApplicationController
respond_to do |format|
format.turbo_stream { render "reactions/create" }
format.json { head :created }
format.json { render "reactions/show", status: :created }
end
end
@@ -4,11 +4,21 @@ class Cards::ReadingsController < ApplicationController
def create
@notification = @card.read_by(Current.user)
record_board_access
respond_to do |format|
format.turbo_stream
format.json { head :created }
end
end
def destroy
@notification = @card.unread_by(Current.user)
record_board_access
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
private
+7 -1
View File
@@ -1,14 +1,20 @@
class Cards::StepsController < ApplicationController
wrap_parameters :step, include: %i[ content completed ]
include CardScoped
before_action :set_step, only: %i[ show edit update destroy ]
def index
fresh_when etag: @card.steps
end
def create
@step = @card.steps.create!(step_params)
respond_to do |format|
format.turbo_stream
format.json { head :created, location: card_step_path(@card, @step, format: :json) }
format.json { render :show, status: :created, location: card_step_path(@card, @step, format: :json) }
end
end
+4 -2
View File
@@ -1,4 +1,6 @@
class CardsController < ApplicationController
wrap_parameters :card, include: %i[ title description image created_at last_active_at ]
include FilterScoped
before_action :set_board, only: %i[ create ]
@@ -18,8 +20,8 @@ class CardsController < ApplicationController
end
format.json do
card = @board.cards.create! card_params.merge(creator: Current.user, status: "published")
head :created, location: card_path(card, format: :json)
@card = @board.cards.create! card_params.merge(creator: Current.user, status: "published")
render :show, status: :created, location: card_path(@card, format: :json)
end
end
end
@@ -4,5 +4,10 @@ class Columns::LeftPositionsController < ApplicationController
def create
@left_column = @column.left_column
@column.move_left
respond_to do |format|
format.turbo_stream
format.json { head :created }
end
end
end
@@ -4,5 +4,10 @@ class Columns::RightPositionsController < ApplicationController
def create
@right_column = @column.right_column
@column.move_right
respond_to do |format|
format.turbo_stream
format.json { head :created }
end
end
end
+1 -1
View File
@@ -7,6 +7,6 @@ module SetPlatform
private
def platform
@platform ||= ApplicationPlatform.new(request.user_agent)
@platform ||= ApplicationPlatform.new(cookies[:x_user_agent].presence || request.user_agent)
end
end
+11 -2
View File
@@ -1,4 +1,8 @@
class My::AccessTokensController < ApplicationController
wrap_parameters :access_token, include: %i[ description permission ]
skip_before_action :require_account
def index
@access_tokens = my_access_tokens.order(created_at: :desc)
end
@@ -24,14 +28,19 @@ class My::AccessTokensController < ApplicationController
format.json do
render status: :created, json: \
{ token: access_token.token, description: access_token.description, permission: access_token.permission }
{ id: access_token.id, token: access_token.token, description: access_token.description,
permission: access_token.permission, created_at: access_token.created_at.utc }
end
end
end
def destroy
my_access_tokens.find(params[:id]).destroy!
redirect_to my_access_tokens_path
respond_to do |format|
format.html { redirect_to my_access_tokens_path }
format.json { head :no_content }
end
end
private
@@ -0,0 +1,7 @@
class My::PasskeyChallengesController < ActionPack::Passkey::ChallengesController
include Authentication
include Authorization
allow_unauthenticated_access
disallow_account_scope
end
+34
View File
@@ -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
@@ -1,4 +1,6 @@
class Notifications::SettingsController < ApplicationController
wrap_parameters :user_settings, include: %i[ bundle_email_frequency ]
before_action :set_settings
def show
@@ -7,7 +9,11 @@ class Notifications::SettingsController < ApplicationController
def update
@settings.update!(settings_params)
redirect_to notifications_settings_path, notice: "Settings updated"
respond_to do |format|
format.html { redirect_to notifications_settings_path, notice: "Settings updated" }
format.json { head :no_content }
end
end
private
+15 -3
View File
@@ -5,10 +5,22 @@ class SearchesController < ApplicationController
@query = params[:q].blank? ? nil : params[:q]
if card = Current.user.accessible_cards.find_by_id(@query)
@card = card
respond_to do |format|
format.html { @card = card }
format.json { set_page_and_extract_portion_from Current.user.accessible_cards.where(id: card.id) }
end
else
set_page_and_extract_portion_from Current.user.search(@query)
@recent_search_queries = Current.user.search_queries.order(updated_at: :desc).limit(10)
respond_to do |format|
format.html do
set_page_and_extract_portion_from Current.user.search(@query)
@recent_search_queries = Current.user.search_queries.order(updated_at: :desc).limit(10)
end
format.json do
set_page_and_extract_portion_from \
Current.user.accessible_cards.mentioning(@query, user: Current.user).distinct.latest.preloaded
end
end
end
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
+3
View File
@@ -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
@@ -1,4 +1,6 @@
class Signups::CompletionsController < ApplicationController
wrap_parameters :signup, include: %i[ full_name ]
layout "public"
disallow_account_scope
+2
View File
@@ -1,4 +1,6 @@
class SignupsController < ApplicationController
wrap_parameters :signup, include: %i[ email_address ]
disallow_account_scope
allow_unauthenticated_access
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_signup_path, alert: "Try again later." }
+5 -1
View File
@@ -16,7 +16,11 @@ class Users::AvatarsController < ApplicationController
def destroy
@user.avatar.destroy
redirect_to @user
respond_to do |format|
format.html { redirect_to @user }
format.json { head :no_content }
end
end
private
+7 -1
View File
@@ -1,4 +1,6 @@
class Users::JoinsController < ApplicationController
wrap_parameters :user, include: %i[ name avatar ]
layout "public"
def new
@@ -6,7 +8,11 @@ class Users::JoinsController < ApplicationController
def create
Current.user.update!(user_params)
redirect_to landing_path
respond_to do |format|
format.html { redirect_to landing_path }
format.json { head :no_content }
end
end
private
@@ -1,16 +1,27 @@
class Users::PushSubscriptionsController < ApplicationController
wrap_parameters :push_subscription, include: %i[ endpoint p256dh_key auth_key ]
before_action :set_push_subscriptions
def index
end
def create
@push_subscriptions.create_with(user_agent: request.user_agent).create_or_find_by!(push_subscription_params)
subscription = @push_subscriptions.create_with(user_agent: request.user_agent).create_or_find_by!(push_subscription_params)
respond_to do |format|
format.html { head :no_content }
format.json { head :created }
end
end
def destroy
@push_subscriptions.destroy_by(id: params[:id])
redirect_to user_push_subscriptions_url
respond_to do |format|
format.html { redirect_to user_push_subscriptions_url }
format.json { head :no_content }
end
end
private
+7 -1
View File
@@ -1,10 +1,16 @@
class Users::RolesController < ApplicationController
wrap_parameters :user, include: %i[ role ]
before_action :set_user
before_action :ensure_permission_to_administer_user
def update
@user.update!(role_params)
redirect_to account_settings_path
respond_to do |format|
format.html { redirect_to account_settings_path }
format.json { head :no_content }
end
end
private
+2
View File
@@ -1,4 +1,6 @@
class UsersController < ApplicationController
wrap_parameters :user, include: %i[ name avatar ]
before_action :set_user, except: %i[ index ]
before_action :ensure_permission_to_change_user, only: %i[ update destroy ]
@@ -4,9 +4,12 @@ class Webhooks::ActivationsController < ApplicationController
before_action :ensure_admin
def create
webhook = @board.webhooks.find(params[:webhook_id])
webhook.activate
@webhook = @board.webhooks.find(params[:webhook_id])
@webhook.activate
redirect_to webhook
respond_to do |format|
format.html { redirect_to @webhook }
format.json { render "webhooks/show", status: :created }
end
end
end
+31 -5
View File
@@ -1,4 +1,6 @@
class WebhooksController < ApplicationController
wrap_parameters :webhook, include: %i[ name url subscribed_actions ]
include BoardScoped
before_action :ensure_admin
@@ -16,21 +18,45 @@ class WebhooksController < ApplicationController
end
def create
webhook = @board.webhooks.create!(webhook_params)
redirect_to webhook
@webhook = @board.webhooks.new(webhook_params)
if @webhook.save
respond_to do |format|
format.html { redirect_to @webhook }
format.json { render :show, status: :created, location: board_webhook_url(@webhook.board, @webhook, format: :json) }
end
else
respond_to do |format|
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @webhook.errors, status: :unprocessable_entity }
end
end
end
def edit
end
def update
@webhook.update!(webhook_params.except(:url))
redirect_to @webhook
if @webhook.update(webhook_params.except(:url))
respond_to do |format|
format.html { redirect_to @webhook }
format.json { render :show }
end
else
respond_to do |format|
format.html { render :edit, status: :unprocessable_entity }
format.json { render json: @webhook.errors, status: :unprocessable_entity }
end
end
end
def destroy
@webhook.destroy!
redirect_to board_webhooks_path
respond_to do |format|
format.html { redirect_to board_webhooks_path }
format.json { head :no_content }
end
end
private
+7 -2
View File
@@ -10,8 +10,13 @@ module ApplicationHelper
tag.span class: class_names("icon icon--#{name}", options.delete(:class)), "aria-hidden": true, **options
end
def back_link_to(label, url, action, **options)
link_to url, class: "btn btn--back btn--circle-mobile", data: { controller: "hotkey", action: action }, **options do
def back_link_to(label, url, action, prefer_referrer: [], **options)
data = { controller: "hotkey", action: action }
if prefer_referrer.any?
data[:turbo_navigation_target] = "referrerBackLink"
data[:turbo_navigation_allowed_referrer_paths] = prefer_referrer.join(",")
end
link_to url, class: "btn btn--back btn--circle-mobile", data: data, **options do
icon_tag("arrow-left") + tag.strong("Back to #{label}", class: "overflow-ellipsis") + tag.kbd("ESC", class: "txt-x-small hide-on-touch").html_safe
end
end
+2 -2
View File
@@ -1,6 +1,6 @@
module BoardsHelper
def link_back_to_board(board)
back_link_to board.name, board, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click click->turbo-navigation#backIfSamePath"
def link_back_to_board(board, prefer_referrer: [])
back_link_to board.name, board, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click click->turbo-navigation#backIfSamePath", prefer_referrer:
end
def link_to_edit_board(board)
-4
View File
@@ -1,8 +1,4 @@
module EntropyHelper
def entropy_auto_close_options
[ 3, 7, 30, 90, 365, 11 ]
end
def entropy_bubble_options_for(card)
{
daysBeforeReminder: card.entropy.days_before_reminder,
+1
View File
@@ -6,3 +6,4 @@ import "controllers"
import "lexxy"
import "@rails/actiontext"
import "lib/action_pack/passkey"
@@ -0,0 +1,44 @@
import { BridgeComponent } from "@hotwired/hotwire-native-bridge"
export default class extends BridgeComponent {
static component = "stamp"
static values = { scopeSelector: { type: String, default: "body" } }
connect() {
super.connect()
if (this.element.closest(this.scopeSelectorValue)) {
this.notifyBridgeOfConnect()
this.#observeStamp()
}
}
disconnect() {
super.disconnect()
this.notifyBridgeOfDisconnect()
this.stampObserver?.disconnect()
}
notifyBridgeOfConnect() {
const bridgeElement = this.bridgeElement
this.send("connect", {
title: bridgeElement.title,
description: bridgeElement.bridgeAttribute("description")
})
}
notifyBridgeOfDisconnect() {
this.send("disconnect")
}
#observeStamp() {
this.stampObserver = new MutationObserver(() => {
this.notifyBridgeOfConnect()
})
this.stampObserver.observe(this.element, {
attributes: true
})
}
}
@@ -0,0 +1,8 @@
import { Controller } from "@hotwired/stimulus"
import { Turbo } from "@hotwired/turbo-rails"
export default class extends Controller {
clearCache() {
Turbo.offline.clearCache()
}
}
@@ -15,7 +15,7 @@ export default class extends Controller {
submit() {
if (this.inputTarget.disabled) return
this.element.submit()
this.element.requestSubmit()
this.inputTarget.disabled = true
}
}
@@ -1,8 +1,12 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { label: String }
static targets = [ "referrerBackLink" ]
rememberLocation() {
sessionStorage.setItem("referrerUrl", window.location.href)
sessionStorage.setItem("referrerLabel", this.labelValue)
}
backIfSamePath(event) {
@@ -16,6 +20,20 @@ export default class extends Controller {
Turbo.visit(this.#referrerUrl)
}
}
referrerBackLinkTargetConnected(link) {
if (!this.#referrerUrl || !this.#referrerLabel) { return }
const stripTrailingSlash = path => path.replace(/\/$/, "")
const allowedPaths = (link.dataset.turboNavigationAllowedReferrerPaths || "").split(",").map(stripTrailingSlash)
const referrerPath = stripTrailingSlash(new URL(this.#referrerUrl).pathname)
if (!allowedPaths.includes(referrerPath)) { return }
link.href = this.#referrerUrl
const strong = link.querySelector("strong")
if (strong) { strong.textContent = `Back to ${this.#referrerLabel}` }
}
get #referrerPath() {
if (!this.#referrerUrl) return null
return new URL(this.#referrerUrl).pathname
@@ -24,4 +42,8 @@ export default class extends Controller {
get #referrerUrl() {
return sessionStorage.getItem("referrerUrl")
}
get #referrerLabel() {
return sessionStorage.getItem("referrerLabel")
}
}
@@ -15,7 +15,7 @@ export default class extends Controller {
}
#showFileName() {
this.fileNameTarget.innerHTML = this.#file.name
this.fileNameTarget.innerText = this.#file.name
this.fileNameTarget.removeAttribute("hidden")
this.placeholderTarget.setAttribute("hidden", true)
}
@@ -5,12 +5,13 @@ BridgeElement.prototype.getButton = function() {
title: this.title,
icon: this.getIcon(),
displayTitle: this.getDisplayTitle(),
displayAsPrimaryAction: this.getDisplayAsPrimaryAction()
displayAsPrimaryAction: this.getDisplayAsPrimaryAction(),
slot: this.getSlot()
}
}
BridgeElement.prototype.getIcon = function() {
const url = this.bridgeAttribute(`icon-url`)
const url = this.bridgeAttribute("icon-url")
if (url) {
return { url }
@@ -20,9 +21,13 @@ BridgeElement.prototype.getIcon = function() {
}
BridgeElement.prototype.getDisplayTitle = function() {
return !!this.bridgeAttribute(`display-title`)
return !!this.bridgeAttribute("display-title")
}
BridgeElement.prototype.getDisplayAsPrimaryAction = function() {
return !!this.bridgeAttribute(`display-as-primary-action`)
return !!this.bridgeAttribute("display-as-primary-action")
}
BridgeElement.prototype.getSlot = function () {
return this.bridgeAttribute("slot") ?? "right"
}
+2
View File
@@ -1,2 +1,4 @@
import "initializers/current"
import "initializers/bridge/bridge_element"
import "initializers/offline"
import "initializers/lexxy_markdown_paste"
@@ -0,0 +1,3 @@
document.addEventListener("lexxy:insert-markdown", (event) => {
event.detail.addBlockSpacing()
})
+9
View File
@@ -0,0 +1,9 @@
import { Turbo } from "@hotwired/turbo-rails"
if (Current.user) {
Turbo.offline.start("/service-worker.js", {
scope: "/",
native: true,
preload: /\/assets\//
})
}
+246
View File
@@ -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(/=+$/, "")
}
@@ -0,0 +1,11 @@
class Account::DataTransfer::ActiveStorage::AttachmentRecordSet < Account::DataTransfer::RecordSet
def initialize(account)
super(account: account, model: ::ActiveStorage::Attachment)
end
private
def records
::ActiveStorage::Attachment.where(account: account)
.where.not(record_type: INTERNAL_RECORD_TYPES)
end
end
@@ -8,11 +8,20 @@ class Account::DataTransfer::ActiveStorage::BlobRecordSet < Account::DataTransfe
end
private
def records
::ActiveStorage::Blob.where(account: account).where.not(id: excluded_blob_ids)
end
def excluded_blob_ids
::ActiveStorage::Attachment.where(account: account, record_type: INTERNAL_RECORD_TYPES).select(:blob_id)
end
def import_batch(files)
batch_data = files.map do |file|
data = load(file)
data.slice(*attributes).merge(
"account_id" => account.id,
"key" => ::ActiveStorage::Blob.generate_unique_secure_token(length: ::ActiveStorage::Blob::MINIMUM_TOKEN_LENGTH),
"service_name" => ::ActiveStorage::Blob.service.name
)
end
@@ -5,7 +5,11 @@ class Account::DataTransfer::ActiveStorage::FileRecordSet < Account::DataTransfe
private
def records
::ActiveStorage::Blob.where(account: account)
::ActiveStorage::Blob.where(account: account).where.not(id: excluded_blob_ids)
end
def excluded_blob_ids
::ActiveStorage::Attachment.where(account: account, record_type: INTERNAL_RECORD_TYPES).select(:blob_id)
end
def export_record(blob)
@@ -22,9 +26,12 @@ class Account::DataTransfer::ActiveStorage::FileRecordSet < Account::DataTransfe
def import_batch(files)
files.each do |file|
key = File.basename(file)
blob = ::ActiveStorage::Blob.find_by(key: key, account: account)
next unless blob
old_key = file.delete_prefix("storage/")
blob_id = old_key_to_blob_id[old_key]
raise IntegrityError, "Storage file #{old_key} has no matching blob metadata in export" unless blob_id
blob = ::ActiveStorage::Blob.find_by(id: blob_id, account: account)
raise IntegrityError, "Blob #{blob_id} not found for storage key #{old_key}" unless blob
zip.read(file) do |stream|
blob.upload(stream)
@@ -32,12 +39,31 @@ class Account::DataTransfer::ActiveStorage::FileRecordSet < Account::DataTransfe
end
end
def check_record(file_path)
key = File.basename(file_path)
def old_key_to_blob_id
@old_key_to_blob_id ||= build_old_key_to_blob_id
end
unless zip.exists?("data/active_storage_blobs/#{key}.json") || ::ActiveStorage::Blob.exists?(key: key, account: account)
# File exists without corresponding blob record - could be orphaned or blob not yet imported
# We allow this since blob metadata is imported before files
def build_old_key_to_blob_id
zip.glob("data/active_storage_blobs/*.json").each_with_object({}) do |file, map|
data = load(file)
old_key = data["key"]
if map.key?(old_key)
raise IntegrityError, "Duplicate blob key in export: #{old_key}"
end
map[old_key] = data["id"]
end
end
def with_zip(zip)
@old_key_to_blob_id = nil
super
end
def check_record(file_path)
old_key = file_path.delete_prefix("storage/")
unless old_key_to_blob_id.key?(old_key)
raise IntegrityError, "Storage file #{old_key} has no matching blob metadata in export"
end
end
end
+36 -8
View File
@@ -26,20 +26,42 @@ class Account::DataTransfer::Manifest
[
Account::DataTransfer::AccountRecordSet.new(account),
Account::DataTransfer::UserRecordSet.new(account),
*build_record_sets(::User::Settings, ::Tag, ::Board, ::Column),
*build_record_sets(
::User::Settings,
::Tag,
::Board,
::Column
),
Account::DataTransfer::EntropyRecordSet.new(account),
*build_record_sets(
::Board::Publication, ::Webhook, ::Access, ::Card, ::Comment, ::Step,
::Assignment, ::Tagging, ::Closure, ::Card::Goldness, ::Card::NotNow,
::Card::ActivitySpike, ::Watch, ::Pin, ::Reaction, ::Mention,
::Filter, ::Webhook::DelinquencyTracker, ::Event,
::Notification, ::Notification::Bundle, ::Webhook::Delivery
::Board::Publication,
::Webhook,
::Access,
::Card,
::Comment,
::Step,
::Assignment,
::Tagging,
::Closure,
::Card::Goldness,
::Card::NotNow,
::Card::ActivitySpike,
::Watch,
::Pin,
::Reaction,
::Mention,
::Filter,
::Webhook::DelinquencyTracker,
::Event,
::Notification,
::Notification::Bundle,
::Webhook::Delivery
),
Account::DataTransfer::ActiveStorage::BlobRecordSet.new(account),
*build_record_sets(::ActiveStorage::Attachment),
Account::DataTransfer::ActiveStorage::AttachmentRecordSet.new(account),
Account::DataTransfer::ActionText::RichTextRecordSet.new(account),
Account::DataTransfer::ActiveStorage::FileRecordSet.new(account)
]
].then { set_importable_model_names(it) }
end
def build_record_sets(*models)
@@ -47,4 +69,10 @@ class Account::DataTransfer::Manifest
Account::DataTransfer::RecordSet.new(account: account, model: model)
end
end
def set_importable_model_names(record_sets)
model_names = record_sets.filter_map { |record_set| record_set.model&.name }
record_sets.each { |record_set| record_set.importable_model_names = model_names }
record_sets
end
end
+13 -2
View File
@@ -3,13 +3,16 @@ class Account::DataTransfer::RecordSet
class ConflictError < IntegrityError; end
IMPORT_BATCH_SIZE = 100
INTERNAL_RECORD_TYPES = %w[Export Account::Import].freeze
attr_accessor :importable_model_names
attr_reader :account, :model, :attributes
def initialize(account:, model:, attributes: nil)
def initialize(account:, model:, attributes: nil, importable_model_names: nil)
@account = account
@model = model
@attributes = (attributes || model.column_names).map(&:to_s)
@importable_model_names = importable_model_names || [ model.name ]
end
def export(to:, start: nil)
@@ -113,7 +116,7 @@ class Account::DataTransfer::RecordSet
def check_association_doesnt_exist(data, association, associated_id)
if association.polymorphic?
type_column = association.foreign_type.to_s
associated_class = data[type_column].constantize
associated_class = verify_model_type(data[type_column])
else
associated_class = association.klass
end
@@ -123,6 +126,14 @@ class Account::DataTransfer::RecordSet
end
end
def verify_model_type(type_name)
if importable_model_names.include?(type_name)
type_name.constantize
else
raise IntegrityError, "Unrecognized model type: #{type_name}"
end
end
def skip_to(file_list, last_id)
index = file_list.index(last_id)
+6
View File
@@ -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>
+20
View File
@@ -43,6 +43,17 @@ class ApplicationPlatform < PlatformAgent
operating_system == "Windows"
end
def bridge_name
case
when native? && android? then :android
when native? && ios? then :ios
end
end
def bridge_components
extract_list_from_native_user_agent("bridge-components")
end
def type
if native? && android?
"native android"
@@ -67,4 +78,13 @@ class ApplicationPlatform < PlatformAgent
os =~ /Linux/ ? "Linux" : os
end
end
private
def extract_list_from_native_user_agent(prefix)
if native?
user_agent.to_s.match(/#{Regexp.escape(prefix)}: \[(.*?)\]/) { |matches| matches[1] }.to_s
else
""
end
end
end
+1 -3
View File
@@ -6,9 +6,7 @@ module Board::AutoPostponing
end
private
DEFAULT_AUTO_POSTPONE_PERIOD = 30.days
def set_default_auto_postpone_period
self.auto_postpone_period ||= DEFAULT_AUTO_POSTPONE_PERIOD unless attribute_present?(:auto_postpone_period)
self.auto_postpone_period ||= Entropy::DEFAULT_AUTO_POSTPONE_PERIOD_IN_DAYS.days unless attribute_present?(:auto_postpone_period)
end
end
+6 -2
View File
@@ -2,7 +2,7 @@ module Board::Entropic
extend ActiveSupport::Concern
included do
delegate :auto_postpone_period, to: :entropy
delegate :auto_postpone_period, :auto_postpone_period_in_days, to: :entropy
has_one :entropy, as: :container, dependent: :destroy
end
@@ -12,6 +12,10 @@ module Board::Entropic
def auto_postpone_period=(new_value)
entropy ||= association(:entropy).reader || self.build_entropy
entropy.update auto_postpone_period: new_value
entropy.update! auto_postpone_period: new_value
end
def auto_postpone_period_in_days=(value)
self.auto_postpone_period = value.to_i.days.to_i
end
end
+1 -1
View File
@@ -1,6 +1,6 @@
class Board::Publication < ApplicationRecord
belongs_to :account, default: -> { board.account }
belongs_to :board
belongs_to :board, touch: true
has_secure_token :key
end
+29
View File
@@ -1,6 +1,35 @@
class Entropy < ApplicationRecord
DEFAULT_AUTO_POSTPONE_PERIOD_IN_DAYS = 30
AUTO_POSTPONE_PERIODS_IN_DAYS = [ 3, 7, 30, 90, 365, 11 ].freeze
AUTO_POSTPONE_PERIODS_IN_SECONDS = AUTO_POSTPONE_PERIODS_IN_DAYS.map { |n| n.day.in_seconds }.freeze
belongs_to :account, default: -> { container.account }
belongs_to :container, polymorphic: true
validates :auto_postpone_period, inclusion: { in: AUTO_POSTPONE_PERIODS_IN_SECONDS }
after_commit -> { container.cards.touch_all if container }
def auto_postpone_period_in_days
days = auto_postpone_period / 1.day.to_i
if days.in?(AUTO_POSTPONE_PERIODS_IN_DAYS)
days
else
default_auto_postpone_period_in_days
end
end
def auto_postpone_period_in_days=(new_value)
self.auto_postpone_period = new_value.to_i.days.to_i
end
private
def default_auto_postpone_period_in_days
if container.is_a?(Board)
container.account.entropy.auto_postpone_period_in_days
else
DEFAULT_AUTO_POSTPONE_PERIOD_IN_DAYS
end
end
end
+2
View File
@@ -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
+23
View File
@@ -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
+4 -1
View File
@@ -10,7 +10,10 @@ module Search::Record::SQLite
after_save :upsert_to_fts5_table
scope :matching, ->(query, account_id) { joins(:search_records_fts).where("search_records_fts MATCH ?", query) }
scope :matching, ->(query, account_id) {
joins("INNER JOIN search_records_fts ON search_records_fts.rowid = #{table_name}.id")
.where("search_records_fts MATCH ?", query)
}
end
class_methods do
+1 -1
View File
@@ -20,6 +20,6 @@ module User::Named
def familiar_name
names = name.split
return name if names.length <= 1
"#{names.first} #{names[1..].map { |n| "#{n[0]}." }.join}"
"#{names.first}\u00A0#{names[1..].map { |n| "#{n[0]}." }.join}"
end
end
+2 -1
View File
@@ -84,7 +84,8 @@ class ZipFile
def read_from_s3(blob)
url = blob.url(expires_in: 6.hour)
remote_io = RemoteIO.new(url)
ssl_verify_peer = blob.service.client.client.config.ssl_verify_peer
remote_io = RemoteIO.new(url, ssl_verify_peer: ssl_verify_peer)
reader = Reader.new(remote_io)
yield reader
end
+6 -2
View File
@@ -1,4 +1,9 @@
class ZipFile::RemoteIO < ZipKit::RemoteIO
def initialize(url, ssl_verify_peer: true)
super(url)
@ssl_verify_peer = ssl_verify_peer
end
protected
def request_range(range)
with_http do |http|
@@ -37,8 +42,7 @@ class ZipFile::RemoteIO < ZipKit::RemoteIO
def with_http
http = Net::HTTP.new(@uri.hostname, @uri.port)
http.use_ssl = @uri.scheme == "https"
# FIXME: Disable SSL verification for now to avoid issues with our self-signed certificates for PureStorage
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
http.verify_mode = OpenSSL::SSL::VERIFY_NONE unless @ssl_verify_peer
http.start { yield http }
end
end
@@ -0,0 +1,6 @@
json.(@export, :id, :status)
json.created_at @export.created_at.utc
if @export.completed? && @export.file.attached?
json.download_url rails_blob_url(@export.file, disposition: "attachment")
end
@@ -0,0 +1,3 @@
json.(@join_code, :code, :usage_count, :usage_limit)
json.url join_url(code: @join_code.code, script_name: Current.account.slug)
json.active !!@join_code.active?
-2
View File
@@ -21,5 +21,3 @@
<%= render "account/settings/cancellation" %>
</div>
</div>
<%= render "account/settings/subscription_panel" if Fizzy.saas? %>
@@ -0,0 +1,3 @@
json.(@account, :id, :name, :cards_count)
json.created_at @account.created_at.utc
json.auto_postpone_period_in_days @account.entropy.auto_postpone_period_in_days

Some files were not shown because too many files have changed in this diff Show More