diff options
Diffstat (limited to 'code/frontend/src/routes/(main)/(public)')
14 files changed, 564 insertions, 0 deletions
diff --git a/code/frontend/src/routes/(main)/(public)/+layout.svelte b/code/frontend/src/routes/(main)/(public)/+layout.svelte new file mode 100644 index 0000000..6da653c --- /dev/null +++ b/code/frontend/src/routes/(main)/(public)/+layout.svelte @@ -0,0 +1,18 @@ +<script> + import { LocaleSwitcher } from "$components"; + import LL from "$i18n/i18n-svelte"; +</script> + +<LocaleSwitcher tabindex={-1} /> +<slot /> +<footer class="grid sm:gap-5 grid-flow-row sm:justify-center px-2 sm:grid-flow-col"> + <a href="https://greatoffice.life/privacy" class="link"> + {$LL.privacyPolicy()} + </a> + <a href="https://greatoffice.life/terms" class="link"> + {$LL.tos()} + </a> + <a href="https://greatoffice.life/docs" class="link"> + {$LL.documentation()} + </a> +</footer> diff --git a/code/frontend/src/routes/(main)/(public)/portal/+page.svelte b/code/frontend/src/routes/(main)/(public)/portal/+page.svelte new file mode 100644 index 0000000..cc16681 --- /dev/null +++ b/code/frontend/src/routes/(main)/(public)/portal/+page.svelte @@ -0,0 +1,26 @@ +<script lang="ts"> + import { onMount } from "svelte"; + import type { PageData } from "./$types"; + import type { PortalMessage } from "$configuration"; + import { goto } from "$app/navigation"; + import { sgs } from "$utils/global-state"; + + export let data: PageData; + + onMount(async () => { + switch (data.message as PortalMessage) { + case "emailValidated": { + sgs("showEmailValidatedAlertWhenLoggedIn", true); + await goto("/home"); + break; + } + default: { + await goto("/home"); + } + } + }); +</script> + +<div class="p-3"> + <h1>Warping...</h1> +</div> diff --git a/code/frontend/src/routes/(main)/(public)/portal/+page.ts b/code/frontend/src/routes/(main)/(public)/portal/+page.ts new file mode 100644 index 0000000..72338cb --- /dev/null +++ b/code/frontend/src/routes/(main)/(public)/portal/+page.ts @@ -0,0 +1,9 @@ +import type { PortalMessage } from '$configuration'; +import { redirect } from '@sveltejs/kit'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ url }) => { + const message = url.searchParams.get("msg") as PortalMessage; + if (!message) throw redirect(302, "/"); + return { message }; +};
\ No newline at end of file diff --git a/code/frontend/src/routes/(main)/(public)/reset-password/+page.svelte b/code/frontend/src/routes/(main)/(public)/reset-password/+page.svelte new file mode 100644 index 0000000..a45ccdd --- /dev/null +++ b/code/frontend/src/routes/(main)/(public)/reset-password/+page.svelte @@ -0,0 +1,81 @@ +<script lang="ts"> + import { Alert, Input, Button } from "$components"; + import LL from "$i18n/i18n-svelte"; + import { FormError } from "$models/internal/FormError"; + import { PasswordResetService } from "$services/password-reset-service"; + + const formData = { + email: { + value: "", + errors: [], + }, + }; + + const formError = new FormError(); + const passwordResetService = PasswordResetService.resolve(); + + let loading = false; + let showSuccessAlert = false; + let showErrorAlert = false; + + async function submit_form_async() { + formError.set(); + showSuccessAlert = false; + showErrorAlert = false; + loading = true; + const response = await passwordResetService.create_request_async(formData.email.value); + loading = false; + if (response.isCreated) { + showSuccessAlert = true; + } else if (response.knownProblem) { + formError.set_from_known_problem(response.knownProblem); + for (const error of Object.entries(response.knownProblem.errors)) { + if (error[0] === "email") { + let errors = []; + error[1].forEach((e) => errors.push(e)); + formData.email.errors = errors; + } + } + } else { + formError.set($LL.unexpectedError(), $LL.tryAgainSoon()); + } + showErrorAlert = formError.has_error() && !showSuccessAlert; + } +</script> + +<div class="min-h-full flex flex-col justify-center py-12 sm:px-6 lg:px-8"> + <div class="sm:mx-auto sm:w-full p-2 sm:p-0 sm:max-w-md"> + <h2 class="mt-6 text-3xl tracking-tight font-bold text-gray-900"> + {$LL.resetPasswordPage.requestAPasswordReset()} + </h2> + <p class="mt-2 text-sm text-gray-600"> + {$LL.or().toLowerCase()} + <a href="/sign-in" class="link"> + {$LL.signIntoYourAccount().toLowerCase()} + </a> + </p> + </div> + + <div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md"> + <div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10"> + <form class="space-y-6" on:submit|preventDefault={submit_form_async}> + {#if showErrorAlert} + <Alert title={formError.title} message={formError.subtitle} type="error" /> + {:else if showSuccessAlert} + <Alert type="success" title={$LL.success()} message={$LL.resetPasswordPage.requestSentMessage()} /> + {/if} + <Input + id="email" + name="email" + type="email" + autocomplete="email" + errors={formData.email.errors} + bind:value={formData.email.value} + required + label={$LL.emailAddress()} + /> + <Button text={$LL.submit()} type="submit" {loading} fullWidth /> + </form> + </div> + </div> +</div> diff --git a/code/frontend/src/routes/(main)/(public)/reset-password/+page.ts b/code/frontend/src/routes/(main)/(public)/reset-password/+page.ts new file mode 100644 index 0000000..c0859e0 --- /dev/null +++ b/code/frontend/src/routes/(main)/(public)/reset-password/+page.ts @@ -0,0 +1,11 @@ +import LL from '$i18n/i18n-svelte'; +import { get } from 'svelte/store'; +import type { PageLoad } from './$types'; + +const l = get(LL); + +export const load: PageLoad = async () => { + return { + title: l.resetPasswordPage.title(), + }; +};
\ No newline at end of file diff --git a/code/frontend/src/routes/(main)/(public)/reset-password/[id]/+page.server.ts b/code/frontend/src/routes/(main)/(public)/reset-password/[id]/+page.server.ts new file mode 100644 index 0000000..9e24736 --- /dev/null +++ b/code/frontend/src/routes/(main)/(public)/reset-password/[id]/+page.server.ts @@ -0,0 +1,11 @@ +import { is_guid } from "$utils/validators"; +import { redirect } from "@sveltejs/kit"; +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async ({ params }) => { + const resetRequestId = params.id ?? ""; + if (!is_guid(resetRequestId)) throw redirect(302, "/reset-password"); + return { + resetRequestId, + }; +};
\ No newline at end of file diff --git a/code/frontend/src/routes/(main)/(public)/reset-password/[id]/+page.svelte b/code/frontend/src/routes/(main)/(public)/reset-password/[id]/+page.svelte new file mode 100644 index 0000000..27a1af5 --- /dev/null +++ b/code/frontend/src/routes/(main)/(public)/reset-password/[id]/+page.svelte @@ -0,0 +1,82 @@ +<script lang="ts"> + import { onMount } from "svelte"; + import LL from "$i18n/i18n-svelte"; + import { Alert, Input, Button } from "$components"; + import type { PageServerData } from "./$types"; + import { goto } from "$app/navigation"; + import { SignInPageMessage, signInPageMessageQueryKey } from "$routes/(main)/(public)/sign-in"; + import { PasswordResetService } from "$services/password-reset-service"; + + export let data: PageServerData; + const passwordResetService = PasswordResetService.resolve(); + + const formData = { + newPassword: { + value: "", + errors: [], + }, + }; + + let finishedPreliminaryLoading = false; + let loading = false; + let canSubmit = true; + let requestIsInvalid = false; + + async function submitFormAsync() { + if (!canSubmit) return; + loading = true; + const request = await passwordResetService.fulfill_request_async(data.resetRequestId, formData.newPassword.value); + if (request.isFulfilled) { + goto("/sign-in?" + signInPageMessageQueryKey + "=" + SignInPageMessage.AFTER_PASSWORD_RESET); + } else if (request.knownProblem) { + } + loading = false; + } + + onMount(async () => { + const response = await passwordResetService.request_is_valid_async(data.resetRequestId); + requestIsInvalid = !response.isValid; + finishedPreliminaryLoading = true; + }); +</script> + +<div class="min-h-full flex flex-col justify-center py-12 sm:px-6 lg:px-8"> + {#if finishedPreliminaryLoading} + <div class="sm:mx-auto sm:w-full p-2 sm:p-0 sm:max-w-md"> + <h2 class="mt-6 text-3xl tracking-tight font-bold text-gray-900"> + {$LL.resetPasswordPage.setANewPassword()} + </h2> + <p class="mt-2 text-sm text-gray-600"> + {$LL.or().toLowerCase()} + <a href="/sign-in" class="link"> + {$LL.signIntoYourAccount().toLowerCase()} + </a> + </p> + </div> + + <div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md"> + <div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10"> + <form class="space-y-6" on:submit|preventDefault={submitFormAsync}> + {#if requestIsInvalid} + <Alert + title={$LL.resetPasswordPage.invalidRequestTitle()} + message={$LL.resetPasswordPage.invalidRequestMessage()} + /> + {/if} + <Input + id="password" + name="password" + type="password" + autocomplete="new-password" + required + bind:value={formData.newPassword.value} + label={$LL.resetPasswordPage.newPassword()} + /> + <Button text={$LL.submit()} type="submit" {loading} fullWidth /> + </form> + </div> + </div> + {:else} + <p>Checking your request...</p> + {/if} +</div> diff --git a/code/frontend/src/routes/(main)/(public)/reset-password/[id]/+page.ts b/code/frontend/src/routes/(main)/(public)/reset-password/[id]/+page.ts new file mode 100644 index 0000000..3252b7a --- /dev/null +++ b/code/frontend/src/routes/(main)/(public)/reset-password/[id]/+page.ts @@ -0,0 +1,11 @@ +import LL from '$i18n/i18n-svelte'; +import { get } from 'svelte/store'; +import type { PageLoad } from './$types'; + +const l = get(LL); + +export const load: PageLoad = async () => { + return { + title: l.resetPasswordPage.fulfillTitle(), + }; +};
\ No newline at end of file diff --git a/code/frontend/src/routes/(main)/(public)/sign-in/+page.svelte b/code/frontend/src/routes/(main)/(public)/sign-in/+page.svelte new file mode 100644 index 0000000..66d4575 --- /dev/null +++ b/code/frontend/src/routes/(main)/(public)/sign-in/+page.svelte @@ -0,0 +1,155 @@ +<script lang="ts"> + import { goto } from "$app/navigation"; + import { Button, Checkbox, Input, Alert } from "$components"; + import LL from "$i18n/i18n-svelte"; + import pwKey from "$actions/pwKey"; + import { onMount } from "svelte"; + import { signInPageMessageQueryKey, signInPageTestKeys, type SignInPageMessage } from "."; + import { AccountService } from "$services/account-service"; + import type { LoginPayload } from "$services/abstractions/IAccountService"; + import { FormError } from "$models/internal/FormError"; + import type { IForm } from "$models/internal/IForm"; + + let messageType: SignInPageMessage | undefined = undefined; + + const accountService = AccountService.resolve(); + const form = { + fields: { + username: { + value: "", + errors: [], + }, + password: { + value: "", + errors: [], + }, + persist: { + value: false, + errors: [], + }, + }, + error: new FormError(), + isLoading: false, + showError: false, + get_payload(): LoginPayload { + return { + password: form.fields.password.value, + username: form.fields.username.value, + persist: !form.fields.persist.value, + }; + }, + async submit_async() { + console.log("sadf"); + form.error.set(); + form.showError = form.error.has_error(); + form.isLoading = true; + const loginResponse = await accountService.login_async(form.get_payload()); + if (loginResponse.isLoggedIn) { + await goto("/home"); + } else if (loginResponse.knownProblem) { + form.error.set_from_known_problem(loginResponse.knownProblem); + } else { + form.error.set($LL.unexpectedError(), $LL.tryAgainSoon()); + } + form.isLoading = false; + form.showError = form.error.has_error(); + }, + } as IForm; + + onMount(() => { + const queryParams = new URLSearchParams(window.location.search); + if (queryParams.get(signInPageMessageQueryKey)) { + messageType = queryParams.get(signInPageMessageQueryKey) as SignInPageMessage; + queryParams.delete(signInPageMessageQueryKey); + window.history.replaceState(null, "", window.location.origin + window.location.pathname); + } + }); +</script> + +<div class="min-h-full flex flex-col justify-center py-12 sm:px-6 lg:px-8"> + {#if messageType} + <div class="sm:max-w-md sm:mx-auto sm:w-full"> + {#if messageType === "after-password-reset"} + <Alert + title={$LL.signInPage.yourNewPasswordIsApplied()} + _pwKey={signInPageTestKeys.afterPasswordResetAlert} + message={$LL.signInPage.signInBelow()} + closeable + /> + {:else if messageType === "user-disabled"} + <Alert + title={$LL.signInPage.yourAccountIsDisabled()} + _pwKey={signInPageTestKeys.userDisabledAlert} + message={$LL.signInPage.contactYourAdminIfDisabled()} + closeable + /> + {:else if messageType === "user-inactivity"} + <Alert + title={$LL.signInPage.youHaveReachedInactivityLimit()} + _pwKey={signInPageTestKeys.userInactivityAlert} + message={$LL.signInPage.feelFreeToSignInAgain()} + closeable + /> + {/if} + </div> + {/if} + <div class="sm:mx-auto sm:w-full p-2 sm:p-0 sm:max-w-md"> + <h2 class="mt-6 text-3xl tracking-tight font-bold text-gray-900"> + {$LL.signInPage.signIn()} + </h2> + <p class="mt-2 text-sm text-gray-600"> + {$LL.or().toLowerCase()} + <a href="/sign-up" use:pwKey={signInPageTestKeys.signUpAnchor} class="link"> + {$LL.createANewAccount().toLowerCase()} + </a> + </p> + </div> + <div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md"> + <div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10"> + {#if form.showError} + <Alert title={form.error.title} message={form.error.subtitle} type="error" _pwKey={signInPageTestKeys.formErrorAlert} /> + {/if} + <form class="space-y-6 mt-2" use:pwKey={signInPageTestKeys.signInForm} on:submit|preventDefault={() => form.submit_async()}> + <Input + id="username" + _pwKey={signInPageTestKeys.usernameInput} + name="username" + type="email" + label={$LL.emailAddress()} + required + errors={form.fields.username.errors} + bind:value={form.fields.username.value} + /> + + <Input + id="password" + name="password" + type="password" + label={$LL.password()} + _pwKey={signInPageTestKeys.passwordInput} + autocomplete="current-password" + required + errors={form.fields.password.errors} + bind:value={form.fields.password.value} + /> + + <div class="flex items-center justify-between"> + <Checkbox + id="remember-me" + _pwKey={signInPageTestKeys.rememberMeCheckbox} + name="remember-me" + bind:checked={form.fields.persist.value} + label={$LL.signInPage.notMyComputer()} + /> + <div class="text-sm"> + <a href="/reset-password" class="link" use:pwKey={signInPageTestKeys.resetPasswordAnchor}> + {$LL.signInPage.resetPassword()} + </a> + </div> + </div> + + <Button text={$LL.submit()} fullWidth type="submit" loading={form.isLoading} /> + </form> + </div> + </div> +</div> diff --git a/code/frontend/src/routes/(main)/(public)/sign-in/+page.ts b/code/frontend/src/routes/(main)/(public)/sign-in/+page.ts new file mode 100644 index 0000000..bebc459 --- /dev/null +++ b/code/frontend/src/routes/(main)/(public)/sign-in/+page.ts @@ -0,0 +1,11 @@ +import LL from '$i18n/i18n-svelte'; +import { get } from 'svelte/store'; +import type { PageLoad } from './$types'; + +const l = get(LL); + +export const load: PageLoad = async () => { + return { + title: l.signInPage.title(), + }; +};
\ No newline at end of file diff --git a/code/frontend/src/routes/(main)/(public)/sign-in/index.spec.js b/code/frontend/src/routes/(main)/(public)/sign-in/index.spec.js new file mode 100644 index 0000000..3bccf72 --- /dev/null +++ b/code/frontend/src/routes/(main)/(public)/sign-in/index.spec.js @@ -0,0 +1,12 @@ +import { test, expect } from "@playwright/test"; +import { signInPageTestKeys } from "./index.js"; +import { get_test_context } from "$configuration/test"; +import { get_pw_key_selector } from "$utils/testing-helpers"; + +const context = get_test_context(); + +test("form loads", async ({ page }) => { + page.goto("/sign-in"); + const form = page.locator(get_pw_key_selector(signInPageTestKeys.signInForm)); + expect(form.isVisible()).toBeTruthy(); +}); diff --git a/code/frontend/src/routes/(main)/(public)/sign-in/index.ts b/code/frontend/src/routes/(main)/(public)/sign-in/index.ts new file mode 100644 index 0000000..c1a1929 --- /dev/null +++ b/code/frontend/src/routes/(main)/(public)/sign-in/index.ts @@ -0,0 +1,20 @@ +export enum SignInPageMessage { + AFTER_PASSWORD_RESET = "after-password-reset", + USER_INACTIVITY = "user-inactivity", + USER_DISABLED = "user-disabled", + LOGGED_OUT = "logged-out" +} + +export const signInPageMessageQueryKey = "m"; +export const signInPageTestKeys = { + passwordInput: "password-input", + usernameInput: "username-input", + rememberMeCheckbox: "remember-me-checkbox", + signInForm: "sign-in-form", + userInactivityAlert: SignInPageMessage.USER_INACTIVITY + "-alert", + userDisabledAlert: SignInPageMessage.USER_DISABLED + "-alert", + afterPasswordResetAlert: SignInPageMessage.AFTER_PASSWORD_RESET + "-alert", + formErrorAlert: "form-error-alert", + resetPasswordAnchor: "reset-password-anchor", + signUpAnchor: "sign-up-anchor", +};
\ No newline at end of file diff --git a/code/frontend/src/routes/(main)/(public)/sign-up/+page.svelte b/code/frontend/src/routes/(main)/(public)/sign-up/+page.svelte new file mode 100644 index 0000000..470ac5d --- /dev/null +++ b/code/frontend/src/routes/(main)/(public)/sign-up/+page.svelte @@ -0,0 +1,106 @@ +<script lang="ts"> + import { goto } from "$app/navigation"; + import { Button, Input, Alert } from "$components"; + import LL from "$i18n/i18n-svelte"; + import { FormError } from "$models/internal/FormError"; + import type { CreateAccountPayload } from "$services/abstractions/IAccountService"; + import { AccountService } from "$services/account-service"; + + const formData = { + username: { + value: "", + errors: [], + }, + password: { + value: "", + errors: [], + }, + as_payload(): CreateAccountPayload { + return { + username: formData.username.value, + password: formData.password.value, + }; + }, + }; + + const formError = new FormError(); + const accountService = new AccountService(); + + let loading = false; + let showErrorAlert = false; + + async function submit_form_async() { + loading = true; + showErrorAlert = false; + formError.set(); + formData.username.errors = []; + formData.password.errors = []; + const response = await accountService.create_account_async(formData.as_payload()); + if (response.isCreated) { + await goto("/home"); + } else if (response.knownProblem) { + formError.set_from_known_problem(response.knownProblem); + for (const error of Object.entries(response.knownProblem.errors)) { + if (error[0] === "username") { + const errors = []; + error[1].forEach((e) => errors.push(e)); + formData.username.errors = errors; + } + if (error[0] === "password") { + const errors = []; + error[1].forEach((e) => errors.push(e)); + formData.password.errors = errors; + } + } + } else { + formError.set($LL.unexpectedError(), $LL.tryAgainSoon()); + } + loading = false; + showErrorAlert = formError.has_error(); + } +</script> + +<div class="min-h-full flex flex-col justify-center py-12 sm:px-6 lg:px-8"> + <div class="sm:mx-auto sm:w-full p-2 sm:p-0 sm:max-w-md"> + <h2 class="mt-6 text-3xl tracking-tight font-bold text-gray-900"> + {$LL.signUpPage.createYourNewAccount()} + </h2> + <p class="mt-2 text-sm text-gray-600"> + {$LL.or().toLowerCase()} + <a href="/sign-in" class="link"> + {$LL.signIntoYourAccount().toLowerCase()} + </a> + </p> + </div> + + <div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md"> + <div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10"> + {#if showErrorAlert} + <Alert title={formError.title} message={formError.subtitle} type="error" class="mb-2" /> + {/if} + <form class="space-y-6" on:submit|preventDefault={submit_form_async}> + <Input + label={$LL.emailAddress()} + id="email" + name="email" + autocomplete="email" + required + type="email" + bind:value={formData.username.value} + errors={formData.username.errors} + /> + + <Input + label={$LL.password()} + id="password" + name="password" + required + type="password" + bind:value={formData.password.value} + errors={formData.password.errors} + /> + <Button type="submit" text={$LL.submit()} {loading} fullWidth /> + </form> + </div> + </div> +</div> diff --git a/code/frontend/src/routes/(main)/(public)/sign-up/+page.ts b/code/frontend/src/routes/(main)/(public)/sign-up/+page.ts new file mode 100644 index 0000000..8c86f55 --- /dev/null +++ b/code/frontend/src/routes/(main)/(public)/sign-up/+page.ts @@ -0,0 +1,11 @@ +import LL from '$i18n/i18n-svelte'; +import { get } from 'svelte/store'; +import type { PageLoad } from './$types'; + +const l = get(LL); + +export const load: PageLoad = async () => { + return { + title: l.signUpPage.title(), + }; +};
\ No newline at end of file |
