aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--code/api/src/Data/Models/KnownProblemModel.cs1
-rw-r--r--code/api/src/Endpoints/EndpointBase.cs14
-rw-r--r--code/api/src/Endpoints/V1/Labels/CreateLabelRoute.cs2
-rw-r--r--code/api/src/Utilities/SwaggerDefaultValues.cs2
-rw-r--r--code/api/src/Utilities/SwaggerGenOptionsExtensions.cs8
-rw-r--r--code/app/package.json1
-rw-r--r--code/app/pnpm-lock.yaml85
-rw-r--r--code/app/src/hooks.server.ts8
-rw-r--r--code/app/src/lib/api/_fetch.ts121
-rw-r--r--code/app/src/lib/api/account/index.ts39
-rw-r--r--code/app/src/lib/api/account/models/CreateAccountPayload.ts (renamed from code/app/src/lib/models/internal/CreateAccountPayload.ts)0
-rw-r--r--code/app/src/lib/api/account/models/LoginPayload.ts (renamed from code/app/src/lib/models/internal/LoginPayload.ts)0
-rw-r--r--code/app/src/lib/api/account/models/UpdateProfilePayload.ts (renamed from code/app/src/lib/models/internal/UpdateProfilePayload.ts)0
-rw-r--r--code/app/src/lib/api/internal-fetch.ts170
-rw-r--r--code/app/src/lib/api/password-reset-request/index.ts17
-rw-r--r--code/app/src/lib/api/root.ts12
-rw-r--r--code/app/src/lib/api/time-entry.ts83
-rw-r--r--code/app/src/lib/api/user.ts47
-rw-r--r--code/app/src/lib/components/checkbox.svelte7
-rw-r--r--code/app/src/lib/components/combobox.svelte57
-rw-r--r--code/app/src/lib/components/input.svelte3
-rw-r--r--code/app/src/lib/components/textarea.svelte21
-rw-r--r--code/app/src/lib/i18n/en/app/index.ts4
-rw-r--r--code/app/src/lib/i18n/i18n-types.ts11
-rw-r--r--code/app/src/lib/i18n/nb/app/index.ts3
-rw-r--r--code/app/src/lib/models/base/SessionData.ts5
-rw-r--r--code/app/src/lib/models/internal/ErrorResult.ts2
-rw-r--r--code/app/src/lib/models/internal/IInternalFetchRequest.ts6
-rw-r--r--code/app/src/lib/models/internal/IInternalFetchResponse.ts6
-rw-r--r--code/app/src/lib/models/internal/ISession.ts2
-rw-r--r--code/app/src/lib/models/internal/IValidationResult.ts31
-rw-r--r--code/app/src/lib/models/internal/UnwrappedEntryDateTime.ts9
-rw-r--r--code/app/src/lib/services/abstractions/ISettingsService.ts3
-rw-r--r--code/app/src/lib/services/settings-service.ts0
-rw-r--r--code/app/src/lib/session.ts16
-rw-r--r--code/app/src/lib/swr.ts6
-rw-r--r--code/app/src/routes/(main)/(app)/projects/+page.svelte11
-rw-r--r--code/app/src/routes/(main)/(app)/projects/create/+page.svelte (renamed from code/app/src/routes/(main)/(app)/projects/new/+page.svelte)14
-rw-r--r--code/app/src/routes/(main)/(app)/settings/+page.svelte199
-rw-r--r--code/app/src/routes/(main)/(public)/reset-password/+page.svelte2
-rw-r--r--code/app/src/routes/(main)/(public)/reset-password/[id]/+page.svelte2
-rw-r--r--code/app/src/routes/(main)/(public)/sign-in/+page.svelte4
-rw-r--r--code/app/src/routes/(main)/(public)/sign-in/index.ts11
-rw-r--r--code/app/src/routes/(main)/(public)/sign-up/+page.svelte4
-rw-r--r--code/app/src/routes/book/inputs/+page.svelte9
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 = {
+ /**
+ * M​e​m​b​e​r​s
+ */
+ 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>