diff options
Diffstat (limited to 'apps/portal/src/app')
| -rw-r--r-- | apps/portal/src/app/index.d.ts | 48 | ||||
| -rw-r--r-- | apps/portal/src/app/index.scss | 21 | ||||
| -rw-r--r-- | apps/portal/src/app/index.svelte | 61 | ||||
| -rw-r--r-- | apps/portal/src/app/index.ts | 14 | ||||
| -rw-r--r-- | apps/portal/src/app/pages/_layout.svelte | 142 | ||||
| -rw-r--r-- | apps/portal/src/app/pages/forgot.svelte | 99 | ||||
| -rw-r--r-- | apps/portal/src/app/pages/login.svelte | 145 | ||||
| -rw-r--r-- | apps/portal/src/app/pages/not-found.svelte | 23 | ||||
| -rw-r--r-- | apps/portal/src/app/pages/reset-password.svelte | 135 | ||||
| -rw-r--r-- | apps/portal/src/app/pages/sign-up.svelte | 128 |
10 files changed, 816 insertions, 0 deletions
diff --git a/apps/portal/src/app/index.d.ts b/apps/portal/src/app/index.d.ts new file mode 100644 index 0000000..c044583 --- /dev/null +++ b/apps/portal/src/app/index.d.ts @@ -0,0 +1,48 @@ +/* Use this file to declare any custom file extensions for importing */ +/* Use this folder to also add/extend a package d.ts file, if needed. */ + +/* CSS MODULES */ +declare module "*.module.css" { + const classes: { [key: string]: string }; + export default classes; +} +declare module "*.module.scss" { + const classes: { [key: string]: string }; + export default classes; +} + +/* CSS */ +declare module "*.css"; +declare module "*.scss"; + +/* IMAGES */ +declare module "*.svg" { + const ref: string; + export default ref; +} +declare module "*.bmp" { + const ref: string; + export default ref; +} +declare module "*.gif" { + const ref: string; + export default ref; +} +declare module "*.jpg" { + const ref: string; + export default ref; +} +declare module "*.jpeg" { + const ref: string; + export default ref; +} +declare module "*.png" { + const ref: string; + export default ref; +} + +/* CUSTOM: ADD YOUR OWN HERE */ +declare module "*.svelte" { + const value: any; + export default value; +} diff --git a/apps/portal/src/app/index.scss b/apps/portal/src/app/index.scss new file mode 100644 index 0000000..56ac1c0 --- /dev/null +++ b/apps/portal/src/app/index.scss @@ -0,0 +1,21 @@ +@use '../../web-shared/src/styles/base'as * with ($breakpoints: ('xs': "768px", + 'sm': "768px", + 'md': "1200px", + 'lg': "1200px", + 'xl': "1600px", + ), + $grid-columns: 12); + +@use '../../web-shared/src/styles/custom-style/colors'; +@use '../../web-shared/src/styles/custom-style/spacing'; +@use '../../web-shared/src/styles/custom-style/shared-styles'; +@use '../../web-shared/src/styles/custom-style/typography'; +@use '../../web-shared/src/styles/custom-style/icons'; +@use '../../web-shared/src/styles/custom-style/buttons'; +@use '../../web-shared/src/styles/custom-style/forms'; +@use '../../web-shared/src/styles/custom-style/util'; + +@use '../../web-shared/src/styles/components/radios-checkboxes'; +@use '../../web-shared/src/styles/components/btn-states'; +@use '../../web-shared/src/styles/components/alert'; +@use '../../web-shared/src/styles/components/details'; diff --git a/apps/portal/src/app/index.svelte b/apps/portal/src/app/index.svelte new file mode 100644 index 0000000..40fe6ae --- /dev/null +++ b/apps/portal/src/app/index.svelte @@ -0,0 +1,61 @@ +<svelte:options immutable={true}/> +<svelte:window bind:online={online}/> + +<script> + import {projects_base} from "$shared/lib/configuration"; + import Router from "svelte-spa-router"; + import {wrap} from "svelte-spa-router/wrap"; + import {is_active} from "$shared/lib/session"; + import NotFound from "$app/pages/not-found.svelte"; + import SignUp from "$app/pages/sign-up.svelte"; + import Login from "$app/pages/login.svelte"; + import Forgot from "$app/pages/forgot.svelte"; + import Reset from "$app/pages/reset-password.svelte"; + import PreHeader from "$shared/components/pre-header.svelte"; + + let online = true; + + async function user_is_logged_in() { + if (await is_active()) { + location.replace(projects_base("#/home")); + } + return true; + } + + const routes = { + "/login": wrap({ + component: Login, + conditions: [user_is_logged_in], + }), + "/": wrap({ + component: Login, + conditions: [user_is_logged_in], + }), + "/signup": wrap({ + component: SignUp, + conditions: [user_is_logged_in], + }), + "/reset-password": wrap({ + component: Reset, + conditions: [user_is_logged_in], + }), + "/forgot": wrap({ + component: Forgot, + conditions: [user_is_logged_in], + }), + "*": NotFound, + }; +</script> + +<PreHeader show="{!online}">You seem to be offline, please check your internet connection.</PreHeader> + +<Router + {routes} + restoreScrollState={true} + on:routeLoading={() => { + document.getElementById("loader").style.display = "inline-block"; + }} + on:routeLoaded={() => { + document.getElementById("loader").style.display = "none"; + }} +/> diff --git a/apps/portal/src/app/index.ts b/apps/portal/src/app/index.ts new file mode 100644 index 0000000..0bfb30d --- /dev/null +++ b/apps/portal/src/app/index.ts @@ -0,0 +1,14 @@ +import App from "./index.svelte"; +import "./index.scss"; +import {is_debug, is_development} from "$shared/lib/configuration"; +import {noop} from "$shared/lib/helpers"; + +if (!is_development() && !is_debug()) { + console.log("%c Production; Suppressing logs", "background-color:yellow;color:black;font-size:18px;"); + console.log = noop; +} + +// @ts-ignore +export default new App({ + target: document.getElementById("root"), +}); diff --git a/apps/portal/src/app/pages/_layout.svelte b/apps/portal/src/app/pages/_layout.svelte new file mode 100644 index 0000000..8c2e4a8 --- /dev/null +++ b/apps/portal/src/app/pages/_layout.svelte @@ -0,0 +1,142 @@ +<script> + import Details from "$shared/components/details.svelte"; + import Button from "$shared/components/button.svelte"; + import {switch_theme} from "$shared/lib/helpers"; +</script> + +<style> + #decoration { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + width: 100%; + height: 100%; + overflow: hidden; + } + + #decoration svg { + position: absolute; + top: 0; + left: 50%; + -webkit-transform: translateX(-50%); + transform: translateX(-50%); + width: 134%; + min-width: 1280px; + max-width: 1920px; + height: auto; + } +</style> + +<main class="container-fluid padding-x-xs padding-x-xxl@xs padding-y-md padding-y-lg@md max-width-sm"> + <slot/> + + <Details summary="About"> + <p>Time Tracker is a tool to keep track of time spent.</p> + <p>Use demo@demo.demo 123456 to demo the app.</p> + <a href="https://git.ivarlovlie.no/time-tracker">Source</a> + <a href="https://git.ivarlovlie.no/time-tracker/tree/LICENSE">License</a> + <a href="/assets/third-party-licenses.txt">License notices</a> + </Details> + + <Details summary="Pricing"/> + + <Details summary="Privacy policy"> + <h3>Information we collect</h3> + <p>We collect information you the user provide, explicitly this means:</p> + <ul> + <li>Username</li> + <li>Password</li> + <li>Entries generated by you</li> + <li>Labels generated by you</li> + <li>Categories generated by you</li> + <li>Your IP address when making requests to our API (using the service)</li> + </ul> + + <h3>How we use your information</h3> + <p>We use your information to provide the time-tracker service.</p> + + <h3>How we share your information</h3> + <p> + We do not share your information with anyone nor any entity. All information is handled by us the provider and you the user + exclusively. + </p> + + <h3>Right to delete</h3> + <p> + You can at any time delete any data related to your personal information by navigating to your profile page inside of the + service. + </p> + + <h3>Right to inspect</h3> + <p>You can at any time download all of your generated data by navigating to your profile page inside of the service.</p> + + <h3>Contact</h3> + <p>Please direct any inquires about your personal data to time-tracker@ivarlovlie.no.</p> + </Details> + + <Details summary="Terms of service"/> + + <Button on:click={() => switch_theme()} + text="Switch theme" + variant="secondary"/> + + <figure id="decoration" + aria-hidden="true"> + <svg class="color-contrast-higher opacity-10%" + viewBox="0 0 1920 450" + fill="none"> + <g stroke="currentColor" + stroke-width="2"> + <rect x="1286" + y="64" + width="128" + height="128"/> + <circle cx="1350" + cy="128" + r="64"/> + <path d="M1286 64L1414 192"/> + <circle cx="1478" + cy="128" + r="64"/> + <rect x="1414" + y="192" + width="128" + height="128"/> + <circle cx="1478" + cy="256" + r="64"/> + <path d="M1414 192L1542 320"/> + <circle cx="1606" + cy="256" + r="64"/> + <rect x="1542" + y="320" + width="128" + height="128"/> + <circle cx="1606" + cy="384" + r="64"/> + <path d="M1542 320L1670 448"/> + <rect x="1690" + y="192" + width="128" + height="128"/> + <circle cx="1754" + cy="256" + r="64"/> + <path d="M1690 192L1818 320"/> + <rect x="1542" + y="64" + width="128" + height="128"/> + <circle cx="1606" + cy="128" + r="64"/> + <path d="M1542 64L1670 192"/> + <circle cx="1478" + r="64"/> + </g> + </svg> + </figure> +</main> diff --git a/apps/portal/src/app/pages/forgot.svelte b/apps/portal/src/app/pages/forgot.svelte new file mode 100644 index 0000000..f22d664 --- /dev/null +++ b/apps/portal/src/app/pages/forgot.svelte @@ -0,0 +1,99 @@ +<script> + import {onMount} from "svelte"; + import {link} from "svelte-spa-router"; + import {create_forgot_password_request} from "$shared/lib/api/user"; + import {is_email} from "$shared/lib/helpers"; + import Alert from "$shared/components/alert.svelte"; + import Button from "$shared/components/button.svelte"; + import Layout from "./_layout.svelte"; + + let isLoading = false; + let username; + + const alert = { + title: "", + type: "", + message: "", + isVisible: false, + show(type, obj) { + alert.title = obj.title; + alert.message = obj.text; + alert.type = type; + alert.isVisible = true; + isLoading = false; + }, + hide() { + alert.isVisible = false; + alert.title = ""; + alert.message = ""; + alert.type = ""; + isLoading = false; + }, + }; + + function is_valid() { + return is_email(username); + } + + async function submit_form() { + if (isLoading) { + return; + } + if (is_valid()) { + isLoading = true; + const response = await create_forgot_password_request(username); + if (response.ok) { + alert.show("success", { + title: "Request is sent", + text: "If we find an account associated with this email address, you will receive an email with a reset link very soon.", + }); + } else { + console.error(response.data); + alert.show("error", { + title: response.data?.title ?? "An error occured", + text: response.data?.text ?? "Please try again soon", + }); + } + } + } + + onMount(() => { + document.addEventListener("DOMContentLoaded", () => { + document.getElementById("email-address").focus(); + }); + }); +</script> + +<Layout> + <form on:submit|preventDefault={submit_form} + class="margin-bottom-md max-width-xxs"> + <fieldset> + <legend class="form-legend"> + <span class="margin-bottom-xs">Send reset link</span> <br/> + <span class="text-sm">... or <a href="/login" + use:link>log in</a></span> + </legend> + <div class="margin-bottom-xs"> + <p>Provide your email address, and we'll send you a link to set your new password.</p> + </div> + <div class="margin-bottom-xxs max-width-xxs"> + <Alert visible={alert.isVisible} + title={alert.title} + message={alert.message} + type={alert.type}/> + </div> + <div class="margin-bottom-xs"> + <input type="email" + id="email-address" + placeholder="Email address" + class="form-control width-100%" + bind:value={username}/> + </div> + <div class="flex justify-end"> + <Button text="Send reset link" + type="primary" + loading={isLoading}/> + </div> + </fieldset> + </form> +</Layout> diff --git a/apps/portal/src/app/pages/login.svelte b/apps/portal/src/app/pages/login.svelte new file mode 100644 index 0000000..3324056 --- /dev/null +++ b/apps/portal/src/app/pages/login.svelte @@ -0,0 +1,145 @@ +<script> + import {onMount} from "svelte"; + import {link, querystring} from "svelte-spa-router"; + import {api_base, projects_base, IconNames} from "$shared/lib/configuration"; + import Button from "$shared/components/button.svelte"; + import Alert from "$shared/components/alert.svelte"; + import {login} from "$shared/lib/api/user"; + import {is_email} from "$shared/lib/helpers"; + import Layout from "./_layout.svelte"; + + const loginForm = { + loading: false, + values: { + username: "", + password: "", + }, + alert: { + title: "", + type: "", + message: "", + isVisible: false, + show(type, obj) { + loginForm.alert.title = obj.title; + loginForm.alert.message = obj.text; + loginForm.alert.type = type; + loginForm.alert.isVisible = true; + loginForm.loading = false; + }, + hide() { + loginForm.alert.isVisible = false; + loginForm.alert.title = ""; + loginForm.alert.message = ""; + loginForm.alert.type = ""; + }, + }, + is_valid() { + return is_email(loginForm.values.username) && loginForm.values.password.length > 0; + }, + async submit_form() { + if (loginForm.loading) { + return; + } + if (loginForm.is_valid()) { + loginForm.alert.hide(); + loginForm.loading = true; + try { + const response = await login(loginForm.values); + if (response.ok) { + location.replace(projects_base("#/home")); + } else { + if (response.data.title || response.data.text) { + loginForm.alert.show("error", { + title: response.data.title ?? "", + text: response.data.text ?? "", + }); + } else { + loginForm.alert.show("error", { + title: "An unknown error occured", + text: "Try again soon", + }); + } + } + } catch (e) { + console.error(e); + loginForm.alert.show("error", { + title: "An error occured", + text: "Could not connect to server, please check your internet connection", + }); + } + } else { + loginForm.alert.show("error", { + title: "Invalid form", + }); + } + }, + }; + + onMount(() => { + if ($querystring === "deleted") { + loginForm.alert.show("info", { + title: "Account deleted", + text: "Your account and all its data was successfully deleted.", + }); + } + if ($querystring === "expired") { + loginForm.alert.show("info", { + title: "Session expired", + text: "Your session has expired, feel free to log in again.", + }); + } + }); +</script> + +<Layout> + <form on:submit|preventDefault={loginForm.submit_form} + class="margin-bottom-md max-width-xxs"> + <fieldset> + <legend class="form-legend"> + <span class="margin-bottom-xs">Log into your account</span> + <br/> + <span class="text-sm">... or <a href="/signup" + use:link>create a new one</a></span> + </legend> + <div class="margin-bottom-xxs max-width-xxs"> + <Alert visible={loginForm.alert.isVisible} + title={loginForm.alert.title} + message={loginForm.alert.message} + type={loginForm.alert.type}/> + </div> + <div class="margin-bottom-xxs"> + <input type="email" + placeholder="Email address" + class="form-control width-100%" + id="username" + bind:value={loginForm.values.username}/> + </div> + <div class="margin-bottom-xxs"> + <input type="password" + placeholder="Password" + id="password" + class="form-control width-100%" + bind:value={loginForm.values.password}/> + <div class="flex justify-end"> + <a tabindex="-1" + class="text-sm" + href="/forgot" + use:link>Reset password</a> + </div> + </div> + <div class="flex justify-between"> + <Button text="Login with Github" + variant="secondary" + icon="{IconNames.github}" + icon_right_aligned="true" + href={api_base("_/account/create-github-session")} + loading={loginForm.loading} + /> + <Button text="Login" + type="submit" + variant="primary" + loading={loginForm.loading}/> + </div> + </fieldset> + </form> +</Layout> diff --git a/apps/portal/src/app/pages/not-found.svelte b/apps/portal/src/app/pages/not-found.svelte new file mode 100644 index 0000000..34568ba --- /dev/null +++ b/apps/portal/src/app/pages/not-found.svelte @@ -0,0 +1,23 @@ +<script> + import {link} from "svelte-spa-router"; +</script> +<style> + header { + font-size: 12rem; + } + + main { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + text-align: center; + } +</style> + +<main> + <header>404</header> + <p>Page not found!</p> + <a use:link + href="/">Go to front</a> +</main> diff --git a/apps/portal/src/app/pages/reset-password.svelte b/apps/portal/src/app/pages/reset-password.svelte new file mode 100644 index 0000000..56c4f62 --- /dev/null +++ b/apps/portal/src/app/pages/reset-password.svelte @@ -0,0 +1,135 @@ +<script> + import {querystring, link} from "svelte-spa-router"; + import {check_forgot_password_request, fulfill_forgot_password_request} from "$shared/lib/api/user"; + import Alert from "$shared/components/alert.svelte"; + import Button from "$shared/components/button.svelte"; + import Layout from "./_layout.svelte"; + + const requestId = new URLSearchParams($querystring).get("id"); + let isLoading = false; + let newPassword; + let newPasswordError; + let alert = { + title: "", + type: "", + message: "", + isVisible: false, + show(type, obj) { + alert.title = obj.title; + alert.message = obj.text; + alert.type = type; + alert.isVisible = true; + isLoading = false; + }, + hide() { + alert.isVisible = false; + alert.title = ""; + alert.message = ""; + alert.type = ""; + isLoading = false; + }, + }; + + function is_valid() { + let isValid = true; + if (!newPassword.length > 5) { + newPasswordError = "The new password must be at least 5 characters"; + isValid = false; + } + return isValid; + } + + async function submit() { + if (isLoading) { + return; + } + if (is_valid()) { + isLoading = true; + const response = await fulfill_forgot_password_request(requestId, newPassword); + if (response.ok) { + alert.show("success", { + title: "Your new password is set", + text: "<a href='/#/login'>Click here to log in</a>", + }); + } else { + console.error(response.data); + alert.show("error", { + title: response.data?.title ?? "An error occured", + text: response.data?.text ?? "Please try again soon", + }); + } + } + } + + async function is_valid_password_reset_request() { + const response = await check_forgot_password_request(requestId); + if (response.ok) { + return response.data === true; + } + return false; + } +</script> + +<Layout> + <form on:submit|preventDefault={submit} + class="margin-bottom-md max-width-xxs {isLoading ? 'c-disabled loading' : ''}"> + {#if requestId} + {#await is_valid_password_reset_request()} + <p>Checking your request...</p> + <a href="/login" + use:link>cancel</a> + {:then isActive} + {#if isActive === true} + <fieldset> + <legend class="form-legend"> + <span class="margin-bottom-xs">Set your new password</span> <br/> + <span class="text-sm"> + ... or + <a href="/login" + use:link> log in </a> + </span> + </legend> + <div class="margin-bottom-xxs max-width-xxs"> + <Alert visible={alert.isVisible} + title={alert.title} + message={alert.message} + type={alert.type}/> + </div> + <div class="margin-bottom-xs"> + <input + type="password" + id="new-password" + placeholder="New password" + class="form-control width-100%" + bind:value={newPassword} + /> + {#if newPasswordError} + <small class="color-danger">{newPasswordError}</small> + {/if} + </div> + <div class="flex justify-end"> + <Button text="Set new password" + type="primary" + loading={isLoading} + on:click={submit}/> + </div> + </fieldset> + {:else} + <Alert title="This request is expired" + message="Please submit the forgot password form again" + type="warning"/> + <div class="flex justify-between width-100% margin-y-sm"> + <a href="/forgot" + use:link>Go to forgot form</a> + <a href="/login" + use:link>Go to login form</a> + </div> + {/if} + {:catch _} + <Alert title="An error occured" + message="Please try again soon" + type="error"/> + {/await} + {/if} + </form> +</Layout> diff --git a/apps/portal/src/app/pages/sign-up.svelte b/apps/portal/src/app/pages/sign-up.svelte new file mode 100644 index 0000000..80780e0 --- /dev/null +++ b/apps/portal/src/app/pages/sign-up.svelte @@ -0,0 +1,128 @@ +<script> + import {create_account} from "$shared/lib/api/user"; + import {is_email} from "$shared/lib/helpers"; + import Alert from "$shared/components/alert.svelte"; + import Button from "$shared/components/button.svelte"; + import {link} from "svelte-spa-router"; + import Layout from "./_layout.svelte"; + + const signupForm = { + loading: false, + values: { + username: "", + password: "", + }, + alert: { + title: "", + type: "", + message: "", + isVisible: false, + show(type, obj) { + signupForm.alert.title = obj.title; + signupForm.alert.message = obj.text; + signupForm.alert.type = type; + signupForm.alert.isVisible = true; + signupForm.loading = false; + }, + hide() { + signupForm.alert.isVisible = false; + signupForm.alert.title = ""; + signupForm.alert.message = ""; + signupForm.alert.type = ""; + }, + }, + is_valid() { + return ( + is_email(signupForm.values.username) && + signupForm.values.password.length > 0 + ); + }, + async submit_form() { + if (signupForm.loading) { + return; + } + if (signupForm.is_valid()) { + signupForm.alert.hide(); + signupForm.loading = true; + try { + const response = await create_account(signupForm.values); + if (response.ok) { + location.reload(); + } else { + if (response.data.title || response.data.text) { + signupForm.alert.show("error", { + title: response.data.title ?? "", + text: response.data.text ?? "", + }); + } else { + signupForm.alert.show("error", { + title: "An unknown error occured", + text: "Try again soon", + }); + } + } + } catch (e) { + console.error(e); + signupForm.alert.show("error", { + title: "An error occured", + text: "Could not connect to server, please check your internet connection", + }); + } + } else { + signupForm.alert.show("error", { + title: "Invalid form", + }); + } + }, + }; +</script> + +<Layout> + <form + on:submit|preventDefault={signupForm.submit_form} + class="margin-bottom-md max-width-xxs" + > + <fieldset> + <legend class="form-legend"> + <span class="margin-bottom-xs">Create your account</span> <br/> + <span class="text-sm" + >... or <a href="/login" + use:link>log in</a></span + > + </legend> + <div class="margin-bottom-xxs max-width-xxs"> + <Alert + visible={signupForm.alert.isVisible} + title={signupForm.alert.title} + message={signupForm.alert.message} + type={signupForm.alert.type} + /> + </div> + <div class="margin-bottom-xxs"> + <input + type="email" + placeholder="Email address" + class="form-control width-100%" + id="email-address" + bind:value={signupForm.values.username} + /> + </div> + <div class="margin-bottom-xxs"> + <input + type="password" + placeholder="Password" + class="form-control width-100%" + bind:value={signupForm.values.password} + /> + </div> + <div class="flex justify-end"> + <Button + class="margin-bottom-xs" + text="Submit" + type="primary" + loading={signupForm.loading} + /> + </div> + </fieldset> + </form> +</Layout> |
