diff options
| author | ivarlovlie <git@ivarlovlie.no> | 2022-12-13 14:48:11 +0100 |
|---|---|---|
| committer | ivarlovlie <git@ivarlovlie.no> | 2022-12-13 14:48:21 +0100 |
| commit | a8219611cbebbd27501d9f30c804979048b98107 (patch) | |
| tree | 632dbab8b724f0148b06525771d85ad6ddb55e35 /code/app/src | |
| parent | db2d937a38d5c309e3c6aa14f2eaf3cfe58f415e (diff) | |
| download | greatoffice-a8219611cbebbd27501d9f30c804979048b98107.tar.xz greatoffice-a8219611cbebbd27501d9f30c804979048b98107.zip | |
feat: A whole slew of things
- Use a md5 hash of the session cookie value as key for session validity check
- Introduce global state
- Introduce a common interface for form logic, and implement it on the sign-in form
- Introduce static resolve() on all services instead of new-upping all over.
- Implement /portal on the frontend to support giving the frontend a inital context from server or anywhere.
- Show a notification when users sign in for the first time after validating their email
Diffstat (limited to 'code/app/src')
19 files changed, 415 insertions, 271 deletions
diff --git a/code/app/src/components/button.svelte b/code/app/src/components/button.svelte index d573d01..f92be97 100644 --- a/code/app/src/components/button.svelte +++ b/code/app/src/components/button.svelte @@ -5,7 +5,7 @@ <script lang="ts"> import pwKey from "$actions/pwKey"; - import {SpinnerIcon} from "./icons"; + import { SpinnerIcon } from "./icons"; export let kind = "primary" as ButtonKind; export let size = "md" as ButtonSize; @@ -75,30 +75,30 @@ {#if href} <a - use:pwKey={_pwKey} - {...shared_props} - {href} - class="{sizeClasses} {kindClasses} {loading ? 'disabled:' : ''} {$$restProps.class ?? ''} {fullWidth + use:pwKey={_pwKey} + {...shared_props} + {href} + class="{sizeClasses} {kindClasses} {loading ? 'disabled:' : ''} {$$restProps.class ?? ''} {fullWidth ? 'w-full justify-center' - : ''} inline-flex items-center border font-medium rounded shadow-sm focus:outline-none focus:ring-2" + : ''} disabled:cursor-not-allowed inline-flex items-center border font-medium rounded shadow-sm focus:outline-none focus:ring-2" > {#if loading} - <SpinnerIcon class={spinnerTextClasses + " " + spinnerMarginClasses}/> + <SpinnerIcon class={spinnerTextClasses + " " + spinnerMarginClasses} /> {/if} {text} </a> {:else} <button - use:pwKey={_pwKey} - {...shared_props} - on:click - class="btn {sizeClasses} {kindClasses} {$$restProps.class ?? ''} + use:pwKey={_pwKey} + {...shared_props} + on:click + class="btn {sizeClasses} {kindClasses} {$$restProps.class ?? ''} {fullWidth ? 'w-full justify-center' : ''} inline-flex items-center border font-medium rounded shadow-sm focus:outline-none focus:ring-2" > {#if loading} - <SpinnerIcon class={spinnerTextClasses + " " + spinnerMarginClasses}/> + <SpinnerIcon class={spinnerTextClasses + " " + spinnerMarginClasses} /> {/if} {text} </button> diff --git a/code/app/src/configuration/index.ts b/code/app/src/configuration/index.ts index 8e4334f..f4fb87b 100644 --- a/code/app/src/configuration/index.ts +++ b/code/app/src/configuration/index.ts @@ -57,4 +57,6 @@ export const StorageKeys = { entries: "entries", stopwatch: "stopwatchState", logLevel: "logLevel", -};
\ No newline at end of file +}; + +export type PortalMessage = "emailValidated";
\ No newline at end of file diff --git a/code/app/src/help/cache.ts b/code/app/src/help/cache.ts new file mode 100644 index 0000000..e253399 --- /dev/null +++ b/code/app/src/help/cache.ts @@ -0,0 +1,38 @@ +import { Temporal } from "temporal-polyfill"; +import { log_debug } from "$help/logger"; + +let cache = {}; + +export const CacheKeys = { + isAuthenticated: "isAuthenticated" +} + +export async function cached_result_async<T>(key: string, staleAfterSeconds: number, get_result: any, forceRefresh: boolean = false) { + if (!cache[key]) { + cache[key] = { + l: 0, + c: undefined as T, + }; + } + const staleEpoch = ((cache[key]?.l ?? 0) + staleAfterSeconds); + const isStale = forceRefresh || (staleEpoch < Temporal.Now.instant().epochSeconds); + if (isStale || !cache[key]?.c) { + cache[key].c = await get_result(); + cache[key].l = Temporal.Now.instant().epochSeconds; + } + + log_debug("Ran cached_result_async", { + cacheKey: key, + isStale, + cache: cache[key], + staleEpoch, + }); + + return cache[key].c as T; +} + +export function clear_cache(key: string) { + if (!key) throw new Error("No key was specified"); + cache[key].c = undefined; + log_debug("Cleared cache with key: " + key); +}
\ No newline at end of file diff --git a/code/app/src/help/global-state.ts b/code/app/src/help/global-state.ts new file mode 100644 index 0000000..a253ae9 --- /dev/null +++ b/code/app/src/help/global-state.ts @@ -0,0 +1,22 @@ +import { get } from "svelte/store"; +import { writable_persistent } from "./persistent-store"; + +const state = writable_persistent<any>({ + initialState: {}, + name: "global-state" +}); + +export type GlobalStateKeys = "isLoggedIn" | "showEmailValidatedAlertWhenLoggedIn" | "all"; + +export function fgs(key: GlobalStateKeys): any { + const value = get(state); + if (key === "all") return value; + return value[key]; +} + +export function sgs(key: GlobalStateKeys, value: any) { + if (key === "all") throw new Error("Not allowed to set global state key: all"); + const stateValue = get(state); + stateValue[key] = JSON.stringify(value) + state.set(stateValue); +}
\ No newline at end of file diff --git a/code/app/src/help/md5.ts b/code/app/src/help/md5.ts new file mode 100644 index 0000000..0265194 --- /dev/null +++ b/code/app/src/help/md5.ts @@ -0,0 +1,48 @@ +// A formatted version of a popular md5 implementation. +// Original copyright (c) Paul Johnston & Greg Holt. +// The function itself is now 42 lines long. +// https://stackoverflow.com/a/60467595 "Don't deny." + +export function md5(inputString: string): string { + const hc = "0123456789abcdef"; + function rh(n) { var j, s = ""; for (j = 0; j <= 3; j++) s += hc.charAt((n >> (j * 8 + 4)) & 0x0F) + hc.charAt((n >> (j * 8)) & 0x0F); return s; } + function ad(x, y) { var l = (x & 0xFFFF) + (y & 0xFFFF); var m = (x >> 16) + (y >> 16) + (l >> 16); return (m << 16) | (l & 0xFFFF); } + function rl(n, c) { return (n << c) | (n >>> (32 - c)); } + function cm(q, a, b, x, s, t) { return ad(rl(ad(ad(a, q), ad(x, t)), s), b); } + function ff(a, b, c, d, x, s, t) { return cm((b & c) | ((~b) & d), a, b, x, s, t); } + function gg(a, b, c, d, x, s, t) { return cm((b & d) | (c & (~d)), a, b, x, s, t); } + function hh(a, b, c, d, x, s, t) { return cm(b ^ c ^ d, a, b, x, s, t); } + function ii(a, b, c, d, x, s, t) { return cm(c ^ (b | (~d)), a, b, x, s, t); } + function sb(x) { + var i; var nblk = ((x.length + 8) >> 6) + 1; var blks = new Array(nblk * 16); for (i = 0; i < nblk * 16; i++) blks[i] = 0; + for (i = 0; i < x.length; i++) blks[i >> 2] |= x.charCodeAt(i) << ((i % 4) * 8); + blks[i >> 2] |= 0x80 << ((i % 4) * 8); blks[nblk * 16 - 2] = x.length * 8; return blks; + } + var i, x = sb(inputString), a = 1732584193, b = -271733879, c = -1732584194, d = 271733878, olda, oldb, oldc, oldd; + for (i = 0; i < x.length; i += 16) { + olda = a; oldb = b; oldc = c; oldd = d; + a = ff(a, b, c, d, x[i + 0], 7, -680876936); d = ff(d, a, b, c, x[i + 1], 12, -389564586); c = ff(c, d, a, b, x[i + 2], 17, 606105819); + b = ff(b, c, d, a, x[i + 3], 22, -1044525330); a = ff(a, b, c, d, x[i + 4], 7, -176418897); d = ff(d, a, b, c, x[i + 5], 12, 1200080426); + c = ff(c, d, a, b, x[i + 6], 17, -1473231341); b = ff(b, c, d, a, x[i + 7], 22, -45705983); a = ff(a, b, c, d, x[i + 8], 7, 1770035416); + d = ff(d, a, b, c, x[i + 9], 12, -1958414417); c = ff(c, d, a, b, x[i + 10], 17, -42063); b = ff(b, c, d, a, x[i + 11], 22, -1990404162); + a = ff(a, b, c, d, x[i + 12], 7, 1804603682); d = ff(d, a, b, c, x[i + 13], 12, -40341101); c = ff(c, d, a, b, x[i + 14], 17, -1502002290); + b = ff(b, c, d, a, x[i + 15], 22, 1236535329); a = gg(a, b, c, d, x[i + 1], 5, -165796510); d = gg(d, a, b, c, x[i + 6], 9, -1069501632); + c = gg(c, d, a, b, x[i + 11], 14, 643717713); b = gg(b, c, d, a, x[i + 0], 20, -373897302); a = gg(a, b, c, d, x[i + 5], 5, -701558691); + d = gg(d, a, b, c, x[i + 10], 9, 38016083); c = gg(c, d, a, b, x[i + 15], 14, -660478335); b = gg(b, c, d, a, x[i + 4], 20, -405537848); + a = gg(a, b, c, d, x[i + 9], 5, 568446438); d = gg(d, a, b, c, x[i + 14], 9, -1019803690); c = gg(c, d, a, b, x[i + 3], 14, -187363961); + b = gg(b, c, d, a, x[i + 8], 20, 1163531501); a = gg(a, b, c, d, x[i + 13], 5, -1444681467); d = gg(d, a, b, c, x[i + 2], 9, -51403784); + c = gg(c, d, a, b, x[i + 7], 14, 1735328473); b = gg(b, c, d, a, x[i + 12], 20, -1926607734); a = hh(a, b, c, d, x[i + 5], 4, -378558); + d = hh(d, a, b, c, x[i + 8], 11, -2022574463); c = hh(c, d, a, b, x[i + 11], 16, 1839030562); b = hh(b, c, d, a, x[i + 14], 23, -35309556); + a = hh(a, b, c, d, x[i + 1], 4, -1530992060); d = hh(d, a, b, c, x[i + 4], 11, 1272893353); c = hh(c, d, a, b, x[i + 7], 16, -155497632); + b = hh(b, c, d, a, x[i + 10], 23, -1094730640); a = hh(a, b, c, d, x[i + 13], 4, 681279174); d = hh(d, a, b, c, x[i + 0], 11, -358537222); + c = hh(c, d, a, b, x[i + 3], 16, -722521979); b = hh(b, c, d, a, x[i + 6], 23, 76029189); a = hh(a, b, c, d, x[i + 9], 4, -640364487); + d = hh(d, a, b, c, x[i + 12], 11, -421815835); c = hh(c, d, a, b, x[i + 15], 16, 530742520); b = hh(b, c, d, a, x[i + 2], 23, -995338651); + a = ii(a, b, c, d, x[i + 0], 6, -198630844); d = ii(d, a, b, c, x[i + 7], 10, 1126891415); c = ii(c, d, a, b, x[i + 14], 15, -1416354905); + b = ii(b, c, d, a, x[i + 5], 21, -57434055); a = ii(a, b, c, d, x[i + 12], 6, 1700485571); d = ii(d, a, b, c, x[i + 3], 10, -1894986606); + c = ii(c, d, a, b, x[i + 10], 15, -1051523); b = ii(b, c, d, a, x[i + 1], 21, -2054922799); a = ii(a, b, c, d, x[i + 8], 6, 1873313359); + d = ii(d, a, b, c, x[i + 15], 10, -30611744); c = ii(c, d, a, b, x[i + 6], 15, -1560198380); b = ii(b, c, d, a, x[i + 13], 21, 1309151649); + a = ii(a, b, c, d, x[i + 4], 6, -145523070); d = ii(d, a, b, c, x[i + 11], 10, -1120210379); c = ii(c, d, a, b, x[i + 2], 15, 718787259); + b = ii(b, c, d, a, x[i + 9], 21, -343485551); a = ad(a, olda); b = ad(b, oldb); c = ad(c, oldc); d = ad(d, oldd); + } + return rh(a) + rh(b) + rh(c) + rh(d); +}
\ No newline at end of file diff --git a/code/app/src/help/persistent-store.ts b/code/app/src/help/persistent-store.ts index 6a54282..cb12547 100644 --- a/code/app/src/help/persistent-store.ts +++ b/code/app/src/help/persistent-store.ts @@ -1,6 +1,7 @@ import {browser} from "$app/environment"; import {writable as _writable, readable as _readable} from "svelte/store"; import type {Writable, Readable, StartStopNotifier} from "svelte/store"; +import { log_debug, log_info } from "./logger"; enum StoreType { SESSION = 0, @@ -76,11 +77,11 @@ function subscribe<T>(store: Writable<T> | Readable<T>, options: WritableStore<T function writable_persistent<T>(options: WritableStore<T>): Writable<T> { if (!browser) { - console.warn("Persistent store is only available in the browser"); + log_info("WARN: Persistent store is only available in the browser"); return; } if (options.options === undefined) options.options = default_store_options; - console.log("Creating writable store with options: ", options); + log_debug("creating writable store with options: ", options); const store = _writable<T>(options.initialState); hydrate(store, options); subscribe(store, options); @@ -89,11 +90,11 @@ function writable_persistent<T>(options: WritableStore<T>): Writable<T> { function readable_persistent<T>(options: ReadableStore<T>): Readable<T> { if (!browser) { - console.warn("Persistent store is only available in the browser"); + log_info("WARN: Persistent store is only available in the browser"); return; } if (options.options === undefined) options.options = default_store_options; - console.log("Creating readable store with options: ", options); + log_debug("Creating readable store with options: ", options); const store = _readable<T>(options.initialState, options.callback); // hydrate(store, options); subscribe(store, options); diff --git a/code/app/src/models/internal/IForm.ts b/code/app/src/models/internal/IForm.ts new file mode 100644 index 0000000..c14b770 --- /dev/null +++ b/code/app/src/models/internal/IForm.ts @@ -0,0 +1,15 @@ +import type { FormError } from "./FormError"; + +export interface IForm { + fields: Record<string, IFormField>; + error: FormError; + get_payload: Function; + submit_async: Function; + isLoading: boolean; + showError: boolean; +} + +export interface IFormField { + value: any; + errors: Array<string>; +} diff --git a/code/app/src/routes/(main)/(app)/+layout.svelte b/code/app/src/routes/(main)/(app)/+layout.svelte index e57bc3b..936e0a7 100644 --- a/code/app/src/routes/(main)/(app)/+layout.svelte +++ b/code/app/src/routes/(main)/(app)/+layout.svelte @@ -22,12 +22,13 @@ TransitionRoot, } from "@rgossiaux/svelte-headlessui"; import { DialogPanel } from "@developermuch/dev-svelte-headlessui"; - import { Input } from "$components"; + import { Input, Notification } from "$components"; import { goto } from "$app/navigation"; import { page } from "$app/stores"; + import { onMount } from "svelte"; + import { fgs, sgs } from "$help/global-state"; - const accountService = new AccountService(); - + const accountService = AccountService.resolve(); const session = { profile: { username: "Brukernavn", @@ -37,6 +38,12 @@ let sidebarOpen = false; let sidebarSearchValue: string | undefined; + let showEmailValidatedNotif = false; + + onMount(() => { + showEmailValidatedNotif = fgs("showEmailValidatedAlertWhenLoggedIn") === "true"; + if (showEmailValidatedNotif) sgs("showEmailValidatedAlertWhenLoggedIn", false); + }); function sign_out() { accountService.end_session(() => goto("/sign-in")); @@ -71,6 +78,20 @@ ]; </script> +{#if showEmailValidatedNotif} + <Notification + title="Email successfully validated" + subtitle="Because of this, you now have gained access to more functionality" + show={true} + /> + + <Notification + title="Email successfully validated" + subtitle="Because of this, you now have gained access to more functionality" + show={true} + /> +{/if} + <div class="min-h-full"> <!-- Mobile sidebar --> <TransitionRoot show={sidebarOpen}> diff --git a/code/app/src/routes/(main)/(app)/projects/+page.svelte b/code/app/src/routes/(main)/(app)/projects/+page.svelte index 1508118..2585331 100644 --- a/code/app/src/routes/(main)/(app)/projects/+page.svelte +++ b/code/app/src/routes/(main)/(app)/projects/+page.svelte @@ -1,41 +1,14 @@ <script lang="ts"> - import {Button, ProjectStatusBadge, Input} from "$components"; - import type {Project} from "$models/projects/Project"; - import {onMount} from "svelte"; - import {faker} from "@faker-js/faker"; - import {Temporal} from "temporal-polyfill"; - import {createTable, Subscribe, Render} from "svelte-headless-table"; - import {addSortBy, addTableFilter} from "svelte-headless-table/plugins"; - import {ProjectStatus} from "$models/projects/ProjectStatus"; - import {writable, type Writable} from "svelte/store"; - import { - ChevronDownIcon, - ChevronUpIcon, - ChevronUpDownIcon, - MagnifyingGlassIcon, - FunnelIcon, - } from "$components/icons"; + import { Button, ProjectStatusBadge, Input } from "$components"; + import type { Project } from "$models/projects/Project"; + import { createTable, Subscribe, Render } from "svelte-headless-table"; + import { addSortBy, addTableFilter } from "svelte-headless-table/plugins"; + import { writable, type Writable } from "svelte/store"; + import { ChevronDownIcon, ChevronUpIcon, ChevronUpDownIcon, MagnifyingGlassIcon, FunnelIcon } from "$components/icons"; import LL from "$i18n/i18n-svelte"; - import {goto} from "$app/navigation"; + import { goto } from "$app/navigation"; - let projects: Writable<Array<Project>> = writable([]); - - onMount(() => { - let i = 0; - const tempProjects = []; - while (i < 101) { - tempProjects.push({ - id: crypto.randomUUID(), - name: faker.lorem.word(), - start: Temporal.Now.plainDateISO().toLocaleString(), - description: faker.lorem.words(3), - members: [], - status: ProjectStatus.IDLE, - }); - i++; - } - projects.set(tempProjects); - }); + const projects: Writable<Array<Project>> = writable([]); function on_open_project(event) { if (event.code && (event.code !== "Enter" || event.code !== "Space")) return; @@ -50,14 +23,14 @@ }); const columns = table.createColumns([ - table.column({header: $LL.name(), accessor: "name"}), - table.column({header: "Status", accessor: "status"}), - table.column({header: "Start", accessor: "start"}), - table.column({header: "Description", accessor: "description", plugins: {sort: {disable: true}}}), + table.column({ header: $LL.name(), accessor: "name" }), + table.column({ header: "Status", accessor: "status" }), + table.column({ header: "Start", accessor: "start" }), + table.column({ header: "Description", accessor: "description", plugins: { sort: { disable: true } } }), ]); - const {headerRows, rows, tableAttrs, tableBodyAttrs, pluginStates} = table.createViewModel(columns); - const {filterValue} = pluginStates.filter; + const { headerRows, rows, tableAttrs, tableBodyAttrs, pluginStates } = table.createViewModel(columns); + const { filterValue } = pluginStates.filter; </script> <div class="sm:flex sm:items-center"> @@ -66,78 +39,80 @@ <p class="mt-2 text-sm text-gray-700">A list of all the projects in your organsation.</p> </div> <div class="mt-4 sm:mt-0 sm:ml-16 inline-flex gap-1 sm:flex-none"> - <Input icon={MagnifyingGlassIcon} placeholder="Search" bind:value={$filterValue}/> - <Button text="Create project" href="/projects/create"/> + <Input icon={MagnifyingGlassIcon} placeholder="Search" bind:value={$filterValue} /> + <Button text="Create project" href="/projects/create" /> </div> </div> <div class="-mx-2 mt-6 rounded-md shadow overflow-auto max-h-[80vh] sm:-mx-6 md:mx-0"> <table {...$tableAttrs} class="min-w-full divide-y divide-gray-300"> <thead class="bg-gray-50"> - {#each $headerRows as headerRow (headerRow.id)} - <Subscribe rowAttrs={headerRow.attrs()} let:rowAttrs> - <tr {...rowAttrs} class="shadow-sm"> - {#each headerRow.cells as cell (cell.id)} - <Subscribe attrs={cell.attrs()} let:attrs props={cell.props()} let:props> - <th + {#each $headerRows as headerRow (headerRow.id)} + <Subscribe rowAttrs={headerRow.attrs()} let:rowAttrs> + <tr {...rowAttrs} class="shadow-sm"> + {#each headerRow.cells as cell (cell.id)} + <Subscribe attrs={cell.attrs()} let:attrs props={cell.props()} let:props> + <th {...attrs} scope="col" class="sticky top-0 bg-gray-50 bg-opacity-100 whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-gray-900" - > - <div class="group inline-flex"> - <Render of={cell.render()}/> - <span + > + <div class="group inline-flex"> + <Render of={cell.render()} /> + <span on:click={props.sort.toggle} on:keypress={props.sort.toggle} class="{props.sort.disabled - ? 'bg-gray-200 text-gray-900 group-hover:bg-gray-300' - : 'invisible text-gray-400 group-hover:visible group-focus:visible'} + ? 'bg-gray-200 text-gray-900 group-hover:bg-gray-300' + : 'invisible text-gray-400 group-hover:visible group-focus:visible'} {props.sort.disabled ? '' : 'cursor-pointer'} ml-2 flex-none rounded" - > - {#if props.sort.order === "asc"} - <ChevronUpIcon/> - {:else if props.sort.order === "desc"} - <ChevronDownIcon/> - {:else if !props.sort.disabled} - <ChevronUpDownIcon/> - {/if} - </span> - {#if cell.id === "status"} - <span class="invisible text-gray-400 cursor-pointer group-hover:visible group-focus:visible ml-2 flex-none rounded"> - <FunnelIcon/> - </span> - {/if} - </div> - </th> - </Subscribe> - {/each} - </tr> - </Subscribe> - {/each} + > + {#if props.sort.order === "asc"} + <ChevronUpIcon /> + {:else if props.sort.order === "desc"} + <ChevronDownIcon /> + {:else if !props.sort.disabled} + <ChevronUpDownIcon /> + {/if} + </span> + {#if cell.id === "status"} + <span + class="invisible text-gray-400 cursor-pointer group-hover:visible group-focus:visible ml-2 flex-none rounded" + > + <FunnelIcon /> + </span> + {/if} + </div> + </th> + </Subscribe> + {/each} + </tr> + </Subscribe> + {/each} </thead> <tbody {...$tableBodyAttrs} class="divide-y divide-gray-200 bg-white"> - {#each $rows as row (row.id)} - <Subscribe rowAttrs={row.attrs()} let:rowAttrs> - <tr {...rowAttrs}> - {#each row.cells as cell (cell.id)} - {@const materialisedCell = cell.render()} - <Subscribe attrs={cell.attrs()} let:attrs> - <td {...attrs} class="whitespace-nowrap px-2 py-2 text-sm"> - {#if cell.id === "name"} - <span class="link" title="Open project" on:click={on_open_project} on:keypress={on_open_project}> - <Render of={materialisedCell}/> - </span> - {:else if cell.id === "status"} - <ProjectStatusBadge status={materialisedCell.toString()}/> - {:else} - <Render of={materialisedCell}/> - {/if} - </td> - </Subscribe> - {/each} - </tr> - </Subscribe> - {/each} + {#each $rows as row (row.id)} + <Subscribe rowAttrs={row.attrs()} let:rowAttrs> + <tr {...rowAttrs}> + {#each row.cells as cell (cell.id)} + {@const materialisedCell = cell.render()} + <Subscribe attrs={cell.attrs()} let:attrs> + <td {...attrs} class="whitespace-nowrap px-2 py-2 text-sm"> + {#if cell.id === "name"} + <span class="link" title="Open project" on:click={on_open_project} on:keypress={on_open_project}> + <Render of={materialisedCell} /> + </span> + {:else if cell.id === "status"} + <ProjectStatusBadge status={materialisedCell.toString()} /> + {:else} + <Render of={materialisedCell} /> + {/if} + </td> + </Subscribe> + {/each} + </tr> + </Subscribe> + {/each} </tbody> </table> </div> diff --git a/code/app/src/routes/(main)/(public)/portal/+page.svelte b/code/app/src/routes/(main)/(public)/portal/+page.svelte new file mode 100644 index 0000000..bd6aa15 --- /dev/null +++ b/code/app/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 "$help/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/app/src/routes/(main)/(public)/portal/+page.ts b/code/app/src/routes/(main)/(public)/portal/+page.ts new file mode 100644 index 0000000..49bf3db --- /dev/null +++ b/code/app/src/routes/(main)/(public)/portal/+page.ts @@ -0,0 +1,10 @@ +import type { PortalMessage } from '$configuration'; +import { redirect } from '@sveltejs/kit'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ url }) => { + const queryParams = new URLSearchParams(url.search); + const message = queryParams.get("msg") as PortalMessage; + if (!message) throw redirect(302, "/"); + return { message }; +};
\ No newline at end of file diff --git a/code/app/src/routes/(main)/(public)/reset-password/+page.svelte b/code/app/src/routes/(main)/(public)/reset-password/+page.svelte index 55859f6..a45ccdd 100644 --- a/code/app/src/routes/(main)/(public)/reset-password/+page.svelte +++ b/code/app/src/routes/(main)/(public)/reset-password/+page.svelte @@ -12,7 +12,7 @@ }; const formError = new FormError(); - const resetRequests = new PasswordResetService(); + const passwordResetService = PasswordResetService.resolve(); let loading = false; let showSuccessAlert = false; @@ -23,7 +23,7 @@ showSuccessAlert = false; showErrorAlert = false; loading = true; - const response = await resetRequests.create_request_async(formData.email.value); + const response = await passwordResetService.create_request_async(formData.email.value); loading = false; if (response.isCreated) { showSuccessAlert = true; @@ -37,17 +37,12 @@ } } } else { - formError.title = $LL.unexpectedError(); - formError.subtitle = $LL.tryAgainSoon(); + formError.set($LL.unexpectedError(), $LL.tryAgainSoon()); } showErrorAlert = formError.has_error() && !showSuccessAlert; } </script> -<svlete:head> - <title>Reset password - Greatoffice</title> -</svlete:head> - <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"> 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 index 8f817bf..27a1af5 100644 --- a/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.svelte +++ b/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.svelte @@ -1,14 +1,15 @@ <script lang="ts"> - import {onMount} from "svelte"; + 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"; + 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 passwordResets = new PasswordResetService(); + const passwordResetService = PasswordResetService.resolve(); + const formData = { newPassword: { value: "", @@ -24,7 +25,7 @@ async function submitFormAsync() { if (!canSubmit) return; loading = true; - const request = await passwordResets.fulfill_request_async(data.resetRequestId, formData.newPassword.value); + 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) { @@ -33,7 +34,7 @@ } onMount(async () => { - const response = await passwordResets.request_is_valid_async(data.resetRequestId); + const response = await passwordResetService.request_is_valid_async(data.resetRequestId); requestIsInvalid = !response.isValid; finishedPreliminaryLoading = true; }); @@ -57,19 +58,21 @@ <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()}/> + <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()} + 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/> + <Button text={$LL.submit()} type="submit" {loading} fullWidth /> </form> </div> </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 index c4ecb1a..66d4575 100644 --- a/code/app/src/routes/(main)/(public)/sign-in/+page.svelte +++ b/code/app/src/routes/(main)/(public)/sign-in/+page.svelte @@ -8,62 +8,62 @@ 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 loading = false; - let showErrorAlert = false; let messageType: SignInPageMessage | undefined = undefined; - const accountService = new AccountService(); - - const formData = { - username: { - value: "", - errors: [], - }, - password: { - value: "", - errors: [], + const accountService = AccountService.resolve(); + const form = { + fields: { + username: { + value: "", + errors: [], + }, + password: { + value: "", + errors: [], + }, + persist: { + value: false, + errors: [], + }, }, - persist: { - value: false, - errors: [], - }, - as_payload(): LoginPayload { + error: new FormError(), + isLoading: false, + showError: false, + get_payload(): LoginPayload { return { - password: formData.password.value, - username: formData.username.value, - persist: !formData.persist.value, + password: form.fields.password.value, + username: form.fields.username.value, + persist: !form.fields.persist.value, }; }, - }; - - const formError = new FormError(); + 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 searcher = new URLSearchParams(window.location.search); - if (searcher.get(signInPageMessageQueryKey)) { - messageType = searcher.get(signInPageMessageQueryKey) as SignInPageMessage; - searcher.delete(signInPageMessageQueryKey); - history.replaceState(null, "", window.location.origin + window.location.pathname); + 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); } }); - - async function submit_form_async() { - formError.set(); - showErrorAlert = formError.has_error(); - loading = true; - const loginResponse = await accountService.login_async(formData.as_payload()); - if (loginResponse.isLoggedIn) { - await goto("/home"); - } else if (loginResponse.knownProblem) { - formError.set_from_known_problem(loginResponse.knownProblem); - } else { - formError.title = $LL.unexpectedError(); - formError.subtitle = $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"> @@ -106,10 +106,10 @@ </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" _pwKey={signInPageTestKeys.formErrorAlert} /> + {#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={submit_form_async}> + <form class="space-y-6 mt-2" use:pwKey={signInPageTestKeys.signInForm} on:submit|preventDefault={() => form.submit_async()}> <Input id="username" _pwKey={signInPageTestKeys.usernameInput} @@ -117,7 +117,8 @@ type="email" label={$LL.emailAddress()} required - bind:value={formData.username.value} + errors={form.fields.username.errors} + bind:value={form.fields.username.value} /> <Input @@ -128,7 +129,8 @@ _pwKey={signInPageTestKeys.passwordInput} autocomplete="current-password" required - bind:value={formData.password.value} + errors={form.fields.password.errors} + bind:value={form.fields.password.value} /> <div class="flex items-center justify-between"> @@ -136,7 +138,7 @@ id="remember-me" _pwKey={signInPageTestKeys.rememberMeCheckbox} name="remember-me" - bind:checked={formData.persist.value} + bind:checked={form.fields.persist.value} label={$LL.signInPage.notMyComputer()} /> <div class="text-sm"> @@ -146,7 +148,7 @@ </div> </div> - <Button text={$LL.submit()} fullWidth type="submit" {loading} /> + <Button text={$LL.submit()} fullWidth type="submit" loading={form.isLoading} /> </form> </div> </div> diff --git a/code/app/src/routes/(main)/+layout.server.ts b/code/app/src/routes/(main)/+layout.server.ts index 4199d7f..b040b8f 100644 --- a/code/app/src/routes/(main)/+layout.server.ts +++ b/code/app/src/routes/(main)/+layout.server.ts @@ -1,35 +1,41 @@ -import {api_base, CookieNames} from "$configuration"; -import {log_debug, log_error} from "$help/logger"; -import {error, redirect} from "@sveltejs/kit"; -import {Temporal} from "temporal-polyfill"; -import type {LayoutServerLoad} from "./$types"; +import { api_base, CookieNames } from "$configuration"; +import { cached_result_async, CacheKeys } from "$help/cache"; +import { log_debug, log_error } from "$help/logger"; +import { md5 } from "$help/md5"; +import { error, redirect } from "@sveltejs/kit"; +import type { LayoutServerLoad } from "./$types"; -export const load: LayoutServerLoad = async ({url, request, route, cookies, locals, fetch}) => { - console.log(url.toString()); +export const load: LayoutServerLoad = async ({ route, cookies, locals, fetch }) => { const isBaseRoute = route.id === "/(main)"; - const isPublicRoute = (route.id?.startsWith("/(main)/(public)") || isBaseRoute) ?? true; + const isPortalRoute = route.id === "/(main)/(public)/portal"; + const isPublicRoute = (isBaseRoute || (route.id?.startsWith("/(main)/(public)") ?? false)) ?? true; const sessionCookieValue = cookies.get(CookieNames.session); - const hasSessionCookie = (sessionCookieValue?.length > 0 ?? false); - const sessionIsValid = hasSessionCookie && (await cached_result_async<Response>("sessionCheck", 120, () => fetch(api_base("_/is-authenticated"), { - headers: { - Cookie: CookieNames.session + "=" + sessionCookieValue, - }, - }).catch((e) => { - log_error(e); - throw error(503, { - message: "We are experiencing a service disruption! Have patience while we resolve the issue.", - }); - }))).ok; + let sessionIsValid = false; + if ((sessionCookieValue?.length > 0 ?? false)) { + const sessionHash = md5(sessionCookieValue); + sessionIsValid = (await cached_result_async<Response>(sessionHash + "_" + CacheKeys.isAuthenticated, 120, () => fetch(api_base("_/is-authenticated"), { + headers: { + Cookie: CookieNames.session + "=" + sessionCookieValue, + }, + }).catch((e) => { + log_error(e); + throw error(503, { + message: "We are experiencing a service disruption! Have patience while we resolve the issue.", + }); + }))).ok; + } log_debug("Base Layout loaded", { sessionIsValid, isPublicRoute, + isBaseRoute, + isPortalRoute, routeId: route.id, }); - if (sessionIsValid && isPublicRoute) { + if (sessionIsValid && isPublicRoute && !isPortalRoute) { throw redirect(302, "/home"); - } else if (isBaseRoute || !sessionIsValid && !isPublicRoute) { + } else if (!isPortalRoute && (isBaseRoute || !sessionIsValid && !isPublicRoute)) { throw redirect(302, "/sign-in"); } @@ -37,29 +43,3 @@ export const load: LayoutServerLoad = async ({url, request, route, cookies, loca locale: locals.locale, }; }; - -let resultCache = {}; - -async function cached_result_async<T>(key: string, staleAfterSeconds: number, get_result: any, forceRefresh: boolean = false) { - if (!resultCache[key]) { - resultCache[key] = { - l: 0, - c: undefined as T, - }; - } - const staleEpoch = ((resultCache[key]?.l ?? 0) + staleAfterSeconds); - const isStale = forceRefresh || (staleEpoch < Temporal.Now.instant().epochSeconds); - if (isStale || !resultCache[key]?.c) { - resultCache[key].c = await get_result(); - resultCache[key].l = Temporal.Now.instant().epochSeconds; - } - - log_debug("Ran cached_result_async", { - cacheKey: key, - isStale, - cache: resultCache[key], - staleEpoch, - }); - - return resultCache[key].c as T; -}
\ No newline at end of file diff --git a/code/app/src/services/abstractions/IAccountService.ts b/code/app/src/services/abstractions/IAccountService.ts index 0645eb6..ad0c45b 100644 --- a/code/app/src/services/abstractions/IAccountService.ts +++ b/code/app/src/services/abstractions/IAccountService.ts @@ -1,17 +1,13 @@ -import type {KnownProblem} from "$models/internal/KnownProblem"; -import type {Writable} from "svelte/store"; +import type { KnownProblem } from "$models/internal/KnownProblem"; +import type { Writable } from "svelte/store"; export interface IAccountService { session: Writable<Session>, - login_async(payload: LoginPayload): Promise<LoginResponse>, - logout_async(): Promise<void>, - + end_session(callback: Function): Promise<void>, create_account_async(payload: CreateAccountPayload): Promise<CreateAccountResponse>, - delete_current_async(): Promise<DeleteAccountResponse>, - update_current_async(payload: UpdateAccountPayload): Promise<UpdateAccountResponse>, } diff --git a/code/app/src/services/account-service.ts b/code/app/src/services/account-service.ts index 92c6126..330e588 100644 --- a/code/app/src/services/account-service.ts +++ b/code/app/src/services/account-service.ts @@ -1,12 +1,12 @@ -import {http_delete_async, http_get_async, http_post_async} from "$api/_fetch"; -import {browser} from "$app/environment"; -import {api_base, CookieNames, StorageKeys} from "$configuration"; -import {is_known_problem} from "$models/internal/KnownProblem"; -import {log_debug} from "$help/logger"; -import {StoreType, writable_persistent} from "$help/persistent-store"; -import {get} from "svelte/store"; -import type {Writable} from "svelte/store"; -import {Temporal} from "temporal-polyfill"; +import { http_delete_async, http_get_async, http_post_async } from "$api/_fetch"; +import { browser } from "$app/environment"; +import { api_base, CookieNames, StorageKeys } from "$configuration"; +import { is_known_problem } from "$models/internal/KnownProblem"; +import { log_debug } from "$help/logger"; +import { StoreType, writable_persistent } from "$help/persistent-store"; +import { get } from "svelte/store"; +import type { Writable } from "svelte/store"; +import { Temporal } from "temporal-polyfill"; import type { CreateAccountPayload, CreateAccountResponse, @@ -38,6 +38,10 @@ export class AccountService implements IAccountService { } } + static resolve(): IAccountService { + return new AccountService(); + } + async refresh_session(forceRefresh: boolean = false): Promise<void> { if (!this.session) return; const currentValue = get(this.session); @@ -67,7 +71,7 @@ export class AccountService implements IAccountService { async login_async(payload: LoginPayload): Promise<LoginResponse> { const response = await http_post_async(api_base("_/account/login"), payload); - if (response.ok) return {isLoggedIn: true}; + if (response.ok) return { isLoggedIn: true }; if (is_known_problem(response)) return { isLoggedIn: false, knownProblem: await response.json(), @@ -90,7 +94,7 @@ export class AccountService implements IAccountService { async create_account_async(payload: CreateAccountPayload): Promise<CreateAccountResponse> { const response = await http_post_async(api_base("_/account/create"), payload); - if (response.ok) return {isCreated: true}; + if (response.ok) return { isCreated: true }; if (is_known_problem(response)) return { isCreated: false, knownProblem: await response.json(), @@ -109,7 +113,7 @@ export class AccountService implements IAccountService { async update_current_async(payload: UpdateAccountPayload): Promise<UpdateAccountResponse> { const response = await http_post_async(api_base("_/account/update"), payload); - if (response.ok) return {isUpdated: true}; + if (response.ok) return { isUpdated: true }; if (is_known_problem(response)) return { isUpdated: false, knownProblem: await response.json(), diff --git a/code/app/src/services/password-reset-service.ts b/code/app/src/services/password-reset-service.ts index ab3a953..d7700b3 100644 --- a/code/app/src/services/password-reset-service.ts +++ b/code/app/src/services/password-reset-service.ts @@ -1,6 +1,6 @@ -import {http_get_async, http_post_async} from "$api/_fetch"; -import {api_base} from "$configuration"; -import {is_known_problem} from "$models/internal/KnownProblem"; +import { http_get_async, http_post_async } from "$api/_fetch"; +import { api_base } from "$configuration"; +import { is_known_problem } from "$models/internal/KnownProblem"; import type { CreateRequestResponse, FulfillRequestResponse, @@ -9,9 +9,12 @@ import type { } from "./abstractions/IPasswordResetService"; export class PasswordResetService implements IPasswordResetService { + static resolve(): IPasswordResetService { + return new PasswordResetService(); + } async create_request_async(email: string): Promise<CreateRequestResponse> { - const response = await http_post_async(api_base("_/password-reset-request/create"), {email}); - if (response.ok) return {isCreated: true}; + const response = await http_post_async(api_base("_/password-reset-request/create"), { email }); + if (response.ok) return { isCreated: true }; if (is_known_problem(response)) return { isCreated: false, knownProblem: await response.json(), @@ -23,8 +26,8 @@ export class PasswordResetService implements IPasswordResetService { } async fulfill_request_async(id: string, newPassword: string): Promise<FulfillRequestResponse> { - const response = await http_post_async(api_base("_/password-reset-request/fulfill"), {id: id, newPassword}); - if (response.ok) return {isFulfilled: true}; + const response = await http_post_async(api_base("_/password-reset-request/fulfill"), { id: id, newPassword }); + if (response.ok) return { isFulfilled: true }; if (is_known_problem(response)) return { isFulfilled: false, knownProblem: await response.json(), diff --git a/code/app/src/services/settings-service.ts b/code/app/src/services/settings-service.ts index 20053a9..7f8cb2b 100644 --- a/code/app/src/services/settings-service.ts +++ b/code/app/src/services/settings-service.ts @@ -1,6 +1,9 @@ -import type {ISettingsService} from "./abstractions/ISettingsService"; +import type { ISettingsService } from "./abstractions/ISettingsService"; export class SettingsService implements ISettingsService { + static resolve(): ISettingsService { + return new SettingsService(); + } get_user_settings(): Promise<void> { throw new Error("Method not implemented."); } |
