aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--code/api/docker-compose.yml34
-rw-r--r--code/frontend/.dockerignore7
-rw-r--r--code/frontend/.env-example4
-rw-r--r--code/frontend/.eslintignore13
-rw-r--r--code/frontend/.eslintrc.cjs31
-rw-r--r--code/frontend/.gitignore10
-rw-r--r--code/frontend/.npmrc1
-rw-r--r--code/frontend/.prettierignore4
-rw-r--r--code/frontend/.prettierrc9
-rw-r--r--code/frontend/.typesafe-i18n.json5
-rw-r--r--code/frontend/.version1
-rw-r--r--code/frontend/.version-dev1
-rw-r--r--code/frontend/Dockerfile13
-rw-r--r--code/frontend/build_and_push.sh89
-rwxr-xr-xcode/frontend/bun.lockbbin0 -> 314639 bytes
-rw-r--r--code/frontend/components.json14
-rw-r--r--code/frontend/package.json65
-rw-r--r--code/frontend/playwright.config.ts12
-rw-r--r--code/frontend/postcss.config.cjs13
-rw-r--r--code/frontend/src/actions/pwKey.ts4
-rw-r--r--code/frontend/src/app.d.ts13
-rw-r--r--code/frontend/src/app.html12
-rw-r--r--code/frontend/src/app.pcss108
-rw-r--r--code/frontend/src/components/locale-switcher.svelte56
-rw-r--r--code/frontend/src/components/sonner.svelte21
-rw-r--r--code/frontend/src/components/style-changer.svelte22
-rw-r--r--code/frontend/src/components/ui/button/button.svelte25
-rw-r--r--code/frontend/src/components/ui/button/index.ts48
-rw-r--r--code/frontend/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte35
-rw-r--r--code/frontend/src/components/ui/dropdown-menu/dropdown-menu-content.svelte26
-rw-r--r--code/frontend/src/components/ui/dropdown-menu/dropdown-menu-item.svelte31
-rw-r--r--code/frontend/src/components/ui/dropdown-menu/dropdown-menu-label.svelte19
-rw-r--r--code/frontend/src/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte11
-rw-r--r--code/frontend/src/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte35
-rw-r--r--code/frontend/src/components/ui/dropdown-menu/dropdown-menu-separator.svelte11
-rw-r--r--code/frontend/src/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte13
-rw-r--r--code/frontend/src/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte29
-rw-r--r--code/frontend/src/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte32
-rw-r--r--code/frontend/src/components/ui/dropdown-menu/index.ts48
-rw-r--r--code/frontend/src/configuration/index.ts38
-rw-r--r--code/frontend/src/configuration/test.ts21
-rw-r--r--code/frontend/src/global.d.ts11
-rw-r--r--code/frontend/src/hooks.server.ts48
-rw-r--r--code/frontend/src/i18n/en/app/index.ts7
-rw-r--r--code/frontend/src/i18n/en/index.ts63
-rw-r--r--code/frontend/src/i18n/formatters.ts13
-rw-r--r--code/frontend/src/i18n/i18n-svelte.ts12
-rw-r--r--code/frontend/src/i18n/i18n-types.ts461
-rw-r--r--code/frontend/src/i18n/i18n-util.async.ts42
-rw-r--r--code/frontend/src/i18n/i18n-util.sync.ts35
-rw-r--r--code/frontend/src/i18n/i18n-util.ts44
-rw-r--r--code/frontend/src/i18n/nb/app/index.ts7
-rw-r--r--code/frontend/src/i18n/nb/index.ts51
-rw-r--r--code/frontend/src/index.test.ts7
-rw-r--r--code/frontend/src/models/base/Customer.ts21
-rw-r--r--code/frontend/src/models/base/CustomerContact.ts8
-rw-r--r--code/frontend/src/models/base/CustomerEvent.ts6
-rw-r--r--code/frontend/src/models/base/SessionData.ts5
-rw-r--r--code/frontend/src/models/base/Tenant.ts8
-rw-r--r--code/frontend/src/models/base/User.ts13
-rw-r--r--code/frontend/src/models/base/UserRole.ts5
-rw-r--r--code/frontend/src/models/internal/FormError.ts24
-rw-r--r--code/frontend/src/models/internal/IForm.ts15
-rw-r--r--code/frontend/src/models/internal/KnownProblem.ts10
-rw-r--r--code/frontend/src/models/projects/Project.ts13
-rw-r--r--code/frontend/src/models/projects/ProjectLabel.ts5
-rw-r--r--code/frontend/src/models/projects/ProjectMember.ts10
-rw-r--r--code/frontend/src/models/projects/ProjectMeta.ts7
-rw-r--r--code/frontend/src/models/projects/ProjectRole.ts7
-rw-r--r--code/frontend/src/models/projects/ProjectStatus.ts5
-rw-r--r--code/frontend/src/models/work/WorkCategory.ts5
-rw-r--r--code/frontend/src/models/work/WorkEntry.ts13
-rw-r--r--code/frontend/src/models/work/WorkEntryQueryResponse.ts27
-rw-r--r--code/frontend/src/models/work/WorkLabel.ts5
-rw-r--r--code/frontend/src/models/work/WorkQuery.ts17
-rw-r--r--code/frontend/src/routes/(main)/(app)/+layout.svelte379
-rw-r--r--code/frontend/src/routes/(main)/(app)/home/+page.svelte1
-rw-r--r--code/frontend/src/routes/(main)/(app)/org/+page.svelte4
-rw-r--r--code/frontend/src/routes/(main)/(app)/profile/+page.svelte4
-rw-r--r--code/frontend/src/routes/(main)/(app)/projects/+page.svelte118
-rw-r--r--code/frontend/src/routes/(main)/(app)/projects/[id]/+page.svelte5
-rw-r--r--code/frontend/src/routes/(main)/(app)/projects/create/+page.svelte59
-rw-r--r--code/frontend/src/routes/(main)/(app)/settings/+page.svelte205
-rw-r--r--code/frontend/src/routes/(main)/(app)/tickets/+page.svelte4
-rw-r--r--code/frontend/src/routes/(main)/(app)/todo/+page.svelte4
-rw-r--r--code/frontend/src/routes/(main)/(app)/wiki/+page.svelte4
-rw-r--r--code/frontend/src/routes/(main)/(public)/+layout.svelte18
-rw-r--r--code/frontend/src/routes/(main)/(public)/portal/+page.svelte26
-rw-r--r--code/frontend/src/routes/(main)/(public)/portal/+page.ts9
-rw-r--r--code/frontend/src/routes/(main)/(public)/reset-password/+page.svelte81
-rw-r--r--code/frontend/src/routes/(main)/(public)/reset-password/+page.ts11
-rw-r--r--code/frontend/src/routes/(main)/(public)/reset-password/[id]/+page.server.ts11
-rw-r--r--code/frontend/src/routes/(main)/(public)/reset-password/[id]/+page.svelte82
-rw-r--r--code/frontend/src/routes/(main)/(public)/reset-password/[id]/+page.ts11
-rw-r--r--code/frontend/src/routes/(main)/(public)/sign-in/+page.svelte155
-rw-r--r--code/frontend/src/routes/(main)/(public)/sign-in/+page.ts11
-rw-r--r--code/frontend/src/routes/(main)/(public)/sign-in/index.spec.js12
-rw-r--r--code/frontend/src/routes/(main)/(public)/sign-in/index.ts20
-rw-r--r--code/frontend/src/routes/(main)/(public)/sign-up/+page.svelte106
-rw-r--r--code/frontend/src/routes/(main)/(public)/sign-up/+page.ts11
-rw-r--r--code/frontend/src/routes/(main)/+page.svelte1
-rw-r--r--code/frontend/src/routes/+layout.server.ts44
-rw-r--r--code/frontend/src/routes/+layout.svelte50
-rw-r--r--code/frontend/src/routes/+layout.ts10
-rw-r--r--code/frontend/src/services/abstractions/IAccountService.ts54
-rw-r--r--code/frontend/src/services/abstractions/IApiTokenService.ts34
-rw-r--r--code/frontend/src/services/abstractions/IPasswordResetService.ts21
-rw-r--r--code/frontend/src/services/abstractions/ISettingsService.ts3
-rw-r--r--code/frontend/src/services/account-service.ts123
-rw-r--r--code/frontend/src/services/api-tokens-service.ts22
-rw-r--r--code/frontend/src/services/password-reset-service.ts48
-rw-r--r--code/frontend/src/services/settings-service.ts10
-rw-r--r--code/frontend/src/utils/_fetch.ts93
-rw-r--r--code/frontend/src/utils/colors.ts47
-rw-r--r--code/frontend/src/utils/crypto-helpers.ts49
-rw-r--r--code/frontend/src/utils/global-state.ts22
-rw-r--r--code/frontend/src/utils/misc-helpers.ts77
-rw-r--r--code/frontend/src/utils/persistent-store.ts110
-rw-r--r--code/frontend/src/utils/storage-helpers.ts26
-rw-r--r--code/frontend/src/utils/testing-helpers.ts7
-rw-r--r--code/frontend/src/utils/ui.ts56
-rw-r--r--code/frontend/src/utils/validators.ts34
-rw-r--r--code/frontend/static/favicon.pngbin0 -> 1571 bytes
-rw-r--r--code/frontend/svelte.config.js23
-rw-r--r--code/frontend/tailwind.config.js64
-rw-r--r--code/frontend/tests/test.ts6
-rw-r--r--code/frontend/tsconfig.json14
-rw-r--r--code/frontend/vite.config.ts18
128 files changed, 4431 insertions, 0 deletions
diff --git a/code/api/docker-compose.yml b/code/api/docker-compose.yml
new file mode 100644
index 0000000..670208e
--- /dev/null
+++ b/code/api/docker-compose.yml
@@ -0,0 +1,34 @@
+version: "3"
+
+volumes:
+ greatoffice_dev_appdata:
+ external: true
+networks:
+ default:
+ external:
+ name: postgres_bridge
+services:
+ app:
+ restart: always
+ image: dr.ivar.systems/greatoffice/server:latest-staging
+ ports:
+ - "127.0.0.1:5530:80"
+ volumes:
+ - greatoffice_staging_appdata:/app/AppData:rw
+ environment:
+ ASPNETCORE_ENVIRONMENT: Staging
+ EMAIL_FROM_ADDRESS: heystaging@greatoffice.life
+ CANONICAL_FRONTEND_URL: https://staging.greatoffice.app,
+ CANONICAL_BACKEND_URL: https://staging-api.greatoffice.app,
+ POSTMARK_TOKEN: b530c311-45c7-43e5-aa48-f2c992886e51,
+ DB_HOST: localhost
+ DB_PORT: 5432,
+ DB_NAME: greatoffice_staging,
+ DB_PASSWORD: p7fikwr1z327u1h3q1ubb8ht,
+ DB_USER: greatoffice_staging,
+ QUARTZ_DB_HOST: localhost,
+ QUARTZ_DB_PORT: 5432,
+ QUARTZ_DB_NAME: greatoffice_quartz_staging,
+ QUARTZ_DB_PASSWORD: p7fikwr1z327u1h3q1ubb8ht,
+ QUARTZ_DB_USER: greatoffice_staging,
+ APP_CERT: MIII2QIBAzCCCJ8GCSqGSIb3DQEHAaCCCJAEggiMMIIIiDCCAz8GCSqGSIb3DQEHBqCCAzAwggMsAgEAMIIDJQYJKoZIhvcNAQcBMBwGCiqGSIb3DQEMAQYwDgQI1JebRQOOJekCAggAgIIC+FTMxILwgOWxknEWvucjaHTtf/KUcSPwc/zWg0RoFzu03o1vZkStztz92L2J+nnNrQti7WEx0C/h5ug67qCQAdzkjNHZE9wn1pI2EqaCwKYwZnOTmyJDLV86xQlH9QYTs4/L1F2qTwpKdBoB2lNyTswYgZ8WNYY65fbFKUUVIaHkReGnma/ERm8F38Ymp7cudqQ4G6sh6E+JFSo2IfcTFb260fWO/iMDU3GryNsaUl4amT4aQfsSrNtf6ODy8Ivh7tJeLbR6bqzMtsKPzavT5ZbA6AP2GYAQSVejNP79lqmtoOs8+dz7HaJdxORBESYi02z+j2rb4ssI+ZPx+YtGvgpr79gVf3qM1/VX4ROzLDUUJGWS2RnRDBBY/d0uMqftndUHSPMFuhfvgBUXdzLqhoEqYNMTP3BpOyBQ7f26soLKrc3zup2WIn8WSnQFLi2D8cWPVPS9iAb0EgQ5cBjuaLz2aX1WVMCSzya7a6Z93rLxUf9s3+PEy75ibhoh/cJvAlCMTfiVAhJOaIroR1K4MKkO23ylyLHv49/2GYIaZ8n0WRO57fM5jDUhOfti+ZzPM6hrSJkRSla+pr8DFlpqOqObksGwxGGTqq6ZvWon19kXesFl5n640uJBu7Viq8IdxGAbX/aRkZNlvja7sOgfiNz3Hxomz7DWwgWLKaNKlFSqFMzsTUye+mUByC1AfEn14/SYyyxRTB99PmItxWFyjo9nOsxH5riz7tPTPxUXzhVb4eDt7PjY+ZsEKTC3a/LFqf3k5MWk+qc4p0Kx1sGaGEAPCCE04mZ7NOdqk6dhoP46FNUEh8CmxDDVaMSdThrvyzv9KrclwQnRMJz7BJWVXUemyQl3aModepXIhvLv90nH1qzYlFDQ0H6rxzCB4f1l//GoWPyYFBxGh6UrkunXWx2fopR87zi2OF3azxqscF/qLVU4FHKzhMrec7eE0/dk3d+0If/AxQ4p7Cso92i/5n0Bsg5DWc4EIWBuldodsjVxxq7dKxinKJkwggVBBgkqhkiG9w0BBwGgggUyBIIFLjCCBSowggUmBgsqhkiG9w0BDAoBAqCCBO4wggTqMBwGCiqGSIb3DQEMAQMwDgQIb6GEBS5DxrkCAggABIIEyHcCXqJMUG8t0PhvDwf0dHZo6SiA2WsLz1hM+KgNBrE8YwuXEZTGYzfHy85cEWNB2WLV5kxgu/vtifCnnlS1bvc2kMKT3dIFER/7hOqRh8pNvzMoeNc4zNkiEB1ZXxlctUKDsQozbLUhnRATwNyeaMkt3B0KQuRaMxGuA9riRISnmGd1K5GTm3VZ0I7e6vDqXCllLzfOQ+aoz8WIOFJ1aoN2E5+bDTtcr18xYJMd+kNOMjMcbg5f9kmNZAk1MBRuiEWtUjMhRySYWk1Km/y5WHRNRShHTae/E4ifmpLuUKsfOjX7T/4RDWg8rYCnxUpLfCln+omQ9t0gFSN+Et7Dw+cyW48Kkrw6StnRz/AeLxo3SU/PAXVazmAa6ZkuNe+uasvTniYM+enw4hgcXPzTu90R40fTGHO1Sp8EV3IbvrtwFu9kjCxtgleLQ139HTtpWXjVZ0o1ikmn2uN4f73gxKIKxmSX4xZZN6IDOze3OOY1aalUIzkbwFAYCV74zEL05dJzo3GOOJfdQsp2GNJPfkcAcuMPMvi+mieBk6XjKDCj95b41hSWDqfuMUgPh3xm3/felVD1HRNO9NF0RscosP02NLi44TcNz4LX9j/E9PHpNFF+W4ba1w7v7h4P5/leQFRP7+H90fPHA2M8UOHZ4AwmwdA5sHYXBoXkVS3snbVzhzkvW5GblFn0l1AFj8AO0HLCwGSumZ1uUEvEA021hmluHbs62iIiOYJbacbcT/TUpO5/2tFMPKr042LmpQFDIuEfrurLTC3r1iXuS6fkWbf2IxdjTrtL61AtPqtFagKSGsyHViO7nPu6yhbhTbmQJ4G0t++b6h18RPS+3muwrnSxgAz6OmbBWybNKOlAyTjd4JO3hfCaQ+K/mO2Q9TnSUOTgeobXXZsOEdltPXFJExQ7+cqkr4gKdPeoTZEcv8jRoS+NHasZIvMPGrwYnvOuSJ09qAwtIcvhGaPkEmTZ6b3wQl0mnTMPCQHXGTXztucB0O33kbn8sClfs6P6dg0GdR6ZnNFacwIpe8T8PmLg5q8bu5FL1eNo4+ijzC64lrZkKeRKKT1vBtZfcGQTvE8TTdQQS5MkKcptfL/3HVE9VopNZlzryJGYj89GMeQ1PfABi1Ovs5gjfro+0xBbtVuAWbP8dM5ugozO1//vjTMZYwXml4nIFkHuGe7R4ZpKRIVjVy7RScelCuQ0yNMGIzx/5Dz3FQXWq1Jii669Oxs/R7iupwo+f6O9XmCJAGXIw5a11Yw9cULptVNc9rPHrauAOeNpE77aSQRKEOJZADvdLB8cXjpXFf2mvzFib69Cuks8QxktAK7Yk3fke1CJpoIb75d8iHkY21epOtqavTppezEd+0uq5RJThH+/nMyZVhRI3tSJ0kVDc1HVX2bTquWcXtniuZNOWYklLxKPfQNho8n0pHRk22UmB8DOxMjnAyt3s7xBNpujU+I7D30lK9N3PH4U+Oyc9pIWc2T7pFILvvToxoE3l2flg6eHnBd6a7ENDVbz1ELwwmt36QQAVQytEngTBYkorbJcQC6e2r/RqoqpP2N4dB7+2ZDMVw97VBraMl7ELaYdf9SOdzuis2engAojSiUUO/gdKGaJGnnldOSi5rvnxs+iMjElMCMGCSqGSIb3DQEJFTEWBBRSLC58imQohokANg6rVjq9KE/MxjAxMCEwCQYFKw4DAhoFAAQUILUGtKvqxRY/ywrrlxKrsuLiNLwECCWv9bVh/bZZAgIIAA==
diff --git a/code/frontend/.dockerignore b/code/frontend/.dockerignore
new file mode 100644
index 0000000..00774fa
--- /dev/null
+++ b/code/frontend/.dockerignore
@@ -0,0 +1,7 @@
+.env
+.env-example
+.svelte-kit
+.git
+static
+node_modules
+build \ No newline at end of file
diff --git a/code/frontend/.env-example b/code/frontend/.env-example
new file mode 100644
index 0000000..270860f
--- /dev/null
+++ b/code/frontend/.env-example
@@ -0,0 +1,4 @@
+VITE_LOG_LEVEL=DEBUG
+VITE_TESTING=true
+VITE_TEST_USERNAME="ms@test.tld"
+VITE_TEST_PASSWORD="test123" \ No newline at end of file
diff --git a/code/frontend/.eslintignore b/code/frontend/.eslintignore
new file mode 100644
index 0000000..3897265
--- /dev/null
+++ b/code/frontend/.eslintignore
@@ -0,0 +1,13 @@
+.DS_Store
+node_modules
+/build
+/.svelte-kit
+/package
+.env
+.env.*
+!.env.example
+
+# Ignore files for PNPM, NPM and YARN
+pnpm-lock.yaml
+package-lock.json
+yarn.lock
diff --git a/code/frontend/.eslintrc.cjs b/code/frontend/.eslintrc.cjs
new file mode 100644
index 0000000..0b75758
--- /dev/null
+++ b/code/frontend/.eslintrc.cjs
@@ -0,0 +1,31 @@
+/** @type { import("eslint").Linter.Config } */
+module.exports = {
+ root: true,
+ extends: [
+ 'eslint:recommended',
+ 'plugin:@typescript-eslint/recommended',
+ 'plugin:svelte/recommended',
+ 'prettier'
+ ],
+ parser: '@typescript-eslint/parser',
+ plugins: ['@typescript-eslint'],
+ parserOptions: {
+ sourceType: 'module',
+ ecmaVersion: 2020,
+ extraFileExtensions: ['.svelte']
+ },
+ env: {
+ browser: true,
+ es2017: true,
+ node: true
+ },
+ overrides: [
+ {
+ files: ['*.svelte'],
+ parser: 'svelte-eslint-parser',
+ parserOptions: {
+ parser: '@typescript-eslint/parser'
+ }
+ }
+ ]
+};
diff --git a/code/frontend/.gitignore b/code/frontend/.gitignore
new file mode 100644
index 0000000..6635cf5
--- /dev/null
+++ b/code/frontend/.gitignore
@@ -0,0 +1,10 @@
+.DS_Store
+node_modules
+/build
+/.svelte-kit
+/package
+.env
+.env.*
+!.env.example
+vite.config.js.timestamp-*
+vite.config.ts.timestamp-*
diff --git a/code/frontend/.npmrc b/code/frontend/.npmrc
new file mode 100644
index 0000000..b6f27f1
--- /dev/null
+++ b/code/frontend/.npmrc
@@ -0,0 +1 @@
+engine-strict=true
diff --git a/code/frontend/.prettierignore b/code/frontend/.prettierignore
new file mode 100644
index 0000000..cc41cea
--- /dev/null
+++ b/code/frontend/.prettierignore
@@ -0,0 +1,4 @@
+# Ignore files for PNPM, NPM and YARN
+pnpm-lock.yaml
+package-lock.json
+yarn.lock
diff --git a/code/frontend/.prettierrc b/code/frontend/.prettierrc
new file mode 100644
index 0000000..2b9389a
--- /dev/null
+++ b/code/frontend/.prettierrc
@@ -0,0 +1,9 @@
+{
+ "useTabs": true,
+ "singleQuote": true,
+ "trailingComma": "none",
+ "semi": false,
+ "svelteAllowShorthand": true,
+ "printWidth": 120,
+ "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"]
+}
diff --git a/code/frontend/.typesafe-i18n.json b/code/frontend/.typesafe-i18n.json
new file mode 100644
index 0000000..4bb9efd
--- /dev/null
+++ b/code/frontend/.typesafe-i18n.json
@@ -0,0 +1,5 @@
+{
+ "adapter": "svelte",
+ "$schema": "https://unpkg.com/typesafe-i18n@5.26.2/schema/typesafe-i18n.json",
+ "outputPath": "src/i18n"
+} \ No newline at end of file
diff --git a/code/frontend/.version b/code/frontend/.version
new file mode 100644
index 0000000..626799f
--- /dev/null
+++ b/code/frontend/.version
@@ -0,0 +1 @@
+v1
diff --git a/code/frontend/.version-dev b/code/frontend/.version-dev
new file mode 100644
index 0000000..c227083
--- /dev/null
+++ b/code/frontend/.version-dev
@@ -0,0 +1 @@
+0 \ No newline at end of file
diff --git a/code/frontend/Dockerfile b/code/frontend/Dockerfile
new file mode 100644
index 0000000..e381c8d
--- /dev/null
+++ b/code/frontend/Dockerfile
@@ -0,0 +1,13 @@
+FROM registry.hub.docker.com/library/node:lts-buster-slim AS builder
+WORKDIR .
+COPY package.json .
+RUN npm i
+COPY . .
+RUN npm run build
+FROM registry.hub.docker.com/library/node:lts-buster-slim
+USER node:node
+WORKDIR .
+COPY --from=builder --chown=node:node build build
+COPY --from=builder --chown=node:node node_modules node_modules
+COPY --chown=node:node package.json .
+CMD ["node","build"] \ No newline at end of file
diff --git a/code/frontend/build_and_push.sh b/code/frontend/build_and_push.sh
new file mode 100644
index 0000000..6143419
--- /dev/null
+++ b/code/frontend/build_and_push.sh
@@ -0,0 +1,89 @@
+#!/usr/bin/env bash
+
+set -Eueo pipefail
+
+CURRENT_DEV_VERSION=$(cat .version-dev)
+CURRENT_DEV_VERSION_INT=${CURRENT_DEV_VERSION//[!0-9]/}
+CURRENT_VERSION=$(cat .version)
+CURRENT_VERSION_INT=${CURRENT_VERSION//[!0-9]/}
+if [ ${1-prod} == "dev" ]; then
+ NEW_VERSION="v$((CURRENT_DEV_VERSION_INT + 1))-dev"
+ OLD_VERSION=$CURRENT_DEV_VERSION
+else
+ NEW_VERSION="v$((CURRENT_VERSION_INT + 1))"
+ OLD_VERSION=$CURRENT_VERSION
+fi
+IMAGE_NAME="greatoffice/app"
+HUB_NAME="dr.ivar.systems/greatoffice/app"
+
+# Check for uncommited changes and optionally commit them
+if [ "$(git status --untracked-files=no --porcelain)" ]; then
+ echo "Unclean git tree! press CTRL+C to exit or press ENTER to commit and push to the default branch"
+ read -n 1
+
+ read -p "Enter commit message: " COMMIT_MESSAGE
+ git add ..
+ git commit --quiet -m "$COMMIT_MESSAGE"
+fi
+
+if [ ${1-prod} == "dev" ]; then
+ echo $NEW_VERSION >|.version-dev
+ git add .version-dev
+else
+ echo $NEW_VERSION >|.version
+ git add .version
+fi
+
+echo "Starting build of $HUB_NAME:$NEW_VERSION at $(date -u)..."
+echo
+
+# Put version.txt inside of server
+pushd static
+echo "$NEW_VERSION" >version.txt
+git add version.txt
+popd
+
+git commit --quiet -m "chore(release): Bump version"
+
+read -p "Do you want to tag this build? (y/n) " -n 1 -r
+echo
+if [[ $REPLY =~ ^[Yy]$ ]]; then
+ read -p "Enter tag message (can be empty): " TAG_MESSAGE
+ git tag -am "$TAG_MESSAGE" $NEW_VERSION
+fi
+
+read -p "Do you want to push the latest commits and tags to origin? (y/n) " -n 1 -r
+echo
+if [[ $REPLY =~ ^[Yy]$ ]]; then
+ echo "Pushing latest changes to remotes..."
+ echo
+ git push --quiet --follow-tags
+fi
+
+# Build docker image
+echo "Building docker image"
+echo
+
+docker build -t $IMAGE_NAME:$NEW_VERSION .
+
+docker tag $IMAGE_NAME:$NEW_VERSION $HUB_NAME:$NEW_VERSION
+
+if [ ${1-prod} == "dev" ]; then
+ docker tag $IMAGE_NAME:$NEW_VERSION $HUB_NAME:latest-dev
+fi
+if [ ${1-prod} == "prod" ]; then
+ docker tag $IMAGE_NAME:$NEW_VERSION $HUB_NAME:latest
+fi
+
+# Optionally push images to docker registry
+echo "Press CTRL+C to exit or press ENTER to push docker image to registry"
+read -n 1
+docker push $HUB_NAME:$NEW_VERSION
+
+if [ ${1-prod} == "dev" ]; then
+ docker push $HUB_NAME:latest-dev
+fi
+
+if [ ${1-prod} == "prod" ]; then
+ docker push $HUB_NAME:latest
+fi
diff --git a/code/frontend/bun.lockb b/code/frontend/bun.lockb
new file mode 100755
index 0000000..8da4b45
--- /dev/null
+++ b/code/frontend/bun.lockb
Binary files differ
diff --git a/code/frontend/components.json b/code/frontend/components.json
new file mode 100644
index 0000000..cb6136f
--- /dev/null
+++ b/code/frontend/components.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://shadcn-svelte.com/schema.json",
+ "style": "new-york",
+ "tailwind": {
+ "config": "tailwind.config.js",
+ "css": "src/app.pcss",
+ "baseColor": "stone"
+ },
+ "aliases": {
+ "components": "$components",
+ "utils": "$utils/ui"
+ },
+ "typescript": true
+}
diff --git a/code/frontend/package.json b/code/frontend/package.json
new file mode 100644
index 0000000..71f27d3
--- /dev/null
+++ b/code/frontend/package.json
@@ -0,0 +1,65 @@
+{
+ "name": "greatoffice-frontend",
+ "version": "0.0.1",
+ "private": true,
+ "scripts": {
+ "dev": "npm-run-all --parallel vite typesafe-i18n",
+ "typesafe-i18n": "typesafe-i18n",
+ "vite": "vite dev",
+ "build": "vite build",
+ "preview": "vite preview",
+ "test": "npm run test:integration && npm run test:unit",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
+ "lint": "prettier --check . && eslint .",
+ "format": "prettier --write .",
+ "test:integration": "playwright test",
+ "test:unit": "vitest"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.28.1",
+ "@sveltejs/adapter-node": "^5.0.1",
+ "@sveltejs/kit": "^2.0.0",
+ "@sveltejs/vite-plugin-svelte": "^3.0.0",
+ "@tanstack/svelte-query": "^5.29.0",
+ "@tanstack/svelte-table": "^8.16.0",
+ "@types/eslint": "^8.56.0",
+ "@typescript-eslint/eslint-plugin": "^7.0.0",
+ "@faker-js/faker": "^8.4.1",
+ "@typescript-eslint/parser": "^7.0.0",
+ "@vite-pwa/sveltekit": "^0.4.0",
+ "npm-run-all": "^4.1.5",
+ "typesafe-i18n": "^5.26.2",
+ "autoprefixer": "^10.4.16",
+ "bits-ui": "^0.21.3",
+ "clsx": "^2.1.0",
+ "eslint": "^8.56.0",
+ "eslint-config-prettier": "^9.1.0",
+ "eslint-plugin-svelte": "^2.35.1",
+ "formsnap": "^1.0.0",
+ "mode-watcher": "^0.3.0",
+ "paneforge": "^0.0.4",
+ "postcss": "^8.4.32",
+ "postcss-load-config": "^5.0.2",
+ "prettier": "^3.1.1",
+ "prettier-plugin-svelte": "^3.1.2",
+ "prettier-plugin-tailwindcss": "^0.5.9",
+ "scheduler-polyfill": "^1.2.1",
+ "svelte": "^4.2.7",
+ "svelte-check": "^3.6.0",
+ "svelte-interactions": "^0.2.0",
+ "svelte-radix": "^1.1.0",
+ "svelte-sonner": "^0.3.22",
+ "sveltekit-superforms": "^2.12.4",
+ "tailwind-merge": "^2.2.2",
+ "tailwind-variants": "^0.2.1",
+ "tailwindcss": "^3.3.6",
+ "temporal-polyfill": "^0.2.4",
+ "tslib": "^2.4.1",
+ "typescript": "^5.0.0",
+ "vite": "^5.0.3",
+ "ofetch": "^1.3.4",
+ "vitest": "^1.2.0"
+ },
+ "type": "module"
+}
diff --git a/code/frontend/playwright.config.ts b/code/frontend/playwright.config.ts
new file mode 100644
index 0000000..30e57ee
--- /dev/null
+++ b/code/frontend/playwright.config.ts
@@ -0,0 +1,12 @@
+import type { PlaywrightTestConfig } from '@playwright/test';
+
+const config: PlaywrightTestConfig = {
+ webServer: {
+ command: 'bun run build && bun run preview',
+ port: 4173
+ },
+ testDir: 'tests',
+ testMatch: /(.+\.)?(test|spec)\.[jt]s/
+};
+
+export default config;
diff --git a/code/frontend/postcss.config.cjs b/code/frontend/postcss.config.cjs
new file mode 100644
index 0000000..fe10e55
--- /dev/null
+++ b/code/frontend/postcss.config.cjs
@@ -0,0 +1,13 @@
+const tailwindcss = require('tailwindcss');
+const autoprefixer = require('autoprefixer');
+
+const config = {
+ plugins: [
+ //Some plugins, like tailwindcss/nesting, need to run before Tailwind,
+ tailwindcss(),
+ //But others, like autoprefixer, need to run after,
+ autoprefixer
+ ]
+};
+
+module.exports = config;
diff --git a/code/frontend/src/actions/pwKey.ts b/code/frontend/src/actions/pwKey.ts
new file mode 100644
index 0000000..e8f615c
--- /dev/null
+++ b/code/frontend/src/actions/pwKey.ts
@@ -0,0 +1,4 @@
+export default function pwKey(node: HTMLElement, value: string | undefined) {
+ if (!value) return;
+ node.setAttribute("pw-key", value);
+} \ No newline at end of file
diff --git a/code/frontend/src/app.d.ts b/code/frontend/src/app.d.ts
new file mode 100644
index 0000000..743f07b
--- /dev/null
+++ b/code/frontend/src/app.d.ts
@@ -0,0 +1,13 @@
+// See https://kit.svelte.dev/docs/types#app
+// for information about these interfaces
+declare global {
+ namespace App {
+ // interface Error {}
+ // interface Locals {}
+ // interface PageData {}
+ // interface PageState {}
+ // interface Platform {}
+ }
+}
+
+export {};
diff --git a/code/frontend/src/app.html b/code/frontend/src/app.html
new file mode 100644
index 0000000..77a5ff5
--- /dev/null
+++ b/code/frontend/src/app.html
@@ -0,0 +1,12 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <link rel="icon" href="%sveltekit.assets%/favicon.png" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ %sveltekit.head%
+ </head>
+ <body data-sveltekit-preload-data="hover">
+ <div style="display: contents">%sveltekit.body%</div>
+ </body>
+</html>
diff --git a/code/frontend/src/app.pcss b/code/frontend/src/app.pcss
new file mode 100644
index 0000000..37c673f
--- /dev/null
+++ b/code/frontend/src/app.pcss
@@ -0,0 +1,108 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ :root {
+ --background: 0 0% 100%;
+ --foreground: 20 14.3% 4.1%;
+
+ --muted: 60 4.8% 95.9%;
+ --muted-foreground: 25 5.3% 44.7%;
+
+ --popover: 0 0% 100%;
+ --popover-foreground: 20 14.3% 4.1%;
+
+ --card: 0 0% 100%;
+ --card-foreground: 20 14.3% 4.1%;
+
+ --border: 20 5.9% 90%;
+ --input: 20 5.9% 90%;
+
+ --primary: 24 9.8% 10%;
+ --primary-foreground: 60 9.1% 97.8%;
+
+ --secondary: 60 4.8% 95.9%;
+ --secondary-foreground: 24 9.8% 10%;
+
+ --accent: 60 4.8% 95.9%;
+ --accent-foreground: 24 9.8% 10%;
+
+ --destructive: 0 72.2% 50.6%;
+ --destructive-foreground: 60 9.1% 97.8%;
+
+ --ring: 20 14.3% 4.1%;
+
+ --radius: 0.5rem;
+ }
+
+ .dark {
+ --background: 20 14.3% 4.1%;
+ --foreground: 60 9.1% 97.8%;
+
+ --muted: 12 6.5% 15.1%;
+ --muted-foreground: 24 5.4% 63.9%;
+
+ --popover: 20 14.3% 4.1%;
+ --popover-foreground: 60 9.1% 97.8%;
+
+ --card: 20 14.3% 4.1%;
+ --card-foreground: 60 9.1% 97.8%;
+
+ --border: 12 6.5% 15.1%;
+ --input: 12 6.5% 15.1%;
+
+ --primary: 60 9.1% 97.8%;
+ --primary-foreground: 24 9.8% 10%;
+
+ --secondary: 12 6.5% 15.1%;
+ --secondary-foreground: 60 9.1% 97.8%;
+
+ --accent: 12 6.5% 15.1%;
+ --accent-foreground: 60 9.1% 97.8%;
+
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 60 9.1% 97.8%;
+
+ --ring: 24 5.7% 82.9%;
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
+
+pre {
+ font-family: monospace !important;
+}
+
+*:focus-visible {
+ outline: 1px auto;
+}
+
+.c-disabled {
+ cursor: not-allowed !important;
+ filter: opacity(.45);
+ pointer-events: none !important;
+}
+
+.c-disabled.loading {
+ cursor: wait !important;
+}
+
+.link {
+ @apply text-blue-600 hover:text-blue-700 transition duration-300 ease-in-out mb-4 cursor-pointer;
+
+ &.danger {
+ @apply text-red-600 hover:text-red-700;
+ }
+
+ &.active {
+ @apply underline
+ }
+}
diff --git a/code/frontend/src/components/locale-switcher.svelte b/code/frontend/src/components/locale-switcher.svelte
new file mode 100644
index 0000000..fc03f39
--- /dev/null
+++ b/code/frontend/src/components/locale-switcher.svelte
@@ -0,0 +1,56 @@
+<script lang="ts">
+ import pwKey from "$actions/pwKey";
+ import {browser} from "$app/environment";
+ import {page} from "$app/stores";
+ import {CookieNames} from "$configuration";
+ import {setLocale, locale} from "$i18n/i18n-svelte";
+ import type {Locales} from "$i18n/i18n-types";
+ import {locales} from "$i18n/i18n-util";
+ import {loadLocaleAsync} from "$i18n/i18n-util.async";
+ import Cookies from "js-cookie";
+
+ export let _pwKey: string | undefined = undefined;
+ export let tabindex: number | undefined = undefined;
+ let currentLocale = Cookies.get(CookieNames.locale);
+
+ async function switch_locale(newLocale: Locales) {
+ if (!newLocale || $locale === newLocale) return;
+ await loadLocaleAsync(newLocale);
+ setLocale(newLocale);
+ document.querySelector("html")?.setAttribute("lang", newLocale);
+ Cookies.set(CookieNames.locale, newLocale);
+ currentLocale = newLocale;
+ console.log("Switched to: " + newLocale);
+ }
+
+ function on_change(event: Event) {
+ const target = event.target as HTMLSelectElement;
+ switch_locale(target.options[target.selectedIndex].value as Locales);
+ }
+
+ $: if (browser) {
+ switch_locale($page.params.lang as Locales);
+ }
+
+ function get_locale_name(iso: string) {
+ switch (iso) {
+ case "nb": {
+ return "Norsk Bokmål";
+ }
+ case "en": {
+ return "English";
+ }
+ }
+ }
+</script>
+
+<select
+ {tabindex}
+ use:pwKey={_pwKey}
+ on:change={on_change}
+ class="mt-1 mr-1 block border-none py-2 pl-3 pr-10 text-base rounded-md right-0 absolute focus:outline-none focus:ring-teal-500 sm:text-sm"
+>
+ {#each locales as aLocale}
+ <option value={aLocale} selected={aLocale === currentLocale}>{get_locale_name(aLocale)}</option>
+ {/each}
+</select>
diff --git a/code/frontend/src/components/sonner.svelte b/code/frontend/src/components/sonner.svelte
new file mode 100644
index 0000000..422e189
--- /dev/null
+++ b/code/frontend/src/components/sonner.svelte
@@ -0,0 +1,21 @@
+<script lang="ts">
+ import { Toaster as Sonner, type ToasterProps as SonnerProps } from 'svelte-sonner'
+ import { mode } from 'mode-watcher'
+
+ type $$Props = SonnerProps
+</script>
+
+<Sonner
+ theme={$mode}
+ class="toaster group"
+ toastOptions={{
+ classes: {
+ toast:
+ 'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
+ description: 'group-[.toast]:text-muted-foreground',
+ actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
+ cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground'
+ }
+ }}
+ {...$$restProps}
+/>
diff --git a/code/frontend/src/components/style-changer.svelte b/code/frontend/src/components/style-changer.svelte
new file mode 100644
index 0000000..b219b10
--- /dev/null
+++ b/code/frontend/src/components/style-changer.svelte
@@ -0,0 +1,22 @@
+<script lang="ts">
+ import Sun from 'svelte-radix/Sun.svelte'
+ import Moon from 'svelte-radix/Moon.svelte'
+ import { resetMode, setMode } from 'mode-watcher'
+ import { Button } from '$components/ui/button'
+ import * as DropdownMenu from '$components/ui/dropdown-menu'
+</script>
+
+<DropdownMenu.Root>
+ <DropdownMenu.Trigger asChild let:builder>
+ <Button builders={[builder]} variant="outline" size="icon">
+ <Sun class="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
+ <Moon class="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
+ <span class="sr-only">Toggle theme</span>
+ </Button>
+ </DropdownMenu.Trigger>
+ <DropdownMenu.Content align="end">
+ <DropdownMenu.Item on:click={() => setMode('light')}>Light</DropdownMenu.Item>
+ <DropdownMenu.Item on:click={() => setMode('dark')}>Dark</DropdownMenu.Item>
+ <DropdownMenu.Item on:click={() => resetMode()}>System</DropdownMenu.Item>
+ </DropdownMenu.Content>
+</DropdownMenu.Root>
diff --git a/code/frontend/src/components/ui/button/button.svelte b/code/frontend/src/components/ui/button/button.svelte
new file mode 100644
index 0000000..196ae77
--- /dev/null
+++ b/code/frontend/src/components/ui/button/button.svelte
@@ -0,0 +1,25 @@
+<script lang="ts">
+ import { Button as ButtonPrimitive } from 'bits-ui'
+ import { type Events, type Props, buttonVariants } from './index.js'
+ import { cn } from '$utils/ui.js'
+
+ type $$Props = Props
+ type $$Events = Events
+
+ let className: $$Props['class'] = undefined
+ export let variant: $$Props['variant'] = 'default'
+ export let size: $$Props['size'] = 'default'
+ export let builders: $$Props['builders'] = []
+ export { className as class }
+</script>
+
+<ButtonPrimitive.Root
+ {builders}
+ class={cn(buttonVariants({ variant, size, className }))}
+ type="button"
+ {...$$restProps}
+ on:click
+ on:keydown
+>
+ <slot />
+</ButtonPrimitive.Root>
diff --git a/code/frontend/src/components/ui/button/index.ts b/code/frontend/src/components/ui/button/index.ts
new file mode 100644
index 0000000..9cfd91c
--- /dev/null
+++ b/code/frontend/src/components/ui/button/index.ts
@@ -0,0 +1,48 @@
+import type { Button as ButtonPrimitive } from 'bits-ui'
+import { type VariantProps, tv } from 'tailwind-variants'
+import Root from './button.svelte'
+
+const buttonVariants = tv({
+ base: 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
+ destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
+ outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
+ secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
+ ghost: 'hover:bg-accent hover:text-accent-foreground',
+ link: 'text-primary underline-offset-4 hover:underline'
+ },
+ size: {
+ default: 'h-9 px-4 py-2',
+ sm: 'h-8 rounded-md px-3 text-xs',
+ lg: 'h-10 rounded-md px-8',
+ icon: 'h-9 w-9'
+ }
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default'
+ }
+})
+
+type Variant = VariantProps<typeof buttonVariants>['variant']
+type Size = VariantProps<typeof buttonVariants>['size']
+
+type Props = ButtonPrimitive.Props & {
+ variant?: Variant
+ size?: Size
+}
+
+type Events = ButtonPrimitive.Events
+
+export {
+ Root,
+ type Props,
+ type Events,
+ //
+ Root as Button,
+ type Props as ButtonProps,
+ type Events as ButtonEvents,
+ buttonVariants
+}
diff --git a/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte
new file mode 100644
index 0000000..ea02af0
--- /dev/null
+++ b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte
@@ -0,0 +1,35 @@
+<script lang="ts">
+ import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'
+ import Check from 'svelte-radix/Check.svelte'
+ import { cn } from '$utils/ui'
+
+ type $$Props = DropdownMenuPrimitive.CheckboxItemProps
+ type $$Events = DropdownMenuPrimitive.CheckboxItemEvents
+
+ let className: $$Props['class'] = undefined
+ export let checked: $$Props['checked'] = undefined
+ export { className as class }
+</script>
+
+<DropdownMenuPrimitive.CheckboxItem
+ bind:checked
+ class={cn(
+ 'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50',
+ className
+ )}
+ {...$$restProps}
+ on:click
+ on:keydown
+ on:focusin
+ on:focusout
+ on:pointerdown
+ on:pointerleave
+ on:pointermove
+>
+ <span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
+ <DropdownMenuPrimitive.CheckboxIndicator>
+ <Check class="h-4 w-4" />
+ </DropdownMenuPrimitive.CheckboxIndicator>
+ </span>
+ <slot />
+</DropdownMenuPrimitive.CheckboxItem>
diff --git a/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-content.svelte b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-content.svelte
new file mode 100644
index 0000000..a2b8da7
--- /dev/null
+++ b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-content.svelte
@@ -0,0 +1,26 @@
+<script lang="ts">
+ import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'
+ import { cn, flyAndScale } from '$utils/ui'
+
+ type $$Props = DropdownMenuPrimitive.ContentProps
+
+ let className: $$Props['class'] = undefined
+ export let sideOffset: $$Props['sideOffset'] = 4
+ export let transition: $$Props['transition'] = flyAndScale
+ export let transitionConfig: $$Props['transitionConfig'] = undefined
+ export { className as class }
+</script>
+
+<DropdownMenuPrimitive.Content
+ {transition}
+ {transitionConfig}
+ {sideOffset}
+ class={cn(
+ 'z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md focus:outline-none',
+ className
+ )}
+ {...$$restProps}
+ on:keydown
+>
+ <slot />
+</DropdownMenuPrimitive.Content>
diff --git a/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-item.svelte b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-item.svelte
new file mode 100644
index 0000000..ed45da7
--- /dev/null
+++ b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-item.svelte
@@ -0,0 +1,31 @@
+<script lang="ts">
+ import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'
+ import { cn } from '$utils/ui'
+
+ type $$Props = DropdownMenuPrimitive.ItemProps & {
+ inset?: boolean
+ }
+ type $$Events = DropdownMenuPrimitive.ItemEvents
+
+ let className: $$Props['class'] = undefined
+ export let inset: $$Props['inset'] = undefined
+ export { className as class }
+</script>
+
+<DropdownMenuPrimitive.Item
+ class={cn(
+ 'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50',
+ inset && 'pl-8',
+ className
+ )}
+ on:click
+ on:keydown
+ on:focusin
+ on:focusout
+ on:pointerdown
+ on:pointerleave
+ on:pointermove
+ {...$$restProps}
+>
+ <slot />
+</DropdownMenuPrimitive.Item>
diff --git a/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-label.svelte b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-label.svelte
new file mode 100644
index 0000000..69fddd1
--- /dev/null
+++ b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-label.svelte
@@ -0,0 +1,19 @@
+<script lang="ts">
+ import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'
+ import { cn } from '$utils/ui'
+
+ type $$Props = DropdownMenuPrimitive.LabelProps & {
+ inset?: boolean
+ }
+
+ let className: $$Props['class'] = undefined
+ export let inset: $$Props['inset'] = undefined
+ export { className as class }
+</script>
+
+<DropdownMenuPrimitive.Label
+ class={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
+ {...$$restProps}
+>
+ <slot />
+</DropdownMenuPrimitive.Label>
diff --git a/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte
new file mode 100644
index 0000000..c07bd1a
--- /dev/null
+++ b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte
@@ -0,0 +1,11 @@
+<script lang="ts">
+ import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'
+
+ type $$Props = DropdownMenuPrimitive.RadioGroupProps
+
+ export let value: $$Props['value'] = undefined
+</script>
+
+<DropdownMenuPrimitive.RadioGroup {...$$restProps} bind:value>
+ <slot />
+</DropdownMenuPrimitive.RadioGroup>
diff --git a/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte
new file mode 100644
index 0000000..c754953
--- /dev/null
+++ b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte
@@ -0,0 +1,35 @@
+<script lang="ts">
+ import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'
+ import DotFilled from 'svelte-radix/DotFilled.svelte'
+ import { cn } from '$utils/ui'
+
+ type $$Props = DropdownMenuPrimitive.RadioItemProps
+ type $$Events = DropdownMenuPrimitive.RadioItemEvents
+
+ let className: $$Props['class'] = undefined
+ export let value: DropdownMenuPrimitive.RadioItemProps['value']
+ export { className as class }
+</script>
+
+<DropdownMenuPrimitive.RadioItem
+ class={cn(
+ 'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50',
+ className
+ )}
+ {value}
+ {...$$restProps}
+ on:click
+ on:keydown
+ on:focusin
+ on:focusout
+ on:pointerdown
+ on:pointerleave
+ on:pointermove
+>
+ <span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
+ <DropdownMenuPrimitive.RadioIndicator>
+ <DotFilled class="h-4 w-4 fill-current" />
+ </DropdownMenuPrimitive.RadioIndicator>
+ </span>
+ <slot />
+</DropdownMenuPrimitive.RadioItem>
diff --git a/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-separator.svelte b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-separator.svelte
new file mode 100644
index 0000000..b6c5798
--- /dev/null
+++ b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-separator.svelte
@@ -0,0 +1,11 @@
+<script lang="ts">
+ import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'
+ import { cn } from '$utils/ui'
+
+ type $$Props = DropdownMenuPrimitive.SeparatorProps
+
+ let className: $$Props['class'] = undefined
+ export { className as class }
+</script>
+
+<DropdownMenuPrimitive.Separator class={cn('-mx-1 my-1 h-px bg-muted', className)} {...$$restProps} />
diff --git a/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte
new file mode 100644
index 0000000..f9e5953
--- /dev/null
+++ b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte
@@ -0,0 +1,13 @@
+<script lang="ts">
+ import type { HTMLAttributes } from 'svelte/elements'
+ import { cn } from '$utils/ui'
+
+ type $$Props = HTMLAttributes<HTMLSpanElement>
+
+ let className: $$Props['class'] = undefined
+ export { className as class }
+</script>
+
+<span class={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...$$restProps}>
+ <slot />
+</span>
diff --git a/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte
new file mode 100644
index 0000000..7c00a1b
--- /dev/null
+++ b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte
@@ -0,0 +1,29 @@
+<script lang="ts">
+ import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'
+ import { cn, flyAndScale } from '$utils/ui'
+
+ type $$Props = DropdownMenuPrimitive.SubContentProps
+
+ let className: $$Props['class'] = undefined
+ export let transition: $$Props['transition'] = flyAndScale
+ export let transitionConfig: $$Props['transitionConfig'] = {
+ x: -10,
+ y: 0
+ }
+ export { className as class }
+</script>
+
+<DropdownMenuPrimitive.SubContent
+ {transition}
+ {transitionConfig}
+ class={cn(
+ 'z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-lg focus:outline-none',
+ className
+ )}
+ {...$$restProps}
+ on:keydown
+ on:focusout
+ on:pointermove
+>
+ <slot />
+</DropdownMenuPrimitive.SubContent>
diff --git a/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte
new file mode 100644
index 0000000..4967d2b
--- /dev/null
+++ b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte
@@ -0,0 +1,32 @@
+<script lang="ts">
+ import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'
+ import ChevronRight from 'svelte-radix/ChevronRight.svelte'
+ import { cn } from '$utils/ui'
+
+ type $$Props = DropdownMenuPrimitive.SubTriggerProps & {
+ inset?: boolean
+ }
+ type $$Events = DropdownMenuPrimitive.SubTriggerEvents
+
+ let className: $$Props['class'] = undefined
+ export let inset: $$Props['inset'] = undefined
+ export { className as class }
+</script>
+
+<DropdownMenuPrimitive.SubTrigger
+ class={cn(
+ 'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[state=open]:bg-accent data-[highlighted]:text-accent-foreground data-[state=open]:text-accent-foreground',
+ inset && 'pl-8',
+ className
+ )}
+ {...$$restProps}
+ on:click
+ on:keydown
+ on:focusin
+ on:focusout
+ on:pointerleave
+ on:pointermove
+>
+ <slot />
+ <ChevronRight class="ml-auto h-4 w-4" />
+</DropdownMenuPrimitive.SubTrigger>
diff --git a/code/frontend/src/components/ui/dropdown-menu/index.ts b/code/frontend/src/components/ui/dropdown-menu/index.ts
new file mode 100644
index 0000000..df959fa
--- /dev/null
+++ b/code/frontend/src/components/ui/dropdown-menu/index.ts
@@ -0,0 +1,48 @@
+import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'
+import Item from './dropdown-menu-item.svelte'
+import Label from './dropdown-menu-label.svelte'
+import Content from './dropdown-menu-content.svelte'
+import Shortcut from './dropdown-menu-shortcut.svelte'
+import RadioItem from './dropdown-menu-radio-item.svelte'
+import Separator from './dropdown-menu-separator.svelte'
+import RadioGroup from './dropdown-menu-radio-group.svelte'
+import SubContent from './dropdown-menu-sub-content.svelte'
+import SubTrigger from './dropdown-menu-sub-trigger.svelte'
+import CheckboxItem from './dropdown-menu-checkbox-item.svelte'
+
+const Sub = DropdownMenuPrimitive.Sub
+const Root = DropdownMenuPrimitive.Root
+const Trigger = DropdownMenuPrimitive.Trigger
+const Group = DropdownMenuPrimitive.Group
+
+export {
+ Sub,
+ Root,
+ Item,
+ Label,
+ Group,
+ Trigger,
+ Content,
+ Shortcut,
+ Separator,
+ RadioItem,
+ SubContent,
+ SubTrigger,
+ RadioGroup,
+ CheckboxItem,
+ //
+ Root as DropdownMenu,
+ Sub as DropdownMenuSub,
+ Item as DropdownMenuItem,
+ Label as DropdownMenuLabel,
+ Group as DropdownMenuGroup,
+ Content as DropdownMenuContent,
+ Trigger as DropdownMenuTrigger,
+ Shortcut as DropdownMenuShortcut,
+ RadioItem as DropdownMenuRadioItem,
+ Separator as DropdownMenuSeparator,
+ RadioGroup as DropdownMenuRadioGroup,
+ SubContent as DropdownMenuSubContent,
+ SubTrigger as DropdownMenuSubTrigger,
+ CheckboxItem as DropdownMenuCheckboxItem
+}
diff --git a/code/frontend/src/configuration/index.ts b/code/frontend/src/configuration/index.ts
new file mode 100644
index 0000000..1ffd67f
--- /dev/null
+++ b/code/frontend/src/configuration/index.ts
@@ -0,0 +1,38 @@
+export const APP_ADDRESS = "https://stage.greatoffice.app";
+export const API_ADDRESS = "https://stage-api.greatoffice.app";
+export const DEV_APP_ADDRESS = "http://localhost";
+export const DEV_API_ADDRESS = "http://localhost:5000";
+
+export function api_base(path: string = "", explicitVersion = 1): string {
+ if (path && !path.startsWith("_")) path = "v" + explicitVersion + path;
+ return (is_development() ? DEV_API_ADDRESS : API_ADDRESS) + (path !== "" ? "/" + path : "");
+}
+
+export function is_development(): boolean {
+ return import.meta.env.DEV;
+}
+
+export const CookieNames = {
+ theme: "go_theme",
+ locale: "go_locale",
+ session: "go_session",
+};
+
+export const QueryKeys = {
+ labels: "labels",
+ categories: "categories",
+ entries: "entries",
+};
+
+export const StorageKeys = {
+ session: "sessionData",
+ theme: "theme",
+ debug: "debug",
+ categories: "categories",
+ labels: "labels",
+ entries: "entries",
+ stopwatch: "stopwatchState",
+ logLevel: "logLevel",
+};
+
+export type PortalMessage = "emailValidated"; \ No newline at end of file
diff --git a/code/frontend/src/configuration/test.ts b/code/frontend/src/configuration/test.ts
new file mode 100644
index 0000000..12392de
--- /dev/null
+++ b/code/frontend/src/configuration/test.ts
@@ -0,0 +1,21 @@
+import {env} from "$env/dynamic/private";
+
+export function get_test_context(): TestContext {
+ return {
+ user: {
+ username: env.TEST_USERNAME,
+ password: env.TEST_PASSWORD,
+ },
+ };
+}
+
+export function is_testing(): boolean {
+ return env.TESTING == "true";
+}
+
+export interface TestContext {
+ user: {
+ username: string,
+ password: string
+ };
+}
diff --git a/code/frontend/src/global.d.ts b/code/frontend/src/global.d.ts
new file mode 100644
index 0000000..13f5e16
--- /dev/null
+++ b/code/frontend/src/global.d.ts
@@ -0,0 +1,11 @@
+/// <reference types="@sveltejs/kit" />
+
+type Locales = import('$lib/i18n/i18n-types').Locales
+type TranslationFunctions = import('$lib/i18n/i18n-types').TranslationFunctions
+
+declare namespace App {
+ interface Locals {
+ locale: Locales
+ LL: TranslationFunctions
+ }
+} \ No newline at end of file
diff --git a/code/frontend/src/hooks.server.ts b/code/frontend/src/hooks.server.ts
new file mode 100644
index 0000000..b636e31
--- /dev/null
+++ b/code/frontend/src/hooks.server.ts
@@ -0,0 +1,48 @@
+import {CookieNames} from "$configuration";
+import {detectLocale, i18n, isLocale, locales} from "$i18n/i18n-util";
+import type {Handle, RequestEvent} from "@sveltejs/kit";
+import {initAcceptLanguageHeaderDetector} from "typesafe-i18n/detectors";
+import type {Locales} from "$i18n/i18n-types";
+import {loadAllLocales} from "$i18n/i18n-util.sync";
+
+loadAllLocales();
+const L = i18n();
+
+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.debug("Handling locale", {
+ locales,
+ localeCookie,
+ preferredLocale,
+ finalLocale,
+ });
+
+ if (!isLocale(finalLocale)) {
+ console.debug(finalLocale + " is not a valid locale or it does not exist, switching to default: en");
+ finalLocale = "en";
+ forceCookieSet = true;
+ }
+
+ if (!localeCookie || forceCookieSet) {
+ // Set a locale cookie
+ event.cookies.set(CookieNames.locale, finalLocale, {
+ sameSite: "strict",
+ path: "/",
+ httpOnly: false,
+ });
+ }
+
+ event.locals.locale = finalLocale as Locales;
+ event.locals.LL = L[finalLocale as Locales];
+
+ return resolve(event, {transformPageChunk: ({html}) => html.replace("%lang%", finalLocale)});
+};
+
+function getPreferredLocale(event: RequestEvent) {
+ const acceptLanguageDetector = initAcceptLanguageHeaderDetector(event.request);
+ return detectLocale(acceptLanguageDetector);
+}
diff --git a/code/frontend/src/i18n/en/app/index.ts b/code/frontend/src/i18n/en/app/index.ts
new file mode 100644
index 0000000..7ccfc97
--- /dev/null
+++ b/code/frontend/src/i18n/en/app/index.ts
@@ -0,0 +1,7 @@
+import type { BaseTranslation } from '../../i18n-types'
+
+const en_app: BaseTranslation = {
+ members: "Members",
+}
+
+export default en_app \ No newline at end of file
diff --git a/code/frontend/src/i18n/en/index.ts b/code/frontend/src/i18n/en/index.ts
new file mode 100644
index 0000000..b38eb48
--- /dev/null
+++ b/code/frontend/src/i18n/en/index.ts
@@ -0,0 +1,63 @@
+import type { BaseTranslation } from "../i18n-types";
+
+const en: BaseTranslation = {
+ or: "Or",
+ name: "Name",
+ emailAddress: "Email address",
+ password: "Password",
+ pageNotFound: "Page not found",
+ noInternet: "It seems like your device does not have a internet connection, please check your connection.",
+ reset: "Reset",
+ of: "{0} of {1}",
+ isRequired: "{0} is required",
+ submit: "Submit",
+ success: "Success",
+ tryAgainSoon: "Try again soon",
+ createANewAccount: "Create a new account",
+ unexpectedError: "An unexpected error occured",
+ notFound: "Not found",
+ documentation: "Documentation",
+ tos: "Terms of service",
+ privacyPolicy: "Privacy policy",
+ signIntoYourAccount: "Sign into your account",
+ combobox: {
+ search: "Search",
+ noRecordsFound: "No records found",
+ createRecordHelpText: "Create a record by typing the name in the search bar and pressing enter",
+ createRecordButtonText: "Press enter or click here to create {0}"
+ },
+ signInPage: {
+ title: "Sign in",
+ notMyComputer: "This is not my computer",
+ resetPassword: "Reset password",
+ yourPasswordIsUpdated: "Your password is updated",
+ signIn: "Sign In",
+ yourNewPasswordIsApplied: "Your new password is applied",
+ signInBelow: "Sign in below",
+ yourAccountIsDisabled: "Your account is disabled",
+ contactYourAdminIfDisabled: "Contact your administrator if this feels wrong",
+ youHaveReachedInactivityLimit: "You've reached the hidden inactivity limit",
+ feelFreeToSignInAgain: "Feel free to sign in again"
+ },
+ signUpPage: {
+ title: "Sign up",
+ createYourNewAccount: "Create your new account",
+ },
+ resetPasswordPage: {
+ title: "Reset password",
+ fulfillTitle: "Set new password",
+ setANewPassword: "Set a new password",
+ expired: "Expired",
+ requestHasExpired: "Your request has expired",
+ requestANewReset: "Request a new reset",
+ invalidRequestTitle: "Your request is invalid",
+ invalidRequestMessage: "This could be due to it being expired, nonexsistent or something else",
+ newPassword: "New password",
+ requestSentMessage: "If we find your email address in our systems, you will receive an email with instructions on how to set a new password for your account.",
+ requestAPasswordReset: "Request a password reset",
+ requestNotFound: "Your request was not found",
+ submitANewRequestBelow: "Submit a new reset request below"
+ }
+};
+
+export default en;
diff --git a/code/frontend/src/i18n/formatters.ts b/code/frontend/src/i18n/formatters.ts
new file mode 100644
index 0000000..f310eb7
--- /dev/null
+++ b/code/frontend/src/i18n/formatters.ts
@@ -0,0 +1,13 @@
+import { capitalise } from "$utils/misc-helpers";
+import type { FormattersInitializer } from "typesafe-i18n";
+import type { Locales, Formatters } from "./i18n-types";
+
+export const initFormatters: FormattersInitializer<Locales, Formatters> = (locale: Locales) => {
+
+ const formatters: Formatters = {
+ // add your formatter functions here
+ capitalise: (value: string) => capitalise(value),
+ };
+
+ return formatters;
+};
diff --git a/code/frontend/src/i18n/i18n-svelte.ts b/code/frontend/src/i18n/i18n-svelte.ts
new file mode 100644
index 0000000..6cdffb3
--- /dev/null
+++ b/code/frontend/src/i18n/i18n-svelte.ts
@@ -0,0 +1,12 @@
+// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
+/* eslint-disable */
+
+import { initI18nSvelte } from 'typesafe-i18n/svelte'
+import type { Formatters, Locales, TranslationFunctions, Translations } from './i18n-types'
+import { loadedFormatters, loadedLocales } from './i18n-util'
+
+const { locale, LL, setLocale } = initI18nSvelte<Locales, Translations, TranslationFunctions, Formatters>(loadedLocales, loadedFormatters)
+
+export { locale, LL, setLocale }
+
+export default LL
diff --git a/code/frontend/src/i18n/i18n-types.ts b/code/frontend/src/i18n/i18n-types.ts
new file mode 100644
index 0000000..ef1d664
--- /dev/null
+++ b/code/frontend/src/i18n/i18n-types.ts
@@ -0,0 +1,461 @@
+// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
+/* eslint-disable */
+import type { BaseTranslation as BaseTranslationType, LocalizedString, RequiredParams } from 'typesafe-i18n'
+
+export type BaseTranslation = BaseTranslationType & DisallowNamespaces
+export type BaseLocale = 'en'
+
+export type Locales =
+ | 'en'
+ | 'nb'
+
+export type Translation = RootTranslation & DisallowNamespaces
+
+export type Translations = RootTranslation &
+{
+ app: NamespaceAppTranslation
+}
+
+type RootTranslation = {
+ /**
+ * O​r
+ */
+ or: string
+ /**
+ * N​a​m​e
+ */
+ name: string
+ /**
+ * E​m​a​i​l​ ​a​d​d​r​e​s​s
+ */
+ emailAddress: string
+ /**
+ * P​a​s​s​w​o​r​d
+ */
+ password: string
+ /**
+ * P​a​g​e​ ​n​o​t​ ​f​o​u​n​d
+ */
+ pageNotFound: string
+ /**
+ * I​t​ ​s​e​e​m​s​ ​l​i​k​e​ ​y​o​u​r​ ​d​e​v​i​c​e​ ​d​o​e​s​ ​n​o​t​ ​h​a​v​e​ ​a​ ​i​n​t​e​r​n​e​t​ ​c​o​n​n​e​c​t​i​o​n​,​ ​p​l​e​a​s​e​ ​c​h​e​c​k​ ​y​o​u​r​ ​c​o​n​n​e​c​t​i​o​n​.
+ */
+ noInternet: string
+ /**
+ * R​e​s​e​t
+ */
+ reset: string
+ /**
+ * {​0​}​ ​o​f​ ​{​1​}
+ * @param {unknown} 0
+ * @param {unknown} 1
+ */
+ of: RequiredParams<'0' | '1'>
+ /**
+ * {​0​}​ ​i​s​ ​r​e​q​u​i​r​e​d
+ * @param {unknown} 0
+ */
+ isRequired: RequiredParams<'0'>
+ /**
+ * S​u​b​m​i​t
+ */
+ submit: string
+ /**
+ * S​u​c​c​e​s​s
+ */
+ success: string
+ /**
+ * T​r​y​ ​a​g​a​i​n​ ​s​o​o​n
+ */
+ tryAgainSoon: string
+ /**
+ * C​r​e​a​t​e​ ​a​ ​n​e​w​ ​a​c​c​o​u​n​t
+ */
+ createANewAccount: string
+ /**
+ * A​n​ ​u​n​e​x​p​e​c​t​e​d​ ​e​r​r​o​r​ ​o​c​c​u​r​e​d
+ */
+ unexpectedError: string
+ /**
+ * N​o​t​ ​f​o​u​n​d
+ */
+ notFound: string
+ /**
+ * D​o​c​u​m​e​n​t​a​t​i​o​n
+ */
+ documentation: string
+ /**
+ * T​e​r​m​s​ ​o​f​ ​s​e​r​v​i​c​e
+ */
+ tos: string
+ /**
+ * P​r​i​v​a​c​y​ ​p​o​l​i​c​y
+ */
+ privacyPolicy: string
+ /**
+ * S​i​g​n​ ​i​n​t​o​ ​y​o​u​r​ ​a​c​c​o​u​n​t
+ */
+ signIntoYourAccount: string
+ combobox: {
+ /**
+ * S​e​a​r​c​h
+ */
+ search: string
+ /**
+ * N​o​ ​r​e​c​o​r​d​s​ ​f​o​u​n​d
+ */
+ noRecordsFound: string
+ /**
+ * C​r​e​a​t​e​ ​a​ ​r​e​c​o​r​d​ ​b​y​ ​t​y​p​i​n​g​ ​t​h​e​ ​n​a​m​e​ ​i​n​ ​t​h​e​ ​s​e​a​r​c​h​ ​b​a​r​ ​a​n​d​ ​p​r​e​s​s​i​n​g​ ​e​n​t​e​r
+ */
+ createRecordHelpText: string
+ /**
+ * P​r​e​s​s​ ​e​n​t​e​r​ ​o​r​ ​c​l​i​c​k​ ​h​e​r​e​ ​t​o​ ​c​r​e​a​t​e​ ​{​0​}
+ * @param {unknown} 0
+ */
+ createRecordButtonText: RequiredParams<'0'>
+ }
+ signInPage: {
+ /**
+ * S​i​g​n​ ​i​n
+ */
+ title: string
+ /**
+ * T​h​i​s​ ​i​s​ ​n​o​t​ ​m​y​ ​c​o​m​p​u​t​e​r
+ */
+ notMyComputer: string
+ /**
+ * R​e​s​e​t​ ​p​a​s​s​w​o​r​d
+ */
+ resetPassword: string
+ /**
+ * Y​o​u​r​ ​p​a​s​s​w​o​r​d​ ​i​s​ ​u​p​d​a​t​e​d
+ */
+ yourPasswordIsUpdated: string
+ /**
+ * S​i​g​n​ ​I​n
+ */
+ signIn: string
+ /**
+ * Y​o​u​r​ ​n​e​w​ ​p​a​s​s​w​o​r​d​ ​i​s​ ​a​p​p​l​i​e​d
+ */
+ yourNewPasswordIsApplied: string
+ /**
+ * S​i​g​n​ ​i​n​ ​b​e​l​o​w
+ */
+ signInBelow: string
+ /**
+ * Y​o​u​r​ ​a​c​c​o​u​n​t​ ​i​s​ ​d​i​s​a​b​l​e​d
+ */
+ yourAccountIsDisabled: string
+ /**
+ * C​o​n​t​a​c​t​ ​y​o​u​r​ ​a​d​m​i​n​i​s​t​r​a​t​o​r​ ​i​f​ ​t​h​i​s​ ​f​e​e​l​s​ ​w​r​o​n​g
+ */
+ contactYourAdminIfDisabled: string
+ /**
+ * Y​o​u​'​v​e​ ​r​e​a​c​h​e​d​ ​t​h​e​ ​h​i​d​d​e​n​ ​i​n​a​c​t​i​v​i​t​y​ ​l​i​m​i​t
+ */
+ youHaveReachedInactivityLimit: string
+ /**
+ * F​e​e​l​ ​f​r​e​e​ ​t​o​ ​s​i​g​n​ ​i​n​ ​a​g​a​i​n
+ */
+ feelFreeToSignInAgain: string
+ }
+ signUpPage: {
+ /**
+ * S​i​g​n​ ​u​p
+ */
+ title: string
+ /**
+ * C​r​e​a​t​e​ ​y​o​u​r​ ​n​e​w​ ​a​c​c​o​u​n​t
+ */
+ createYourNewAccount: string
+ }
+ resetPasswordPage: {
+ /**
+ * R​e​s​e​t​ ​p​a​s​s​w​o​r​d
+ */
+ title: string
+ /**
+ * S​e​t​ ​n​e​w​ ​p​a​s​s​w​o​r​d
+ */
+ fulfillTitle: string
+ /**
+ * S​e​t​ ​a​ ​n​e​w​ ​p​a​s​s​w​o​r​d
+ */
+ setANewPassword: string
+ /**
+ * E​x​p​i​r​e​d
+ */
+ expired: string
+ /**
+ * Y​o​u​r​ ​r​e​q​u​e​s​t​ ​h​a​s​ ​e​x​p​i​r​e​d
+ */
+ requestHasExpired: string
+ /**
+ * R​e​q​u​e​s​t​ ​a​ ​n​e​w​ ​r​e​s​e​t
+ */
+ requestANewReset: string
+ /**
+ * Y​o​u​r​ ​r​e​q​u​e​s​t​ ​i​s​ ​i​n​v​a​l​i​d
+ */
+ invalidRequestTitle: string
+ /**
+ * T​h​i​s​ ​c​o​u​l​d​ ​b​e​ ​d​u​e​ ​t​o​ ​i​t​ ​b​e​i​n​g​ ​e​x​p​i​r​e​d​,​ ​n​o​n​e​x​s​i​s​t​e​n​t​ ​o​r​ ​s​o​m​e​t​h​i​n​g​ ​e​l​s​e
+ */
+ invalidRequestMessage: string
+ /**
+ * N​e​w​ ​p​a​s​s​w​o​r​d
+ */
+ newPassword: string
+ /**
+ * I​f​ ​w​e​ ​f​i​n​d​ ​y​o​u​r​ ​e​m​a​i​l​ ​a​d​d​r​e​s​s​ ​i​n​ ​o​u​r​ ​s​y​s​t​e​m​s​,​ ​y​o​u​ ​w​i​l​l​ ​r​e​c​e​i​v​e​ ​a​n​ ​e​m​a​i​l​ ​w​i​t​h​ ​i​n​s​t​r​u​c​t​i​o​n​s​ ​o​n​ ​h​o​w​ ​t​o​ ​s​e​t​ ​a​ ​n​e​w​ ​p​a​s​s​w​o​r​d​ ​f​o​r​ ​y​o​u​r​ ​a​c​c​o​u​n​t​.
+ */
+ requestSentMessage: string
+ /**
+ * R​e​q​u​e​s​t​ ​a​ ​p​a​s​s​w​o​r​d​ ​r​e​s​e​t
+ */
+ requestAPasswordReset: string
+ /**
+ * Y​o​u​r​ ​r​e​q​u​e​s​t​ ​w​a​s​ ​n​o​t​ ​f​o​u​n​d
+ */
+ requestNotFound: string
+ /**
+ * S​u​b​m​i​t​ ​a​ ​n​e​w​ ​r​e​s​e​t​ ​r​e​q​u​e​s​t​ ​b​e​l​o​w
+ */
+ submitANewRequestBelow: string
+ }
+}
+
+export type NamespaceAppTranslation = {
+ /**
+ * M​e​m​b​e​r​s
+ */
+ members: string
+}
+
+export type Namespaces =
+ | 'app'
+
+type DisallowNamespaces = {
+ /**
+ * reserved for 'app'-namespace\
+ * you need to use the `./app/index.ts` file instead
+ */
+ app?: "[typesafe-i18n] reserved for 'app'-namespace. You need to use the `./app/index.ts` file instead."
+}
+
+export type TranslationFunctions = {
+ /**
+ * Or
+ */
+ or: () => LocalizedString
+ /**
+ * Name
+ */
+ name: () => LocalizedString
+ /**
+ * Email address
+ */
+ emailAddress: () => LocalizedString
+ /**
+ * Password
+ */
+ password: () => LocalizedString
+ /**
+ * Page not found
+ */
+ pageNotFound: () => LocalizedString
+ /**
+ * It seems like your device does not have a internet connection, please check your connection.
+ */
+ noInternet: () => LocalizedString
+ /**
+ * Reset
+ */
+ reset: () => LocalizedString
+ /**
+ * {0} of {1}
+ */
+ of: (arg0: unknown, arg1: unknown) => LocalizedString
+ /**
+ * {0} is required
+ */
+ isRequired: (arg0: unknown) => LocalizedString
+ /**
+ * Submit
+ */
+ submit: () => LocalizedString
+ /**
+ * Success
+ */
+ success: () => LocalizedString
+ /**
+ * Try again soon
+ */
+ tryAgainSoon: () => LocalizedString
+ /**
+ * Create a new account
+ */
+ createANewAccount: () => LocalizedString
+ /**
+ * An unexpected error occured
+ */
+ unexpectedError: () => LocalizedString
+ /**
+ * Not found
+ */
+ notFound: () => LocalizedString
+ /**
+ * Documentation
+ */
+ documentation: () => LocalizedString
+ /**
+ * Terms of service
+ */
+ tos: () => LocalizedString
+ /**
+ * Privacy policy
+ */
+ privacyPolicy: () => LocalizedString
+ /**
+ * Sign into your account
+ */
+ signIntoYourAccount: () => LocalizedString
+ combobox: {
+ /**
+ * Search
+ */
+ search: () => LocalizedString
+ /**
+ * No records found
+ */
+ noRecordsFound: () => LocalizedString
+ /**
+ * Create a record by typing the name in the search bar and pressing enter
+ */
+ createRecordHelpText: () => LocalizedString
+ /**
+ * Press enter or click here to create {0}
+ */
+ createRecordButtonText: (arg0: unknown) => LocalizedString
+ }
+ signInPage: {
+ /**
+ * Sign in
+ */
+ title: () => LocalizedString
+ /**
+ * This is not my computer
+ */
+ notMyComputer: () => LocalizedString
+ /**
+ * Reset password
+ */
+ resetPassword: () => LocalizedString
+ /**
+ * Your password is updated
+ */
+ yourPasswordIsUpdated: () => LocalizedString
+ /**
+ * Sign In
+ */
+ signIn: () => LocalizedString
+ /**
+ * Your new password is applied
+ */
+ yourNewPasswordIsApplied: () => LocalizedString
+ /**
+ * Sign in below
+ */
+ signInBelow: () => LocalizedString
+ /**
+ * Your account is disabled
+ */
+ yourAccountIsDisabled: () => LocalizedString
+ /**
+ * Contact your administrator if this feels wrong
+ */
+ contactYourAdminIfDisabled: () => LocalizedString
+ /**
+ * You've reached the hidden inactivity limit
+ */
+ youHaveReachedInactivityLimit: () => LocalizedString
+ /**
+ * Feel free to sign in again
+ */
+ feelFreeToSignInAgain: () => LocalizedString
+ }
+ signUpPage: {
+ /**
+ * Sign up
+ */
+ title: () => LocalizedString
+ /**
+ * Create your new account
+ */
+ createYourNewAccount: () => LocalizedString
+ }
+ resetPasswordPage: {
+ /**
+ * Reset password
+ */
+ title: () => LocalizedString
+ /**
+ * Set new password
+ */
+ fulfillTitle: () => LocalizedString
+ /**
+ * Set a new password
+ */
+ setANewPassword: () => LocalizedString
+ /**
+ * Expired
+ */
+ expired: () => LocalizedString
+ /**
+ * Your request has expired
+ */
+ requestHasExpired: () => LocalizedString
+ /**
+ * Request a new reset
+ */
+ requestANewReset: () => LocalizedString
+ /**
+ * Your request is invalid
+ */
+ invalidRequestTitle: () => LocalizedString
+ /**
+ * This could be due to it being expired, nonexsistent or something else
+ */
+ invalidRequestMessage: () => LocalizedString
+ /**
+ * New password
+ */
+ newPassword: () => LocalizedString
+ /**
+ * If we find your email address in our systems, you will receive an email with instructions on how to set a new password for your account.
+ */
+ requestSentMessage: () => LocalizedString
+ /**
+ * Request a password reset
+ */
+ requestAPasswordReset: () => LocalizedString
+ /**
+ * Your request was not found
+ */
+ requestNotFound: () => LocalizedString
+ /**
+ * Submit a new reset request below
+ */
+ submitANewRequestBelow: () => LocalizedString
+ }
+ app: {
+ /**
+ * Members
+ */
+ members: () => LocalizedString
+ }
+}
+
+export type Formatters = {}
diff --git a/code/frontend/src/i18n/i18n-util.async.ts b/code/frontend/src/i18n/i18n-util.async.ts
new file mode 100644
index 0000000..2e6717e
--- /dev/null
+++ b/code/frontend/src/i18n/i18n-util.async.ts
@@ -0,0 +1,42 @@
+// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
+/* eslint-disable */
+
+import { initFormatters } from './formatters'
+import type { Locales, Namespaces, Translations } from './i18n-types'
+import { loadedFormatters, loadedLocales, locales } from './i18n-util'
+
+const localeTranslationLoaders = {
+ en: () => import('./en'),
+ nb: () => import('./nb'),
+}
+
+const localeNamespaceLoaders = {
+ en: {
+ app: () => import('./en/app')
+ },
+ nb: {
+ app: () => import('./nb/app')
+ }
+}
+
+const updateDictionary = (locale: Locales, dictionary: Partial<Translations>): Translations =>
+ loadedLocales[locale] = { ...loadedLocales[locale], ...dictionary }
+
+export const importLocaleAsync = async (locale: Locales): Promise<Translations> =>
+ (await localeTranslationLoaders[locale]()).default as unknown as Translations
+
+export const loadLocaleAsync = async (locale: Locales): Promise<void> => {
+ updateDictionary(locale, await importLocaleAsync(locale))
+ loadFormatters(locale)
+}
+
+export const loadAllLocalesAsync = (): Promise<void[]> => Promise.all(locales.map(loadLocaleAsync))
+
+export const loadFormatters = (locale: Locales): void =>
+ void (loadedFormatters[locale] = initFormatters(locale))
+
+export const importNamespaceAsync = async<Namespace extends Namespaces>(locale: Locales, namespace: Namespace) =>
+ (await localeNamespaceLoaders[locale][namespace]()).default as unknown as Translations[Namespace]
+
+export const loadNamespaceAsync = async <Namespace extends Namespaces>(locale: Locales, namespace: Namespace): Promise<void> =>
+ void updateDictionary(locale, { [namespace]: await importNamespaceAsync(locale, namespace )})
diff --git a/code/frontend/src/i18n/i18n-util.sync.ts b/code/frontend/src/i18n/i18n-util.sync.ts
new file mode 100644
index 0000000..8144fdc
--- /dev/null
+++ b/code/frontend/src/i18n/i18n-util.sync.ts
@@ -0,0 +1,35 @@
+// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
+/* eslint-disable */
+
+import { initFormatters } from './formatters'
+import type { Locales, Translations } from './i18n-types'
+import { loadedFormatters, loadedLocales, locales } from './i18n-util'
+
+import en from './en'
+import nb from './nb'
+
+import en_app from './en/app'
+import nb_app from './nb/app'
+
+const localeTranslations = {
+ en: {
+ ...en,
+ app: en_app
+ },
+ nb: {
+ ...nb,
+ app: nb_app
+ },
+}
+
+export const loadLocale = (locale: Locales): void => {
+ if (loadedLocales[locale]) return
+
+ loadedLocales[locale] = localeTranslations[locale] as unknown as Translations
+ loadFormatters(locale)
+}
+
+export const loadAllLocales = (): void => locales.forEach(loadLocale)
+
+export const loadFormatters = (locale: Locales): void =>
+ void (loadedFormatters[locale] = initFormatters(locale))
diff --git a/code/frontend/src/i18n/i18n-util.ts b/code/frontend/src/i18n/i18n-util.ts
new file mode 100644
index 0000000..55b52bd
--- /dev/null
+++ b/code/frontend/src/i18n/i18n-util.ts
@@ -0,0 +1,44 @@
+// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
+/* eslint-disable */
+
+import { i18n as initI18n, i18nObject as initI18nObject, i18nString as initI18nString } from 'typesafe-i18n'
+import type { LocaleDetector } from 'typesafe-i18n/detectors'
+import type { LocaleTranslationFunctions, TranslateByString } from 'typesafe-i18n'
+import { detectLocale as detectLocaleFn } from 'typesafe-i18n/detectors'
+import { initExtendDictionary } from 'typesafe-i18n/utils'
+import type { Formatters, Locales, Namespaces, Translations, TranslationFunctions } from './i18n-types'
+
+export const baseLocale: Locales = 'en'
+
+export const locales: Locales[] = [
+ 'en',
+ 'nb'
+]
+
+export const namespaces: Namespaces[] = [
+ 'app'
+]
+
+export const isLocale = (locale: string): locale is Locales => locales.includes(locale as Locales)
+
+export const isNamespace = (namespace: string): namespace is Namespaces => namespaces.includes(namespace as Namespaces)
+
+export const loadedLocales: Record<Locales, Translations> = {} as Record<Locales, Translations>
+
+export const loadedFormatters: Record<Locales, Formatters> = {} as Record<Locales, Formatters>
+
+export const extendDictionary = initExtendDictionary<Translations>()
+
+export const i18nString = (locale: Locales): TranslateByString => initI18nString<Locales, Formatters>(locale, loadedFormatters[locale])
+
+export const i18nObject = (locale: Locales): TranslationFunctions =>
+ initI18nObject<Locales, Translations, TranslationFunctions, Formatters>(
+ locale,
+ loadedLocales[locale],
+ loadedFormatters[locale]
+ )
+
+export const i18n = (): LocaleTranslationFunctions<Locales, Translations, TranslationFunctions> =>
+ initI18n<Locales, Translations, TranslationFunctions, Formatters>(loadedLocales, loadedFormatters)
+
+export const detectLocale = (...detectors: LocaleDetector[]): Locales => detectLocaleFn<Locales>(baseLocale, locales, ...detectors)
diff --git a/code/frontend/src/i18n/nb/app/index.ts b/code/frontend/src/i18n/nb/app/index.ts
new file mode 100644
index 0000000..6bf9ba6
--- /dev/null
+++ b/code/frontend/src/i18n/nb/app/index.ts
@@ -0,0 +1,7 @@
+import type { NamespaceAppTranslation } from '../../i18n-types'
+
+const nb_app: NamespaceAppTranslation = {
+ members: "Medlemmer"
+}
+
+export default nb_app
diff --git a/code/frontend/src/i18n/nb/index.ts b/code/frontend/src/i18n/nb/index.ts
new file mode 100644
index 0000000..ef67504
--- /dev/null
+++ b/code/frontend/src/i18n/nb/index.ts
@@ -0,0 +1,51 @@
+import type { Translation } from "../i18n-types";
+
+const nb: Translation = {
+ or: "Eller",
+ name: "Navn",
+ emailAddress: "E-postadresse",
+ password: "Passord",
+ pageNotFound: "Fant ikke siden",
+ noInternet: "Det ser ut som at du ikke tilkoblet internettet, sjekk tilkoblingen din for å fortsette",
+ reset: "Tilbakestill",
+ of: "{0} av {1}",
+ isRequired: "{0} er påkrevd",
+ submit: "Send",
+ success: "Suksess",
+ tryAgainSoon: "Prøv igjen snart",
+ createANewAccount: "Lag en ny konto",
+ unexpectedError: "En uventet feil oppstod",
+ notFound: "Ikke funnet",
+ documentation: "Dokumentasjon",
+ tos: "Vilkår",
+ privacyPolicy: "Personvernerklæring",
+ signIntoYourAccount: "Logg inn med din konto",
+ signInPage: {
+ notMyComputer: "Dette er ikke min datamaskin",
+ resetPassword: "Tilbakestill passord",
+ yourPasswordIsUpdated: "Ditt passord er oppdater",
+ signIn: "Logg inn",
+ yourNewPasswordIsApplied: "Ditt nye passord er satt",
+ signInBelow: "Logg inn nedenfor",
+ yourAccountIsDisabled: "Din konto er deaktivert",
+ contactYourAdminIfDisabled: "Ta kontakt med din administrator hvis dette føles feil",
+ youHaveReachedInactivityLimit: "Du har nådd den hemmelige inaktivitetsgrensen",
+ feelFreeToSignInAgain: "Logg gjerne inn igjen"
+ },
+ signUpPage: {
+ createYourNewAccount: "Opprett din nye konto",
+ },
+ resetPasswordPage: {
+ setANewPassword: "Skriv et nytt passord",
+ expired: "Utgått",
+ requestHasExpired: "Din forespørsel er utgått",
+ requestANewReset: "Spør om en ny tilbakestillingslenke",
+ newPassword: "Nytt passord",
+ requestSentMessage: "Hvis vi finner e-postadressen din i våre systemer, vil du få en e-post med instrukser for å sette ditt nye passord.",
+ requestAPasswordReset: "Forespør tilbakestilling av ditt passord",
+ requestNotFound: "Din forespørsel ble ikke funnet",
+ submitANewRequestBelow: "Spør om en ny tilbakestillingslenke nedenfor"
+ }
+}
+
+export default nb; \ No newline at end of file
diff --git a/code/frontend/src/index.test.ts b/code/frontend/src/index.test.ts
new file mode 100644
index 0000000..e07cbbd
--- /dev/null
+++ b/code/frontend/src/index.test.ts
@@ -0,0 +1,7 @@
+import { describe, it, expect } from 'vitest';
+
+describe('sum test', () => {
+ it('adds 1 + 2 to equal 3', () => {
+ expect(1 + 2).toBe(3);
+ });
+});
diff --git a/code/frontend/src/models/base/Customer.ts b/code/frontend/src/models/base/Customer.ts
new file mode 100644
index 0000000..ff52fbd
--- /dev/null
+++ b/code/frontend/src/models/base/Customer.ts
@@ -0,0 +1,21 @@
+import type {CustomerContact} from "./CustomerContact";
+import type {User} from "./User";
+
+export type Customer = {
+ /**
+ * Guid id for customer
+ */
+ id: string,
+ /**
+ * The name of the company
+ */
+ name: string,
+ /**
+ * Responsible contact in the current tenant
+ */
+ tenantContact: User,
+ /**
+ * The customers main contact
+ */
+ mainContact: CustomerContact,
+} \ No newline at end of file
diff --git a/code/frontend/src/models/base/CustomerContact.ts b/code/frontend/src/models/base/CustomerContact.ts
new file mode 100644
index 0000000..e8abea5
--- /dev/null
+++ b/code/frontend/src/models/base/CustomerContact.ts
@@ -0,0 +1,8 @@
+export type CustomerContact = {
+ firstName: string,
+ lastname: string,
+ email: string,
+ phone: string,
+ workTitle: string,
+ note: string
+} \ No newline at end of file
diff --git a/code/frontend/src/models/base/CustomerEvent.ts b/code/frontend/src/models/base/CustomerEvent.ts
new file mode 100644
index 0000000..af86511
--- /dev/null
+++ b/code/frontend/src/models/base/CustomerEvent.ts
@@ -0,0 +1,6 @@
+export type CustomerEvent = {
+ /**
+ * A descriptive name for the occured event
+ */
+ name: string,
+} \ No newline at end of file
diff --git a/code/frontend/src/models/base/SessionData.ts b/code/frontend/src/models/base/SessionData.ts
new file mode 100644
index 0000000..015cbf3
--- /dev/null
+++ b/code/frontend/src/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/frontend/src/models/base/Tenant.ts b/code/frontend/src/models/base/Tenant.ts
new file mode 100644
index 0000000..6307efc
--- /dev/null
+++ b/code/frontend/src/models/base/Tenant.ts
@@ -0,0 +1,8 @@
+import type {User} from "./User";
+
+export type Tenant = {
+ id: string,
+ name: string,
+ description: string,
+ masterUser: User,
+} \ No newline at end of file
diff --git a/code/frontend/src/models/base/User.ts b/code/frontend/src/models/base/User.ts
new file mode 100644
index 0000000..2b74d0e
--- /dev/null
+++ b/code/frontend/src/models/base/User.ts
@@ -0,0 +1,13 @@
+import type {UserRole} from "./UserRole";
+
+export type User = {
+ /**
+ * Guid id for user
+ */
+ id: string,
+ firstName: string,
+ lastName: string,
+ role: UserRole,
+ username: string,
+ email: string
+} \ No newline at end of file
diff --git a/code/frontend/src/models/base/UserRole.ts b/code/frontend/src/models/base/UserRole.ts
new file mode 100644
index 0000000..ec32852
--- /dev/null
+++ b/code/frontend/src/models/base/UserRole.ts
@@ -0,0 +1,5 @@
+export enum UserRole {
+ REGULAR = "reg",
+ ADMINISTRATOR = "adm",
+ OWNER = "own"
+} \ No newline at end of file
diff --git a/code/frontend/src/models/internal/FormError.ts b/code/frontend/src/models/internal/FormError.ts
new file mode 100644
index 0000000..f6d8978
--- /dev/null
+++ b/code/frontend/src/models/internal/FormError.ts
@@ -0,0 +1,24 @@
+import type { KnownProblem } from "./KnownProblem";
+
+export class FormError {
+ title: string;
+ subtitle: string;
+ constructor(title: string = "", subtitle: string = "") {
+ this.title = title;
+ this.title = subtitle;
+ }
+
+ set(title: string = "", subtitle: string = "") {
+ this.title = title;
+ this.subtitle = subtitle;
+ }
+
+ set_from_known_problem(knownProblem: KnownProblem) {
+ this.title = knownProblem.title ?? "";
+ this.subtitle = knownProblem.subtitle ?? "";
+ }
+
+ has_error() {
+ return this.title?.length > 0 || this.subtitle?.length > 0;
+ }
+} \ No newline at end of file
diff --git a/code/frontend/src/models/internal/IForm.ts b/code/frontend/src/models/internal/IForm.ts
new file mode 100644
index 0000000..c14b770
--- /dev/null
+++ b/code/frontend/src/models/internal/IForm.ts
@@ -0,0 +1,15 @@
+import type { FormError } from "./FormError";
+
+export interface IForm {
+ fields: Record<string, IFormField>;
+ error: FormError;
+ get_payload: Function;
+ submit_async: Function;
+ isLoading: boolean;
+ showError: boolean;
+}
+
+export interface IFormField {
+ value: any;
+ errors: Array<string>;
+}
diff --git a/code/frontend/src/models/internal/KnownProblem.ts b/code/frontend/src/models/internal/KnownProblem.ts
new file mode 100644
index 0000000..b6923d9
--- /dev/null
+++ b/code/frontend/src/models/internal/KnownProblem.ts
@@ -0,0 +1,10 @@
+export type KnownProblem = {
+ title: string,
+ subtitle: string,
+ errors: Record<string, string[]>,
+ traceId: string,
+}
+
+export function is_known_problem(response: Response): boolean {
+ return response.headers.has("X-IsKnownProblem");
+} \ No newline at end of file
diff --git a/code/frontend/src/models/projects/Project.ts b/code/frontend/src/models/projects/Project.ts
new file mode 100644
index 0000000..f265e67
--- /dev/null
+++ b/code/frontend/src/models/projects/Project.ts
@@ -0,0 +1,13 @@
+import type { Temporal } from "temporal-polyfill"
+import type { ProjectMember } from "./ProjectMember"
+import type { ProjectStatus } from "./ProjectStatus"
+
+export type Project = {
+ id: string,
+ name: string,
+ description?: string,
+ start: Temporal.PlainDate,
+ stop?: Temporal.PlainDate,
+ members: Array<ProjectMember>,
+ status: ProjectStatus
+} \ No newline at end of file
diff --git a/code/frontend/src/models/projects/ProjectLabel.ts b/code/frontend/src/models/projects/ProjectLabel.ts
new file mode 100644
index 0000000..59aa9d5
--- /dev/null
+++ b/code/frontend/src/models/projects/ProjectLabel.ts
@@ -0,0 +1,5 @@
+export type ProjectLabel = {
+ id: string,
+ name: string,
+ color: string
+} \ No newline at end of file
diff --git a/code/frontend/src/models/projects/ProjectMember.ts b/code/frontend/src/models/projects/ProjectMember.ts
new file mode 100644
index 0000000..de348ef
--- /dev/null
+++ b/code/frontend/src/models/projects/ProjectMember.ts
@@ -0,0 +1,10 @@
+import type { ProjectRole } from "./ProjectRole"
+
+export type ProjectMember = {
+ id: string,
+ name: string,
+ role: ProjectRole,
+ email: string,
+ userId?: string,
+ customerId?: string
+} \ No newline at end of file
diff --git a/code/frontend/src/models/projects/ProjectMeta.ts b/code/frontend/src/models/projects/ProjectMeta.ts
new file mode 100644
index 0000000..c583b47
--- /dev/null
+++ b/code/frontend/src/models/projects/ProjectMeta.ts
@@ -0,0 +1,7 @@
+import type { Temporal } from "temporal-polyfill"
+import type { User } from "../base/User"
+
+export type ProjectMeta = {
+ created: Temporal.PlainDateTime,
+ createdBy: User,
+} \ No newline at end of file
diff --git a/code/frontend/src/models/projects/ProjectRole.ts b/code/frontend/src/models/projects/ProjectRole.ts
new file mode 100644
index 0000000..0fa2347
--- /dev/null
+++ b/code/frontend/src/models/projects/ProjectRole.ts
@@ -0,0 +1,7 @@
+export enum ProjectRole {
+ EXTERNAL = "ext",
+ INTERNAL = "int",
+ RESOURCE = "res",
+ MANAGER = "man",
+ OWNER = "own"
+} \ No newline at end of file
diff --git a/code/frontend/src/models/projects/ProjectStatus.ts b/code/frontend/src/models/projects/ProjectStatus.ts
new file mode 100644
index 0000000..2df4b88
--- /dev/null
+++ b/code/frontend/src/models/projects/ProjectStatus.ts
@@ -0,0 +1,5 @@
+export enum ProjectStatus {
+ ACTIVE = "act",
+ EXPIRED = "exp",
+ IDLE = "idl"
+} \ No newline at end of file
diff --git a/code/frontend/src/models/work/WorkCategory.ts b/code/frontend/src/models/work/WorkCategory.ts
new file mode 100644
index 0000000..7dd85d5
--- /dev/null
+++ b/code/frontend/src/models/work/WorkCategory.ts
@@ -0,0 +1,5 @@
+export type WorkCategory = {
+ id: string,
+ name: string,
+ color: string
+}
diff --git a/code/frontend/src/models/work/WorkEntry.ts b/code/frontend/src/models/work/WorkEntry.ts
new file mode 100644
index 0000000..2108b88
--- /dev/null
+++ b/code/frontend/src/models/work/WorkEntry.ts
@@ -0,0 +1,13 @@
+import type { WorkLabel } from "./WorkLabel";
+import type { WorkCategory } from "./WorkCategory";
+import type { Project } from "../projects/Project";
+
+export type WorkEntry = {
+ id: string,
+ start: string,
+ stop: string,
+ description: string,
+ labels?: Array<WorkLabel>,
+ category?: WorkCategory,
+ project?: Project
+}
diff --git a/code/frontend/src/models/work/WorkEntryQueryResponse.ts b/code/frontend/src/models/work/WorkEntryQueryResponse.ts
new file mode 100644
index 0000000..a6974f1
--- /dev/null
+++ b/code/frontend/src/models/work/WorkEntryQueryResponse.ts
@@ -0,0 +1,27 @@
+import type { WorkCategory } from "./WorkCategory";
+import type { WorkLabel } from "./WorkLabel";
+import type { Temporal } from "temporal-polyfill";
+
+export interface WorkEntryQueryResponse {
+ duration: WorkEntryQueryDuration,
+ categories?: Array<WorkCategory>,
+ labels?: Array<WorkLabel>,
+ dateRange?: WorkEntryQueryDateRange,
+ specificDate?: Temporal.PlainDateTime
+ page: number,
+ pageSize: number
+}
+
+export interface WorkEntryQueryDateRange {
+ from: Temporal.PlainDateTime,
+ to: Temporal.PlainDateTime
+}
+
+export enum WorkEntryQueryDuration {
+ TODAY = 0,
+ THIS_WEEK = 1,
+ THIS_MONTH = 2,
+ THIS_YEAR = 3,
+ SPECIFIC_DATE = 4,
+ DATE_RANGE = 5,
+}
diff --git a/code/frontend/src/models/work/WorkLabel.ts b/code/frontend/src/models/work/WorkLabel.ts
new file mode 100644
index 0000000..f7e2795
--- /dev/null
+++ b/code/frontend/src/models/work/WorkLabel.ts
@@ -0,0 +1,5 @@
+export interface WorkLabel {
+ id?: string,
+ name?: string,
+ color?: string
+}
diff --git a/code/frontend/src/models/work/WorkQuery.ts b/code/frontend/src/models/work/WorkQuery.ts
new file mode 100644
index 0000000..93b0aa4
--- /dev/null
+++ b/code/frontend/src/models/work/WorkQuery.ts
@@ -0,0 +1,17 @@
+import type {WorkEntry} from "./WorkEntry";
+
+export interface IWorkQuery {
+ results: Array<WorkEntry>,
+ page: number,
+ pageSize: number,
+ totalRecords: number,
+ totalPageCount: number,
+}
+
+export class WorkQuery implements IWorkQuery {
+ results: WorkEntry[];
+ page: number;
+ pageSize: number;
+ totalRecords: number;
+ totalPageCount: number;
+}
diff --git a/code/frontend/src/routes/(main)/(app)/+layout.svelte b/code/frontend/src/routes/(main)/(app)/+layout.svelte
new file mode 100644
index 0000000..3141931
--- /dev/null
+++ b/code/frontend/src/routes/(main)/(app)/+layout.svelte
@@ -0,0 +1,379 @@
+<script lang="ts">
+ import {
+ ChevronUpDownIcon,
+ MagnifyingGlassIcon,
+ Bars3CenterLeftIcon,
+ XMarkIcon,
+ HomeIcon,
+ MegaphoneIcon,
+ FolderOpenIcon,
+ QueueListIcon,
+ CalendarIcon,
+ } from "$components/icons";
+ import { AccountService } from "$services/account-service";
+ import {
+ Dialog,
+ Menu,
+ MenuButton,
+ MenuItem,
+ MenuItems,
+ Transition,
+ TransitionChild,
+ TransitionRoot,
+ } from "@rgossiaux/svelte-headlessui";
+ import { DialogPanel } from "@developermuch/dev-svelte-headlessui";
+ import { Input, Notification } from "$components";
+ import { goto } from "$app/navigation";
+ import { page } from "$app/stores";
+ import { onMount } from "svelte";
+ import { fgs, sgs } from "$utils/global-state";
+
+ const accountService = AccountService.resolve();
+ const session = {
+ profile: {
+ username: "Brukernavn",
+ displayName: "epost@adresse.no",
+ },
+ };
+
+ let sidebarOpen = false;
+ let sidebarSearchValue: string | undefined;
+ let showEmailValidatedNotif = false;
+
+ onMount(() => {
+ showEmailValidatedNotif =
+ fgs("showEmailValidatedAlertWhenLoggedIn") === "true";
+ if (showEmailValidatedNotif)
+ sgs("showEmailValidatedAlertWhenLoggedIn", false);
+ });
+
+ function sign_out() {
+ accountService.end_session(() => goto("/sign-in"));
+ }
+ const navigationItems = [
+ {
+ href: "/home",
+ name: "Home",
+ icon: HomeIcon,
+ },
+ {
+ href: "/projects",
+ name: "Projects",
+ icon: CalendarIcon,
+ },
+ {
+ href: "/tickets",
+ name: "Tickets",
+ icon: MegaphoneIcon,
+ },
+ {
+ href: "/todo",
+ name: "Todo",
+ icon: QueueListIcon,
+ },
+ {
+ href: "/wiki",
+ name: "Wiki",
+ icon: FolderOpenIcon,
+ },
+ ];
+</script>
+
+{#if showEmailValidatedNotif}
+ <Notification
+ title="Email successfully validated"
+ subtitle="Because of this, you now have gained access to more functionality"
+ show={true}
+ />
+{/if}
+
+<div class="min-h-full">
+ <!-- Mobile sidebar -->
+ <TransitionRoot show={sidebarOpen}>
+ <Dialog
+ as="div"
+ class="relative z-40 lg:hidden"
+ on:close={() => (sidebarOpen = false)}
+ >
+ <TransitionChild
+ as="div"
+ enter="transition-opacity ease-linear duration-300"
+ enterFrom="opacity-0"
+ enterTo="opacity-100"
+ leave="transition-opacity ease-linear duration-300"
+ leaveFrom="opacity-100"
+ leaveTo="opacity-0"
+ >
+ <div class="fixed inset-0 bg-gray-600 bg-opacity-75" />
+ </TransitionChild>
+
+ <div class="fixed inset-0 z-40 flex">
+ <TransitionChild
+ as="div"
+ enter="transition ease-in-out duration-300 transform"
+ enterFrom="-translate-x-full"
+ enterTo="translate-x-0"
+ leave="transition ease-in-out duration-300 transform"
+ leaveFrom="translate-x-0"
+ leaveTo="-translate-x-full"
+ >
+ <DialogPanel
+ class="relative flex w-full max-w-xs flex-1 flex-col bg-white pt-5 pb-4"
+ >
+ <TransitionChild
+ as="div"
+ enter="ease-in-out duration-300"
+ enterFrom="opacity-0"
+ enterTo="opacity-100"
+ leave="ease-in-out duration-300"
+ leaveFrom="opacity-100"
+ leaveTo="opacity-0"
+ >
+ <div class="absolute top-0 right-0 -mr-12 pt-2">
+ <button
+ type="button"
+ class="ml-1 flex h-10 w-10 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
+ on:click={() => (sidebarOpen = false)}
+ >
+ <span class="sr-only">Close sidebar</span>
+ <XMarkIcon class="text-white" aria-hidden="true" />
+ </button>
+ </div>
+ </TransitionChild>
+ <div class="mt-5 h-0 flex-1 overflow-y-auto">
+ <nav class="px-2">
+ <div class="space-y-1">
+ {#each navigationItems as item}
+ {@const current = $page.url.pathname.startsWith(item.href)}
+ <a
+ href={item.href}
+ aria-current={current ? "page" : undefined}
+ class="group flex items-center px-2 py-2 text-base leading-5 font-medium rounded-md
+ {current
+ ? 'bg-gray-100 text-gray-900'
+ : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'}"
+ >
+ <svelte:component
+ this={item.icon}
+ class="mr-3 flex-shrink-0 h-6 w-6 {current
+ ? 'text-gray-500'
+ : 'text-gray-400 group-hover:text-gray-500'}"
+ aria-hidden="true"
+ />
+ {item.name}
+ </a>
+ {/each}
+ </div>
+ </nav>
+ </div>
+ </DialogPanel>
+ </TransitionChild>
+ <div class="w-14 flex-shrink-0" aria-hidden="true">
+ <!-- Dummy element to force sidebar to shrink to fit close icon -->
+ </div>
+ </div>
+ </Dialog>
+ </TransitionRoot>
+
+ <!-- Static sidebar for desktop -->
+ <div
+ class="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col lg:border-r lg:border-gray-200 lg:bg-gray-100 lg:pb-4"
+ >
+ <div class="flex h-0 flex-1 p-3 flex-col overflow-y-auto">
+ <!-- User account dropdown -->
+ <Menu class="relative inline-block text-left">
+ <MenuButton
+ class="group w-full rounded-md bg-gray-100 px-3.5 py-2 text-left text-sm font-medium text-gray-700 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2 focus:ring-offset-gray-100"
+ >
+ <span class="flex w-full items-center justify-between">
+ <span class="flex min-w-0 items-center justify-between space-x-3">
+ <span class="flex min-w-0 flex-1 flex-col">
+ <span class="truncate text-sm font-medium text-gray-900">
+ {session.profile.username}
+ </span>
+ <span class="truncate text-sm text-gray-500"
+ >{session.profile.displayName}</span
+ >
+ </span>
+ </span>
+ <ChevronUpDownIcon
+ class="flex-shrink-0 text-gray-400 group-hover:text-gray-500"
+ aria-hidden="true"
+ />
+ </span>
+ </MenuButton>
+ <Transition
+ leave="transition ease-in duration-75"
+ enter="transition ease-out duration-100"
+ enterFrom="transform opacity-0 scale-95"
+ enterTo="transform opacity-100 scale-100"
+ leaveFrom="transform opacity-100 scale-100"
+ leaveTo="transform opacity-0 scale-95"
+ as="div"
+ >
+ <MenuItems
+ class="absolute right-0 left-0 z-10 mt-1 origin-top divide-y divide-gray-200 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
+ >
+ <div class="py-1">
+ <MenuItem>
+ <a
+ href="/profile"
+ class="text-gray-700 block px-4 py-2 text-sm hover:text-gray-900 hover:bg-gray-100"
+ >
+ View profile
+ </a>
+ </MenuItem>
+ <MenuItem>
+ <a
+ href="/settings"
+ class="text-gray-700 block px-4 py-2 text-sm hover:text-gray-900 hover:bg-gray-100"
+ >
+ Settings
+ </a>
+ </MenuItem>
+ </div>
+ <div class="py-1">
+ <MenuItem>
+ <span
+ on:click={() => sign_out()}
+ class="text-gray-700 block px-4 py-2 text-sm hover:bg-red-200 hover:text-red-900 cursor-pointer"
+ >
+ Sign out
+ </span>
+ </MenuItem>
+ </div>
+ </MenuItems>
+ </Transition>
+ </Menu>
+ <!-- Sidebar Search -->
+ <div class="mt-3 hidden">
+ <label for="search" class="sr-only">Search</label>
+ <div class="relative mt-1 rounded-md shadow-sm">
+ <Input
+ type="search"
+ name="search"
+ icon={MagnifyingGlassIcon}
+ placeholder="Search"
+ bind:value={sidebarSearchValue}
+ />
+ </div>
+ </div>
+ <!-- Navigation -->
+ <nav class="mt-5">
+ <div class="space-y-1">
+ {#each navigationItems as item}
+ {@const current = $page.url.pathname.startsWith(item.href)}
+ <a
+ href={item.href}
+ aria-current={current ? "page" : undefined}
+ class="group flex items-center px-2 py-2 text-base leading-5 font-medium rounded-md
+ {current
+ ? 'bg-gray-200 text-gray-900'
+ : 'text-gray-700 hover:text-gray-900 hover:bg-gray-50'}"
+ >
+ <svelte:component
+ this={item.icon}
+ class="mr-3 flex-shrink-0 h-6 w-6 {current
+ ? 'text-gray-500'
+ : 'text-gray-400 group-hover:text-gray-500'}"
+ aria-hidden="true"
+ />
+ {item.name}
+ </a>
+ {/each}
+ </div>
+ </nav>
+ </div>
+ </div>
+
+ <!-- Main column -->
+ <div class="flex flex-col lg:pl-64">
+ <!-- Search header -->
+ <div
+ class="sticky top-0 z-10 flex h-16 flex-shrink-0 border-b border-gray-200 bg-white lg:hidden"
+ >
+ <button
+ type="button"
+ class="border-r border-gray-200 px-4 text-gray-500 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-teal-500 lg:hidden"
+ on:click={() => (sidebarOpen = true)}
+ >
+ <span class="sr-only">Open sidebar</span>
+ <Bars3CenterLeftIcon aria-hidden="true" />
+ </button>
+ <div class="flex flex-1 justify-between px-4 sm:px-6 lg:px-8">
+ <div class="flex flex-1">
+ <form class="flex w-full md:ml-0" action="#" method="GET">
+ <label for="search-field" class="sr-only">Search</label>
+ <div
+ class="relative w-full text-gray-400 focus-within:text-gray-600"
+ >
+ <Input
+ bind:value={sidebarSearchValue}
+ icon={MagnifyingGlassIcon}
+ id="search-field"
+ name="search-field"
+ placeholder="Search"
+ type="search"
+ />
+ </div>
+ </form>
+ </div>
+ <div class="flex items-center">
+ <!-- Profile dropdown -->
+ <Menu as="div" class="relative ml-3">
+ <div>
+ <MenuButton
+ class="flex max-w-xs items-center rounded-full bg-white text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2"
+ >
+ <span class="sr-only">Open user menu</span>
+ </MenuButton>
+ </div>
+ <Transition
+ enterFrom="transform opacity-0 scale-95"
+ enterTo="transform opacity-100 scale-100"
+ leaveFrom="transform opacity-100 scale-100"
+ leaveTo="transform opacity-0 scale-95"
+ as="div"
+ >
+ <MenuItems
+ class="absolute right-0 z-10 mt-2 w-48 origin-top-right divide-y divide-gray-200 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
+ >
+ <div class="py-1">
+ <MenuItem>
+ <a
+ href="/profile"
+ class="text-gray-700 block px-4 py-2 text-sm"
+ >
+ View profile
+ </a>
+ </MenuItem>
+ <MenuItem>
+ <a
+ href="/settings"
+ class="text-gray-700 block px-4 py-2 text-sm hover:text-gray-900 hover:bg-gray-100"
+ >
+ Settings
+ </a>
+ </MenuItem>
+ <div class="py-1">
+ <MenuItem>
+ <span
+ on:click={() => sign_out()}
+ class="text-gray-700 block px-4 py-2 text-sm"
+ >
+ Sign out
+ </span>
+ </MenuItem>
+ </div>
+ </div>
+ </MenuItems>
+ </Transition>
+ </Menu>
+ </div>
+ </div>
+ </div>
+ <main class="flex-1 p-3">
+ <slot />
+ </main>
+ </div>
+</div>
diff --git a/code/frontend/src/routes/(main)/(app)/home/+page.svelte b/code/frontend/src/routes/(main)/(app)/home/+page.svelte
new file mode 100644
index 0000000..247ee47
--- /dev/null
+++ b/code/frontend/src/routes/(main)/(app)/home/+page.svelte
@@ -0,0 +1 @@
+<h1>Welcome Home</h1> \ No newline at end of file
diff --git a/code/frontend/src/routes/(main)/(app)/org/+page.svelte b/code/frontend/src/routes/(main)/(app)/org/+page.svelte
new file mode 100644
index 0000000..429ec25
--- /dev/null
+++ b/code/frontend/src/routes/(main)/(app)/org/+page.svelte
@@ -0,0 +1,4 @@
+<script lang="ts">
+</script>
+
+<h1>$ORGNAME</h1>
diff --git a/code/frontend/src/routes/(main)/(app)/profile/+page.svelte b/code/frontend/src/routes/(main)/(app)/profile/+page.svelte
new file mode 100644
index 0000000..7c6eb3e
--- /dev/null
+++ b/code/frontend/src/routes/(main)/(app)/profile/+page.svelte
@@ -0,0 +1,4 @@
+<script lang="ts">
+</script>
+
+<h1>Hi, Ivar</h1>
diff --git a/code/frontend/src/routes/(main)/(app)/projects/+page.svelte b/code/frontend/src/routes/(main)/(app)/projects/+page.svelte
new file mode 100644
index 0000000..2585331
--- /dev/null
+++ b/code/frontend/src/routes/(main)/(app)/projects/+page.svelte
@@ -0,0 +1,118 @@
+<script lang="ts">
+ import { Button, ProjectStatusBadge, Input } from "$components";
+ import type { Project } from "$models/projects/Project";
+ import { createTable, Subscribe, Render } from "svelte-headless-table";
+ import { addSortBy, addTableFilter } from "svelte-headless-table/plugins";
+ import { writable, type Writable } from "svelte/store";
+ import { ChevronDownIcon, ChevronUpIcon, ChevronUpDownIcon, MagnifyingGlassIcon, FunnelIcon } from "$components/icons";
+ import LL from "$i18n/i18n-svelte";
+ import { goto } from "$app/navigation";
+
+ const projects: Writable<Array<Project>> = writable([]);
+
+ 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);
+ }
+
+ const table = createTable(projects, {
+ sort: addSortBy(),
+ filter: addTableFilter(),
+ });
+
+ const columns = table.createColumns([
+ table.column({ header: $LL.name(), accessor: "name" }),
+ table.column({ header: "Status", accessor: "status" }),
+ table.column({ header: "Start", accessor: "start" }),
+ table.column({ header: "Description", accessor: "description", plugins: { sort: { disable: true } } }),
+ ]);
+
+ const { headerRows, rows, tableAttrs, tableBodyAttrs, pluginStates } = table.createViewModel(columns);
+ const { filterValue } = pluginStates.filter;
+</script>
+
+<div class="sm:flex sm:items-center">
+ <div class="sm:flex-auto">
+ <h1 class="text-xl font-semibold text-gray-900">Projects</h1>
+ <p class="mt-2 text-sm text-gray-700">A list of all the projects in your organsation.</p>
+ </div>
+ <div class="mt-4 sm:mt-0 sm:ml-16 inline-flex gap-1 sm:flex-none">
+ <Input icon={MagnifyingGlassIcon} placeholder="Search" bind:value={$filterValue} />
+ <Button text="Create project" href="/projects/create" />
+ </div>
+</div>
+<div class="-mx-2 mt-6 rounded-md shadow overflow-auto max-h-[80vh] sm:-mx-6 md:mx-0">
+ <table {...$tableAttrs} class="min-w-full divide-y divide-gray-300">
+ <thead class="bg-gray-50">
+ {#each $headerRows as headerRow (headerRow.id)}
+ <Subscribe rowAttrs={headerRow.attrs()} let:rowAttrs>
+ <tr {...rowAttrs} class="shadow-sm">
+ {#each headerRow.cells as cell (cell.id)}
+ <Subscribe attrs={cell.attrs()} let:attrs props={cell.props()} let:props>
+ <th
+ {...attrs}
+ scope="col"
+ class="sticky top-0 bg-gray-50 bg-opacity-100 whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-gray-900"
+ >
+ <div class="group inline-flex">
+ <Render of={cell.render()} />
+ <span
+ 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'}
+ {props.sort.disabled ? '' : 'cursor-pointer'}
+ ml-2 flex-none rounded"
+ >
+ {#if props.sort.order === "asc"}
+ <ChevronUpIcon />
+ {:else if props.sort.order === "desc"}
+ <ChevronDownIcon />
+ {:else if !props.sort.disabled}
+ <ChevronUpDownIcon />
+ {/if}
+ </span>
+ {#if cell.id === "status"}
+ <span
+ class="invisible text-gray-400 cursor-pointer group-hover:visible group-focus:visible ml-2 flex-none rounded"
+ >
+ <FunnelIcon />
+ </span>
+ {/if}
+ </div>
+ </th>
+ </Subscribe>
+ {/each}
+ </tr>
+ </Subscribe>
+ {/each}
+ </thead>
+ <tbody {...$tableBodyAttrs} class="divide-y divide-gray-200 bg-white">
+ {#each $rows as row (row.id)}
+ <Subscribe rowAttrs={row.attrs()} let:rowAttrs>
+ <tr {...rowAttrs}>
+ {#each row.cells as cell (cell.id)}
+ {@const materialisedCell = cell.render()}
+ <Subscribe attrs={cell.attrs()} let:attrs>
+ <td {...attrs} class="whitespace-nowrap px-2 py-2 text-sm">
+ {#if cell.id === "name"}
+ <span class="link" title="Open project" on:click={on_open_project} on:keypress={on_open_project}>
+ <Render of={materialisedCell} />
+ </span>
+ {:else if cell.id === "status"}
+ <ProjectStatusBadge status={materialisedCell.toString()} />
+ {:else}
+ <Render of={materialisedCell} />
+ {/if}
+ </td>
+ </Subscribe>
+ {/each}
+ </tr>
+ </Subscribe>
+ {/each}
+ </tbody>
+ </table>
+</div>
diff --git a/code/frontend/src/routes/(main)/(app)/projects/[id]/+page.svelte b/code/frontend/src/routes/(main)/(app)/projects/[id]/+page.svelte
new file mode 100644
index 0000000..ca474e2
--- /dev/null
+++ b/code/frontend/src/routes/(main)/(app)/projects/[id]/+page.svelte
@@ -0,0 +1,5 @@
+<script lang="ts">
+ import { page } from "$app/stores";
+</script>
+
+<h1>{$page.params.id}</h1>
diff --git a/code/frontend/src/routes/(main)/(app)/projects/create/+page.svelte b/code/frontend/src/routes/(main)/(app)/projects/create/+page.svelte
new file mode 100644
index 0000000..d710edc
--- /dev/null
+++ b/code/frontend/src/routes/(main)/(app)/projects/create/+page.svelte
@@ -0,0 +1,59 @@
+<script lang="ts">
+ import { Input, TextArea, Combobox, Button } from "$components";
+ import type { ProjectMember } from "$models/projects/ProjectMember";
+ import LL from "$i18n/i18n-svelte";
+
+ let members = [];
+ const formData = {
+ name: {
+ value: "",
+ errors: [],
+ },
+ description: {
+ value: "",
+ errors: [],
+ },
+ start: {
+ value: "",
+ errors: [],
+ },
+ stop: {
+ value: "",
+ errors: [],
+ },
+ members: {
+ value: [] as Array<ProjectMember>,
+ errors: [],
+ },
+ };
+
+ const formError = {
+ title: "",
+ subtitle: "",
+ };
+
+ async function submit_form_async() {
+ alert("Submitted");
+ }
+</script>
+
+<h1>Create a new project</h1>
+<form on:submit|preventDefault={submit_form_async} class="max-w-md flex flex-col gap-2">
+ <Input label="Name" bind:value={formData.name.value} errors={formData.name.errors} required />
+ <TextArea label="Description" bind:value={formData.description.value} errors={formData.description.errors} />
+ <section class="grid grid-flow-row sm:grid-flow-col gap-2">
+ <Input type="date" label="Start" bind:value={formData.start.value} errors={formData.start.errors} />
+ <Input type="date" label="Stop" bind:value={formData.stop.value} errors={formData.stop.errors} />
+ </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/frontend/src/routes/(main)/(app)/settings/+page.svelte b/code/frontend/src/routes/(main)/(app)/settings/+page.svelte
new file mode 100644
index 0000000..8e99661
--- /dev/null
+++ b/code/frontend/src/routes/(main)/(app)/settings/+page.svelte
@@ -0,0 +1,205 @@
+<script lang="ts">
+ import {Input, Button, Switch} from "$components";
+</script>
+
+<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>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/frontend/src/routes/(main)/(app)/tickets/+page.svelte b/code/frontend/src/routes/(main)/(app)/tickets/+page.svelte
new file mode 100644
index 0000000..2a4792b
--- /dev/null
+++ b/code/frontend/src/routes/(main)/(app)/tickets/+page.svelte
@@ -0,0 +1,4 @@
+<script lang="ts">
+</script>
+
+<h1>Tickets</h1>
diff --git a/code/frontend/src/routes/(main)/(app)/todo/+page.svelte b/code/frontend/src/routes/(main)/(app)/todo/+page.svelte
new file mode 100644
index 0000000..e29f263
--- /dev/null
+++ b/code/frontend/src/routes/(main)/(app)/todo/+page.svelte
@@ -0,0 +1,4 @@
+<script lang="ts">
+</script>
+
+<h1>Todo</h1>
diff --git a/code/frontend/src/routes/(main)/(app)/wiki/+page.svelte b/code/frontend/src/routes/(main)/(app)/wiki/+page.svelte
new file mode 100644
index 0000000..1762d43
--- /dev/null
+++ b/code/frontend/src/routes/(main)/(app)/wiki/+page.svelte
@@ -0,0 +1,4 @@
+<script lang="ts">
+</script>
+
+<h1>Wiki</h1>
diff --git a/code/frontend/src/routes/(main)/(public)/+layout.svelte b/code/frontend/src/routes/(main)/(public)/+layout.svelte
new file mode 100644
index 0000000..6da653c
--- /dev/null
+++ b/code/frontend/src/routes/(main)/(public)/+layout.svelte
@@ -0,0 +1,18 @@
+<script>
+ import { LocaleSwitcher } from "$components";
+ import LL from "$i18n/i18n-svelte";
+</script>
+
+<LocaleSwitcher tabindex={-1} />
+<slot />
+<footer class="grid sm:gap-5 grid-flow-row sm:justify-center px-2 sm:grid-flow-col">
+ <a href="https://greatoffice.life/privacy" class="link">
+ {$LL.privacyPolicy()}
+ </a>
+ <a href="https://greatoffice.life/terms" class="link">
+ {$LL.tos()}
+ </a>
+ <a href="https://greatoffice.life/docs" class="link">
+ {$LL.documentation()}
+ </a>
+</footer>
diff --git a/code/frontend/src/routes/(main)/(public)/portal/+page.svelte b/code/frontend/src/routes/(main)/(public)/portal/+page.svelte
new file mode 100644
index 0000000..cc16681
--- /dev/null
+++ b/code/frontend/src/routes/(main)/(public)/portal/+page.svelte
@@ -0,0 +1,26 @@
+<script lang="ts">
+ import { onMount } from "svelte";
+ import type { PageData } from "./$types";
+ import type { PortalMessage } from "$configuration";
+ import { goto } from "$app/navigation";
+ import { sgs } from "$utils/global-state";
+
+ export let data: PageData;
+
+ onMount(async () => {
+ switch (data.message as PortalMessage) {
+ case "emailValidated": {
+ sgs("showEmailValidatedAlertWhenLoggedIn", true);
+ await goto("/home");
+ break;
+ }
+ default: {
+ await goto("/home");
+ }
+ }
+ });
+</script>
+
+<div class="p-3">
+ <h1>Warping...</h1>
+</div>
diff --git a/code/frontend/src/routes/(main)/(public)/portal/+page.ts b/code/frontend/src/routes/(main)/(public)/portal/+page.ts
new file mode 100644
index 0000000..72338cb
--- /dev/null
+++ b/code/frontend/src/routes/(main)/(public)/portal/+page.ts
@@ -0,0 +1,9 @@
+import type { PortalMessage } from '$configuration';
+import { redirect } from '@sveltejs/kit';
+import type { PageLoad } from './$types';
+
+export const load: PageLoad = async ({ url }) => {
+ const message = url.searchParams.get("msg") as PortalMessage;
+ if (!message) throw redirect(302, "/");
+ return { message };
+}; \ No newline at end of file
diff --git a/code/frontend/src/routes/(main)/(public)/reset-password/+page.svelte b/code/frontend/src/routes/(main)/(public)/reset-password/+page.svelte
new file mode 100644
index 0000000..a45ccdd
--- /dev/null
+++ b/code/frontend/src/routes/(main)/(public)/reset-password/+page.svelte
@@ -0,0 +1,81 @@
+<script lang="ts">
+ import { Alert, Input, Button } from "$components";
+ import LL from "$i18n/i18n-svelte";
+ import { FormError } from "$models/internal/FormError";
+ import { PasswordResetService } from "$services/password-reset-service";
+
+ const formData = {
+ email: {
+ value: "",
+ errors: [],
+ },
+ };
+
+ const formError = new FormError();
+ const passwordResetService = PasswordResetService.resolve();
+
+ let loading = false;
+ let showSuccessAlert = false;
+ let showErrorAlert = false;
+
+ async function submit_form_async() {
+ formError.set();
+ showSuccessAlert = false;
+ showErrorAlert = false;
+ loading = true;
+ const response = await passwordResetService.create_request_async(formData.email.value);
+ loading = false;
+ if (response.isCreated) {
+ showSuccessAlert = true;
+ } else if (response.knownProblem) {
+ formError.set_from_known_problem(response.knownProblem);
+ for (const error of Object.entries(response.knownProblem.errors)) {
+ if (error[0] === "email") {
+ let errors = [];
+ error[1].forEach((e) => errors.push(e));
+ formData.email.errors = errors;
+ }
+ }
+ } else {
+ formError.set($LL.unexpectedError(), $LL.tryAgainSoon());
+ }
+ showErrorAlert = formError.has_error() && !showSuccessAlert;
+ }
+</script>
+
+<div class="min-h-full flex flex-col justify-center py-12 sm:px-6 lg:px-8">
+ <div class="sm:mx-auto sm:w-full p-2 sm:p-0 sm:max-w-md">
+ <h2 class="mt-6 text-3xl tracking-tight font-bold text-gray-900">
+ {$LL.resetPasswordPage.requestAPasswordReset()}
+ </h2>
+ <p class="mt-2 text-sm text-gray-600">
+ {$LL.or().toLowerCase()}
+ <a href="/sign-in" class="link">
+ {$LL.signIntoYourAccount().toLowerCase()}
+ </a>
+ </p>
+ </div>
+
+ <div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
+ <div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
+ <form class="space-y-6" on:submit|preventDefault={submit_form_async}>
+ {#if showErrorAlert}
+ <Alert title={formError.title} message={formError.subtitle} type="error" />
+ {:else if showSuccessAlert}
+ <Alert type="success" title={$LL.success()} message={$LL.resetPasswordPage.requestSentMessage()} />
+ {/if}
+ <Input
+ id="email"
+ name="email"
+ type="email"
+ autocomplete="email"
+ errors={formData.email.errors}
+ bind:value={formData.email.value}
+ required
+ label={$LL.emailAddress()}
+ />
+ <Button text={$LL.submit()} type="submit" {loading} fullWidth />
+ </form>
+ </div>
+ </div>
+</div>
diff --git a/code/frontend/src/routes/(main)/(public)/reset-password/+page.ts b/code/frontend/src/routes/(main)/(public)/reset-password/+page.ts
new file mode 100644
index 0000000..c0859e0
--- /dev/null
+++ b/code/frontend/src/routes/(main)/(public)/reset-password/+page.ts
@@ -0,0 +1,11 @@
+import LL from '$i18n/i18n-svelte';
+import { get } from 'svelte/store';
+import type { PageLoad } from './$types';
+
+const l = get(LL);
+
+export const load: PageLoad = async () => {
+ return {
+ title: l.resetPasswordPage.title(),
+ };
+}; \ No newline at end of file
diff --git a/code/frontend/src/routes/(main)/(public)/reset-password/[id]/+page.server.ts b/code/frontend/src/routes/(main)/(public)/reset-password/[id]/+page.server.ts
new file mode 100644
index 0000000..9e24736
--- /dev/null
+++ b/code/frontend/src/routes/(main)/(public)/reset-password/[id]/+page.server.ts
@@ -0,0 +1,11 @@
+import { is_guid } from "$utils/validators";
+import { redirect } from "@sveltejs/kit";
+import type { PageServerLoad } from "./$types";
+
+export const load: PageServerLoad = async ({ params }) => {
+ const resetRequestId = params.id ?? "";
+ if (!is_guid(resetRequestId)) throw redirect(302, "/reset-password");
+ return {
+ resetRequestId,
+ };
+}; \ No newline at end of file
diff --git a/code/frontend/src/routes/(main)/(public)/reset-password/[id]/+page.svelte b/code/frontend/src/routes/(main)/(public)/reset-password/[id]/+page.svelte
new file mode 100644
index 0000000..27a1af5
--- /dev/null
+++ b/code/frontend/src/routes/(main)/(public)/reset-password/[id]/+page.svelte
@@ -0,0 +1,82 @@
+<script lang="ts">
+ import { onMount } from "svelte";
+ import LL from "$i18n/i18n-svelte";
+ import { Alert, Input, Button } from "$components";
+ import type { PageServerData } from "./$types";
+ import { goto } from "$app/navigation";
+ import { SignInPageMessage, signInPageMessageQueryKey } from "$routes/(main)/(public)/sign-in";
+ import { PasswordResetService } from "$services/password-reset-service";
+
+ export let data: PageServerData;
+ const passwordResetService = PasswordResetService.resolve();
+
+ const formData = {
+ newPassword: {
+ value: "",
+ errors: [],
+ },
+ };
+
+ let finishedPreliminaryLoading = false;
+ let loading = false;
+ let canSubmit = true;
+ let requestIsInvalid = false;
+
+ async function submitFormAsync() {
+ if (!canSubmit) return;
+ loading = true;
+ const request = await passwordResetService.fulfill_request_async(data.resetRequestId, formData.newPassword.value);
+ if (request.isFulfilled) {
+ goto("/sign-in?" + signInPageMessageQueryKey + "=" + SignInPageMessage.AFTER_PASSWORD_RESET);
+ } else if (request.knownProblem) {
+ }
+ loading = false;
+ }
+
+ onMount(async () => {
+ const response = await passwordResetService.request_is_valid_async(data.resetRequestId);
+ requestIsInvalid = !response.isValid;
+ finishedPreliminaryLoading = true;
+ });
+</script>
+
+<div class="min-h-full flex flex-col justify-center py-12 sm:px-6 lg:px-8">
+ {#if finishedPreliminaryLoading}
+ <div class="sm:mx-auto sm:w-full p-2 sm:p-0 sm:max-w-md">
+ <h2 class="mt-6 text-3xl tracking-tight font-bold text-gray-900">
+ {$LL.resetPasswordPage.setANewPassword()}
+ </h2>
+ <p class="mt-2 text-sm text-gray-600">
+ {$LL.or().toLowerCase()}
+ <a href="/sign-in" class="link">
+ {$LL.signIntoYourAccount().toLowerCase()}
+ </a>
+ </p>
+ </div>
+
+ <div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
+ <div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
+ <form class="space-y-6" on:submit|preventDefault={submitFormAsync}>
+ {#if requestIsInvalid}
+ <Alert
+ title={$LL.resetPasswordPage.invalidRequestTitle()}
+ message={$LL.resetPasswordPage.invalidRequestMessage()}
+ />
+ {/if}
+ <Input
+ id="password"
+ name="password"
+ type="password"
+ autocomplete="new-password"
+ required
+ bind:value={formData.newPassword.value}
+ label={$LL.resetPasswordPage.newPassword()}
+ />
+ <Button text={$LL.submit()} type="submit" {loading} fullWidth />
+ </form>
+ </div>
+ </div>
+ {:else}
+ <p>Checking your request...</p>
+ {/if}
+</div>
diff --git a/code/frontend/src/routes/(main)/(public)/reset-password/[id]/+page.ts b/code/frontend/src/routes/(main)/(public)/reset-password/[id]/+page.ts
new file mode 100644
index 0000000..3252b7a
--- /dev/null
+++ b/code/frontend/src/routes/(main)/(public)/reset-password/[id]/+page.ts
@@ -0,0 +1,11 @@
+import LL from '$i18n/i18n-svelte';
+import { get } from 'svelte/store';
+import type { PageLoad } from './$types';
+
+const l = get(LL);
+
+export const load: PageLoad = async () => {
+ return {
+ title: l.resetPasswordPage.fulfillTitle(),
+ };
+}; \ No newline at end of file
diff --git a/code/frontend/src/routes/(main)/(public)/sign-in/+page.svelte b/code/frontend/src/routes/(main)/(public)/sign-in/+page.svelte
new file mode 100644
index 0000000..66d4575
--- /dev/null
+++ b/code/frontend/src/routes/(main)/(public)/sign-in/+page.svelte
@@ -0,0 +1,155 @@
+<script lang="ts">
+ import { goto } from "$app/navigation";
+ import { Button, Checkbox, Input, Alert } from "$components";
+ import LL from "$i18n/i18n-svelte";
+ import pwKey from "$actions/pwKey";
+ import { onMount } from "svelte";
+ import { signInPageMessageQueryKey, signInPageTestKeys, type SignInPageMessage } from ".";
+ import { AccountService } from "$services/account-service";
+ import type { LoginPayload } from "$services/abstractions/IAccountService";
+ import { FormError } from "$models/internal/FormError";
+ import type { IForm } from "$models/internal/IForm";
+
+ let messageType: SignInPageMessage | undefined = undefined;
+
+ const accountService = AccountService.resolve();
+ const form = {
+ fields: {
+ username: {
+ value: "",
+ errors: [],
+ },
+ password: {
+ value: "",
+ errors: [],
+ },
+ persist: {
+ value: false,
+ errors: [],
+ },
+ },
+ error: new FormError(),
+ isLoading: false,
+ showError: false,
+ get_payload(): LoginPayload {
+ return {
+ password: form.fields.password.value,
+ username: form.fields.username.value,
+ persist: !form.fields.persist.value,
+ };
+ },
+ async submit_async() {
+ console.log("sadf");
+ form.error.set();
+ form.showError = form.error.has_error();
+ form.isLoading = true;
+ const loginResponse = await accountService.login_async(form.get_payload());
+ if (loginResponse.isLoggedIn) {
+ await goto("/home");
+ } else if (loginResponse.knownProblem) {
+ form.error.set_from_known_problem(loginResponse.knownProblem);
+ } else {
+ form.error.set($LL.unexpectedError(), $LL.tryAgainSoon());
+ }
+ form.isLoading = false;
+ form.showError = form.error.has_error();
+ },
+ } as IForm;
+
+ onMount(() => {
+ const queryParams = new URLSearchParams(window.location.search);
+ if (queryParams.get(signInPageMessageQueryKey)) {
+ messageType = queryParams.get(signInPageMessageQueryKey) as SignInPageMessage;
+ queryParams.delete(signInPageMessageQueryKey);
+ window.history.replaceState(null, "", window.location.origin + window.location.pathname);
+ }
+ });
+</script>
+
+<div class="min-h-full flex flex-col justify-center py-12 sm:px-6 lg:px-8">
+ {#if messageType}
+ <div class="sm:max-w-md sm:mx-auto sm:w-full">
+ {#if messageType === "after-password-reset"}
+ <Alert
+ title={$LL.signInPage.yourNewPasswordIsApplied()}
+ _pwKey={signInPageTestKeys.afterPasswordResetAlert}
+ message={$LL.signInPage.signInBelow()}
+ closeable
+ />
+ {:else if messageType === "user-disabled"}
+ <Alert
+ title={$LL.signInPage.yourAccountIsDisabled()}
+ _pwKey={signInPageTestKeys.userDisabledAlert}
+ message={$LL.signInPage.contactYourAdminIfDisabled()}
+ closeable
+ />
+ {:else if messageType === "user-inactivity"}
+ <Alert
+ title={$LL.signInPage.youHaveReachedInactivityLimit()}
+ _pwKey={signInPageTestKeys.userInactivityAlert}
+ message={$LL.signInPage.feelFreeToSignInAgain()}
+ closeable
+ />
+ {/if}
+ </div>
+ {/if}
+ <div class="sm:mx-auto sm:w-full p-2 sm:p-0 sm:max-w-md">
+ <h2 class="mt-6 text-3xl tracking-tight font-bold text-gray-900">
+ {$LL.signInPage.signIn()}
+ </h2>
+ <p class="mt-2 text-sm text-gray-600">
+ {$LL.or().toLowerCase()}
+ <a href="/sign-up" use:pwKey={signInPageTestKeys.signUpAnchor} class="link">
+ {$LL.createANewAccount().toLowerCase()}
+ </a>
+ </p>
+ </div>
+ <div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
+ <div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
+ {#if form.showError}
+ <Alert title={form.error.title} message={form.error.subtitle} type="error" _pwKey={signInPageTestKeys.formErrorAlert} />
+ {/if}
+ <form class="space-y-6 mt-2" use:pwKey={signInPageTestKeys.signInForm} on:submit|preventDefault={() => form.submit_async()}>
+ <Input
+ id="username"
+ _pwKey={signInPageTestKeys.usernameInput}
+ name="username"
+ type="email"
+ label={$LL.emailAddress()}
+ required
+ errors={form.fields.username.errors}
+ bind:value={form.fields.username.value}
+ />
+
+ <Input
+ id="password"
+ name="password"
+ type="password"
+ label={$LL.password()}
+ _pwKey={signInPageTestKeys.passwordInput}
+ autocomplete="current-password"
+ required
+ errors={form.fields.password.errors}
+ bind:value={form.fields.password.value}
+ />
+
+ <div class="flex items-center justify-between">
+ <Checkbox
+ id="remember-me"
+ _pwKey={signInPageTestKeys.rememberMeCheckbox}
+ name="remember-me"
+ bind:checked={form.fields.persist.value}
+ label={$LL.signInPage.notMyComputer()}
+ />
+ <div class="text-sm">
+ <a href="/reset-password" class="link" use:pwKey={signInPageTestKeys.resetPasswordAnchor}>
+ {$LL.signInPage.resetPassword()}
+ </a>
+ </div>
+ </div>
+
+ <Button text={$LL.submit()} fullWidth type="submit" loading={form.isLoading} />
+ </form>
+ </div>
+ </div>
+</div>
diff --git a/code/frontend/src/routes/(main)/(public)/sign-in/+page.ts b/code/frontend/src/routes/(main)/(public)/sign-in/+page.ts
new file mode 100644
index 0000000..bebc459
--- /dev/null
+++ b/code/frontend/src/routes/(main)/(public)/sign-in/+page.ts
@@ -0,0 +1,11 @@
+import LL from '$i18n/i18n-svelte';
+import { get } from 'svelte/store';
+import type { PageLoad } from './$types';
+
+const l = get(LL);
+
+export const load: PageLoad = async () => {
+ return {
+ title: l.signInPage.title(),
+ };
+}; \ No newline at end of file
diff --git a/code/frontend/src/routes/(main)/(public)/sign-in/index.spec.js b/code/frontend/src/routes/(main)/(public)/sign-in/index.spec.js
new file mode 100644
index 0000000..3bccf72
--- /dev/null
+++ b/code/frontend/src/routes/(main)/(public)/sign-in/index.spec.js
@@ -0,0 +1,12 @@
+import { test, expect } from "@playwright/test";
+import { signInPageTestKeys } from "./index.js";
+import { get_test_context } from "$configuration/test";
+import { get_pw_key_selector } from "$utils/testing-helpers";
+
+const context = get_test_context();
+
+test("form loads", async ({ page }) => {
+ page.goto("/sign-in");
+ const form = page.locator(get_pw_key_selector(signInPageTestKeys.signInForm));
+ expect(form.isVisible()).toBeTruthy();
+});
diff --git a/code/frontend/src/routes/(main)/(public)/sign-in/index.ts b/code/frontend/src/routes/(main)/(public)/sign-in/index.ts
new file mode 100644
index 0000000..c1a1929
--- /dev/null
+++ b/code/frontend/src/routes/(main)/(public)/sign-in/index.ts
@@ -0,0 +1,20 @@
+export enum SignInPageMessage {
+ AFTER_PASSWORD_RESET = "after-password-reset",
+ USER_INACTIVITY = "user-inactivity",
+ USER_DISABLED = "user-disabled",
+ LOGGED_OUT = "logged-out"
+}
+
+export const signInPageMessageQueryKey = "m";
+export const signInPageTestKeys = {
+ passwordInput: "password-input",
+ usernameInput: "username-input",
+ rememberMeCheckbox: "remember-me-checkbox",
+ signInForm: "sign-in-form",
+ 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",
+}; \ No newline at end of file
diff --git a/code/frontend/src/routes/(main)/(public)/sign-up/+page.svelte b/code/frontend/src/routes/(main)/(public)/sign-up/+page.svelte
new file mode 100644
index 0000000..470ac5d
--- /dev/null
+++ b/code/frontend/src/routes/(main)/(public)/sign-up/+page.svelte
@@ -0,0 +1,106 @@
+<script lang="ts">
+ import { goto } from "$app/navigation";
+ import { Button, Input, Alert } from "$components";
+ import LL from "$i18n/i18n-svelte";
+ import { FormError } from "$models/internal/FormError";
+ import type { CreateAccountPayload } from "$services/abstractions/IAccountService";
+ import { AccountService } from "$services/account-service";
+
+ const formData = {
+ username: {
+ value: "",
+ errors: [],
+ },
+ password: {
+ value: "",
+ errors: [],
+ },
+ as_payload(): CreateAccountPayload {
+ return {
+ username: formData.username.value,
+ password: formData.password.value,
+ };
+ },
+ };
+
+ const formError = new FormError();
+ const accountService = new AccountService();
+
+ let loading = false;
+ let showErrorAlert = false;
+
+ async function submit_form_async() {
+ loading = true;
+ showErrorAlert = false;
+ formError.set();
+ formData.username.errors = [];
+ formData.password.errors = [];
+ const response = await accountService.create_account_async(formData.as_payload());
+ if (response.isCreated) {
+ await goto("/home");
+ } else if (response.knownProblem) {
+ formError.set_from_known_problem(response.knownProblem);
+ for (const error of Object.entries(response.knownProblem.errors)) {
+ if (error[0] === "username") {
+ const errors = [];
+ error[1].forEach((e) => errors.push(e));
+ formData.username.errors = errors;
+ }
+ if (error[0] === "password") {
+ const errors = [];
+ error[1].forEach((e) => errors.push(e));
+ formData.password.errors = errors;
+ }
+ }
+ } else {
+ formError.set($LL.unexpectedError(), $LL.tryAgainSoon());
+ }
+ loading = false;
+ showErrorAlert = formError.has_error();
+ }
+</script>
+
+<div class="min-h-full flex flex-col justify-center py-12 sm:px-6 lg:px-8">
+ <div class="sm:mx-auto sm:w-full p-2 sm:p-0 sm:max-w-md">
+ <h2 class="mt-6 text-3xl tracking-tight font-bold text-gray-900">
+ {$LL.signUpPage.createYourNewAccount()}
+ </h2>
+ <p class="mt-2 text-sm text-gray-600">
+ {$LL.or().toLowerCase()}
+ <a href="/sign-in" class="link">
+ {$LL.signIntoYourAccount().toLowerCase()}
+ </a>
+ </p>
+ </div>
+
+ <div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
+ <div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
+ {#if showErrorAlert}
+ <Alert title={formError.title} message={formError.subtitle} type="error" class="mb-2" />
+ {/if}
+ <form class="space-y-6" on:submit|preventDefault={submit_form_async}>
+ <Input
+ label={$LL.emailAddress()}
+ id="email"
+ name="email"
+ autocomplete="email"
+ required
+ type="email"
+ bind:value={formData.username.value}
+ errors={formData.username.errors}
+ />
+
+ <Input
+ label={$LL.password()}
+ id="password"
+ name="password"
+ required
+ type="password"
+ bind:value={formData.password.value}
+ errors={formData.password.errors}
+ />
+ <Button type="submit" text={$LL.submit()} {loading} fullWidth />
+ </form>
+ </div>
+ </div>
+</div>
diff --git a/code/frontend/src/routes/(main)/(public)/sign-up/+page.ts b/code/frontend/src/routes/(main)/(public)/sign-up/+page.ts
new file mode 100644
index 0000000..8c86f55
--- /dev/null
+++ b/code/frontend/src/routes/(main)/(public)/sign-up/+page.ts
@@ -0,0 +1,11 @@
+import LL from '$i18n/i18n-svelte';
+import { get } from 'svelte/store';
+import type { PageLoad } from './$types';
+
+const l = get(LL);
+
+export const load: PageLoad = async () => {
+ return {
+ title: l.signUpPage.title(),
+ };
+}; \ No newline at end of file
diff --git a/code/frontend/src/routes/(main)/+page.svelte b/code/frontend/src/routes/(main)/+page.svelte
new file mode 100644
index 0000000..e507a19
--- /dev/null
+++ b/code/frontend/src/routes/(main)/+page.svelte
@@ -0,0 +1 @@
+<p class="text-bold p-1">Hold on...</p>
diff --git a/code/frontend/src/routes/+layout.server.ts b/code/frontend/src/routes/+layout.server.ts
new file mode 100644
index 0000000..00c8326
--- /dev/null
+++ b/code/frontend/src/routes/+layout.server.ts
@@ -0,0 +1,44 @@
+import {api_base, CookieNames} from "$configuration";
+import {cached_result_async, CacheKeys} from "$utils/cache";
+import {get_md5_hash} from "$utils/crypto-helpers";
+import {error, redirect} from "@sveltejs/kit";
+import type {LayoutServerLoad} from "./$types";
+
+export const load: LayoutServerLoad = async ({route, cookies, locals, fetch}) => {
+ const isBaseRoute = route.id === "/(main)";
+ const isPortalRoute = route.id === "/(main)/(public)/portal";
+ const isPublicRoute = (isBaseRoute || (route.id?.startsWith("/(main)/(public)") ?? false)) ?? true;
+ const sessionCookieValue = cookies.get(CookieNames.session);
+ let sessionIsValid = false;
+ if ((sessionCookieValue?.length > 0 ?? false)) {
+ const sessionHash = get_md5_hash(sessionCookieValue);
+ sessionIsValid = (await cached_result_async<Response>(sessionHash + "_" + CacheKeys.isAuthenticated, 120, () => fetch(api_base("_/is-authenticated"), {
+ headers: {
+ Cookie: CookieNames.session + "=" + sessionCookieValue,
+ },
+ }).catch((e) => {
+ console.error(e);
+ throw error(503, {
+ message: "We are experiencing a service disruption! Have patience while we resolve the issue.",
+ });
+ }))).ok;
+ }
+
+ console.debug("Base Layout loaded", {
+ sessionIsValid,
+ isPublicRoute,
+ isBaseRoute,
+ isPortalRoute,
+ routeId: route.id,
+ });
+
+ if (sessionIsValid && isPublicRoute && !isPortalRoute) {
+ throw redirect(302, "/home");
+ } else if (!isPortalRoute && (isBaseRoute || !sessionIsValid && !isPublicRoute)) {
+ throw redirect(302, "/sign-in");
+ }
+
+ return {
+ locale: locals.locale,
+ };
+};
diff --git a/code/frontend/src/routes/+layout.svelte b/code/frontend/src/routes/+layout.svelte
new file mode 100644
index 0000000..dc3fea6
--- /dev/null
+++ b/code/frontend/src/routes/+layout.svelte
@@ -0,0 +1,50 @@
+<script lang="ts">
+ import "../app.pcss";
+ import { page } from "$app/stores";
+ import type { LayoutData } from "./$types";
+ import Sonner from "$components/sonner.svelte";
+ import { ModeWatcher } from "mode-watcher";
+ import StyleChanger from "$components/style-changer.svelte";
+ import { browser } from "$app/environment";
+ import { QueryClient, QueryClientProvider } from "@tanstack/svelte-query";
+ import { setLocale } from "$i18n/i18n-svelte";
+ import { ExclamationTriangle } from "svelte-radix";
+
+ let online = true;
+ export let data: LayoutData;
+ setLocale(data.locale);
+
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ enabled: browser
+ }
+ }
+ });
+</script>
+
+<svelte:window bind:online/>
+<svelte:head>
+ <title>{$page.data.title ? $page.data.title + " - Greatoffice" : "Greatoffice"}</title>
+</svelte:head>
+
+<ModeWatcher/>
+<Sonner/>
+<StyleChanger/>
+
+<QueryClientProvider client={queryClient}>
+ <slot/>
+</QueryClientProvider>
+
+{#if !online}
+ <div class="bg-yellow-50 relative z-50 p-4">
+ <div class="flex">
+ <div class="flex-shrink-0">
+ <ExclamationTriangle class="bg-yellow-50 text-yellow-500"/>
+ </div>
+ <div class="ml-3">
+ <p class="text-sm text-yellow-700">You seem to be offline, please check your internet connection.</p>
+ </div>
+ </div>
+ </div>
+{/if}
diff --git a/code/frontend/src/routes/+layout.ts b/code/frontend/src/routes/+layout.ts
new file mode 100644
index 0000000..3893260
--- /dev/null
+++ b/code/frontend/src/routes/+layout.ts
@@ -0,0 +1,10 @@
+import type { LayoutLoad } from "./$types";
+import type { Locales } from "$i18n/i18n-types";
+import { loadLocaleAsync } from "$i18n/i18n-util.async";
+import { setLocale } from "$i18n/i18n-svelte";
+
+export const load: LayoutLoad<{ locale: Locales }> = async ({ data: { locale } }) => {
+ await loadLocaleAsync(locale);
+ setLocale(locale);
+ return { locale };
+}; \ No newline at end of file
diff --git a/code/frontend/src/services/abstractions/IAccountService.ts b/code/frontend/src/services/abstractions/IAccountService.ts
new file mode 100644
index 0000000..d3d48b0
--- /dev/null
+++ b/code/frontend/src/services/abstractions/IAccountService.ts
@@ -0,0 +1,54 @@
+import type { KnownProblem } from "$models/internal/KnownProblem";
+import type { Writable } from "svelte/store";
+
+export interface IAccountService {
+ session: Writable<Session>,
+ login_async(payload: LoginPayload): Promise<LoginResponse>,
+ logout_async(): Promise<void>,
+ end_session_async(callback?: Function): Promise<void>,
+ create_account_async(payload: CreateAccountPayload): Promise<CreateAccountResponse>,
+ delete_current_async(): Promise<DeleteAccountResponse>,
+ update_current_async(payload: UpdateAccountPayload): Promise<UpdateAccountResponse>,
+}
+
+export type Session = {
+ username: string,
+ displayName: string,
+ id: string,
+ _lastUpdated: number
+}
+
+export type LoginPayload = {
+ username: string,
+ password: string,
+ persist: boolean
+}
+
+export type LoginResponse = {
+ isLoggedIn: boolean,
+ knownProblem?: KnownProblem
+}
+
+export type CreateAccountPayload = {
+ username: string,
+ password: string,
+}
+
+export type CreateAccountResponse = {
+ isCreated: boolean,
+ knownProblem?: KnownProblem
+}
+
+export type DeleteAccountResponse = {
+ isDeleted: boolean
+}
+
+export type UpdateAccountPayload = {
+ username: string,
+ password: string
+}
+
+export type UpdateAccountResponse = {
+ isUpdated: boolean,
+ knownProblem?: KnownProblem
+} \ No newline at end of file
diff --git a/code/frontend/src/services/abstractions/IApiTokenService.ts b/code/frontend/src/services/abstractions/IApiTokenService.ts
new file mode 100644
index 0000000..fdf82eb
--- /dev/null
+++ b/code/frontend/src/services/abstractions/IApiTokenService.ts
@@ -0,0 +1,34 @@
+import type { Temporal } from "temporal-polyfill"
+
+export interface IApiTokenService {
+ create_token_async(payload: CreateTokenPayload): Promise<CreateTokenResponse>,
+ delete_token_async(payload: DeleteTokenPayload): Promise<DeleteTokenResponse>,
+ get_tokens_async(query: TokenQuery): Promise<GetTokensResponse>
+}
+export type GetTokensResponse = {
+ results: Array<GetTokensTokenModel>
+};
+export type GetTokensTokenModel = {
+ id: string,
+ name: string,
+ permissions: string[]
+}
+export type TokenQuery = {
+ includeStale: boolean
+};
+export type DeleteTokenResponse = {
+ isDeleted: boolean
+};
+export type DeleteTokenPayload = {
+ id: string
+};
+export type CreateTokenResponse = {
+ isCreated: boolean
+};
+export type CreateTokenPayload = {
+ expiryDate: Temporal.PlainDateTime,
+ allowRead: boolean,
+ allowCreate: boolean,
+ allowUpdate: boolean,
+ allowDelete: boolean
+}; \ No newline at end of file
diff --git a/code/frontend/src/services/abstractions/IPasswordResetService.ts b/code/frontend/src/services/abstractions/IPasswordResetService.ts
new file mode 100644
index 0000000..59d2bc6
--- /dev/null
+++ b/code/frontend/src/services/abstractions/IPasswordResetService.ts
@@ -0,0 +1,21 @@
+import type { KnownProblem } from "$models/internal/KnownProblem"
+
+export interface IPasswordResetService {
+ create_request_async(email: string): Promise<CreateRequestResponse>,
+ fulfill_request_async(id: string, newPassword: string): Promise<FulfillRequestResponse>,
+ request_is_valid_async(id: string): Promise<RequestIsValidResponse>
+}
+
+export type RequestIsValidResponse = {
+ isValid: boolean
+}
+
+export type FulfillRequestResponse = {
+ isFulfilled: boolean,
+ knownProblem?: KnownProblem
+}
+
+export type CreateRequestResponse = {
+ isCreated: boolean,
+ knownProblem?: KnownProblem
+} \ No newline at end of file
diff --git a/code/frontend/src/services/abstractions/ISettingsService.ts b/code/frontend/src/services/abstractions/ISettingsService.ts
new file mode 100644
index 0000000..366e337
--- /dev/null
+++ b/code/frontend/src/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/frontend/src/services/account-service.ts b/code/frontend/src/services/account-service.ts
new file mode 100644
index 0000000..96e75f5
--- /dev/null
+++ b/code/frontend/src/services/account-service.ts
@@ -0,0 +1,123 @@
+import {http_delete_async, http_get_async, http_post_async} from "$utils/_fetch";
+import {browser} from "$app/environment";
+import {api_base, CookieNames, StorageKeys} from "$configuration";
+import {is_known_problem} from "$models/internal/KnownProblem";
+import {StoreType, create_writable_persistent} from "$utils/persistent-store";
+import {get} from "svelte/store";
+import type {Writable} from "svelte/store";
+import {Temporal} from "temporal-polyfill";
+import type {
+ CreateAccountPayload,
+ CreateAccountResponse,
+ DeleteAccountResponse,
+ IAccountService,
+ LoginPayload,
+ LoginResponse,
+ Session,
+ UpdateAccountPayload,
+ UpdateAccountResponse,
+} from "./abstractions/IAccountService";
+
+export class AccountService implements IAccountService {
+ session: Writable<Session> | undefined;
+ private sessionCooldown = 3600;
+
+ constructor() {
+ if (browser) {
+ this.session = create_writable_persistent({
+ name: StorageKeys.session,
+ initialState: {} as Session,
+ options: {
+ store: StoreType.LOCAL,
+ },
+ });
+ this.refresh_session();
+ } else {
+ this.session = undefined;
+ }
+ }
+
+ static resolve(): IAccountService {
+ return new AccountService();
+ }
+
+ async refresh_session(forceRefresh: boolean = false): Promise<void> {
+ if (!this.session) return;
+ const currentValue = get(this.session);
+ const currentEpoch = Temporal.Now.instant().epochSeconds;
+ if (!forceRefresh && ((currentValue?._lastUpdated ?? 0) + this.sessionCooldown) > currentEpoch) {
+ console.debug("Session is not stale yet", {
+ currentEpoch,
+ staleEpoch: currentValue?._lastUpdated + this.sessionCooldown,
+ });
+ return;
+ }
+ const sessionResponse = await http_get_async(api_base("_/session-data"));
+ if (sessionResponse.ok) {
+ this.session.set(await sessionResponse.json());
+ } else {
+ this.session.set(null);
+ }
+ }
+
+ async end_session_async(callback: Function = undefined): Promise<void> {
+ if (!this.session) return;
+ await this.logout_async();
+ this.session.set(null);
+ if (callback && typeof callback === "function") callback();
+ }
+
+ async login_async(payload: LoginPayload): Promise<LoginResponse> {
+ const response = await http_post_async(api_base("_/account/login"), payload);
+ if (response.ok) return {isLoggedIn: true};
+ if (is_known_problem(response)) return {
+ isLoggedIn: false,
+ knownProblem: await response.json(),
+ };
+ return {
+ isLoggedIn: false,
+ };
+ }
+
+ async logout_async(): Promise<void> {
+ const response = await http_get_async(api_base("_/account/logout"));
+ if (!response.ok) {
+ const deleteCookieResponse = await fetch("/delete-cookie?key=" + CookieNames.session);
+ if (!deleteCookieResponse.ok) {
+ throw new Error("Could neither logout nor delete session cookie.");
+ }
+ }
+ return;
+ }
+
+ async create_account_async(payload: CreateAccountPayload): Promise<CreateAccountResponse> {
+ const response = await http_post_async(api_base("_/account/create"), payload);
+ if (response.ok) return {isCreated: true};
+ if (is_known_problem(response)) return {
+ isCreated: false,
+ knownProblem: await response.json(),
+ };
+ return {
+ isCreated: false,
+ };
+ }
+
+ async delete_current_async(): Promise<DeleteAccountResponse> {
+ const response = await http_delete_async(api_base("_/account/delete"));
+ return {
+ isDeleted: response.ok,
+ };
+ }
+
+ async update_current_async(payload: UpdateAccountPayload): Promise<UpdateAccountResponse> {
+ const response = await http_post_async(api_base("_/account/update"), payload);
+ if (response.ok) return {isUpdated: true};
+ if (is_known_problem(response)) return {
+ isUpdated: false,
+ knownProblem: await response.json(),
+ };
+ return {
+ isUpdated: false,
+ };
+ }
+} \ No newline at end of file
diff --git a/code/frontend/src/services/api-tokens-service.ts b/code/frontend/src/services/api-tokens-service.ts
new file mode 100644
index 0000000..e0f2c2a
--- /dev/null
+++ b/code/frontend/src/services/api-tokens-service.ts
@@ -0,0 +1,22 @@
+import { api_base } from "$configuration";
+import { http_delete_async, http_get_async, http_post_async } from "$utils/_fetch";
+import type { CreateTokenPayload, CreateTokenResponse, DeleteTokenPayload, DeleteTokenResponse, GetTokensResponse, IApiTokenService, TokenQuery } from "./abstractions/IApiTokenService";
+
+export class ApiTokenService implements IApiTokenService {
+ constructor() { }
+ static resolve() {
+ return new ApiTokenService();
+ }
+ async create_token_async(payload: CreateTokenPayload): Promise<CreateTokenResponse> {
+ const response = await http_post_async(api_base("v1/api-tokens/create"), payload);
+ return;
+ };
+ async delete_token_async(payload: DeleteTokenPayload): Promise<DeleteTokenResponse> {
+ const response = await http_delete_async(api_base("v1/api-tokens/delete"), payload);
+ return;
+ };
+ async get_tokens_async(query: TokenQuery): Promise<GetTokensResponse> {
+ const response = await http_get_async(api_base("v1/api-tokens"));
+ return;
+ };
+} \ No newline at end of file
diff --git a/code/frontend/src/services/password-reset-service.ts b/code/frontend/src/services/password-reset-service.ts
new file mode 100644
index 0000000..edecee2
--- /dev/null
+++ b/code/frontend/src/services/password-reset-service.ts
@@ -0,0 +1,48 @@
+import { http_get_async, http_post_async } from "$utils/_fetch";
+import { api_base } from "$configuration";
+import { is_known_problem } from "$models/internal/KnownProblem";
+import type {
+ CreateRequestResponse,
+ FulfillRequestResponse,
+ IPasswordResetService,
+ RequestIsValidResponse,
+} from "./abstractions/IPasswordResetService";
+
+export class PasswordResetService implements IPasswordResetService {
+ static resolve(): IPasswordResetService {
+ return new PasswordResetService();
+ }
+ async create_request_async(email: string): Promise<CreateRequestResponse> {
+ const response = await http_post_async(api_base("_/password-reset-request/create"), { email });
+ if (response.ok) return { isCreated: true };
+ if (is_known_problem(response)) return {
+ isCreated: false,
+ knownProblem: await response.json(),
+ };
+
+ return {
+ isCreated: false,
+ };
+ }
+
+ async fulfill_request_async(id: string, newPassword: string): Promise<FulfillRequestResponse> {
+ const response = await http_post_async(api_base("_/password-reset-request/fulfill"), { id: id, newPassword });
+ if (response.ok) return { isFulfilled: true };
+ if (is_known_problem(response)) return {
+ isFulfilled: false,
+ knownProblem: await response.json(),
+ };
+
+ return {
+ isFulfilled: false,
+ };
+ }
+
+ async request_is_valid_async(id: string): Promise<RequestIsValidResponse> {
+ const response = await http_get_async(api_base("_/password-reset-request/is-valid?id=" + id));
+ const responseBody = await response.json() as { isValid: boolean };
+ return {
+ isValid: responseBody.isValid,
+ };
+ }
+} \ No newline at end of file
diff --git a/code/frontend/src/services/settings-service.ts b/code/frontend/src/services/settings-service.ts
new file mode 100644
index 0000000..a0a77d4
--- /dev/null
+++ b/code/frontend/src/services/settings-service.ts
@@ -0,0 +1,10 @@
+import type { ISettingsService } from "./abstractions/ISettingsService";
+
+export class SettingService implements ISettingsService {
+ static resolve(): ISettingsService {
+ return new SettingService();
+ }
+ get_user_settings(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+} \ No newline at end of file
diff --git a/code/frontend/src/utils/_fetch.ts b/code/frontend/src/utils/_fetch.ts
new file mode 100644
index 0000000..f884653
--- /dev/null
+++ b/code/frontend/src/utils/_fetch.ts
@@ -0,0 +1,93 @@
+import { Temporal } from "temporal-polyfill";
+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 { AccountService } from "$services/account-service";
+
+
+export async function http_post_async(url: string, body?: object | string, timeout = -1, skip_401_check = false, abort_signal?: AbortSignal): Promise<Response> {
+ 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)) throw new Error("Server returned 401");
+ return response;
+}
+
+export async function http_get_async(url: string, timeout = -1, skip_401_check = false, abort_signal?: AbortSignal): Promise<Response> {
+ 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)) throw new Error("Server returned 401");
+ return response;
+}
+
+export async function http_delete_async(url: string, body?: object | string, timeout = -1, skip_401_check = false, abort_signal?: AbortSignal): Promise<Response> {
+ 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)) throw new Error("Server returned 401");
+ return 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) {
+ if (error.message === "Timeout") {
+ console.error("Request timed out", error);
+ } else if (error.message === "Network request failed") {
+ console.error("No internet connection", error);
+ } 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}`;
+ await AccountService.resolve().end_session_async();
+ if (browser) {
+ await goto(redirectUrl);
+ } else {
+ throw redirect(307, redirectUrl);
+ }
+ }
+ return false;
+}
+
+function make_request_init(method: string, body?: any, signal?: AbortSignal): RequestInit {
+ const init = {
+ method,
+ credentials: "include",
+ 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,
+} \ No newline at end of file
diff --git a/code/frontend/src/utils/colors.ts b/code/frontend/src/utils/colors.ts
new file mode 100644
index 0000000..34c7992
--- /dev/null
+++ b/code/frontend/src/utils/colors.ts
@@ -0,0 +1,47 @@
+export function generate_random_hex_color(skip_contrast_check = false) {
+ let hex = __generate_random_hex_color();
+ if (skip_contrast_check) return hex;
+ while ((__calculate_contrast_ratio("#ffffff", hex) < 4.5) || (__calculate_contrast_ratio("#000000", hex) < 4.5)) {
+ hex = __generate_random_hex_color();
+ }
+
+ return hex;
+}
+
+// Largely copied from chroma js api
+function __generate_random_hex_color(): string {
+ let code = "#";
+ for (let i = 0; i < 6; i++) {
+ code += "0123456789abcdef".charAt(Math.floor(Math.random() * 16));
+ }
+ return code;
+}
+
+function __calculate_contrast_ratio(hex1: string, hex2: string): number {
+ const rgb1 = __hex_to_rgb(hex1);
+ const rgb2 = __hex_to_rgb(hex2);
+ const l1 = __get_luminance(rgb1[0], rgb1[1], rgb1[2]);
+ const l2 = __get_luminance(rgb2[0], rgb2[1], rgb2[2]);
+ const result = l1 > l2 ? (l1 + 0.05) / (l2 + 0.05) : (l2 + 0.05) / (l1 + 0.05);
+ return result;
+}
+
+function __hex_to_rgb(hex: string): number[] {
+ if (!hex.match(/^#([A-Fa-f0-9]{6})$/)) return [];
+ if (hex[0] === "#") hex = hex.substring(1, hex.length);
+ return [parseInt(hex.substring(0, 2), 16), parseInt(hex.substring(2, 4), 16), parseInt(hex.substring(4, 6), 16)];
+}
+
+function __get_luminance(r: any, g: any, b: any) {
+ // relative luminance
+ // see http://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
+ r = __luminance_x(r);
+ g = __luminance_x(g);
+ b = __luminance_x(b);
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+}
+
+function __luminance_x(x: any) {
+ x /= 255;
+ return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
+}
diff --git a/code/frontend/src/utils/crypto-helpers.ts b/code/frontend/src/utils/crypto-helpers.ts
new file mode 100644
index 0000000..c2a5275
--- /dev/null
+++ b/code/frontend/src/utils/crypto-helpers.ts
@@ -0,0 +1,49 @@
+// @ts-nocheck
+// A formatted version of a popular md5 implementation.
+// Original copyright (c) Paul Johnston & Greg Holt.
+// The function itself is now 42 lines long.
+// https://stackoverflow.com/a/60467595 "Don't deny."
+
+export function get_md5_hash(inputString: string): string {
+ const hc = "0123456789abcdef";
+ function rh(n) { var j, s = ""; for (j = 0; j <= 3; j++) s += hc.charAt((n >> (j * 8 + 4)) & 0x0F) + hc.charAt((n >> (j * 8)) & 0x0F); return s; }
+ function ad(x, y) { var l = (x & 0xFFFF) + (y & 0xFFFF); var m = (x >> 16) + (y >> 16) + (l >> 16); return (m << 16) | (l & 0xFFFF); }
+ function rl(n, c) { return (n << c) | (n >>> (32 - c)); }
+ function cm(q, a, b, x, s, t) { return ad(rl(ad(ad(a, q), ad(x, t)), s), b); }
+ function ff(a, b, c, d, x, s, t) { return cm((b & c) | ((~b) & d), a, b, x, s, t); }
+ function gg(a, b, c, d, x, s, t) { return cm((b & d) | (c & (~d)), a, b, x, s, t); }
+ function hh(a, b, c, d, x, s, t) { return cm(b ^ c ^ d, a, b, x, s, t); }
+ function ii(a, b, c, d, x, s, t) { return cm(c ^ (b | (~d)), a, b, x, s, t); }
+ function sb(x) {
+ var i; var nblk = ((x.length + 8) >> 6) + 1; var blks = new Array(nblk * 16); for (i = 0; i < nblk * 16; i++) blks[i] = 0;
+ for (i = 0; i < x.length; i++) blks[i >> 2] |= x.charCodeAt(i) << ((i % 4) * 8);
+ blks[i >> 2] |= 0x80 << ((i % 4) * 8); blks[nblk * 16 - 2] = x.length * 8; return blks;
+ }
+ var i, x = sb(inputString), a = 1732584193, b = -271733879, c = -1732584194, d = 271733878, olda, oldb, oldc, oldd;
+ for (i = 0; i < x.length; i += 16) {
+ olda = a; oldb = b; oldc = c; oldd = d;
+ a = ff(a, b, c, d, x[i + 0], 7, -680876936); d = ff(d, a, b, c, x[i + 1], 12, -389564586); c = ff(c, d, a, b, x[i + 2], 17, 606105819);
+ b = ff(b, c, d, a, x[i + 3], 22, -1044525330); a = ff(a, b, c, d, x[i + 4], 7, -176418897); d = ff(d, a, b, c, x[i + 5], 12, 1200080426);
+ c = ff(c, d, a, b, x[i + 6], 17, -1473231341); b = ff(b, c, d, a, x[i + 7], 22, -45705983); a = ff(a, b, c, d, x[i + 8], 7, 1770035416);
+ d = ff(d, a, b, c, x[i + 9], 12, -1958414417); c = ff(c, d, a, b, x[i + 10], 17, -42063); b = ff(b, c, d, a, x[i + 11], 22, -1990404162);
+ a = ff(a, b, c, d, x[i + 12], 7, 1804603682); d = ff(d, a, b, c, x[i + 13], 12, -40341101); c = ff(c, d, a, b, x[i + 14], 17, -1502002290);
+ b = ff(b, c, d, a, x[i + 15], 22, 1236535329); a = gg(a, b, c, d, x[i + 1], 5, -165796510); d = gg(d, a, b, c, x[i + 6], 9, -1069501632);
+ c = gg(c, d, a, b, x[i + 11], 14, 643717713); b = gg(b, c, d, a, x[i + 0], 20, -373897302); a = gg(a, b, c, d, x[i + 5], 5, -701558691);
+ d = gg(d, a, b, c, x[i + 10], 9, 38016083); c = gg(c, d, a, b, x[i + 15], 14, -660478335); b = gg(b, c, d, a, x[i + 4], 20, -405537848);
+ a = gg(a, b, c, d, x[i + 9], 5, 568446438); d = gg(d, a, b, c, x[i + 14], 9, -1019803690); c = gg(c, d, a, b, x[i + 3], 14, -187363961);
+ b = gg(b, c, d, a, x[i + 8], 20, 1163531501); a = gg(a, b, c, d, x[i + 13], 5, -1444681467); d = gg(d, a, b, c, x[i + 2], 9, -51403784);
+ c = gg(c, d, a, b, x[i + 7], 14, 1735328473); b = gg(b, c, d, a, x[i + 12], 20, -1926607734); a = hh(a, b, c, d, x[i + 5], 4, -378558);
+ d = hh(d, a, b, c, x[i + 8], 11, -2022574463); c = hh(c, d, a, b, x[i + 11], 16, 1839030562); b = hh(b, c, d, a, x[i + 14], 23, -35309556);
+ a = hh(a, b, c, d, x[i + 1], 4, -1530992060); d = hh(d, a, b, c, x[i + 4], 11, 1272893353); c = hh(c, d, a, b, x[i + 7], 16, -155497632);
+ b = hh(b, c, d, a, x[i + 10], 23, -1094730640); a = hh(a, b, c, d, x[i + 13], 4, 681279174); d = hh(d, a, b, c, x[i + 0], 11, -358537222);
+ c = hh(c, d, a, b, x[i + 3], 16, -722521979); b = hh(b, c, d, a, x[i + 6], 23, 76029189); a = hh(a, b, c, d, x[i + 9], 4, -640364487);
+ d = hh(d, a, b, c, x[i + 12], 11, -421815835); c = hh(c, d, a, b, x[i + 15], 16, 530742520); b = hh(b, c, d, a, x[i + 2], 23, -995338651);
+ a = ii(a, b, c, d, x[i + 0], 6, -198630844); d = ii(d, a, b, c, x[i + 7], 10, 1126891415); c = ii(c, d, a, b, x[i + 14], 15, -1416354905);
+ b = ii(b, c, d, a, x[i + 5], 21, -57434055); a = ii(a, b, c, d, x[i + 12], 6, 1700485571); d = ii(d, a, b, c, x[i + 3], 10, -1894986606);
+ c = ii(c, d, a, b, x[i + 10], 15, -1051523); b = ii(b, c, d, a, x[i + 1], 21, -2054922799); a = ii(a, b, c, d, x[i + 8], 6, 1873313359);
+ d = ii(d, a, b, c, x[i + 15], 10, -30611744); c = ii(c, d, a, b, x[i + 6], 15, -1560198380); b = ii(b, c, d, a, x[i + 13], 21, 1309151649);
+ a = ii(a, b, c, d, x[i + 4], 6, -145523070); d = ii(d, a, b, c, x[i + 11], 10, -1120210379); c = ii(c, d, a, b, x[i + 2], 15, 718787259);
+ b = ii(b, c, d, a, x[i + 9], 21, -343485551); a = ad(a, olda); b = ad(b, oldb); c = ad(c, oldc); d = ad(d, oldd);
+ }
+ return rh(a) + rh(b) + rh(c) + rh(d);
+}
diff --git a/code/frontend/src/utils/global-state.ts b/code/frontend/src/utils/global-state.ts
new file mode 100644
index 0000000..b585ced
--- /dev/null
+++ b/code/frontend/src/utils/global-state.ts
@@ -0,0 +1,22 @@
+import { get } from "svelte/store";
+import { create_writable_persistent } from "./persistent-store";
+
+const state = create_writable_persistent<any>({
+ initialState: {},
+ name: "global-state"
+});
+
+export type GlobalStateKeys = "isLoggedIn" | "showEmailValidatedAlertWhenLoggedIn" | "all";
+
+export function fgs(key: GlobalStateKeys): any {
+ const value = get(state);
+ if (key === "all") return value;
+ return value[key];
+}
+
+export function sgs(key: GlobalStateKeys, value: any) {
+ if (key === "all") throw new Error("Not allowed to set global state key: all");
+ const stateValue = get(state);
+ stateValue[key] = JSON.stringify(value)
+ state.set(stateValue);
+} \ No newline at end of file
diff --git a/code/frontend/src/utils/misc-helpers.ts b/code/frontend/src/utils/misc-helpers.ts
new file mode 100644
index 0000000..afb20e7
--- /dev/null
+++ b/code/frontend/src/utils/misc-helpers.ts
@@ -0,0 +1,77 @@
+export function merge_obj_arr<T>(a: Array<T>, b: Array<T>, props: Array<string>): Array<T> {
+ let start = 0;
+ let merge = [];
+
+ while (start < a.length) {
+
+ if (a[start] === b[start]) {
+ //pushing the merged objects into array
+ merge.push({ ...a[start], ...b[start] });
+ }
+ //incrementing start value
+ start = start + 1;
+ }
+ return merge;
+}
+
+export function no_type_check(x: any) {
+ return x;
+}
+
+export function capitalise(value: string): string {
+ return value.charAt(0).toUpperCase() + value.slice(1);
+}
+
+export function get_query_string(params: any = {}): string {
+ const map = Object.keys(params).reduce((arr: Array<string>, key: string) => {
+ if (params[key] !== undefined) {
+ return arr.concat(`${key}=${encodeURIComponent(params[key])}`);
+ }
+ return arr;
+ }, [] as any);
+
+ if (map.length) {
+ return `?${map.join("&")}`;
+ }
+
+ return "";
+}
+
+export function make_url(url: string, params: object): string {
+ return `${url}${get_query_string(params)}`;
+}
+
+export function noop() {
+}
+
+export function random_string(length: number): string {
+ if (!length) {
+ throw new Error("length is undefined");
+ }
+ let result = "";
+ const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+ const charactersLength = characters.length;
+ for (let i = 0; i < length; i++) {
+ result += characters.charAt(Math.floor(Math.random() * charactersLength));
+ }
+ return result;
+}
+
+export function get_random_int(min: number, max: number): number {
+ min = Math.ceil(min);
+ max = Math.floor(max);
+ return Math.floor(Math.random() * (max - min + 1)) + min;
+}
+
+export function get_hash_code(value: string): number | undefined {
+ let hash = 0;
+ if (value.length === 0) {
+ return;
+ }
+ for (let i = 0; i < value.length; i++) {
+ const char = value.charCodeAt(i);
+ hash = (hash << 5) - hash + char;
+ hash |= 0;
+ }
+ return hash;
+}
diff --git a/code/frontend/src/utils/persistent-store.ts b/code/frontend/src/utils/persistent-store.ts
new file mode 100644
index 0000000..d880464
--- /dev/null
+++ b/code/frontend/src/utils/persistent-store.ts
@@ -0,0 +1,110 @@
+import {browser} from "$app/environment";
+import {writable as _writable, readable as _readable} from "svelte/store";
+import type {Writable, Readable, StartStopNotifier} from "svelte/store";
+
+enum StoreType {
+ SESSION = 0,
+ LOCAL = 1
+}
+
+interface StoreOptions {
+ store?: StoreType;
+}
+
+interface WritableStoreInit<T> {
+ name: string,
+ initialState: T,
+ options?: StoreOptions
+}
+
+interface ReadableStoreInit<T> {
+ name: string,
+ initialState: T,
+ callback: StartStopNotifier<any>,
+ options?: StoreOptions
+}
+
+function get_store(type: StoreType): Storage {
+ if (!browser) return undefined;
+ switch (type) {
+ case StoreType.SESSION:
+ return window.sessionStorage;
+ case StoreType.LOCAL:
+ return window.localStorage;
+ }
+}
+
+function prepared_store_value(value: any): string {
+ try {
+ return JSON.stringify(value);
+ } catch (e) {
+ console.error(e);
+ return "__INVALID__";
+ }
+}
+
+function get_store_value<T>(init: WritableStoreInit<T> | ReadableStoreInit<T>): any {
+ try {
+ const storage = get_store(init.options.store);
+ if (!storage) return;
+ const value = storage.getItem(init.name);
+ if (!value) return false;
+ return JSON.parse(value);
+ } catch (e) {
+ console.error(e);
+ return {__INVALID__: true};
+ }
+}
+
+function hydrate<T>(store: Writable<T>, init: WritableStoreInit<T> | ReadableStoreInit<T>): void {
+ const value = get_store_value<T>(init);
+ if (value && store.set) store.set(value);
+}
+
+function subscribe<T>(store: Writable<T> | Readable<T>, init: WritableStoreInit<T> | ReadableStoreInit<T>): void {
+ const storage = get_store(init.options.store);
+ if (!storage) return;
+ if (!store.subscribe) return;
+ store.subscribe((state: any) => {
+ storage.setItem(init.name, prepared_store_value(state));
+ });
+}
+
+function create_writable_persistent<T>(init: WritableStoreInit<T>): Writable<T> {
+ if (!browser) {
+ console.warn("Persistent store is only available in the browser");
+ return;
+ }
+ if (init.options === undefined) throw new Error("init is a required parameter");
+ console.debug("Creating writable store with options: ", init);
+ const store = _writable<T>(init.initialState);
+ hydrate(store, init);
+ subscribe(store, init);
+ return store;
+}
+
+function create_readable_persistent<T>(init: ReadableStoreInit<T>): Readable<T> {
+ if (!browser) {
+ console.warning("Persistent store is only available in the browser");
+ return;
+ }
+ if (init.options === undefined) throw new Error("init is a required parameter");
+ console.debug("Creating readable store with options: ", init);
+ const store = _readable<T>(init.initialState, init.callback);
+ // hydrate(store, options);
+ subscribe(store, init);
+ return store;
+}
+
+export {
+ create_writable_persistent,
+ create_readable_persistent,
+ StoreType,
+};
+
+export type {
+ WritableStoreInit as WritableStore,
+ ReadableStoreInit as ReadableStore,
+ StoreOptions,
+};
+
diff --git a/code/frontend/src/utils/storage-helpers.ts b/code/frontend/src/utils/storage-helpers.ts
new file mode 100644
index 0000000..cce655c
--- /dev/null
+++ b/code/frontend/src/utils/storage-helpers.ts
@@ -0,0 +1,26 @@
+import { browser } from "$app/environment";
+import { is_empty_object } from "./validators";
+
+export type StorageType = "local" | "session";
+export const browserStorage = {
+ remove_with_regex(type: StorageType, regex: RegExp): void {
+ if (!browser) return;
+ const storage = (type === "local" ? window.localStorage : window.sessionStorage);
+ let n = storage.length;
+ while (n--) {
+ const key = storage.key(n);
+ if (key && regex.test(key)) {
+ storage.removeItem(key);
+ }
+ }
+ },
+ set_stringified(type: StorageType, key: string, value: object): void {
+ if (!browser) return;
+ if (is_empty_object(value)) return;
+ (type === "local" ? window.localStorage : window.sessionStorage).setItem(key, JSON.stringify(value));
+ },
+ get_stringified<T>(type: StorageType, key: string): T | any {
+ if (!browser) return;
+ return JSON.parse((type === "local" ? window.localStorage : window.sessionStorage).getItem(key) ?? "{}");
+ }
+} \ No newline at end of file
diff --git a/code/frontend/src/utils/testing-helpers.ts b/code/frontend/src/utils/testing-helpers.ts
new file mode 100644
index 0000000..f21412e
--- /dev/null
+++ b/code/frontend/src/utils/testing-helpers.ts
@@ -0,0 +1,7 @@
+export function get_element_by_pw_key(key: string): HTMLElement | null {
+ return document.querySelector("[pw-key='" + key + "']");
+}
+
+export function get_pw_key_selector(key: string): string {
+ return "[pw-key='" + key + "']";
+} \ No newline at end of file
diff --git a/code/frontend/src/utils/ui.ts b/code/frontend/src/utils/ui.ts
new file mode 100644
index 0000000..019b8f6
--- /dev/null
+++ b/code/frontend/src/utils/ui.ts
@@ -0,0 +1,56 @@
+import { type ClassValue, clsx } from 'clsx'
+import { cubicOut } from 'svelte/easing'
+import { twMerge } from 'tailwind-merge'
+import type { TransitionConfig } from 'svelte/transition'
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
+
+type FlyAndScaleParams = {
+ y?: number
+ x?: number
+ start?: number
+ duration?: number
+}
+
+export function flyAndScale(
+ node: Element,
+ params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 }
+): TransitionConfig {
+ const style = getComputedStyle(node)
+ const transform = style.transform === 'none' ? '' : style.transform
+
+ const scaleConversion = (valueA: number, scaleA: [number, number], scaleB: [number, number]) => {
+ const [minA, maxA] = scaleA
+ const [minB, maxB] = scaleB
+
+ const percentage = (valueA - minA) / (maxA - minA)
+ const valueB = percentage * (maxB - minB) + minB
+
+ return valueB
+ }
+
+ const styleToString = (style: Record<string, number | string | undefined>): string => {
+ return Object.keys(style).reduce((str, key) => {
+ if (style[key] === undefined) return str
+ return str + `${key}:${style[key]};`
+ }, '')
+ }
+
+ return {
+ duration: params.duration ?? 200,
+ delay: 0,
+ css: (t) => {
+ const y = scaleConversion(t, [0, 1], [params.y ?? 5, 0])
+ const x = scaleConversion(t, [0, 1], [params.x ?? 0, 0])
+ const scale = scaleConversion(t, [0, 1], [params.start ?? 0.95, 1])
+
+ return styleToString({
+ transform: `${transform} translate3d(${x}px, ${y}px, 0) scale(${scale})`,
+ opacity: t
+ })
+ },
+ easing: cubicOut
+ }
+}
diff --git a/code/frontend/src/utils/validators.ts b/code/frontend/src/utils/validators.ts
new file mode 100644
index 0000000..b69470e
--- /dev/null
+++ b/code/frontend/src/utils/validators.ts
@@ -0,0 +1,34 @@
+export const EMAIL_REGEX = new RegExp(/^([a-z0-9]+(?:([._\-])[a-z0-9]+)*@(?:[a-z0-9]+(?:(-)[a-z0-9]+)?\.)+[a-z0-9](?:[a-z0-9]*[a-z0-9])?)$/i);
+export const URL_REGEX = new RegExp(/^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[a-z0-9]+([\-.][a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/gm);
+export const GUID_REGEX = new RegExp(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
+export const NORWEGIAN_PHONE_NUMBER_REGEX = new RegExp(/(0047|\+47|47)?\d{8,12}/);
+
+export function is_email(value: string): boolean {
+ return EMAIL_REGEX.test(String(value).toLowerCase());
+}
+
+export function is_url(value: string): boolean {
+ return URL_REGEX.test(String(value).toLowerCase());
+}
+
+export function is_norwegian_phone_number(value: string): boolean {
+ if (value.length < 8 || value.length > 12) {
+ return false;
+ }
+ return NORWEGIAN_PHONE_NUMBER_REGEX.test(String(value));
+}
+
+export function is_guid(value: string): boolean {
+ if (!value) {
+ return false;
+ }
+ if (value[0] === "{") {
+ value = value.substring(1, value.length - 1);
+ }
+ return GUID_REGEX.test(value);
+}
+
+export function is_empty_object(obj: object): boolean {
+ if (!obj) return true;
+ return obj !== void 0 && Object.keys(obj).length > 0;
+} \ No newline at end of file
diff --git a/code/frontend/static/favicon.png b/code/frontend/static/favicon.png
new file mode 100644
index 0000000..825b9e6
--- /dev/null
+++ b/code/frontend/static/favicon.png
Binary files differ
diff --git a/code/frontend/svelte.config.js b/code/frontend/svelte.config.js
new file mode 100644
index 0000000..1152439
--- /dev/null
+++ b/code/frontend/svelte.config.js
@@ -0,0 +1,23 @@
+import adapter from '@sveltejs/adapter-node';
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ preprocess: [vitePreprocess({})],
+ kit: {
+ adapter: adapter(),
+ alias: {
+ "$actions": "./src/actions",
+ "$routes": "./src/routes",
+ "$models": "./src/models",
+ "$api": "./src/api",
+ "$components": "./src/components",
+ "$utils": "./src/utils",
+ "$i18n": "./src/i18n",
+ "$services": "./src/services",
+ "$configuration": "./src/configuration",
+ }
+ }
+};
+
+export default config;
diff --git a/code/frontend/tailwind.config.js b/code/frontend/tailwind.config.js
new file mode 100644
index 0000000..fa1cb36
--- /dev/null
+++ b/code/frontend/tailwind.config.js
@@ -0,0 +1,64 @@
+import { fontFamily } from "tailwindcss/defaultTheme";
+
+/** @type {import('tailwindcss').Config} */
+const config = {
+ darkMode: ["class"],
+ content: ["./src/**/*.{html,js,svelte,ts}"],
+ safelist: ["dark"],
+ theme: {
+ container: {
+ center: true,
+ padding: "2rem",
+ screens: {
+ "2xl": "1400px"
+ }
+ },
+ extend: {
+ colors: {
+ border: "hsl(var(--border) / <alpha-value>)",
+ input: "hsl(var(--input) / <alpha-value>)",
+ ring: "hsl(var(--ring) / <alpha-value>)",
+ background: "hsl(var(--background) / <alpha-value>)",
+ foreground: "hsl(var(--foreground) / <alpha-value>)",
+ primary: {
+ DEFAULT: "hsl(var(--primary) / <alpha-value>)",
+ foreground: "hsl(var(--primary-foreground) / <alpha-value>)"
+ },
+ secondary: {
+ DEFAULT: "hsl(var(--secondary) / <alpha-value>)",
+ foreground: "hsl(var(--secondary-foreground) / <alpha-value>)"
+ },
+ destructive: {
+ DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
+ foreground: "hsl(var(--destructive-foreground) / <alpha-value>)"
+ },
+ muted: {
+ DEFAULT: "hsl(var(--muted) / <alpha-value>)",
+ foreground: "hsl(var(--muted-foreground) / <alpha-value>)"
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent) / <alpha-value>)",
+ foreground: "hsl(var(--accent-foreground) / <alpha-value>)"
+ },
+ popover: {
+ DEFAULT: "hsl(var(--popover) / <alpha-value>)",
+ foreground: "hsl(var(--popover-foreground) / <alpha-value>)"
+ },
+ card: {
+ DEFAULT: "hsl(var(--card) / <alpha-value>)",
+ foreground: "hsl(var(--card-foreground) / <alpha-value>)"
+ }
+ },
+ borderRadius: {
+ lg: "var(--radius)",
+ md: "calc(var(--radius) - 2px)",
+ sm: "calc(var(--radius) - 4px)"
+ },
+ fontFamily: {
+ sans: [...fontFamily.sans]
+ }
+ }
+ },
+};
+
+export default config;
diff --git a/code/frontend/tests/test.ts b/code/frontend/tests/test.ts
new file mode 100644
index 0000000..5816be4
--- /dev/null
+++ b/code/frontend/tests/test.ts
@@ -0,0 +1,6 @@
+import { expect, test } from '@playwright/test';
+
+test('index page has expected h1', async ({ page }) => {
+ await page.goto('/');
+ await expect(page.getByRole('heading', { name: 'Welcome to SvelteKit' })).toBeVisible();
+});
diff --git a/code/frontend/tsconfig.json b/code/frontend/tsconfig.json
new file mode 100644
index 0000000..a8f10c8
--- /dev/null
+++ b/code/frontend/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "moduleResolution": "bundler"
+ }
+}
diff --git a/code/frontend/vite.config.ts b/code/frontend/vite.config.ts
new file mode 100644
index 0000000..bc25f5c
--- /dev/null
+++ b/code/frontend/vite.config.ts
@@ -0,0 +1,18 @@
+import { defineConfig } from "vitest/config";
+import { sveltekit } from "@sveltejs/kit/vite";
+import { SvelteKitPWA } from "@vite-pwa/sveltekit";
+
+export default defineConfig({
+ plugins: [sveltekit(), SvelteKitPWA()],
+ build: {
+ target: "es2020",
+ },
+ test: {
+ include: ["src/**/*.{test,spec}.{js,ts}"]
+ },
+ optimizeDeps: {
+ esbuildOptions: {
+ target: "es2020",
+ },
+ },
+});