diff options
Diffstat (limited to 'code/app/src/routes')
29 files changed, 1140 insertions, 0 deletions
diff --git a/code/app/src/routes/(main)/(app)/+layout.svelte b/code/app/src/routes/(main)/(app)/+layout.svelte new file mode 100644 index 0000000..0be6ff3 --- /dev/null +++ b/code/app/src/routes/(main)/(app)/+layout.svelte @@ -0,0 +1,297 @@ +<script lang="ts"> + import { + ChevronUpDownIcon, + MagnifyingGlassIcon, + Bars3CenterLeftIcon, + XMarkIcon, + HomeIcon, + MegaphoneIcon, + FolderOpenIcon, + QueueListIcon, + CalendarIcon, + } from "$lib/components/icons"; + import { Dialog, Menu, MenuButton, MenuItem, MenuItems, Transition, TransitionChild, TransitionRoot } from "@rgossiaux/svelte-headlessui"; + import { DialogPanel } from "@developermuch/dev-svelte-headlessui"; + import type { ISession } from "$lib/models/ISession"; + import { Input } from "$lib/components"; + import { end_session } from "$lib/session"; + import { goto } from "$app/navigation"; + import { page } from "$app/stores"; + + const session = { + profile: { + username: "Brukernavn", + displayName: "epost@adresse.no", + }, + } as ISession; + + let sidebarOpen = false; + let sidebarSearchValue: string | undefined; + + function sign_out() { + end_session(() => goto("/sign-in")); + } + + const navigationItems = [ + { + href: "/home", + name: "Home", + icon: HomeIcon, + }, + { + href: "/projects", + name: "Projects", + icon: CalendarIcon, + }, + { + href: "/tickets", + name: "Tickets", + icon: MegaphoneIcon, + }, + { + href: "/todo", + name: "Todo", + icon: QueueListIcon, + }, + { + href: "/wiki", + name: "Wiki", + icon: FolderOpenIcon, + }, + ]; +</script> + +<div class="min-h-full"> + <!-- Mobile sidebar --> + <TransitionRoot show={sidebarOpen}> + <Dialog as="div" class="relative z-40 lg:hidden" on:close={() => (sidebarOpen = false)}> + <TransitionChild + as="div" + enter="transition-opacity ease-linear duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="transition-opacity ease-linear duration-300" + leaveFrom="opacity-100" + leaveTo="opacity-0" + > + <div class="fixed inset-0 bg-gray-600 bg-opacity-75" /> + </TransitionChild> + + <div class="fixed inset-0 z-40 flex"> + <TransitionChild + as="div" + enter="transition ease-in-out duration-300 transform" + enterFrom="-translate-x-full" + enterTo="translate-x-0" + leave="transition ease-in-out duration-300 transform" + leaveFrom="translate-x-0" + leaveTo="-translate-x-full" + > + <DialogPanel class="relative flex w-full max-w-xs flex-1 flex-col bg-white pt-5 pb-4"> + <TransitionChild + as="div" + enter="ease-in-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in-out duration-300" + leaveFrom="opacity-100" + leaveTo="opacity-0" + > + <div class="absolute top-0 right-0 -mr-12 pt-2"> + <button + type="button" + class="ml-1 flex h-10 w-10 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white" + on:click={() => (sidebarOpen = false)} + > + <span class="sr-only">Close sidebar</span> + <XMarkIcon class="text-white" aria-hidden="true" /> + </button> + </div> + </TransitionChild> + <div class="mt-5 h-0 flex-1 overflow-y-auto"> + <nav class="px-2"> + <div class="space-y-1"> + {#each navigationItems as item} + {@const current = $page.url.pathname.startsWith(item.href)} + <a + href={item.href} + aria-current={current ? "page" : undefined} + class="group flex items-center px-2 py-2 text-base leading-5 font-medium rounded-md + {current ? 'bg-gray-100 text-gray-900' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'}" + > + <svelte:component + this={item.icon} + class="mr-3 flex-shrink-0 h-6 w-6 {current ? 'text-gray-500' : 'text-gray-400 group-hover:text-gray-500'}" + aria-hidden="true" + /> + {item.name} + </a> + {/each} + </div> + </nav> + </div> + </DialogPanel> + </TransitionChild> + <div class="w-14 flex-shrink-0" aria-hidden="true"> + <!-- Dummy element to force sidebar to shrink to fit close icon --> + </div> + </div> + </Dialog> + </TransitionRoot> + + <!-- Static sidebar for desktop --> + <div class="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col lg:border-r lg:border-gray-200 lg:bg-gray-100 lg:pb-4"> + <div class="flex h-0 flex-1 p-3 flex-col overflow-y-auto"> + <!-- User account dropdown --> + <Menu class="relative inline-block text-left"> + <MenuButton + class="group w-full rounded-md bg-gray-100 px-3.5 py-2 text-left text-sm font-medium text-gray-700 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2 focus:ring-offset-gray-100" + > + <span class="flex w-full items-center justify-between"> + <span class="flex min-w-0 items-center justify-between space-x-3"> + <span class="flex min-w-0 flex-1 flex-col"> + <span class="truncate text-sm font-medium text-gray-900"> + {session.profile.username} + </span> + <span class="truncate text-sm text-gray-500">{session.profile.displayName}</span> + </span> + </span> + <ChevronUpDownIcon class="flex-shrink-0 text-gray-400 group-hover:text-gray-500" aria-hidden="true" /> + </span> + </MenuButton> + <Transition + leave="transition ease-in duration-75" + enter="transition ease-out duration-100" + enterFrom="transform opacity-0 scale-95" + enterTo="transform opacity-100 scale-100" + leaveFrom="transform opacity-100 scale-100" + leaveTo="transform opacity-0 scale-95" + as="div" + > + <MenuItems + class="absolute right-0 left-0 z-10 mt-1 origin-top divide-y divide-gray-200 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none" + > + <div class="py-1"> + <MenuItem> + <a href="/profile" class="text-gray-700 block px-4 py-2 text-sm hover:text-gray-900 hover:bg-gray-100"> View profile </a> + </MenuItem> + <MenuItem> + <a href="/settings" class="text-gray-700 block px-4 py-2 text-sm hover:text-gray-900 hover:bg-gray-100"> Settings </a> + </MenuItem> + </div> + <div class="py-1"> + <MenuItem> + <span + on:click={() => sign_out()} + class="text-gray-700 block px-4 py-2 text-sm hover:bg-red-200 hover:text-red-900 cursor-pointer" + > + Sign out + </span> + </MenuItem> + </div> + </MenuItems> + </Transition> + </Menu> + <!-- Sidebar Search --> + <div class="mt-3 hidden"> + <label for="search" class="sr-only">Search</label> + <div class="relative mt-1 rounded-md shadow-sm"> + <Input type="search" name="search" icon={MagnifyingGlassIcon} placeholder="Search" bind:value={sidebarSearchValue} /> + </div> + </div> + <!-- Navigation --> + <nav class="mt-5"> + <div class="space-y-1"> + {#each navigationItems as item} + {@const current = $page.url.pathname.startsWith(item.href)} + <a + href={item.href} + aria-current={current ? "page" : undefined} + class="group flex items-center px-2 py-2 text-base leading-5 font-medium rounded-md + {current ? 'bg-gray-200 text-gray-900' : 'text-gray-700 hover:text-gray-900 hover:bg-gray-50'}" + > + <svelte:component + this={item.icon} + class="mr-3 flex-shrink-0 h-6 w-6 {current ? 'text-gray-500' : 'text-gray-400 group-hover:text-gray-500'}" + aria-hidden="true" + /> + {item.name} + </a> + {/each} + </div> + </nav> + </div> + </div> + + <!-- Main column --> + <div class="flex flex-col lg:pl-64"> + <!-- Search header --> + <div class="sticky top-0 z-10 flex h-16 flex-shrink-0 border-b border-gray-200 bg-white lg:hidden"> + <button + type="button" + class="border-r border-gray-200 px-4 text-gray-500 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-teal-500 lg:hidden" + on:click={() => (sidebarOpen = true)} + > + <span class="sr-only">Open sidebar</span> + <Bars3CenterLeftIcon aria-hidden="true" /> + </button> + <div class="flex flex-1 justify-between px-4 sm:px-6 lg:px-8"> + <div class="flex flex-1"> + <form class="flex w-full md:ml-0" action="#" method="GET"> + <label for="search-field" class="sr-only">Search</label> + <div class="relative w-full text-gray-400 focus-within:text-gray-600"> + <Input + bind:value={sidebarSearchValue} + icon={MagnifyingGlassIcon} + id="search-field" + name="search-field" + placeholder="Search" + type="search" + /> + </div> + </form> + </div> + <div class="flex items-center"> + <!-- Profile dropdown --> + <Menu as="div" class="relative ml-3"> + <div> + <MenuButton + class="flex max-w-xs items-center rounded-full bg-white text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2" + > + <span class="sr-only">Open user menu</span> + </MenuButton> + </div> + <Transition + enterFrom="transform opacity-0 scale-95" + enterTo="transform opacity-100 scale-100" + leaveFrom="transform opacity-100 scale-100" + leaveTo="transform opacity-0 scale-95" + as="div" + > + <MenuItems + class="absolute right-0 z-10 mt-2 w-48 origin-top-right divide-y divide-gray-200 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none" + > + <div class="py-1"> + <MenuItem> + <a href="/profile" class="text-gray-700 block px-4 py-2 text-sm"> View profile </a> + </MenuItem> + <MenuItem> + <a href="/settings" class="text-gray-700 block px-4 py-2 text-sm hover:text-gray-900 hover:bg-gray-100"> Settings </a> + </MenuItem> + <div class="py-1"> + <MenuItem> + <span on:click={() => sign_out()} class="text-gray-700 block px-4 py-2 text-sm"> Sign out </span> + </MenuItem> + </div> + </div> + </MenuItems> + </Transition> + </Menu> + </div> + </div> + </div> + <main class="flex-1"> + <slot /> + </main> + </div> +</div> diff --git a/code/app/src/routes/(main)/(app)/home/+page.svelte b/code/app/src/routes/(main)/(app)/home/+page.svelte new file mode 100644 index 0000000..247ee47 --- /dev/null +++ b/code/app/src/routes/(main)/(app)/home/+page.svelte @@ -0,0 +1 @@ +<h1>Welcome Home</h1>
\ No newline at end of file diff --git a/code/app/src/routes/(main)/(app)/org/+page.svelte b/code/app/src/routes/(main)/(app)/org/+page.svelte new file mode 100644 index 0000000..429ec25 --- /dev/null +++ b/code/app/src/routes/(main)/(app)/org/+page.svelte @@ -0,0 +1,4 @@ +<script lang="ts"> +</script> + +<h1>$ORGNAME</h1> diff --git a/code/app/src/routes/(main)/(app)/profile/+page.svelte b/code/app/src/routes/(main)/(app)/profile/+page.svelte new file mode 100644 index 0000000..7c6eb3e --- /dev/null +++ b/code/app/src/routes/(main)/(app)/profile/+page.svelte @@ -0,0 +1,4 @@ +<script lang="ts"> +</script> + +<h1>Hi, Ivar</h1> diff --git a/code/app/src/routes/(main)/(app)/projects/+page.svelte b/code/app/src/routes/(main)/(app)/projects/+page.svelte new file mode 100644 index 0000000..683938a --- /dev/null +++ b/code/app/src/routes/(main)/(app)/projects/+page.svelte @@ -0,0 +1,5 @@ +<script lang="ts"> + import { createSvelteTable } from "@tanstack/svelte-table"; +</script> + +<h1>Projects</h1> diff --git a/code/app/src/routes/(main)/(app)/settings/+page.svelte b/code/app/src/routes/(main)/(app)/settings/+page.svelte new file mode 100644 index 0000000..ae6d403 --- /dev/null +++ b/code/app/src/routes/(main)/(app)/settings/+page.svelte @@ -0,0 +1,4 @@ +<script lang="ts"> +</script> + +<h1>Settings</h1> diff --git a/code/app/src/routes/(main)/(app)/tickets/+page.svelte b/code/app/src/routes/(main)/(app)/tickets/+page.svelte new file mode 100644 index 0000000..2a4792b --- /dev/null +++ b/code/app/src/routes/(main)/(app)/tickets/+page.svelte @@ -0,0 +1,4 @@ +<script lang="ts"> +</script> + +<h1>Tickets</h1> diff --git a/code/app/src/routes/(main)/(app)/todo/+page.svelte b/code/app/src/routes/(main)/(app)/todo/+page.svelte new file mode 100644 index 0000000..e29f263 --- /dev/null +++ b/code/app/src/routes/(main)/(app)/todo/+page.svelte @@ -0,0 +1,4 @@ +<script lang="ts"> +</script> + +<h1>Todo</h1> diff --git a/code/app/src/routes/(main)/(app)/wiki/+page.svelte b/code/app/src/routes/(main)/(app)/wiki/+page.svelte new file mode 100644 index 0000000..1762d43 --- /dev/null +++ b/code/app/src/routes/(main)/(app)/wiki/+page.svelte @@ -0,0 +1,4 @@ +<script lang="ts"> +</script> + +<h1>Wiki</h1> diff --git a/code/app/src/routes/(main)/(public)/+layout.svelte b/code/app/src/routes/(main)/(public)/+layout.svelte new file mode 100644 index 0000000..69c29c5 --- /dev/null +++ b/code/app/src/routes/(main)/(public)/+layout.svelte @@ -0,0 +1,18 @@ +<script> + import LL from "$lib/i18n/i18n-svelte"; +</script> + +<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/tos" class="link"> + {$LL.tos()} + </a> + <a href="https://greatoffice.life/documentation" class="link"> + {$LL.documentation()} + </a> +</footer> diff --git a/code/app/src/routes/(main)/(public)/reset-password/+page.svelte b/code/app/src/routes/(main)/(public)/reset-password/+page.svelte new file mode 100644 index 0000000..aa26892 --- /dev/null +++ b/code/app/src/routes/(main)/(public)/reset-password/+page.svelte @@ -0,0 +1,82 @@ +<script lang="ts"> + import { create_forgot_password_request } from "$lib/api/user"; + import { Alert, Input, Button } from "$lib/components"; + import LL from "$lib/i18n/i18n-svelte"; + import type { ErrorResult } from "$lib/models/ErrorResult"; + + const formData = { + email: "", + }; + + $: showErrorAlert = + (errorData?.text.length ?? 0 + errorData?.title.length ?? 0) > 0 && + !showSuccessAlert; + + const errorData = { + text: "", + title: "", + } as ErrorResult; + + let loading = false; + let showSuccessAlert = false; + + async function submitFormAsync() { + errorData.text = ""; + errorData.title = ""; + showSuccessAlert = false; + loading = true; + const request = await create_forgot_password_request(formData.email); + loading = false; + if (!request.ok) { + errorData.text = request.data.text ?? $LL.tryAgainSoon(); + errorData.title = request.data.title ?? $LL.unexpectedError(); + return; + } + showSuccessAlert = true; + } +</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={submitFormAsync}> + <Alert + title={errorData.title} + message={errorData.text} + type="error" + visible={showErrorAlert} + /> + + <Alert + type="success" + title={$LL.success()} + message={$LL.resetPasswordPage.requestSentMessage()} + visible={showSuccessAlert} + /> + + <Input + id="email" + name="email" + type="email" + autocomplete="email" + required + bind:value={formData.email} + label={$LL.emailAddress()} + /> + <Button text={$LL.submit()} type="submit" {loading} fullWidth /> + </form> + </div> + </div> +</div> diff --git a/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.js b/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.js new file mode 100644 index 0000000..1c7fa30 --- /dev/null +++ b/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.js @@ -0,0 +1,11 @@ +import { is_guid } from '$lib/helpers'; +import { redirect } from '@sveltejs/kit'; +export const load = async ({ params }) => { + const resetRequestId = params.id ?? ""; + if (!is_guid(resetRequestId)) + throw redirect(302, "/reset-password"); + return { + resetRequestId + }; +}; +//# sourceMappingURL=+page.server.js.map
\ No newline at end of file diff --git a/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.js.map b/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.js.map new file mode 100644 index 0000000..52fb93b --- /dev/null +++ b/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.js.map @@ -0,0 +1 @@ +{"version":3,"file":"+page.server.js","sourceRoot":"","sources":["+page.server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACvC,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAGzC,MAAM,CAAC,MAAM,IAAI,GAAmB,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE;IACrD,MAAM,cAAc,GAAG,MAAM,CAAC,EAAE,IAAI,EAAE,CAAC;IACvC,IAAI,CAAC,OAAO,CAAC,cAAc,CAAC;QAAE,MAAM,QAAQ,CAAC,GAAG,EAAE,iBAAiB,CAAC,CAAC;IACrE,OAAO;QACH,cAAc;KACjB,CAAC;AACN,CAAC,CAAC"}
\ No newline at end of file diff --git a/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.ts b/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.ts new file mode 100644 index 0000000..389d04c --- /dev/null +++ b/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.ts @@ -0,0 +1,11 @@ +import { is_guid } from '$lib/helpers'; +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/app/src/routes/(main)/(public)/reset-password/[id]/+page.svelte b/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.svelte new file mode 100644 index 0000000..562d902 --- /dev/null +++ b/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.svelte @@ -0,0 +1,132 @@ +<script lang="ts"> + import { + check_forgot_password_request, + fulfill_forgot_password_request, + } from "$lib/api/user"; + import { onMount } from "svelte"; + import LL from "$lib/i18n/i18n-svelte"; + import { Alert, Input, Button } from "$lib/components"; + import type { PageServerData } from "./$types"; + import type { ErrorResult } from "$lib/models/ErrorResult"; + import { goto } from "$app/navigation"; + import { Message, messageQueryKey } from "../../sign-in/+page.svelte"; + + export let data: PageServerData; + + const formData = { + newPassword: "", + }; + + const errorData = { + text: "", + title: "", + } as ErrorResult; + + let errorState: undefined | "expired" | "404" | "unknown"; + + let finishedPreliminaryLoading = false; + let loading = false; + let canSubmit = true; + + async function submitFormAsync() { + if (!canSubmit) return; + loading = true; + const request = await fulfill_forgot_password_request( + data.resetRequestId, + formData.newPassword + ); + if (request.ok) { + goto( + "/sign-in?" + + messageQueryKey + + "=" + + Message.AFTER_PASSWORD_RESET + ); + } + + loading = false; + } + + onMount(async () => { + errorState = undefined; + const isValidRequest = await check_forgot_password_request( + data.resetRequestId + ); + if (!isValidRequest.ok && isValidRequest.status !== 404) { + errorState = "unknown"; + canSubmit = false; + } + if (isValidRequest.status === 404) { + errorState = "404"; + canSubmit = false; + } + if (isValidRequest.ok && isValidRequest.data !== true) { + errorState = "expired"; + canSubmit = false; + } + 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 errorState === "404"} + <Alert + title={$LL.notFound()} + message={$LL.resetPasswordPage.requestNotFound()} + /> + {:else if errorState === "expired"} + <Alert + title={$LL.resetPasswordPage.expired()} + message={$LL.resetPasswordPage.requestHasExpired()} + rightLinkHref="/reset-password" + rightLinkText={$LL.resetPasswordPage.requestANewReset()} + /> + {:else if errorState === "unknown"} + <Alert + title={$LL.unexpectedError()} + message={$LL.tryAgainSoon()} + /> + {/if} + + <Input + id="password" + name="password" + type="password" + autocomplete="new-password" + required + bind:value={formData.newPassword} + 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/app/src/routes/(main)/(public)/sign-in/+page.svelte b/code/app/src/routes/(main)/(public)/sign-in/+page.svelte new file mode 100644 index 0000000..908e2ba --- /dev/null +++ b/code/app/src/routes/(main)/(public)/sign-in/+page.svelte @@ -0,0 +1,133 @@ +<script lang="ts"> + import { goto } from "$app/navigation"; + import { login } from "$lib/api/user"; + import { Button, Checkbox, Input, Alert } from "$lib/components"; + import LL from "$lib/i18n/i18n-svelte"; + import type { ErrorResult } from "$lib/models/ErrorResult"; + import type { LoginPayload } from "$lib/models/LoginPayload"; + import pwKey from "$actions/pwKey"; + import { onMount } from "svelte"; + import { messageQueryKey, signInPageTestKeys, type Message } from "."; + + let loading = false; + let messageType: Message | undefined = undefined; + + const data = { + username: "", + password: "", + persist: true, + } as LoginPayload; + + let errorData = { + text: "", + title: "", + } as ErrorResult; + $: showErrorAlert = (errorData?.text.length ?? 0 + errorData?.title.length ?? 0) > 0; + + onMount(() => { + const searcher = new URLSearchParams(window.location.search); + if (searcher.get(messageQueryKey)) { + messageType = searcher.get(messageQueryKey) as Message; + searcher.delete(messageQueryKey); + history.replaceState(null, "", window.location.origin + window.location.pathname); + } + }); + + async function submitFormAsync() { + errorData = { text: "", title: "" }; + loading = true; + data.persist = !data.persist; + const loginResponse = await login(data); + if (loginResponse.ok) { + await goto("/home"); + } else { + errorData.title = loginResponse.data.title; + errorData.text = loginResponse.data.text; + } + loading = false; + } +</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 showErrorAlert} + <Alert title={errorData.title} message={errorData.text} type="error" _pwKey={signInPageTestKeys.formErrorAlert} /> + {/if} + <form class="space-y-6" use:pwKey={signInPageTestKeys.signInForm} on:submit|preventDefault={submitFormAsync}> + <Input + id="username" + _pwKey={signInPageTestKeys.usernameInput} + name="username" + type="email" + label={$LL.emailAddress()} + required + bind:value={data.username} + /> + + <Input + id="password" + name="password" + type="password" + label={$LL.password()} + _pwKey={signInPageTestKeys.passwordInput} + autocomplete="current-password" + required + bind:value={data.password} + /> + + <div class="flex items-center justify-between"> + <Checkbox + id="remember-me" + _pwKey={signInPageTestKeys.rememberMeCheckbox} + name="remember-me" + bind:checked={data.persist} + 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> + </div> + </div> +</div> diff --git a/code/app/src/routes/(main)/(public)/sign-in/index.ts b/code/app/src/routes/(main)/(public)/sign-in/index.ts new file mode 100644 index 0000000..cbdcbf6 --- /dev/null +++ b/code/app/src/routes/(main)/(public)/sign-in/index.ts @@ -0,0 +1,19 @@ +export enum Message { + AFTER_PASSWORD_RESET = "after-password-reset", + USER_INACTIVITY = "user-inactivity", + USER_DISABLED = "user-disabled", +} + +export const messageQueryKey = "m"; +export const signInPageTestKeys = { + passwordInput: "password-input", + usernameInput: "username-input", + rememberMeCheckbox: "remember-me-checkbox", + signInForm: "sign-in-form", + userInactivityAlert: Message.USER_INACTIVITY + "-alert", + userDisabledAlert: Message.USER_DISABLED + "-alert", + afterPasswordResetAlert: Message.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/app/src/routes/(main)/(public)/sign-in/tests/index.spec.ts b/code/app/src/routes/(main)/(public)/sign-in/tests/index.spec.ts new file mode 100644 index 0000000..ea8c494 --- /dev/null +++ b/code/app/src/routes/(main)/(public)/sign-in/tests/index.spec.ts @@ -0,0 +1,12 @@ +import { test, expect } from "@playwright/test"; +import { signInPageTestKeys } from "../index"; +import { get_test_context } from "$lib/configuration"; +import { get_pw_key_selector } from "$lib/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/app/src/routes/(main)/(public)/sign-up/+page.svelte b/code/app/src/routes/(main)/(public)/sign-up/+page.svelte new file mode 100644 index 0000000..0dfa41a --- /dev/null +++ b/code/app/src/routes/(main)/(public)/sign-up/+page.svelte @@ -0,0 +1,82 @@ +<script lang="ts"> + import { goto } from "$app/navigation"; + import { create_account } from "$lib/api/user"; + import { Button, Input, Alert } from "$lib/components"; + import LL from "$lib/i18n/i18n-svelte"; + import type { CreateAccountPayload } from "$lib/models/CreateAccountPayload"; + import type { ErrorResult } from "$lib/models/ErrorResult"; + + const formData = { + username: "", + password: "", + } as CreateAccountPayload; + + const errorData = { + text: "", + title: "", + } as ErrorResult; + let loading = false; + $: showErrorAlert = + (errorData?.text.length ?? 0 + errorData?.title.length ?? 0) > 0; + + async function submitFormAsync() { + loading = true; + errorData.text = ""; + errorData.title = ""; + const response = await create_account(formData); + loading = false; + if (response.ok) { + await goto("/home"); + return; + } + errorData.title = response.data?.title ?? $LL.unexpectedError(); + errorData.text = response.data?.text ?? $LL.tryAgainSoon(); + } +</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"> + <Alert + title={errorData.title} + message={errorData.text} + type="error" + class="mb-2" + visible={showErrorAlert} + /> + <form class="space-y-6" on:submit|preventDefault={submitFormAsync}> + <Input + label={$LL.emailAddress()} + id="email" + name="email" + autocomplete="email" + required + type="email" + bind:value={formData.username} + /> + + <Input + label={$LL.password()} + id="password" + name="password" + required + type="password" + bind:value={formData.password} + /> + <Button type="submit" text={$LL.submit()} {loading} fullWidth /> + </form> + </div> + </div> +</div> diff --git a/code/app/src/routes/(main)/+layout.server.ts b/code/app/src/routes/(main)/+layout.server.ts new file mode 100644 index 0000000..d2eb2eb --- /dev/null +++ b/code/app/src/routes/(main)/+layout.server.ts @@ -0,0 +1,34 @@ +import { api_base, CookieNames } from "$lib/configuration"; +import { logError } from "$lib/logger"; +import { error, redirect } from "@sveltejs/kit"; +import type { LayoutServerLoad } from "./$types"; + +export const load: LayoutServerLoad = async ({ routeId, cookies, locals }) => { + const isPublicRoute = (routeId?.startsWith("(main)/(public)") || routeId === "(main)") ?? true; + + let sessionIsValid = (await fetch(api_base("_/valid-session"), { + headers: { + Cookie: CookieNames.session + "=" + cookies.get(CookieNames.session) + } + }).catch((e) => { + logError(e); + throw error(503, { + message: "We are experiencing a service distruption! Have patience while we resolve the issue." + }) + })).ok; + + console.log("Base Layout loaded", { + sessionIsValid, + isPublicRoute, + routeId + }); + + if (sessionIsValid && isPublicRoute) { + throw redirect(302, "/home"); + } else if (!sessionIsValid && !isPublicRoute) { + throw redirect(302, "/sign-in"); + } + return { + locale: locals.locale + } +};
\ No newline at end of file diff --git a/code/app/src/routes/(main)/+layout.svelte b/code/app/src/routes/(main)/+layout.svelte new file mode 100644 index 0000000..1a870bb --- /dev/null +++ b/code/app/src/routes/(main)/+layout.svelte @@ -0,0 +1,29 @@ +<script lang="ts"> + import "../../app.pcss"; + import { setLocale } from "$lib/i18n/i18n-svelte"; + import LocaleSwitcher from "$lib/components/locale-switcher.svelte"; + import { ExclamationTriangleIcon } from "$lib/components/icons"; + import type { LayoutData } from "./$types"; + + let online = true; + export let data: LayoutData; + setLocale(data.locale); +</script> + +<svelte:window bind:online /> + +{#if !online} + <div class="bg-yellow-50 relative z-50 p-4"> + <div class="flex"> + <div class="flex-shrink-0"> + <ExclamationTriangleIcon class="bg-yellow-50 text-yellow-500" /> + </div> + <div class="ml-3"> + <p class="text-sm text-yellow-700">You seem to be offline, please check your internet connection.</p> + </div> + </div> + </div> +{/if} + +<LocaleSwitcher /> +<slot /> diff --git a/code/app/src/routes/(main)/+layout.ts b/code/app/src/routes/(main)/+layout.ts new file mode 100644 index 0000000..5d0e005 --- /dev/null +++ b/code/app/src/routes/(main)/+layout.ts @@ -0,0 +1,15 @@ +import type { LayoutLoad } from './$types' +import type { Locales } from '$lib/i18n/i18n-types' +import { loadLocaleAsync } from '$lib/i18n/i18n-util.async' +import { setLocale } from '$lib/i18n/i18n-svelte' + +export const load: LayoutLoad<{ locale: Locales }> = async ({ data: { locale } }) => { + // load dictionary into memory + await loadLocaleAsync(locale) + + // if you need to output a localized string in a `load` function, + // you always need to call `setLocale` right before you access the `LL` store + setLocale(locale) + // pass locale to the "rendering context" + return { locale } +}
\ No newline at end of file diff --git a/code/app/src/routes/(main)/+page.svelte b/code/app/src/routes/(main)/+page.svelte new file mode 100644 index 0000000..e507a19 --- /dev/null +++ b/code/app/src/routes/(main)/+page.svelte @@ -0,0 +1 @@ +<p class="text-bold p-1">Hold on...</p> diff --git a/code/app/src/routes/book/+layout.svelte b/code/app/src/routes/book/+layout.svelte new file mode 100644 index 0000000..aeed0d4 --- /dev/null +++ b/code/app/src/routes/book/+layout.svelte @@ -0,0 +1,64 @@ +<script> + import { page } from "$app/stores"; + import "../../app.pcss"; +</script> + +<div id="wrapper"> + <nav> + <a + href="/book/alerts" + class="link" + class:active={$page.url.pathname.startsWith("/book/alerts")} + >Alerts</a + > + <a + href="/book/buttons" + class="link" + class:active={$page.url.pathname.startsWith("/book/buttons")} + >Buttons</a + > + <a + href="/book/toggles" + class="link" + class:active={$page.url.pathname.startsWith("/book/toggles")} + >Toggles</a + > + <a + href="/book/inputs" + class="link" + class:active={$page.url.pathname.startsWith("/book/inputs")} + >Inputs</a + > + </nav> + <main> + <slot /> + </main> +</div> + +<style global lang="postcss"> + #wrapper { + display: flex; + flex-direction: row; + } + nav { + min-width: 120px; + padding: 10px; + display: flex; + flex-direction: column; + position: sticky; + position: -webkit-sticky; + top: 0; + height: fit-content; + } + main { + width: 100%; + padding: 10px; + } + section { + margin-bottom: 25px; + + h2 { + margin-bottom: 5px; + } + } +</style> diff --git a/code/app/src/routes/book/+page.svelte b/code/app/src/routes/book/+page.svelte new file mode 100644 index 0000000..635b3c2 --- /dev/null +++ b/code/app/src/routes/book/+page.svelte @@ -0,0 +1 @@ +<p>A showcase of greatoffices components</p> diff --git a/code/app/src/routes/book/alerts/+page.svelte b/code/app/src/routes/book/alerts/+page.svelte new file mode 100644 index 0000000..d008d85 --- /dev/null +++ b/code/app/src/routes/book/alerts/+page.svelte @@ -0,0 +1,70 @@ +<script> + import Alert from "$lib/components/alert.svelte"; +</script> + +<section> + <h2>Info</h2> + <Alert type="info" message="This is message" title="This is title" /> +</section> +<section> + <h2>Warning</h2> + <Alert type="warning" message="This is message" title="This is title" /> +</section> +<section> + <h2>Error</h2> + <Alert type="error" message="This is message" title="This is title" /> +</section> +<section> + <h2>Success</h2> + <Alert type="success" message="This is message" title="This is title" /> +</section> +<section> + <h2>Actions</h2> + <Alert + type="info" + message="This is message" + title="This is title" + closeable + actions={[ + { + id: "confirm", + text: "Yes!", + }, + { + id: "cancel", + text: "No!", + color: "red", + }, + ]} + /> +</section> +<section> + <h2>Right link</h2> + <Alert + on:rightLinkCliked={() => alert("Right link clicked")} + rightLinkText="Link or action" + title="Go here" + message="Hehe" + type="error" + /> +</section> +<section> + <h2>List</h2> + <Alert + title="This is title" + listItems={["Message 1", "Message 2"]} + type="error" + message="This is bad dude" + closeable + closeableCooldown="60" + id="alert-1" + on:actrepeat={() => { + alert("Repeat requested"); + }} + actions={[{ id: "repeat", text: "Try again" }]} + /> +</section> +<section> + <h2>Closeable</h2> + <Alert message="This is message" closeable type="info" /> +</section> diff --git a/code/app/src/routes/book/buttons/+page.svelte b/code/app/src/routes/book/buttons/+page.svelte new file mode 100644 index 0000000..19ba163 --- /dev/null +++ b/code/app/src/routes/book/buttons/+page.svelte @@ -0,0 +1,23 @@ +<script> + import Button from "$lib/components/button.svelte"; +</script> + +<section> + <h2>Primary</h2> + <Button kind="primary" text="Small" size="sm" /> + <Button kind="primary" text="Medium/Default" /> + <Button kind="primary" text="Large" size="lg" /> + <Button kind="primary" text="Extra large" size="xl" /> +</section> +<section> + <h2>Secondary</h2> + <Button kind="secondary" text="Click me!" /> +</section> +<section> + <h2>White</h2> + <Button kind="white" text="Click me!" /> +</section> +<section> + <h2>Loading</h2> + <Button kind="primary" loading={true} text="Wait" /> +</section> diff --git a/code/app/src/routes/book/inputs/+page.svelte b/code/app/src/routes/book/inputs/+page.svelte new file mode 100644 index 0000000..a693f69 --- /dev/null +++ b/code/app/src/routes/book/inputs/+page.svelte @@ -0,0 +1,48 @@ +<script lang="ts"> + import Input from "$lib/components/input.svelte"; + import { DatabaseIcon } from "$lib/components/icons"; +</script> + +<section> + <h2>Default</h2> + <Input label="Input me" placeholder="Hello" /> +</section> + +<section> + <h2>With icon</h2> + <Input label="Input me" placeholder="Hello" icon={DatabaseIcon} /> +</section> + +<section> + <h2>With corner hint</h2> + <Input label="Input me ->" placeholder="Hello" cornerHint="Hint hint" /> +</section> + +<section> + <h2>Disabled</h2> + <Input label="No" placeholder="Sorry" disabled /> +</section> + +<section> + <h2>Errored</h2> + <Input + label="No" + placeholder="Sorry" + errorText="That's not right" + icon={DatabaseIcon} + /> +</section> + +<section> + <h2>Help</h2> + <Input label="Go ahead" placeholder="Write here" helpText="Write above" /> +</section> +<section> + <h2>Addon</h2> + <Input + label="Go ahead" + placeholder="Write here" + helpText="Write above" + addon="To the right" + /> +</section> diff --git a/code/app/src/routes/book/toggles/+page.svelte b/code/app/src/routes/book/toggles/+page.svelte new file mode 100644 index 0000000..94228b4 --- /dev/null +++ b/code/app/src/routes/book/toggles/+page.svelte @@ -0,0 +1,27 @@ +<script> + import Switch from "$lib/components/switch.svelte"; +</script> + +<section> + <h2>Default</h2> + <Switch /> +</section> +<section> + <h2>Short</h2> + <Switch type="short" /> +</section> +<section> + <h2>Icon</h2> + <Switch type="icon" /> +</section> +<section> + <h2>Label / Description</h2> + <div class="max-w-md"> + <Switch label="Label" description="Some text" /> + </div> +</section> + +<section> + <h2>Label / Description (right aligned)</h2> + <Switch label="Label" description="Some text" rightAlignedLabelDescription /> +</section>
\ No newline at end of file |
