From 0005595703b2f3f7083ce4ba19bf5770057c75bd Mon Sep 17 00:00:00 2001 From: ivarlovlie Date: Tue, 25 Oct 2022 17:51:37 +0800 Subject: . --- code/app/src/lib/api/_fetch.ts | 121 +++++++++++++++ code/app/src/lib/api/account/index.ts | 39 +++++ .../lib/api/account/models/CreateAccountPayload.ts | 4 + .../app/src/lib/api/account/models/LoginPayload.ts | 5 + .../lib/api/account/models/UpdateProfilePayload.ts | 4 + code/app/src/lib/api/internal-fetch.ts | 170 --------------------- .../src/lib/api/password-reset-request/index.ts | 17 +++ code/app/src/lib/api/root.ts | 12 +- code/app/src/lib/api/time-entry.ts | 83 ---------- code/app/src/lib/api/user.ts | 47 ------ code/app/src/lib/components/checkbox.svelte | 7 +- code/app/src/lib/components/combobox.svelte | 57 ++++--- code/app/src/lib/components/input.svelte | 3 + code/app/src/lib/components/textarea.svelte | 21 ++- code/app/src/lib/i18n/en/app/index.ts | 4 +- code/app/src/lib/i18n/i18n-types.ts | 11 +- code/app/src/lib/i18n/nb/app/index.ts | 3 +- code/app/src/lib/models/base/SessionData.ts | 5 + .../lib/models/internal/CreateAccountPayload.ts | 4 - code/app/src/lib/models/internal/ErrorResult.ts | 2 +- .../lib/models/internal/IInternalFetchRequest.ts | 6 - .../lib/models/internal/IInternalFetchResponse.ts | 6 - code/app/src/lib/models/internal/ISession.ts | 2 +- .../src/lib/models/internal/IValidationResult.ts | 31 ---- code/app/src/lib/models/internal/LoginPayload.ts | 5 - .../lib/models/internal/UnwrappedEntryDateTime.ts | 9 -- .../lib/models/internal/UpdateProfilePayload.ts | 4 - .../lib/services/abstractions/ISettingsService.ts | 3 + code/app/src/lib/services/settings-service.ts | 0 code/app/src/lib/session.ts | 16 +- code/app/src/lib/swr.ts | 6 + 31 files changed, 292 insertions(+), 415 deletions(-) create mode 100644 code/app/src/lib/api/_fetch.ts create mode 100644 code/app/src/lib/api/account/index.ts create mode 100644 code/app/src/lib/api/account/models/CreateAccountPayload.ts create mode 100644 code/app/src/lib/api/account/models/LoginPayload.ts create mode 100644 code/app/src/lib/api/account/models/UpdateProfilePayload.ts delete mode 100644 code/app/src/lib/api/internal-fetch.ts create mode 100644 code/app/src/lib/api/password-reset-request/index.ts delete mode 100644 code/app/src/lib/api/time-entry.ts delete mode 100644 code/app/src/lib/api/user.ts create mode 100644 code/app/src/lib/models/base/SessionData.ts delete mode 100644 code/app/src/lib/models/internal/CreateAccountPayload.ts delete mode 100644 code/app/src/lib/models/internal/IInternalFetchRequest.ts delete mode 100644 code/app/src/lib/models/internal/IInternalFetchResponse.ts delete mode 100644 code/app/src/lib/models/internal/IValidationResult.ts delete mode 100644 code/app/src/lib/models/internal/LoginPayload.ts delete mode 100644 code/app/src/lib/models/internal/UnwrappedEntryDateTime.ts delete mode 100644 code/app/src/lib/models/internal/UpdateProfilePayload.ts create mode 100644 code/app/src/lib/services/abstractions/ISettingsService.ts create mode 100644 code/app/src/lib/services/settings-service.ts create mode 100644 code/app/src/lib/swr.ts (limited to 'code/app/src/lib') diff --git a/code/app/src/lib/api/_fetch.ts b/code/app/src/lib/api/_fetch.ts new file mode 100644 index 0000000..b28e398 --- /dev/null +++ b/code/app/src/lib/api/_fetch.ts @@ -0,0 +1,121 @@ +import { Temporal } from "temporal-polyfill"; +import { clear_session_data } from "$lib/session"; +import type { Result } from "rustic"; +import { Err, Ok } from "rustic"; +import { redirect } from "@sveltejs/kit"; +import { browser } from "$app/environment"; +import { goto } from "$app/navigation"; +import { SignInPageMessage, signInPageMessageQueryKey } from "$routes/(main)/(public)/sign-in"; +import { log_error } from "$lib/logger"; + +export async function http_post_async(url: string, body?: object | string, timeout = -1, skip_401_check = false, abort_signal?: AbortSignal): Promise> { + const init = make_request_init("post", body, abort_signal); + const response = await internal_fetch_async({ url, init, timeout }); + if (!skip_401_check && await redirect_if_401_async(response)) return Err("Server returned 401"); + return make_response_async(response); +} + +export async function http_get_async(url: string, timeout = -1, skip_401_check = false, abort_signal?: AbortSignal): Promise, string>> { + const init = make_request_init("get", undefined, abort_signal); + const response = await internal_fetch_async({ url, init, timeout }); + if (!skip_401_check && await redirect_if_401_async(response)) return Err("Server returned 401"); + return make_response_async(response); +} + +export async function http_delete_async(url: string, body?: object | string, timeout = -1, skip_401_check = false, abort_signal?: AbortSignal): Promise, string>> { + const init = make_request_init("delete", body, abort_signal); + const response = await internal_fetch_async({ url, init, timeout }); + if (!skip_401_check && await redirect_if_401_async(response)) return Err("Server returned 401"); + return make_response_async(response); +} + +async function internal_fetch_async(request: InternalFetchRequest): Promise { + if (!request.init) throw new Error("request.init is required"); + const fetch_request = new Request(request.url, request.init); + let response: any; + + try { + if (request.timeout && request.timeout > 500) { + response = await Promise.race([ + fetch(fetch_request), + new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), request.timeout)) + ]); + } else { + response = await fetch(fetch_request); + } + } catch (error: any) { + log_error(error); + if (error.message === "Timeout") { + console.error("Request timed out"); + } else if (error.message === "Network request failed") { + console.error("No internet connection"); + } else { + throw error; + } + } + + return response; +} + +async function redirect_if_401_async(response: Response): Promise { + if (response.status === 401) { + const redirectUrl = `/sign-in?${signInPageMessageQueryKey}=${SignInPageMessage.LOGGED_OUT}`; + clear_session_data(); + if (browser) { + await goto(redirectUrl) + } else { + throw redirect(307, redirectUrl); + } + } + return false; +} + +async function make_response_async(response: Response): Promise, string>> { + const result = { + ok: response.ok, + status: response.status, + http_response: response, + } as InternalFetchResponse; + + if (response.status !== 204) { + try { + result.data = await response.json() as T; + } catch (error) { + log_error("", { error, result }) + return Err("Deserialisation threw"); + } + } + return Ok(result); +} + +function make_request_init(method: string, body?: any, signal?: AbortSignal): RequestInit { + const init = { + method, + signal, + headers: { + "X-TimeZone": Temporal.Now.timeZone().id, + } + } as RequestInit; + + if (body) { + init.body = JSON.stringify(body); + init.headers["Content-Type"] = "application/json;charset=UTF-8" + } + + return init; +} + + +export type InternalFetchRequest = { + url: string, + init: RequestInit, + timeout?: number + retry_count?: number, +} + +export type InternalFetchResponse = { + ok: boolean, + status: number, + data: T | undefined, + http_response: Response +} \ No newline at end of file diff --git a/code/app/src/lib/api/account/index.ts b/code/app/src/lib/api/account/index.ts new file mode 100644 index 0000000..305bd9f --- /dev/null +++ b/code/app/src/lib/api/account/index.ts @@ -0,0 +1,39 @@ +import { api_base } from "$lib/configuration"; +import { http_delete_async, http_get_async, http_post_async, type InternalFetchResponse } from "../_fetch"; +import type { LoginPayload } from "$lib/api/account/models/LoginPayload"; +import type { Result } from "rustic"; +import { isOk, Ok, Err } from "rustic"; +import type { SessionData } from "$lib/models/base/SessionData"; +import type { CreateAccountPayload } from "./models/CreateAccountPayload"; +import type { UpdateProfilePayload } from "./models/UpdateProfilePayload"; +import type { ErrorResult } from "$lib/models/internal/ErrorResult"; + +export const http_account = { + async login_async(payload: LoginPayload): Promise>> { + const response = await http_post_async>(api_base("_/account/login"), payload); + if (isOk(response)) { + return Ok(); + } + return Err(response.data); + }, + logout_async(): Promise> { + return http_get_async(api_base("_/account/logout")); + }, + delete_account_async(): Promise { + return http_delete_async(api_base("_/account/delete")); + }, + update_profile_async(payload: UpdateProfilePayload): Promise { + if (!payload.password && !payload.username) throw new Error("Password and Username is empty"); + return http_post_async(api_base("_/account/update"), payload); + }, + create_account_async(payload: CreateAccountPayload): Promise { + if (!payload.password && !payload.username) throw new Error("Password and Username is empty"); + return http_post_async(api_base("_/account/create"), payload); + }, + async get_profile_async(suppress_401: boolean): Promise> { + const response = await http_get_async(api_base("_/account"), 0, true); + if (isOk(response)) { + return Ok(response.data.data); + } + } +} \ No newline at end of file diff --git a/code/app/src/lib/api/account/models/CreateAccountPayload.ts b/code/app/src/lib/api/account/models/CreateAccountPayload.ts new file mode 100644 index 0000000..d116308 --- /dev/null +++ b/code/app/src/lib/api/account/models/CreateAccountPayload.ts @@ -0,0 +1,4 @@ +export interface CreateAccountPayload { + username: string, + password: string +} diff --git a/code/app/src/lib/api/account/models/LoginPayload.ts b/code/app/src/lib/api/account/models/LoginPayload.ts new file mode 100644 index 0000000..beb96cf --- /dev/null +++ b/code/app/src/lib/api/account/models/LoginPayload.ts @@ -0,0 +1,5 @@ +export interface LoginPayload { + username: string, + password: string, + persist: boolean +} diff --git a/code/app/src/lib/api/account/models/UpdateProfilePayload.ts b/code/app/src/lib/api/account/models/UpdateProfilePayload.ts new file mode 100644 index 0000000..d2983ff --- /dev/null +++ b/code/app/src/lib/api/account/models/UpdateProfilePayload.ts @@ -0,0 +1,4 @@ +export interface UpdateProfilePayload { + username?: string, + password?: string, +} diff --git a/code/app/src/lib/api/internal-fetch.ts b/code/app/src/lib/api/internal-fetch.ts deleted file mode 100644 index b21d669..0000000 --- a/code/app/src/lib/api/internal-fetch.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { Temporal } from "temporal-polyfill"; -import { clear_session_data } from "$lib/session"; -import { resolve_references } from "$lib/helpers"; -import type { IInternalFetchResponse } from "$lib/models/IInternalFetchResponse"; -import type { IInternalFetchRequest } from "$lib/models/IInternalFetchRequest"; -import { redirect } from "@sveltejs/kit"; - -export async function http_post(url: string, body?: object | string, timeout = -1, skip_401_check = false, abort_signal?: AbortSignal): Promise { - const init = { - method: "post", - } as RequestInit; - - if (abort_signal) { - init.signal = abort_signal; - } - - if (body) { - init.headers = { - "Content-Type": "application/json;charset=UTF-8", - }; - init.body = JSON.stringify(body); - } - - const response = await internal_fetch({ url, init, timeout }); - const result = {} as IInternalFetchResponse; - - if (!skip_401_check && await is_401(response)) return result; - - result.ok = response.ok; - result.status = response.status; - result.http_response = response; - - if (response.status !== 204) { - try { - const ct = response.headers.get("Content-Type")?.toString() ?? ""; - if (ct.startsWith("application/json")) { - const data = await response.json(); - result.data = resolve_references(data); - } else if (ct.startsWith("text/plain")) { - const text = await response.text(); - result.data = text as string; - } - } catch { - // Ignored - } - } - - return result; -} - -export async function http_get(url: string, timeout = -1, skip_401_check = false, abort_signal?: AbortSignal): Promise { - const init = { - method: "get", - } as RequestInit; - - if (abort_signal) { - init.signal = abort_signal; - } - - const response = await internal_fetch({ url, init, timeout }); - const result = {} as IInternalFetchResponse; - - if (!skip_401_check && await is_401(response)) return result; - - result.ok = response.ok; - result.status = response.status; - result.http_response = response; - - if (response.status !== 204) { - try { - const ct = response.headers.get("Content-Type")?.toString() ?? ""; - if (ct.startsWith("application/json")) { - const data = await response.json(); - result.data = resolve_references(data); - } else if (ct.startsWith("text/plain")) { - const text = await response.text(); - result.data = text as string; - } - } catch { - // Ignored - } - } - - return result; -} - -export async function http_delete(url: string, body?: object | string, timeout = -1, skip_401_check = false, abort_signal?: AbortSignal): Promise { - const init = { - method: "delete", - } as RequestInit; - - if (abort_signal) { - init.signal = abort_signal; - } - - if (body) { - init.headers = { - "Content-Type": "application/json;charset=UTF-8", - }; - init.body = JSON.stringify(body); - } - - const response = await internal_fetch({ url, init, timeout }); - const result = {} as IInternalFetchResponse; - - if (!skip_401_check && await is_401(response)) return result; - - result.ok = response.ok; - result.status = response.status; - result.http_response = response; - - if (response.status !== 204) { - try { - const ct = response.headers.get("Content-Type")?.toString() ?? ""; - if (ct.startsWith("application/json")) { - const data = await response.json(); - result.data = resolve_references(data); - } else if (ct.startsWith("text/plain")) { - const text = await response.text(); - result.data = text as string; - } - } catch (error) { - // ignored - } - } - - return result; -} - -async function internal_fetch(request: IInternalFetchRequest): Promise { - if (!request.init) request.init = {}; - request.init.credentials = "include"; - request.init.headers = { - "X-TimeZone": Temporal.Now.timeZone().id, - ...request.init.headers - }; - - const fetch_request = new Request(request.url, request.init); - let response: any; - - try { - if (request.timeout && request.timeout > 500) { - response = await Promise.race([ - fetch(fetch_request), - new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), request.timeout)) - ]); - } else { - response = await fetch(fetch_request); - } - } catch (error: any) { - console.log(error); - if (error.message === "Timeout") { - console.error("Request timed out"); - } else if (error.message === "Network request failed") { - console.error("No internet connection"); - } else { - throw error; // rethrow other unexpected errors - } - } - - return response; -} - -async function is_401(response: Response): Promise { - if (response.status === 401) { - clear_session_data(); - throw redirect(307, "/login"); - } - return false; -} diff --git a/code/app/src/lib/api/password-reset-request/index.ts b/code/app/src/lib/api/password-reset-request/index.ts new file mode 100644 index 0000000..9d6f0dc --- /dev/null +++ b/code/app/src/lib/api/password-reset-request/index.ts @@ -0,0 +1,17 @@ +import { api_base } from "$lib/configuration"; +import { http_get_async, http_post_async, type InternalFetchResponse } from "../_fetch"; + +export const http_password_reset_request = { + create_forgot_password_request(username: string): Promise { + if (!username) throw new Error("Username is empty"); + return http_get_async(api_base("_/forgot-password-requests/create?username=" + username)); + }, + check_forgot_password_request(public_id: string): Promise { + if (!public_id) throw new Error("Id is empty"); + return http_get_async(api_base("_/forgot-password-requests/is-valid?id=" + public_id)); + }, + fulfill_forgot_password_request(public_id: string, newPassword: string): Promise { + if (!public_id) throw new Error("Id is empty"); + return http_post_async(api_base("_/forgot-password-requests/fulfill"), { id: public_id, newPassword }); + }, +} \ No newline at end of file diff --git a/code/app/src/lib/api/root.ts b/code/app/src/lib/api/root.ts index 3e5bda2..661f24b 100644 --- a/code/app/src/lib/api/root.ts +++ b/code/app/src/lib/api/root.ts @@ -1,6 +1,12 @@ -import {http_post} from "$lib/api/internal-fetch"; -import {api_base} from "$lib/configuration"; +import { http_get_async, http_post_async } from "$lib/api/_fetch"; +import { api_base } from "$lib/configuration"; +import type { IInternalFetchResponse } from "$lib/models/internal/IInternalFetchResponse"; +import type { Result } from "rustic"; export function server_log(message: string): void { - http_post(api_base("_/api/log"), message); + http_post_async(api_base("_/api/log"), message); } + +export function server_version(): Promise, string>> { + return http_get_async(api_base("/version.txt")); +} \ No newline at end of file diff --git a/code/app/src/lib/api/time-entry.ts b/code/app/src/lib/api/time-entry.ts deleted file mode 100644 index faedb48..0000000 --- a/code/app/src/lib/api/time-entry.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { api_base } from "$lib/configuration"; -import { is_guid } from "$lib/helpers"; -import { http_delete, http_get, http_post } from "./internal-fetch"; -import type { WorkCategory } from "$lib/models/work/WorkCategory"; -import type { WorkLabel } from "$lib/models/work/WorkLabel"; -import type { WorkEntry } from "$lib/models/work/WorkEntry"; -import type { WorkQuery } from "$lib/models/work/WorkQuery"; -import type { IInternalFetchResponse } from "$lib/models/internal/IInternalFetchResponse"; - - -// ENTRIES - -export async function create_time_entry(payload: WorkEntry): Promise { - return http_post(api_base("v1/entries/create"), payload); -} - -export async function get_time_entry(entryId: string): Promise { - if (is_guid(entryId)) { - return http_get(api_base("v1/entries/" + entryId)); - } - throw new Error("entryId is not a valid guid."); -} - -export async function get_time_entries(entryQuery: WorkQuery): Promise { - return http_post(api_base("v1/entries/query"), entryQuery); -} - -export async function delete_time_entry(id: string): Promise { - if (!is_guid(id)) throw new Error("id is not a valid guid"); - return http_delete(api_base("v1/entries/" + id + "/delete")); -} - -export async function update_time_entry(entryDto: WorkEntry): Promise { - if (!is_guid(entryDto.id ?? "")) throw new Error("id is not a valid guid"); - if (!entryDto.category) throw new Error("category is empty"); - if (!entryDto.stop) throw new Error("stop is empty"); - if (!entryDto.start) throw new Error("start is empty"); - return http_post(api_base("v1/entries/update"), entryDto); -} - -// LABELS -export async function create_time_label(labelDto: WorkLabel): Promise { - return http_post(api_base("v1/labels/create"), labelDto); -} - -export async function get_time_labels(): Promise { - return http_get(api_base("v1/labels")); -} - -export async function delete_time_label(id: string): Promise { - if (!is_guid(id)) throw new Error("id is not a valid guid"); - return http_delete(api_base("v1/labels/" + id + "/delete")); -} - -export async function update_time_label(labelDto: WorkLabel): Promise { - if (!is_guid(labelDto.id ?? "")) throw new Error("id is not a valid guid"); - if (!labelDto.name) throw new Error("name is empty"); - if (!labelDto.color) throw new Error("color is empty"); - return http_post(api_base("v1/labels/update"), labelDto); -} - -// CATEGORIES -export async function create_time_category(category: WorkCategory): Promise { - if (!category.name) throw new Error("name is empty"); - if (!category.color) throw new Error("color is empty"); - return http_post(api_base("v1/categories/create"), category); -} - -export async function get_time_categories(): Promise { - return http_get(api_base("v1/categories")); -} - -export async function delete_time_category(id: string): Promise { - if (!is_guid(id)) throw new Error("id is not a valid guid"); - return http_delete(api_base("v1/categories/" + id + "/delete")); -} - -export async function update_time_category(category: WorkCategory): Promise { - if (!is_guid(category.id ?? "")) throw new Error("id is not a valid guid"); - if (!category.name) throw new Error("name is empty"); - if (!category.color) throw new Error("color is empty"); - return http_post(api_base("v1/categories/update"), category); -} diff --git a/code/app/src/lib/api/user.ts b/code/app/src/lib/api/user.ts deleted file mode 100644 index f08fb6d..0000000 --- a/code/app/src/lib/api/user.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { api_base } from "$lib/configuration"; -import { http_delete, http_get, http_post } from "./internal-fetch"; -import type { LoginPayload } from "$lib/models/internal/LoginPayload"; -import type { UpdateProfilePayload } from "$lib/models/internal/UpdateProfilePayload"; -import type { CreateAccountPayload } from "$lib/models/internal/CreateAccountPayload"; -import type { IInternalFetchResponse } from "$lib/models/internal/IInternalFetchResponse"; - -export async function login(payload: LoginPayload): Promise { - return http_post(api_base("_/account/login"), payload); -} - -export async function logout(): Promise { - return http_get(api_base("_/account/logout")); -} - -export async function create_forgot_password_request(username: string): Promise { - if (!username) throw new Error("Username is empty"); - return http_get(api_base("_/forgot-password-requests/create?username=" + username)); -} - -export async function check_forgot_password_request(public_id: string): Promise { - if (!public_id) throw new Error("Id is empty"); - return http_get(api_base("_/forgot-password-requests/is-valid?id=" + public_id)); -} - -export async function fulfill_forgot_password_request(public_id: string, newPassword: string): Promise { - if (!public_id) throw new Error("Id is empty"); - return http_post(api_base("_/forgot-password-requests/fulfill"), { id: public_id, newPassword }); -} - -export async function delete_account(): Promise { - return http_delete(api_base("_/account/delete")); -} - -export async function update_profile(payload: UpdateProfilePayload): Promise { - if (!payload.password && !payload.username) throw new Error("Password and Username is empty"); - return http_post(api_base("_/account/update"), payload); -} - -export async function create_account(payload: CreateAccountPayload): Promise { - if (!payload.password && !payload.username) throw new Error("Password and Username is empty"); - return http_post(api_base("_/account/create"), payload); -} - -export async function get_profile_for_active_check(): Promise { - return http_get(api_base("_/account"), 0, true); -} diff --git a/code/app/src/lib/components/checkbox.svelte b/code/app/src/lib/components/checkbox.svelte index b2fcddb..12ebedb 100644 --- a/code/app/src/lib/components/checkbox.svelte +++ b/code/app/src/lib/components/checkbox.svelte @@ -7,6 +7,7 @@ export let name: string | undefined = undefined; export let disabled: boolean | null = null; export let checked: boolean; + export let required: boolean | null = null; export let _pwKey: string | undefined = undefined; @@ -16,9 +17,13 @@ use:pwKey={_pwKey} {disabled} {id} + {required} type="checkbox" bind:checked class="h-4 w-4 text-teal-600 focus:ring-teal-500 border-gray-300 rounded" /> - + diff --git a/code/app/src/lib/components/combobox.svelte b/code/app/src/lib/components/combobox.svelte index ee69917..4e7b1cd 100644 --- a/code/app/src/lib/components/combobox.svelte +++ b/code/app/src/lib/components/combobox.svelte @@ -7,7 +7,7 @@