diff options
45 files changed, 538 insertions, 520 deletions
diff --git a/code/api/src/Data/Models/KnownProblemModel.cs b/code/api/src/Data/Models/KnownProblemModel.cs index 445d338..38b3eba 100644 --- a/code/api/src/Data/Models/KnownProblemModel.cs +++ b/code/api/src/Data/Models/KnownProblemModel.cs @@ -12,5 +12,4 @@ public class KnownProblemModel public string Subtitle { get; set; } public Dictionary<string, string> Errors { get; set; } public string TraceId { get; set; } - public string RequestId { get; set; } }
\ No newline at end of file diff --git a/code/api/src/Endpoints/EndpointBase.cs b/code/api/src/Endpoints/EndpointBase.cs index c088976..a2f55a6 100644 --- a/code/api/src/Endpoints/EndpointBase.cs +++ b/code/api/src/Endpoints/EndpointBase.cs @@ -1,8 +1,5 @@ -using System.Diagnostics; - namespace IOL.GreatOffice.Api.Endpoints; -[ApiController] public class EndpointBase : ControllerBase { /// <summary> @@ -13,15 +10,12 @@ public class EndpointBase : ControllerBase Id = User.FindFirstValue(AppClaims.USER_ID).AsGuid(), }; - public ObjectResult KnownProblem(string title = default, string subtitle = default, Dictionary<string, string> errors = default) { - return new ObjectResult(new KnownProblemModel { + public ActionResult KnownProblem(string title = default, string subtitle = default, Dictionary<string, string> errors = default) { + return BadRequest(new KnownProblemModel { Title = title, Subtitle = subtitle, Errors = errors, - TraceId = Activity.Current?.Id, - RequestId = HttpContext.TraceIdentifier - }) { - StatusCode = (int) HttpStatusCode.BadRequest - }; + TraceId = HttpContext.TraceIdentifier + }); } }
\ No newline at end of file diff --git a/code/api/src/Endpoints/V1/Labels/CreateLabelRoute.cs b/code/api/src/Endpoints/V1/Labels/CreateLabelRoute.cs index 31ef7d0..4fe418b 100644 --- a/code/api/src/Endpoints/V1/Labels/CreateLabelRoute.cs +++ b/code/api/src/Endpoints/V1/Labels/CreateLabelRoute.cs @@ -1,12 +1,10 @@ namespace IOL.GreatOffice.Api.Endpoints.V1.Labels; -/// <inheritdoc /> public class CreateLabelRoute : RouteBaseSync.WithRequest<TimeLabel.TimeLabelDto>.WithActionResult<TimeLabel.TimeLabelDto> { private readonly AppDbContext _context; - /// <inheritdoc /> public CreateLabelRoute(AppDbContext context) { _context = context; } diff --git a/code/api/src/Utilities/SwaggerDefaultValues.cs b/code/api/src/Utilities/SwaggerDefaultValues.cs index 4b5c764..5e73fa8 100644 --- a/code/api/src/Utilities/SwaggerDefaultValues.cs +++ b/code/api/src/Utilities/SwaggerDefaultValues.cs @@ -27,7 +27,7 @@ public class SwaggerDefaultValues : IOperationFilter var response = operation.Responses[responseKey]; foreach (var contentType in response.Content.Keys) { - if (!responseType.ApiResponseFormats.Any(x => x.MediaType == contentType)) { + if (responseType.ApiResponseFormats.All(x => x.MediaType != contentType)) { response.Content.Remove(contentType); } } diff --git a/code/api/src/Utilities/SwaggerGenOptionsExtensions.cs b/code/api/src/Utilities/SwaggerGenOptionsExtensions.cs index 0a7e07f..9b70194 100644 --- a/code/api/src/Utilities/SwaggerGenOptionsExtensions.cs +++ b/code/api/src/Utilities/SwaggerGenOptionsExtensions.cs @@ -1,5 +1,5 @@ #nullable enable -using IOL.GreatOffice.Api.Endpoints.V1; +using IOL.GreatOffice.Api.Endpoints; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.Controllers; using Swashbuckle.AspNetCore.SwaggerGen; @@ -10,7 +10,7 @@ public static class SwaggerGenOptionsExtensions { /// <summary> /// Updates Swagger document to support ApiEndpoints.<br/><br/> - /// For controllers inherited from <see cref="V1_EndpointBase"/>:<br/> + /// For controllers inherited from <see cref="EndpointBase"/>:<br/> /// - Replaces action Tag with <c>[namespace]</c><br/> /// </summary> public static void UseApiEndpoints(this SwaggerGenOptions options) { @@ -22,7 +22,7 @@ public static class SwaggerGenOptionsExtensions throw new InvalidOperationException($"Unable to determine tag for endpoint: {api.ActionDescriptor.DisplayName}"); } - if (actionDescriptor.ControllerTypeInfo.GetBaseTypesAndThis().Any(t => t == typeof(V1_EndpointBase))) { + if (actionDescriptor.ControllerTypeInfo.GetBaseTypesAndThis().Any(t => t == typeof(EndpointBase))) { return new[] { actionDescriptor.ControllerTypeInfo.Namespace?.Split('.').Last() }; @@ -34,7 +34,7 @@ public static class SwaggerGenOptionsExtensions } public static IEnumerable<Type> GetBaseTypesAndThis(this Type type) { - Type? current = type; + var current = type; while (current != null) { yield return current; current = current.BaseType; diff --git a/code/app/package.json b/code/app/package.json index 9cba3b4..e88a7fe 100644 --- a/code/app/package.json +++ b/code/app/package.json @@ -30,6 +30,7 @@ "pino-pretty": "^9.1.1", "postcss": "^8.4.18", "postcss-load-config": "^4.0.1", + "rustic": "^1.2.2", "sswr": "^1.7.0", "svelte": "^3.51.0", "svelte-check": "^2.9.2", diff --git a/code/app/pnpm-lock.yaml b/code/app/pnpm-lock.yaml index 9b4fc78..1eaefa8 100644 --- a/code/app/pnpm-lock.yaml +++ b/code/app/pnpm-lock.yaml @@ -17,12 +17,12 @@ specifiers: pino-pretty: ^9.1.1 postcss: ^8.4.18 postcss-load-config: ^4.0.1 + rustic: ^1.2.2 sswr: ^1.7.0 svelte: ^3.51.0 svelte-check: ^2.9.2 svelte-headless-table: ^0.15.1 svelte-preprocess: ^4.10.7 - svelte-select: 5.0.0-beta.31 tailwindcss: ^3.1.8 temporal-polyfill: ^0.0.8 tslib: ^2.4.0 @@ -47,12 +47,12 @@ devDependencies: pino-pretty: 9.1.1 postcss: 8.4.18 postcss-load-config: 4.0.1_postcss@8.4.18 + rustic: 1.2.2 sswr: 1.7.0_svelte@3.51.0 svelte: 3.51.0 svelte-check: 2.9.2_kkxqyz6lqha2hfx26r6uvfpxra svelte-headless-table: 0.15.1_svelte@3.51.0 svelte-preprocess: 4.10.7_6ie472v2wz4cqcxbmxkbhn7ire - svelte-select: 5.0.0-beta.31_c36sqhgzdfaw4kmxogryyrffx4 tailwindcss: 3.1.8_postcss@8.4.18 temporal-polyfill: 0.0.8 tslib: 2.4.0 @@ -93,26 +93,6 @@ packages: engines: {node: '>=14.0.0', npm: '>=6.0.0'} dev: true - /@floating-ui/core/0.7.3: - resolution: {integrity: sha512-buc8BXHmG9l82+OQXOFU3Kr2XQx9ys01U/Q9HMIrZ300iLc8HLMgh7dcCqgYzAzf4BkoQvDcXf5Y+CuEZ5JBYg==} - dev: true - - /@floating-ui/core/1.0.1: - resolution: {integrity: sha512-bO37brCPfteXQfFY0DyNDGB3+IMe4j150KFQcgJ5aBP295p9nBGeHEs/p0czrRbtlHq4Px/yoPXO/+dOCcF4uA==} - dev: true - - /@floating-ui/dom/0.5.4: - resolution: {integrity: sha512-419BMceRLq0RrmTSDxn8hf9R3VCJv2K9PUfugh5JyEFmdjzDo+e8U5EdR8nzKq8Yj1htzLm3b6eQEEam3/rrtg==} - dependencies: - '@floating-ui/core': 0.7.3 - dev: true - - /@floating-ui/dom/1.0.2: - resolution: {integrity: sha512-5X9WSvZ8/fjy3gDu8yx9HAA4KG1lazUN2P4/VnaXLxTO9Dz53HI1oYoh1OlhqFNlHgGDiwFX5WhFCc2ljbW3yA==} - dependencies: - '@floating-ui/core': 1.0.1 - dev: true - /@jridgewell/resolve-uri/3.1.0: resolution: {integrity: sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==} engines: {node: '>=6.0.0'} @@ -573,10 +553,6 @@ packages: ms: 2.1.2 dev: true - /dedent-js/1.0.1: - resolution: {integrity: sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==} - dev: true - /deepmerge/4.2.2: resolution: {integrity: sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==} engines: {node: '>=0.10.0'} @@ -1291,12 +1267,6 @@ packages: strip-bom: 3.0.0 dev: true - /lower-case/2.0.2: - resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} - dependencies: - tslib: 2.4.0 - dev: true - /magic-string/0.25.9: resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} dependencies: @@ -1392,13 +1362,6 @@ packages: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} dev: true - /no-case/3.0.4: - resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} - dependencies: - lower-case: 2.0.2 - tslib: 2.4.0 - dev: true - /node-releases/2.0.6: resolution: {integrity: sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==} dev: true @@ -1487,13 +1450,6 @@ packages: json-parse-better-errors: 1.0.2 dev: true - /pascal-case/3.1.2: - resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} - dependencies: - no-case: 3.0.4 - tslib: 2.4.0 - dev: true - /path-is-absolute/1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -1813,6 +1769,10 @@ packages: queue-microtask: 1.2.3 dev: true + /rustic/1.2.2: + resolution: {integrity: sha512-aagYrcImcYj3QbaP7nirOw8/q8aULMu0LkKucP9B4WsRbXlXpk195nYAEB+uJzAcgk2pKS+yMzbAJtIoOqwZoQ==} + dev: true + /sade/1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} @@ -2046,17 +2006,6 @@ packages: - sugarss dev: true - /svelte-floating-ui/0.3.2_c36sqhgzdfaw4kmxogryyrffx4: - resolution: {integrity: sha512-L5YqnVLfCp+JcGNMD6MFdmzsFSgskFR+TJ/2xhKjSQkAXi4dTowImb5HzAAsJpPOPY10e/+Ta/ucyNTZPGr3VQ==} - dependencies: - '@floating-ui/core': 0.7.3 - '@floating-ui/dom': 0.5.4 - svelte2tsx: 0.5.20_c36sqhgzdfaw4kmxogryyrffx4 - transitivePeerDependencies: - - svelte - - typescript - dev: true - /svelte-headless-table/0.15.1_svelte@3.51.0: resolution: {integrity: sha512-QVJhWtA20imgSjgirxrJp/74DfyGevo+GVlXZ8IGF54JI4D4GSqc1DPA57Rq00MniHRmfNpJl8VXU3r/NGLWJg==} dependencies: @@ -2143,16 +2092,6 @@ packages: svelte-subscribe: 1.0.5 dev: true - /svelte-select/5.0.0-beta.31_c36sqhgzdfaw4kmxogryyrffx4: - resolution: {integrity: sha512-Ruo9mqH4WJ1Ckkel5iwYetxGvcokCQfXm8+xOXf05DMc0bqDl2MzGWeW7tERPU3E585ldMsSICvtSMINICpPVg==} - dependencies: - '@floating-ui/dom': 1.0.2 - svelte-floating-ui: 0.3.2_c36sqhgzdfaw4kmxogryyrffx4 - transitivePeerDependencies: - - svelte - - typescript - dev: true - /svelte-subscribe/1.0.5: resolution: {integrity: sha512-p+vRSBVzR9BQC72mjd2eqCv8zx5euLZQJF7QqAw5d41aKzQVOq90y71/NXch+nDNMjWbRo0CX+brcWYgPryJlw==} dev: true @@ -2162,18 +2101,6 @@ packages: engines: {node: '>= 8'} dev: true - /svelte2tsx/0.5.20_c36sqhgzdfaw4kmxogryyrffx4: - resolution: {integrity: sha512-yNHmN/uoAnJ7d1XqVohiNA6TMFOxibHyEddUAHVt1PiLXtbwAJF3WaGYlg8QbOdoXzOVsVNCAlqRUIdULUm+OA==} - peerDependencies: - svelte: ^3.24 - typescript: ^4.1.2 - dependencies: - dedent-js: 1.0.1 - pascal-case: 3.1.2 - svelte: 3.51.0 - typescript: 4.8.4 - dev: true - /swrev/1.11.0: resolution: {integrity: sha512-bx8egJdPcnJ6k/MFjTJx1GlUhxQwR108emEEudYey10Vh/TKnSvXySMrlgczaGyO4sZm6X016NB/juuA6VtRvQ==} dependencies: diff --git a/code/app/src/hooks.server.ts b/code/app/src/hooks.server.ts index 91bdeff..59acab6 100644 --- a/code/app/src/hooks.server.ts +++ b/code/app/src/hooks.server.ts @@ -13,6 +13,7 @@ export const handle: Handle = async ({ event, resolve }) => { const localeCookie = event.cookies.get(CookieNames.locale); const preferredLocale = getPreferredLocale(event); let finalLocale = localeCookie ?? preferredLocale; + let forceCookieSet = false; console.log("Handling locale", { locales, @@ -22,11 +23,12 @@ export const handle: Handle = async ({ event, resolve }) => { }); if (!isLocale(finalLocale)) { - console.log(finalLocale + " is not a valid locale or it does not exist, defaulting to en"); - finalLocale = "en" + console.log(finalLocale + " is not a valid locale or it does not exist, switching to default: en"); + finalLocale = "en"; + forceCookieSet = true; } - if (!localeCookie) { + if (!localeCookie || forceCookieSet) { // Set a locale cookie event.cookies.set(CookieNames.locale, finalLocale, { sameSite: "strict", 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<T>(url: string, body?: object | string, timeout = -1, skip_401_check = false, abort_signal?: AbortSignal): Promise<InternalFetchResponse<T>> { + 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<T>(url: string, timeout = -1, skip_401_check = false, abort_signal?: AbortSignal): Promise<Result<InternalFetchResponse<T>, 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<T>(url: string, body?: object | string, timeout = -1, skip_401_check = false, abort_signal?: AbortSignal): Promise<Result<InternalFetchResponse<T>, 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<Response> { + 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<boolean> { + 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<T>(response: Response): Promise<Result<InternalFetchResponse<T>, string>> { + const result = { + ok: response.ok, + status: response.status, + http_response: response, + } as InternalFetchResponse<T>; + + 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<T> = { + 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<InternalFetchResponse<Result<void, ErrorResult>>> { + const response = await http_post_async<Result<void, ErrorResult>>(api_base("_/account/login"), payload); + if (isOk(response)) { + return Ok(); + } + return Err(response.data); + }, + logout_async(): Promise<InternalFetchResponse<void>> { + return http_get_async<void>(api_base("_/account/logout")); + }, + delete_account_async(): Promise<InternalFetchResponse> { + return http_delete_async(api_base("_/account/delete")); + }, + update_profile_async(payload: UpdateProfilePayload): Promise<InternalFetchResponse> { + 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<InternalFetchResponse> { + 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<Result<SessionData, string>> { + const response = await http_get_async<SessionData>(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/models/internal/CreateAccountPayload.ts b/code/app/src/lib/api/account/models/CreateAccountPayload.ts index d116308..d116308 100644 --- a/code/app/src/lib/models/internal/CreateAccountPayload.ts +++ b/code/app/src/lib/api/account/models/CreateAccountPayload.ts diff --git a/code/app/src/lib/models/internal/LoginPayload.ts b/code/app/src/lib/api/account/models/LoginPayload.ts index beb96cf..beb96cf 100644 --- a/code/app/src/lib/models/internal/LoginPayload.ts +++ b/code/app/src/lib/api/account/models/LoginPayload.ts diff --git a/code/app/src/lib/models/internal/UpdateProfilePayload.ts b/code/app/src/lib/api/account/models/UpdateProfilePayload.ts index d2983ff..d2983ff 100644 --- a/code/app/src/lib/models/internal/UpdateProfilePayload.ts +++ b/code/app/src/lib/api/account/models/UpdateProfilePayload.ts 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<IInternalFetchResponse> { - 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<IInternalFetchResponse> { - 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<IInternalFetchResponse> { - 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<Response> { - 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<boolean> { - 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<InternalFetchResponse> { + 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<InternalFetchResponse> { + 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<InternalFetchResponse> { + 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<Result<IInternalFetchResponse<string>, 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<IInternalFetchResponse> { - return http_post(api_base("v1/entries/create"), payload); -} - -export async function get_time_entry(entryId: string): Promise<IInternalFetchResponse> { - 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<IInternalFetchResponse> { - return http_post(api_base("v1/entries/query"), entryQuery); -} - -export async function delete_time_entry(id: string): Promise<IInternalFetchResponse> { - 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<IInternalFetchResponse> { - 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<IInternalFetchResponse> { - return http_post(api_base("v1/labels/create"), labelDto); -} - -export async function get_time_labels(): Promise<IInternalFetchResponse> { - return http_get(api_base("v1/labels")); -} - -export async function delete_time_label(id: string): Promise<IInternalFetchResponse> { - 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<IInternalFetchResponse> { - 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<IInternalFetchResponse> { - 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<IInternalFetchResponse> { - return http_get(api_base("v1/categories")); -} - -export async function delete_time_category(id: string): Promise<IInternalFetchResponse> { - 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<IInternalFetchResponse> { - 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<IInternalFetchResponse> { - return http_post(api_base("_/account/login"), payload); -} - -export async function logout(): Promise<IInternalFetchResponse> { - return http_get(api_base("_/account/logout")); -} - -export async function create_forgot_password_request(username: string): Promise<IInternalFetchResponse> { - 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<IInternalFetchResponse> { - 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<IInternalFetchResponse> { - 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<IInternalFetchResponse> { - return http_delete(api_base("_/account/delete")); -} - -export async function update_profile(payload: UpdateProfilePayload): Promise<IInternalFetchResponse> { - 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<IInternalFetchResponse> { - 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<IInternalFetchResponse> { - 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; </script> @@ -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" /> - <label for={id} class="ml-2 block text-sm text-gray-900">{label}</label> + <label for={id} class="ml-2 block text-sm text-gray-900"> + {@html required ? "<span class='text-red-500'>*</span>" : ""} + {label} + </label> </div> 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 @@ </script> <script lang="ts"> - import { CheckCircleIcon, ChevronUpDownIcon, XIcon } from "$lib/components/icons"; + import { CheckCircleIcon, ChevronUpDownIcon, XIcon } from "./icons"; import { element_has_focus, random_string } from "$lib/helpers"; import { go, highlight } from "fuzzysort"; import Badge from "./badge.svelte"; @@ -20,19 +20,19 @@ export let disabled: boolean | undefined = undefined; export let required: boolean | undefined = undefined; export let maxlength: number | undefined = undefined; - export let placeholder = $LL.combobox.search(); + export let placeholder: string = $LL.combobox.search(); export let options: Array<ComboboxOption> | undefined = []; export let createable = false; export let loading = false; export let multiple = false; - export let noResultsText = $LL.combobox.noRecordsFound(); + export let noResultsText: string = $LL.combobox.noRecordsFound(); export let on_create_async = async ({ name: string }) => {}; export const reset = () => methods.reset(); export const select = (id: string) => methods.select_entry(id); export const deselect = (id: string) => methods.deselect_entry(id); - const INTERNAL_ID = "INTERNAL__combobox-" + random_string(3); + const INTERNAL_ID = "INTERNAL__" + id; let optionsListId = id + "--options"; let searchInputNode; @@ -75,7 +75,7 @@ if (!value) { return ""; } - return value.toString().trim().toLowerCase(); + return value.trim().toLowerCase(); }, do() { const query = search.normalise_value(searchValue); @@ -85,9 +85,9 @@ return; } - //@ts-ignore + // @ts-ignore searchResults = go(query, options, { - limit: 10, + limit: 15, allowTypo: true, threshold: -10000, key: "name", @@ -206,11 +206,11 @@ if (searchValue) { return options; } - return (options as any).sort((a, b) => { - search.normalise_value(a.name).localeCompare(search.normalise_value(b.name)); - }); + + return options.sort((a, b) => search.normalise_value(a.name).localeCompare(search.normalise_value(b.name))); }, }; + const windowEvents = { on_mousemove(event: any) { if (!event.target) return; @@ -230,7 +230,7 @@ const spacePressed = event.code === "Space"; const arrowDownPressed = event.code === "ArrowDown"; const searchInputHasFocus = element_has_focus(searchInputNode); - const focusedEntry = document.querySelector("#" + INTERNAL_ID + " ul .focus"); + const focusedEntry = document.querySelector("#" + INTERNAL_ID + " ul li.focus") as HTMLLIElement; if (showDropdown && (enterPressed || arrowDownPressed || arrowUpPressed)) { event.preventDefault(); @@ -262,16 +262,18 @@ focusedEntry.nextElementSibling.classList.add("focus"); focusedEntry.nextElementSibling.scrollIntoView(false); } else { - document.querySelector("#" + INTERNAL_ID + " ul li:first-of-type").classList.add("focus"); - document.querySelector("#" + INTERNAL_ID + " ul li:first-of-type").scrollIntoView(false); + const firstLIEl = document.querySelector("#" + INTERNAL_ID + " ul li:first-of-type"); + firstLIEl.classList.add("focus"); + firstLIEl.scrollIntoView(false); } } else if (arrowUpPressed) { if (focusedEntry.previousElementSibling) { focusedEntry.previousElementSibling.classList.add("focus"); focusedEntry.previousElementSibling.scrollIntoView(false); } else { - document.querySelector("#" + INTERNAL_ID + " ul li:last-of-type").classList.add("focus"); - document.querySelector("#" + INTERNAL_ID + " ul li:last-of-type").scrollIntoView(false); + const lastLIEl = document.querySelector("#" + INTERNAL_ID + " ul li:last-of-type"); + lastLIEl.classList.add("focus"); + lastLIEl.scrollIntoView(false); } } focusedEntry.classList.remove("focus"); @@ -279,7 +281,6 @@ } if (focusedEntry && (spacePressed || enterPressed)) { - //@ts-ignore methods.select_entry(focusedEntry.dataset.id); return; } @@ -303,14 +304,17 @@ <div id={INTERNAL_ID} class:cursor-wait={loading}> {#if label} - <label for={id} class="block text-sm font-medium text-gray-700">{label}</label> + <label for={id} class="block text-sm font-medium text-gray-700"> + {label} + {@html required ? "<span class='text-red-500'>*</span>" : ""} + </label> {/if} <div class="relative {label ? 'mt-1' : ''}"> <div on:click={search.on_input_wrapper_focus} on:keypress={search.on_input_wrapper_focus} - class="cursor-text w-full rounded-md border bg-white py-2 pl-3 pr-12 sm:text-sm - {inputHasFocus ? `border-${colorName}-500 outline-none ring-1 ring-{colorName}-500` : 'shadow-sm border-gray-300'}" + class="cursor-text w-full flex rounded-md border bg-white py-2 pl-3 pr-12 sm:text-sm + {inputHasFocus ? `border-${colorName}-500 outline-none ring-1 ring-${colorName}-500` : 'shadow-sm border-gray-300'}" > {#if multiple === true && hasSelection} <div class="flex gap-1 flex-wrap"> @@ -325,7 +329,7 @@ {/each} </div> {/if} - <div class={multiple === true && hasSelection ? "mt-2" : ""}> + <div> <input {...attributes} type="text" @@ -346,12 +350,13 @@ type="button" on:click={() => reset()} title={$LL.reset()} + tabindex="-1" class="text-gray-400 absolute cursor-pointer inset-y-0 right-0 flex items-center rounded-r-md px-2" > <XIcon /> </button> {:else} - <span class="text-gray-400 absolute inset-y-0 right-0 flex items-center rounded-r-md px-2"> + <span tabindex="-1" class="text-gray-400 absolute inset-y-0 right-0 flex items-center rounded-r-md px-2"> <ChevronUpDownIcon /> </span> {/if} @@ -406,10 +411,12 @@ </li> {/each} {:else} - <p class="px-2">{noResultsText}</p> - {#if createable && !searchValue} - <p class="px-2 text-gray-500">{$LL.combobox.createRecordHelpText()}</p> - {/if} + <slot name="no-records"> + <p class="px-2">{noResultsText}</p> + {#if createable && !searchValue} + <p class="px-2 text-gray-500">{$LL.combobox.createRecordHelpText()}</p> + {/if} + </slot> {/if} </ul> {#if showCreationHint} diff --git a/code/app/src/lib/components/input.svelte b/code/app/src/lib/components/input.svelte index efd8946..5d38597 100644 --- a/code/app/src/lib/components/input.svelte +++ b/code/app/src/lib/components/input.svelte @@ -1,6 +1,7 @@ <script lang="ts"> import pwKey from "$actions/pwKey"; import { random_string } from "$lib/helpers"; + import { htmlLangAttributeDetector } from "typesafe-i18n/detectors"; import { ExclamationCircleIcon } from "./icons"; export let label: string | undefined = undefined; @@ -47,12 +48,14 @@ {#if label && !cornerHint && !hideLabel} <label for={id} class={hideLabel ? "sr-only" : "block text-sm font-medium text-gray-700"}> {label} + {@html required ? "<span class='text-red-500'>*</span>" : ""} </label> {:else if cornerHint && !hideLabel} <div class="flex justify-between"> {#if label} <label for={id} class={hideLabel ? "sr-only" : "block text-sm font-medium text-gray-700"}> {label} + {@html required ? "<span class='text-red-500'>*</span>" : ""} </label> {/if} <span class="text-sm text-gray-500"> diff --git a/code/app/src/lib/components/textarea.svelte b/code/app/src/lib/components/textarea.svelte index 65127af..6629260 100644 --- a/code/app/src/lib/components/textarea.svelte +++ b/code/app/src/lib/components/textarea.svelte @@ -9,27 +9,31 @@ export let placeholder = ""; export let value; export let label = ""; + export let required = false; export let errorText = ""; - $: shared_props = { + $: attributes = { rows: rows || null, cols: cols || null, name: name || null, id: id || null, disabled: disabled || null, + required: required || null, }; - let textarea; + let textareaElement; let scrollHeight = 0; const defaultColorClass = "border-gray-300 focus:border-teal-500 focus:ring-teal-500"; let colorClass = defaultColorClass; + $: if (errorText) { colorClass = "placeholder-red-300 focus:border-red-500 focus:outline-none focus:ring-red-500 text-red-900 pr-10 border-red-300"; } else { colorClass = defaultColorClass; } - $: if (textarea) { - scrollHeight = textarea.scrollHeight; + + $: if (textareaElement) { + scrollHeight = textareaElement.scrollHeight; } function on_input(event) { @@ -40,17 +44,20 @@ <div> {#if label} - <label for={id} class="block text-sm font-medium text-gray-700">{label}</label> + <label for={id} class="block text-sm font-medium text-gray-700"> + {label} + {@html required ? "<span class='text-red-500'>*</span>" : ""} + </label> {/if} <div class="mt-1"> <textarea {rows} {name} {id} - {...shared_props} + {...attributes} style="overflow-y:hidden;min-height:calc(1.5em + .75rem + 2px);{scrollHeight ? 'height:{scrollHeight}px' : ''};" bind:value - bind:this={textarea} + bind:this={textareaElement} on:input={on_input} {placeholder} class="block w-full rounded-md {colorClass} shadow-sm sm:text-sm" diff --git a/code/app/src/lib/i18n/en/app/index.ts b/code/app/src/lib/i18n/en/app/index.ts index 7cd05ee..7ccfc97 100644 --- a/code/app/src/lib/i18n/en/app/index.ts +++ b/code/app/src/lib/i18n/en/app/index.ts @@ -1,5 +1,7 @@ import type { BaseTranslation } from '../../i18n-types' -const en_app: BaseTranslation = {} +const en_app: BaseTranslation = { + members: "Members", +} export default en_app
\ No newline at end of file diff --git a/code/app/src/lib/i18n/i18n-types.ts b/code/app/src/lib/i18n/i18n-types.ts index 63387e8..870bf23 100644 --- a/code/app/src/lib/i18n/i18n-types.ts +++ b/code/app/src/lib/i18n/i18n-types.ts @@ -203,7 +203,12 @@ type RootTranslation = { } } -export type NamespaceAppTranslation = {} +export type NamespaceAppTranslation = { + /** + * Members + */ + members: string +} export type Namespaces = | 'app' @@ -398,6 +403,10 @@ export type TranslationFunctions = { submitANewRequestBelow: () => LocalizedString } app: { + /** + * Members + */ + members: () => LocalizedString } } diff --git a/code/app/src/lib/i18n/nb/app/index.ts b/code/app/src/lib/i18n/nb/app/index.ts index 15d0b9a..6bf9ba6 100644 --- a/code/app/src/lib/i18n/nb/app/index.ts +++ b/code/app/src/lib/i18n/nb/app/index.ts @@ -1,8 +1,7 @@ import type { NamespaceAppTranslation } from '../../i18n-types' const nb_app: NamespaceAppTranslation = { - // TODO: insert translations - + members: "Medlemmer" } export default nb_app diff --git a/code/app/src/lib/models/base/SessionData.ts b/code/app/src/lib/models/base/SessionData.ts new file mode 100644 index 0000000..015cbf3 --- /dev/null +++ b/code/app/src/lib/models/base/SessionData.ts @@ -0,0 +1,5 @@ +export type SessionData = { + id: string, + username: string, + displayName: string, +}
\ No newline at end of file diff --git a/code/app/src/lib/models/internal/ErrorResult.ts b/code/app/src/lib/models/internal/ErrorResult.ts index 7c70017..930b9f3 100644 --- a/code/app/src/lib/models/internal/ErrorResult.ts +++ b/code/app/src/lib/models/internal/ErrorResult.ts @@ -1,4 +1,4 @@ -export interface ErrorResult { +export type ErrorResult = { title: string, text: string } diff --git a/code/app/src/lib/models/internal/IInternalFetchRequest.ts b/code/app/src/lib/models/internal/IInternalFetchRequest.ts deleted file mode 100644 index 68505e2..0000000 --- a/code/app/src/lib/models/internal/IInternalFetchRequest.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface IInternalFetchRequest { - url: string, - init?: RequestInit, - timeout?: number - retry_count?: number -} diff --git a/code/app/src/lib/models/internal/IInternalFetchResponse.ts b/code/app/src/lib/models/internal/IInternalFetchResponse.ts deleted file mode 100644 index 6c91b35..0000000 --- a/code/app/src/lib/models/internal/IInternalFetchResponse.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface IInternalFetchResponse { - ok: boolean, - status: number, - data: any, - http_response: Response -} diff --git a/code/app/src/lib/models/internal/ISession.ts b/code/app/src/lib/models/internal/ISession.ts index 7587145..a452e20 100644 --- a/code/app/src/lib/models/internal/ISession.ts +++ b/code/app/src/lib/models/internal/ISession.ts @@ -1,4 +1,4 @@ -export interface ISession { +export type Session = { profile: { username: string, displayName: string, diff --git a/code/app/src/lib/models/internal/IValidationResult.ts b/code/app/src/lib/models/internal/IValidationResult.ts deleted file mode 100644 index 9a21b13..0000000 --- a/code/app/src/lib/models/internal/IValidationResult.ts +++ /dev/null @@ -1,31 +0,0 @@ -export interface IValidationResult { - errors: Array<IValidationError>, - has_errors: Function, - add_error: Function, - remove_error: Function, -} - -export interface IValidationError { - _id?: string, - title: string, - text?: string -} - -export default class ValidationResult implements IValidationResult { - errors: IValidationError[] - has_errors(): boolean { - return this.errors?.length > 0; - } - add_error(prop: string, error: IValidationError): void { - if (!this.errors) this.errors = []; - error._id = prop; - this.errors.push(error); - } - remove_error(property: string): void { - const new_errors = []; - for (const error of this.errors) { - if (error._id != property) new_errors.push(error) - } - this.errors = new_errors; - } -} diff --git a/code/app/src/lib/models/internal/UnwrappedEntryDateTime.ts b/code/app/src/lib/models/internal/UnwrappedEntryDateTime.ts deleted file mode 100644 index da71bc9..0000000 --- a/code/app/src/lib/models/internal/UnwrappedEntryDateTime.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { Temporal } from "temporal-polyfill"; - -export interface UnwrappedEntryDateTime { - start_date: Temporal.PlainDate, - stop_date: Temporal.PlainDate, - start_time: Temporal.PlainTime, - stop_time: Temporal.PlainTime, - duration: Temporal.Duration, -} diff --git a/code/app/src/lib/services/abstractions/ISettingsService.ts b/code/app/src/lib/services/abstractions/ISettingsService.ts new file mode 100644 index 0000000..366e337 --- /dev/null +++ b/code/app/src/lib/services/abstractions/ISettingsService.ts @@ -0,0 +1,3 @@ +export interface ISettingsService { + get_user_settings(): Promise<void>, +}
\ No newline at end of file diff --git a/code/app/src/lib/services/settings-service.ts b/code/app/src/lib/services/settings-service.ts new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/code/app/src/lib/services/settings-service.ts diff --git a/code/app/src/lib/session.ts b/code/app/src/lib/session.ts index 7cd5fcf..48dcc50 100644 --- a/code/app/src/lib/session.ts +++ b/code/app/src/lib/session.ts @@ -1,13 +1,13 @@ import { log_error, log_info } from "$lib/logger"; import { Temporal } from "temporal-polyfill"; -import { get_profile_for_active_check, logout } from "./api/user"; +import { http_account } from "$lib/api/account"; import { is_guid, session_storage_get_json, session_storage_set_json } from "./helpers"; import { SECONDS_BETWEEN_SESSION_CHECK, StorageKeys } from "./configuration"; -import type { ISession } from "$lib/models/internal/ISession"; +import type { Session } from "$lib/models/internal/ISession"; export async function is_active(forceRefresh: boolean = false): Promise<boolean> { const nowEpoch = Temporal.Now.instant().epochSeconds; - const data = session_storage_get_json(StorageKeys.session) as ISession; + const data = session_storage_get_json(StorageKeys.session) as Session; const expiryEpoch = data?.lastChecked + SECONDS_BETWEEN_SESSION_CHECK; const lastCheckIsStaleOrNone = !is_guid(data?.profile?.id) || (expiryEpoch < nowEpoch); if (forceRefresh || lastCheckIsStaleOrNone) { @@ -23,7 +23,7 @@ export async function is_active(forceRefresh: boolean = false): Promise<boolean> } export async function end_session(cb: Function): Promise<void> { - await logout(); + await http_account.logout_async(); clear_session_data(); cb(); } @@ -31,14 +31,14 @@ export async function end_session(cb: Function): Promise<void> { async function call_api(): Promise<boolean> { log_info("Getting profile data while checking session state"); try { - const response = await get_profile_for_active_check(); + const response = await http_account.get_profile_async(true); if (response.ok) { const userData = await response.data; if (is_guid(userData.id) && userData.username) { const session = { profile: userData, lastChecked: Temporal.Now.instant().epochSeconds - } as ISession; + } as Session; session_storage_set_json(StorageKeys.session, session); log_info("Successfully got profile data while checking session state"); return true; @@ -64,6 +64,6 @@ export function clear_session_data() { log_info("Cleared session data."); } -export function get_session_data(): ISession { - return session_storage_get_json(StorageKeys.session) as ISession; +export function get_session_data(): Session { + return session_storage_get_json(StorageKeys.session) as Session; } diff --git a/code/app/src/lib/swr.ts b/code/app/src/lib/swr.ts new file mode 100644 index 0000000..39c8665 --- /dev/null +++ b/code/app/src/lib/swr.ts @@ -0,0 +1,6 @@ +import { createDefaultSWR } from "sswr"; +import { http_get_async } from "./api/_fetch"; + +export const swr = createDefaultSWR({ + fetcher: (key: string) => http_get_async(key), +}); diff --git a/code/app/src/routes/(main)/(app)/projects/+page.svelte b/code/app/src/routes/(main)/(app)/projects/+page.svelte index 55e9372..e39a886 100644 --- a/code/app/src/routes/(main)/(app)/projects/+page.svelte +++ b/code/app/src/routes/(main)/(app)/projects/+page.svelte @@ -20,7 +20,7 @@ while (i < 101) { tempProjects.push({ id: crypto.randomUUID(), - name: faker.word.preposition(), + name: faker.lorem.word(), start: Temporal.Now.plainDateISO().toLocaleString(), description: faker.lorem.words(3), members: [], @@ -31,7 +31,9 @@ projects.set(tempProjects); }); - function goto_project(name: string) { + function on_open_project(event) { + if (event.code && (event.code !== "Enter" || event.code !== "Space")) return; + const name = event.target.innerText; const projectId = $projects.find((p) => p.name === name).id; goto("/projects/" + projectId); } @@ -59,7 +61,7 @@ </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/new" /> + <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"> @@ -79,6 +81,7 @@ <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'} @@ -115,7 +118,7 @@ <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={() => goto_project(materialisedCell.toString())}> + <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"} diff --git a/code/app/src/routes/(main)/(app)/projects/new/+page.svelte b/code/app/src/routes/(main)/(app)/projects/create/+page.svelte index 4c453dc..2b5e7bc 100644 --- a/code/app/src/routes/(main)/(app)/projects/new/+page.svelte +++ b/code/app/src/routes/(main)/(app)/projects/create/+page.svelte @@ -1,7 +1,8 @@ <script lang="ts"> import { useSWR } from "sswr"; - import { Input, TextArea } from "$lib/components"; + import { Input, TextArea, Combobox, Button } from "$lib/components"; import type { ProjectMember } from "$lib/models/projects/ProjectMember"; + import LL from "$lib/i18n/i18n-svelte"; const formFields = { name: { @@ -40,4 +41,15 @@ <Input type="date" label="Start" bind:value={formFields.start.value} errorText={formFields.start.error} /> <Input type="date" label="Stop" bind:value={formFields.stop.value} errorText={formFields.stop.error} /> </section> + <Combobox options={$members} label={$LL.app.members()}> + <svelte:fragment slot="no-records"> + <h1>No members found</h1> + {#if !$members?.length} + <p> + <a href="/users/create" class="link">Click here</a> to create your first user + </p> + {/if} + </svelte:fragment> + </Combobox> + <Button text={$LL.submit()} /> </form> diff --git a/code/app/src/routes/(main)/(app)/settings/+page.svelte b/code/app/src/routes/(main)/(app)/settings/+page.svelte index ae6d403..1f0cc67 100644 --- a/code/app/src/routes/(main)/(app)/settings/+page.svelte +++ b/code/app/src/routes/(main)/(app)/settings/+page.svelte @@ -1,4 +1,201 @@ <script lang="ts"> + import { Input, Button, Switch } from "$lib/components"; </script> -<h1>Settings</h1> +<div class="relative mx-auto max-w-4xl md:px-8 xl:px-0"> + <div class="pt-10 pb-16"> + <div class="px-4 sm:px-6 md:px-0"> + <h1 class="text-3xl font-bold tracking-tight text-gray-900">Settings</h1> + </div> + <div class="px-4 sm:px-6 md:px-0"> + <div class="py-6"> + <!-- Tabs --> + <div class="lg:hidden"> + <label for="selected-tab" class="sr-only">Select a tab</label> + <select + id="selected-tab" + name="selected-tab" + class="mt-1 block w-full rounded-md border-gray-300 py-2 pl-3 pr-10 text-base focus:border-purple-500 focus:outline-none focus:ring-purple-500 sm:text-sm" + > + <option selected>General</option> + + <option>Password</option> + + <option>Notifications</option> + + <option>Plan</option> + + <option>Billing</option> + + <option>Team Members</option> + </select> + </div> + <div class="hidden lg:block"> + <div class="border-b border-gray-200"> + <nav class="-mb-px flex space-x-8"> + <!-- Current: "border-purple-500 text-purple-600", Default: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700" --> + <a href="#" class="border-purple-500 text-purple-600 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" + >General</a + > + + <a + href="#" + class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" + >Password</a + > + + <a + href="#" + class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" + >Notifications</a + > + + <a + href="#" + class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" + >Plan</a + > + + <a + href="#" + class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" + >Billing</a + > + + <a + href="#" + class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" + >Team Members</a + > + </nav> + </div> + </div> + + <!-- Description list with inline editing --> + <div class="mt-10 divide-y divide-gray-200"> + <div class="space-y-1"> + <h3 class="text-lg font-medium leading-6 text-gray-900">Profile</h3> + <p class="max-w-2xl text-sm text-gray-500"> + This information will be displayed publicly so be careful what you share. + </p> + </div> + <div class="mt-6"> + <dl class="divide-y divide-gray-200"> + <div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5"> + <dt class="text-sm font-medium text-gray-500">Name</dt> + <dd class="mt-1 flex text-sm text-gray-900 sm:col-span-2 sm:mt-0"> + <span class="flex-grow">Chelsea Hagon</span> + <span class="ml-4 flex-shrink-0"> + <button + type="button" + class="rounded-md bg-white font-medium text-purple-600 hover:text-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2" + >Update</button + > + </span> + </dd> + </div> + <div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:pt-5"> + <dt class="text-sm font-medium text-gray-500">Photo</dt> + <dd class="mt-1 flex text-sm text-gray-900 sm:col-span-2 sm:mt-0"> + <span class="flex-grow"> + <img + class="h-8 w-8 rounded-full" + src="https://images.unsplash.com/photo-1550525811-e5869dd03032?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" + alt="" + /> + </span> + <span class="ml-4 flex flex-shrink-0 items-start space-x-4"> + <button + type="button" + class="rounded-md bg-white font-medium text-purple-600 hover:text-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2" + >Update</button + > + <span class="text-gray-300" aria-hidden="true">|</span> + <button + type="button" + class="rounded-md bg-white font-medium text-purple-600 hover:text-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2" + >Remove</button + > + </span> + </dd> + </div> + <div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:pt-5"> + <dt class="text-sm font-medium text-gray-500">Email</dt> + <dd class="mt-1 flex text-sm text-gray-900 sm:col-span-2 sm:mt-0"> + <span class="flex-grow">chelsea.hagon@example.com</span> + <span class="ml-4 flex-shrink-0"> + <button + type="button" + class="rounded-md bg-white font-medium text-purple-600 hover:text-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2" + >Update</button + > + </span> + </dd> + </div> + <div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:border-b sm:border-gray-200 sm:py-5"> + <dt class="text-sm font-medium text-gray-500">Job title</dt> + <dd class="mt-1 flex text-sm text-gray-900 sm:col-span-2 sm:mt-0"> + <span class="flex-grow">Human Resources Manager</span> + <span class="ml-4 flex-shrink-0"> + <button + type="button" + class="rounded-md bg-white font-medium text-purple-600 hover:text-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2" + >Update</button + > + </span> + </dd> + </div> + </dl> + </div> + </div> + + <div class="mt-10 divide-y divide-gray-200"> + <div class="space-y-1"> + <h3 class="text-lg font-medium leading-6 text-gray-900">Account</h3> + <p class="max-w-2xl text-sm text-gray-500">Manage how information is displayed on your account.</p> + </div> + <div class="mt-6"> + <dl class="divide-y divide-gray-200"> + <div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5"> + <dt class="text-sm font-medium text-gray-500">Language</dt> + <dd class="mt-1 flex text-sm text-gray-900 sm:col-span-2 sm:mt-0"> + <span class="flex-grow">English</span> + <span class="ml-4 flex-shrink-0"> + <button + type="button" + class="rounded-md bg-white font-medium text-purple-600 hover:text-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2" + >Update</button + > + </span> + </dd> + </div> + <div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:pt-5"> + <dt class="text-sm font-medium text-gray-500">Date format</dt> + <dd class="mt-1 flex text-sm text-gray-900 sm:col-span-2 sm:mt-0"> + <span class="flex-grow">DD-MM-YYYY</span> + <span class="ml-4 flex flex-shrink-0 items-start space-x-4"> + <button + type="button" + class="rounded-md bg-white font-medium text-purple-600 hover:text-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2" + >Update</button + > + <span class="text-gray-300" aria-hidden="true">|</span> + <button + type="button" + class="rounded-md bg-white font-medium text-purple-600 hover:text-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2" + >Remove</button + > + </span> + </dd> + </div> + <div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:pt-5"> + <dt class="text-sm font-medium text-gray-500" id="timezone-option-label">Automatic timezone</dt> + <Switch /> + </div> + </dl> + </div> + </div> + </div> + </div> + </div> +</div> 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 aa26892..32d4e21 100644 --- a/code/app/src/routes/(main)/(public)/reset-password/+page.svelte +++ b/code/app/src/routes/(main)/(public)/reset-password/+page.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import { create_forgot_password_request } from "$lib/api/user"; + import { create_forgot_password_request } from "$lib/api/account"; import { Alert, Input, Button } from "$lib/components"; import LL from "$lib/i18n/i18n-svelte"; import type { ErrorResult } from "$lib/models/ErrorResult"; 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 c5044b5..3710290 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,5 +1,5 @@ <script lang="ts"> - import { check_forgot_password_request, fulfill_forgot_password_request } from "$lib/api/user"; + import { check_forgot_password_request, fulfill_forgot_password_request } from "$lib/api/account"; import { onMount } from "svelte"; import LL from "$lib/i18n/i18n-svelte"; import { Alert, Input, Button } from "$lib/components"; 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 0e9c07b..d7a8c5a 100644 --- a/code/app/src/routes/(main)/(public)/sign-in/+page.svelte +++ b/code/app/src/routes/(main)/(public)/sign-in/+page.svelte @@ -1,10 +1,10 @@ <script lang="ts"> import { goto } from "$app/navigation"; - import { login } from "$lib/api/user"; + import { login } from "$lib/api/account"; import { Button, Checkbox, Input, Alert } from "$lib/components"; import LL from "$lib/i18n/i18n-svelte"; import type { ErrorResult } from "$lib/models/internal/ErrorResult"; - import type { LoginPayload } from "$lib/models/internal/LoginPayload"; + import type { LoginPayload } from "$lib/api/account/models/LoginPayload"; import pwKey from "$actions/pwKey"; import { onMount } from "svelte"; import { messageQueryKey, signInPageTestKeys, type Message } from "."; diff --git a/code/app/src/routes/(main)/(public)/sign-in/index.ts b/code/app/src/routes/(main)/(public)/sign-in/index.ts index cbdcbf6..c1a1929 100644 --- a/code/app/src/routes/(main)/(public)/sign-in/index.ts +++ b/code/app/src/routes/(main)/(public)/sign-in/index.ts @@ -1,18 +1,19 @@ -export enum Message { +export enum SignInPageMessage { AFTER_PASSWORD_RESET = "after-password-reset", USER_INACTIVITY = "user-inactivity", USER_DISABLED = "user-disabled", + LOGGED_OUT = "logged-out" } -export const messageQueryKey = "m"; +export const signInPageMessageQueryKey = "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", + userInactivityAlert: SignInPageMessage.USER_INACTIVITY + "-alert", + userDisabledAlert: SignInPageMessage.USER_DISABLED + "-alert", + afterPasswordResetAlert: SignInPageMessage.AFTER_PASSWORD_RESET + "-alert", formErrorAlert: "form-error-alert", resetPasswordAnchor: "reset-password-anchor", signUpAnchor: "sign-up-anchor", diff --git a/code/app/src/routes/(main)/(public)/sign-up/+page.svelte b/code/app/src/routes/(main)/(public)/sign-up/+page.svelte index f2f1695..f2b0d7f 100644 --- a/code/app/src/routes/(main)/(public)/sign-up/+page.svelte +++ b/code/app/src/routes/(main)/(public)/sign-up/+page.svelte @@ -1,9 +1,9 @@ <script lang="ts"> import { goto } from "$app/navigation"; - import { create_account } from "$lib/api/user"; + import { create_account } from "$lib/api/account"; import { Button, Input, Alert } from "$lib/components"; import LL from "$lib/i18n/i18n-svelte"; - import type { CreateAccountPayload } from "$lib/models/internal/CreateAccountPayload"; + import type { CreateAccountPayload } from "$lib/api/account/models/CreateAccountPayload"; import type { ErrorResult } from "$lib/models/internal/ErrorResult"; const formData = { diff --git a/code/app/src/routes/book/inputs/+page.svelte b/code/app/src/routes/book/inputs/+page.svelte index e4d19ff..9118a54 100644 --- a/code/app/src/routes/book/inputs/+page.svelte +++ b/code/app/src/routes/book/inputs/+page.svelte @@ -1,6 +1,7 @@ <script lang="ts"> import { TextArea, Input, Combobox } from "$lib/components"; import { DatabaseIcon } from "$lib/components/icons"; + import LL from "$lib/i18n/i18n-svelte"; let value; let i = 0; @@ -27,7 +28,13 @@ <section> <h2>Combobox</h2> - <Combobox {options} label="Wiii" multiple createable on_create_async={add} /> + <Combobox + {options} + label="Wiii" + multiple + createable + on_create_async={add} + /> </section> <section> |
