diff --git a/backend/web/README.md b/backend/web/README.md new file mode 100644 index 0000000000000000000000000000000000000000..663e3a5aee5ce22deba309909fb42d71270b0868 --- /dev/null +++ b/backend/web/README.md @@ -0,0 +1,5 @@ +This `web` directory is responsible for authentication frontend components. + +It uses Tailwind for CSS; when making UI changes open a terminal in the `web` directory and run + +`npx tailwindcss -i ./static/src/input.css -o ./static/css/main.css --watch` diff --git a/backend/web/static/base.js b/backend/web/static/base.js index bf79733c8c7eaf990a84556bf5753d67430be9e9..e09a07bfd2df2766026d802109fa5c60ae562f49 100644 --- a/backend/web/static/base.js +++ b/backend/web/static/base.js @@ -57,7 +57,7 @@ function check_flow_auth() { // Set a custom cookie so the settings page knows we're in // recovery context and can open the right tab. Cookies.set("stackspin_context", "recovery"); - window.location.href = api_url + '/self-service/settings/browser'; + window.location.href = api_url + "/self-service/settings/browser"; return; } @@ -215,8 +215,8 @@ function flow_settings() { // If we are in recovery context, switch to the password tab. if (context == "recovery") { - $('#pills-password-tab').tab('show'); - Cookies.set('stackspin_context', ''); + $("#pills-password-tab").tab("show"); + Cookies.set("stackspin_context", ""); } }, complete: function (obj) { @@ -235,7 +235,7 @@ function flow_settings() { // Redirect so user can enter 2FA. window.location.href = response.redirect_browser_to; return; - } + } } // There was another error, one we don't specifically prepared for. $("#contentProfileSaveFailed").show(); @@ -643,3 +643,50 @@ $.urlParam = function (name) { } return decodeURI(results[1]) || 0; }; + +////////////////////////////////////// +// sign up form for the demo instance +////////////////////////////////////// + +function submitSignup() { + let result = document.querySelector("#signup-result"); + let email = document.querySelector("#signup-email"); + let xhr = new XMLHttpRequest(); + xhr.responseType = "json"; + let url = "/web/demo-user"; + xhr.open("POST", url, true); + xhr.setRequestHeader("Content-Type", "application/json"); + xhr.onreadystatechange = function () { + if (xhr.readyState === 4) { + // In the success case, we get a plain (json) string; in the error + // case, we get an object with `errorMessage` property. + if (typeof this.response == "object" && "errorMessage" in this.response) { + window.console.log("Error in sign-up request."); + result.classList.remove("alert-success"); + result.classList.add("alert-danger"); + if ( + this.response.errorMessage == + "[KratosError] Unable to insert or update resource because a resource with that value exists already" + ) { + result.innerHTML = "A user with that email address already exists."; + } else if ( + this.response.errorMessage == + "[KratosError] The request was malformed or contained invalid parameters" + ) { + result.innerHTML = "That doesn't appear to be a valid email address."; + } else { + result.innerHTML = this.response.errorMessage; + } + } else { + result.classList.add("alert-success"); + result.classList.remove("alert-danger"); + result.innerHTML = this.response; + } + result.style.visibility = "visible"; + } + }; + // Converting JSON data to string + var data = JSON.stringify({ email: email.value }); + // Sending data with the request + xhr.send(data); +} diff --git a/backend/web/static/css/main.css b/backend/web/static/css/main.css index 5821f614e31f12dd454c3dbf55c262af33858611..698b3ea1be893ce15d67c0125d932e40f232dfdc 100644 --- a/backend/web/static/css/main.css +++ b/backend/web/static/css/main.css @@ -1,5 +1,5 @@ /* -! tailwindcss v3.3.2 | MIT License | https://tailwindcss.com +! tailwindcss v3.3.3 | MIT License | https://tailwindcss.com */ /* @@ -191,6 +191,10 @@ select, textarea { font-family: inherit; /* 1 */ + font-feature-settings: inherit; + /* 1 */ + font-variation-settings: inherit; + /* 1 */ font-size: 100%; /* 1 */ font-weight: inherit; @@ -341,6 +345,14 @@ menu { padding: 0; } +/* +Reset default styling for dialogs. +*/ + +dialog { + padding: 0; +} + /* Prevent resizing textareas horizontally by default. */ @@ -422,6 +434,16 @@ video { display: none; } +label { + margin-bottom: 0.25rem; + display: block; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 500; + --tw-text-opacity: 1; + color: rgb(55 65 81 / var(--tw-text-opacity)); +} + *, ::before, ::after { --tw-border-spacing-x: 0; --tw-border-spacing-y: 0; @@ -573,14 +595,18 @@ video { margin-top: 0px; } -.mt-10 { - margin-top: 2.5rem; -} - .block { display: block; } +.inline-block { + display: inline-block; +} + +.inline { + display: inline; +} + .flex { display: flex; } @@ -621,6 +647,14 @@ video { flex-direction: column; } +.place-content-end { + place-content: end; +} + +.place-items-center { + place-items: center; +} + .items-center { align-items: center; } @@ -633,12 +667,35 @@ video { justify-content: center; } +.justify-between { + justify-content: space-between; +} + +.justify-items-end { + justify-items: end; +} + +.gap-4 { + gap: 1rem; +} + .space-y-8 > :not([hidden]) ~ :not([hidden]) { --tw-space-y-reverse: 0; margin-top: calc(2rem * calc(1 - var(--tw-space-y-reverse))); margin-bottom: calc(2rem * var(--tw-space-y-reverse)); } +.divide-y > :not([hidden]) ~ :not([hidden]) { + --tw-divide-y-reverse: 0; + border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); + border-bottom-width: calc(1px * var(--tw-divide-y-reverse)); +} + +.divide-gray-200 > :not([hidden]) ~ :not([hidden]) { + --tw-divide-opacity: 1; + border-color: rgb(229 231 235 / var(--tw-divide-opacity)); +} + .rounded-lg { border-radius: 0.5rem; } @@ -659,6 +716,20 @@ video { border-top-width: 2px; } +.border-t-\[1px\] { + border-top-width: 1px; +} + +.border-gray-300 { + --tw-border-opacity: 1; + border-color: rgb(209 213 219 / var(--tw-border-opacity)); +} + +.border-primary-400 { + --tw-border-opacity: 1; + border-color: rgb(85 198 204 / var(--tw-border-opacity)); +} + .border-transparent { border-color: transparent; } @@ -688,6 +759,10 @@ video { background-color: rgb(255 255 255 / var(--tw-bg-opacity)); } +.p-4 { + padding: 1rem; +} + .p-8 { padding: 2rem; } @@ -731,21 +806,25 @@ video { padding-bottom: 1.25rem; } +.pt-0 { + padding-top: 0px; +} + .pt-2 { padding-top: 0.5rem; } -.text-center { - text-align: center; +.pt-4 { + padding-top: 1rem; } .text-right { text-align: right; } -.text-2xl { - font-size: 1.5rem; - line-height: 2rem; +.text-lg { + font-size: 1.125rem; + line-height: 1.75rem; } .text-sm { @@ -753,6 +832,11 @@ video { line-height: 1.25rem; } +.text-xs { + font-size: 0.75rem; + line-height: 1rem; +} + .font-bold { font-weight: 700; } @@ -761,12 +845,8 @@ video { font-weight: 500; } -.leading-9 { - line-height: 2.25rem; -} - -.tracking-tight { - letter-spacing: -0.025em; +.leading-6 { + line-height: 1.5rem; } .text-gray-400 { @@ -774,6 +854,11 @@ video { color: rgb(156 163 175 / var(--tw-text-opacity)); } +.text-gray-500 { + --tw-text-opacity: 1; + color: rgb(107 114 128 / var(--tw-text-opacity)); +} + .text-gray-900 { --tw-text-opacity: 1; color: rgb(17 24 39 / var(--tw-text-opacity)); @@ -801,6 +886,33 @@ video { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); } +.outline { + outline-style: solid; +} + +.outline-1 { + outline-width: 1px; +} + +.outline-primary-300 { + outline-color: #7AE5EA; +} + +.ring-1 { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} + +.ring-inset { + --tw-ring-inset: inset; +} + +.ring-gray-300 { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(209 213 219 / var(--tw-ring-opacity)); +} + .hover\:bg-primary-700:hover { --tw-bg-opacity: 1; background-color: rgb(21 121 131 / var(--tw-bg-opacity)); @@ -836,104 +948,23 @@ video { --tw-ring-offset-width: 2px; } -@media (min-width: 640px) { - .sm\:mx-auto { - margin-left: auto; - margin-right: auto; - } - - .sm\:w-full { - width: 100%; - } -} - @media (min-width: 768px) { .md\:mb-0 { margin-bottom: 0px; } - .md\:min-h-full { - min-height: 100%; - } - - .md\:w-20 { - width: 5rem; - } - - .md\:w-1\/6 { - width: 16.666667%; - } - - .md\:w-2\/6 { - width: 33.333333%; - } - - .md\:w-1\/4 { - width: 25%; - } - - .md\:w-1\/2 { - width: 50%; - } - - .md\:w-1\/3 { - width: 33.333333%; - } - - .md\:w-80 { - width: 20rem; - } - - .md\:w-96 { - width: 24rem; - } - .md\:w-2\/3 { width: 66.666667%; } - .md\:max-w-md { - max-width: 28rem; - } - - .md\:max-w-sm { - max-width: 24rem; - } - .md\:max-w-lg { max-width: 32rem; } - - .md\:flex-col { - flex-direction: column; - } - - .md\:justify-center { - justify-content: center; - } } @media (min-width: 1024px) { - .lg\:w-1\/4 { - width: 25%; - } - - .lg\:w-1\/3 { - width: 33.333333%; - } - .lg\:px-8 { padding-left: 2rem; padding-right: 2rem; } } - -@media (min-width: 1280px) { - .xl\:w-1\/3 { - width: 33.333333%; - } - - .xl\:max-w-md { - max-width: 28rem; - } -} diff --git a/backend/web/static/favicon.ico b/backend/web/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..863fd60c8b337f1e043f9d7b466e4e8c0961768f Binary files /dev/null and b/backend/web/static/favicon.ico differ diff --git a/backend/web/static/src/input.css b/backend/web/static/src/input.css index b0f89e7fa77802f0c67191a3fd86e36426dae8c1..5081208add5520489884b19d3ef36ac1c1df5c7f 100644 --- a/backend/web/static/src/input.css +++ b/backend/web/static/src/input.css @@ -2,6 +2,12 @@ @tailwind components; @tailwind utilities; +@layer base { + label { + @apply block text-sm font-medium text-gray-700 mb-1; + } +} + @layer components { #pills-tab .nav-link.active { @apply bg-transparent text-gray-800 border-b-2 text-left border-b-primary-700 hover:bg-primary-700 py-2 px-4 rounded-none; diff --git a/backend/web/templates/base.html b/backend/web/templates/base.html index 8cadf73805a5667e70d05b91a294ba8448974280..d5221bfc684a042d70007826c5949538352183e2 100644 --- a/backend/web/templates/base.html +++ b/backend/web/templates/base.html @@ -1,49 +1,55 @@ -<!doctype html> +<!DOCTYPE html> <html class="h-full bg-white"> -<link rel="stylesheet" href="static/style.css"> + <link rel="stylesheet" href="static/style.css" /> + <link rel="icon" type="image/x-icon" href="/static/favicon.ico" /> -<link rel="stylesheet" href="static/css/bootstrap.min.css"> -<link href="static/css/main.css" rel="stylesheet"> -<script src="static/js/jquery-3.6.0.min.js"></script> -<script src="static/js/bootstrap.bundle.min.js"></script> -<script src="static/js/js.cookie.min.js"></script> -<script src="static/base.js"></script> - -<title>Your Stackspin Account</title> + <link rel="stylesheet" href="static/css/bootstrap.min.css" /> + <link href="static/css/main.css" rel="stylesheet" /> + <script src="static/js/jquery-3.6.0.min.js"></script> + <script src="static/js/bootstrap.bundle.min.js"></script> + <script src="static/js/js.cookie.min.js"></script> + <script src="static/base.js"></script> + <title>Your Stackspin Account</title> </html> - - <body class="h-full bg-gray-50"> - - - <script> - var api_url = '{{ api_url }}'; - - // Actions - $(document).ready(function () { - check_flow_expired(); - }); - - </script> - - - - <div class="flex min-h-full flex-col justify-center px-6 py-12 lg:px-8"> - <div class="mx-auto w-full h-full md:w-2/3 md:max-w-lg bg-white shadow rounded-lg space-y-8 mb-4 md:mb-0 p-8"> - - <div id="contentFlowExpired" class='alert alert-warning' style='display:none'>Your request has expired. - Please resubmit your request.</div> - - - <a href="{{ dashboard_url }}"><img class="mx-auto h-10 w-auto mb-4" src='static/logo.svg' /></a> - <!-- <h2 class="mt-10 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">Your Stackspin - account</h2> --> - - - <div id='messages'></div> - - {% block content %}{% endblock %} - </div> - </div> \ No newline at end of file + <script> + var api_url = "{{ api_url }}"; + + // Actions + $(document).ready(function () { + check_flow_expired(); + }); + </script> + + <div class="flex min-h-full flex-col justify-center px-6 py-12 lg:px-8"> + <div + class="mx-auto w-full h-full md:w-2/3 md:max-w-lg bg-white shadow rounded-lg space-y-8 mb-4 md:mb-0 p-8 pt-0" + > + <div + id="contentFlowExpired" + class="alert alert-warning" + style="display: none" + > + Your request has expired. Please resubmit your request. + </div> + + <div class="login-header flex flex-col place-items-center"> + <a href="{{ dashboard_url }}" class="block" + ><img class="mx-auto h-10 w-auto" src="static/logo.svg" /> + </a> + {% if demo %} + <span + class="inline-flex items-center rounded-md bg-gray-50 px-2 py-1 text-xs font-medium text-gray-500 ring-1 ring-inset ring-gray-300" + > + Demo Instance + </span> + {% endif %} + </div> + <div id="messages"></div> + + {% block content %}{% endblock %} + </div> + </div> +</body> diff --git a/backend/web/templates/login.html b/backend/web/templates/login.html index e29bee76ffd961650a2d670c8a2bf586b1e666cf..9a6c891bc1ea3ce35ba34180f7a917eec7e9e4c6 100644 --- a/backend/web/templates/login.html +++ b/backend/web/templates/login.html @@ -1,106 +1,95 @@ -{% extends 'base.html' %} - -{% block content %} +{% extends 'base.html' %} {% block content %} <script> - var api_url = '{{ api_url }}'; + var api_url = '{{ api_url }}'; + + // Actions + $(document).ready(function () { + flow_login(); + }); - // Actions - $(document).ready(function () { - flow_login(); - }); - // Render messages - {% for message in messages %} - renderMessage('{{ message['id'] }}', '{{ message['message'] }}', '{{ message['type'] }}'); - {% endfor %} + {% for message in messages %} + renderMessage('{{ message['id'] }}', '{{ message['message'] }}', '{{ message['type'] }}'); + {% endfor %} + + // On demo instance, submit the sign-up form via ajax + // so we can show the result on the same page. + {% if demo %} + $('#signup-form').submit((event) => { + event.preventDefault(); + window.console.log("signup submit"); + submitSignup(); + }); + {% endif %} </script> -<div id="contentMessages"></div> -<div id="contentLogin_password"></div> -<div id="contentLogin_totp"></div> -<div id="contentHelp" class="flex flex-col font-medium text-primary-600 mt-0 pt-2 border-t-gray-50 border-t-2"> - <a href='recovery' class="block w-full text-right hover:text-primary-700 mb-2">Set new - password</a><a href='https://stackspin.net' - class="block text-right w-full text-sm text-gray-400 hover:text-primary-500">What - is - Stackspin?</a> -</div> {% if demo %} -<br> -<script> - function submitSignup() { - let result = document.querySelector('#signup-result'); - let email = document.querySelector('#signup-email'); - let xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - let url = "/web/demo-user"; - xhr.open("POST", url, true); - xhr.setRequestHeader("Content-Type", "application/json"); - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - // In the success case, we get a plain (json) string; in the error - // case, we get an object with `errorMessage` property. - if (typeof (this.response) == 'object' && 'errorMessage' in this.response) { - window.console.log("Error in sign-up request."); - result.classList.remove('alert-success'); - result.classList.add('alert-danger'); - if (this.response.errorMessage == '[KratosError] Unable to insert or update resource because a resource with that value exists already') { - result.innerHTML = "A user with that email address already exists."; - } else if (this.response.errorMessage == '[KratosError] The request was malformed or contained invalid parameters') { - result.innerHTML = "That doesn't appear to be a valid email address."; - } else { - result.innerHTML = this.response.errorMessage; - } - } else { - result.classList.add('alert-success'); - result.classList.remove('alert-danger'); - result.innerHTML = this.response; - } - result.style.visibility = 'visible'; - } - }; - // Converting JSON data to string - var data = JSON.stringify({ "email": email.value }); - // Sending data with the request - xhr.send(data); - } - $(() => { - // Submit the sign-up form via ajax so we can show the result on the same - // page. - $('#signup-form').submit((event) => { - event.preventDefault(); - window.console.log("signup submit"); - submitSignup(); - }); - }) -</script> -<h4>Sign up for this demo instance</h4> -Enter your email address here to create an account on this Stackspin -instance. -<div class="alert alert-warning" style="margin-top: 1em;"> - Warning: this is a demo instance! That means that: - <ul> - <li>Anyone can create an account on this same instance, like yourself, - and will share the same set of users and data. So any data you create - or upload, including the email address you enter here, becomes - essentially public information.</li> - <li>Every night (Europe/Amsterdam time), this instance gets automatically - reset to an empty state, so any data you create or upload will be - destroyed.</li> - </ul> + +<div + class="rounded-md bg-gray-50 border border-gray-300 outline outline-1 outline-primary-300 p-4 flex flex-col gap-4 text-xs text-gray-500" +> + <p>Please keep in mind this is a demo instance:</p> + <ul class="divide-y divide-gray-200"> + <li class="py-2">Anyone can create an account, just like yourself.</li> + <li class="py-2"> + Any data you upload, including your email address, can be accessible by + other users. + </li> + <li class="py-2"> + The demo is automatically reset every night (Europe/Amsterdam time), + removing all users and their data. + </li> + </ul> </div> +<h1 class="text-lg leading-6 font-bold text-gray-900"> + Create your Stackspin demo account: +</h1> <form id="signup-form"> - <div class="form-group"> - <label for="signup-email">Email address</label> - <input type="email" class="form-control" id="signup-email" name="signup-email" - placeholder="Your email address to sign up with."> - </div> - <div class="form-group"> - <input type="submit" class="btn btn-primary" value="Sign up"> - <div id="signup-result" class="alert" style="visibility: hidden; margin-top: 1em;"></div> - </div> + <label for="signup-email">E-mail address</label> + <div class="form-group flex justify-between gap-4"> + <input + type="email" + class="form-control" + id="signup-email" + name="signup-email" + placeholder="Please provide your e-mail address" + /> + <button + type="submit" + class="inline-flex pointer h-10 items-center px-2.5 py-1.5 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500" + > + Create + </button> + </div> + <div + id="signup-result" + class="form-group rounded-md bg-gray-50 border border-gray-300 outline outline-1 outline-primary-300 p-4 flex flex-col gap-4 text-xs text-gray-500" + style="visibility: hidden" + ></div> </form> + +<h1 + class="text-lg mt-0 leading-6 font-bold text-gray-900 pt-4 border-t-[1px] border-primary-400" +> + If you already have an account, log in: +</h1> + {% endif %} -{% endblock %} \ No newline at end of file +<div id="contentMessages"></div> +<div id="contentLogin_password"></div> +<div id="contentLogin_totp"></div> +<footer + class="font-medium text-primary-600 mt-0 pt-2 border-t-gray-50 border-t-2 flex justify-end" +> + <div id="contentHelp" class="flex flex-col text-right"> + <a href="recovery" class="hover:text-primary-700 mb-2">Set new password</a + ><a + href="https://stackspin.net" + class="text-sm text-gray-400 hover:text-primary-500" + >What is Stackspin?</a + > + </div> + <footer>{% endblock %}</footer> +</footer>