aboutsummaryrefslogtreecommitdiffstats
path: root/code/app/src
diff options
context:
space:
mode:
authorivarlovlie <git@ivarlovlie.no>2022-10-25 11:51:37 +0200
committerivarlovlie <git@ivarlovlie.no>2022-10-25 11:51:37 +0200
commit0005595703b2f3f7083ce4ba19bf5770057c75bd (patch)
tree193a897f61a9a5e566961601de4cf42ae85984a0 /code/app/src
parent585c5c8537eb21dfc9f16108548e63d9ced3d971 (diff)
downloadgreatoffice-0005595703b2f3f7083ce4ba19bf5770057c75bd.tar.xz
greatoffice-0005595703b2f3f7083ce4ba19bf5770057c75bd.zip
.
Diffstat (limited to 'code/app/src')
-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
38 files changed, 522 insertions, 423 deletions
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>