From d3eb55d3f3dbdc82c8da07bb0c8c996ad92a78b4 Mon Sep 17 00:00:00 2001 From: Simon Reinisch Date: Tue, 1 Aug 2023 11:14:32 +0200 Subject: [PATCH 01/15] feat(core): add genesis storage update readme --- .env.example | 7 +- Dockerfile | 10 +- src/app/App.vue | 2 +- src/app/components/base/dialog/Dialog.vue | 11 +- .../base/file-picker/FilePicker.vue | 3 +- .../base/input-field/InputField.vue | 100 +++++++++++ .../components/base/text-cell/TextCell.vue | 1 - src/app/pages/Frame.vue | 2 +- src/app/pages/navigation/ThemeButton.vue | 14 +- .../navigation/{ => auth}/CloudButton.vue | 25 +-- src/app/pages/navigation/auth/LoginDialog.vue | 68 ++++++++ .../pages/navigation/tools/ToolsButton.vue | 4 +- src/main.ts | 9 +- src/storage/genesis-storage/index.ts | 113 +++++++++++++ src/storage/google-drive-storage/index.ts | 160 ------------------ src/storage/google-drive-storage/types.ts | 11 -- src/storage/google-drive-storage/utils.ts | 16 -- src/storage/index.ts | 7 +- src/storage/key-storage/index.ts | 79 ++++----- src/storage/types.ts | 12 +- src/store/settings/index.ts | 1 + src/store/state/index.ts | 1 + vite.config.ts | 8 +- 23 files changed, 362 insertions(+), 302 deletions(-) create mode 100644 src/app/components/base/input-field/InputField.vue rename src/app/pages/navigation/{ => auth}/CloudButton.vue (64%) create mode 100644 src/app/pages/navigation/auth/LoginDialog.vue create mode 100644 src/storage/genesis-storage/index.ts delete mode 100644 src/storage/google-drive-storage/index.ts delete mode 100644 src/storage/google-drive-storage/types.ts delete mode 100644 src/storage/google-drive-storage/utils.ts diff --git a/.env.example b/.env.example index b70db497..96ac322d 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,3 @@ -# Google credentials -OAUTH_URI= -OAUTH_CLIENT_ID= -OAUTH_SCOPE=https://www.googleapis.com/auth/drive.appdata - -# Ackee tracker details +# Optional ackee tracker details ACKEE_HOST= ACKEE_DOMAIN_ID= diff --git a/Dockerfile b/Dockerfile index 4d196325..42fe4614 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,10 @@ FROM node:18-alpine AS build -ARG OAUTH_URI -ARG OAUTH_CLIENT_ID -ARG OAUTH_SCOPE -ARG ACKEE_HOST -ARG ACKEE_DOMAIN_ID +ARG OCULAR_ACKEE_HOST +ARG OCULAR_ACKEE_DOMAIN_ID +ARG OCULAR_GENESIS_HOST +ARG OCULAR_TEST_USERNAME +ARG OCULAR_TEST_PASSWORD ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" diff --git a/src/app/App.vue b/src/app/App.vue index 09af885d..94bf8e2a 100644 --- a/src/app/App.vue +++ b/src/app/App.vue @@ -1,7 +1,7 @@ + + diff --git a/src/app/components/base/text-cell/TextCell.vue b/src/app/components/base/text-cell/TextCell.vue index 8ba5f6ba..d5fd3cc0 100644 --- a/src/app/components/base/text-cell/TextCell.vue +++ b/src/app/components/base/text-cell/TextCell.vue @@ -80,7 +80,6 @@ const change = (e: Event) => { &:focus-within { background: var(--input-field-focus-background); - box-shadow: var(--input-field-focus-box-shadow); } .input { diff --git a/src/app/pages/Frame.vue b/src/app/pages/Frame.vue index 318ff0f6..f68040d4 100644 --- a/src/app/pages/Frame.vue +++ b/src/app/pages/Frame.vue @@ -39,8 +39,8 @@ import { AppIcon } from '@components/base/icon/Icon.types'; import Link from '@components/base/link/Link.vue'; import AnimatedRouterView from '@components/misc/animated-router-view/AnimatedRouterView.vue'; import { useMediaQuery } from '@composables'; -import CloudButton from './navigation/CloudButton.vue'; import ThemeButton from './navigation/ThemeButton.vue'; +import CloudButton from './navigation/auth/CloudButton.vue'; import ChangeCurrencyButton from './navigation/currency/ChangeCurrencyButton.vue'; import ChangeLanguageButton from './navigation/language/ChangeLanguageButton.vue'; import ToolsButton from './navigation/tools/ToolsButton.vue'; diff --git a/src/app/pages/navigation/ThemeButton.vue b/src/app/pages/navigation/ThemeButton.vue index e10398f1..c20892cf 100644 --- a/src/app/pages/navigation/ThemeButton.vue +++ b/src/app/pages/navigation/ThemeButton.vue @@ -1,11 +1,5 @@ - - diff --git a/src/app/pages/navigation/CloudButton.vue b/src/app/pages/navigation/auth/CloudButton.vue similarity index 64% rename from src/app/pages/navigation/CloudButton.vue rename to src/app/pages/navigation/auth/CloudButton.vue index 7e94225c..91352614 100644 --- a/src/app/pages/navigation/CloudButton.vue +++ b/src/app/pages/navigation/auth/CloudButton.vue @@ -1,24 +1,28 @@ - - diff --git a/src/app/pages/navigation/auth/LoginDialog.vue b/src/app/pages/navigation/auth/LoginDialog.vue new file mode 100644 index 00000000..936b80c8 --- /dev/null +++ b/src/app/pages/navigation/auth/LoginDialog.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/src/app/pages/navigation/tools/ToolsButton.vue b/src/app/pages/navigation/tools/ToolsButton.vue index fc1bca04..b6050c53 100644 --- a/src/app/pages/navigation/tools/ToolsButton.vue +++ b/src/app/pages/navigation/tools/ToolsButton.vue @@ -8,7 +8,7 @@ diff --git a/src/app/pages/navigation/auth/LoginDialog.vue b/src/app/pages/navigation/auth/LoginDialog.vue index 936b80c8..788f82a6 100644 --- a/src/app/pages/navigation/auth/LoginDialog.vue +++ b/src/app/pages/navigation/auth/LoginDialog.vue @@ -3,13 +3,7 @@

Sign in

- +
@@ -31,8 +25,8 @@ defineProps<{ }>(); const { login } = useStorage(); -const username = ref('foo'); -const password = ref('hgEiPCZP'); +const username = ref(import.meta.env.APP_USERNAME ?? ''); +const password = ref(import.meta.env.APP_PASSWORD ?? ''); const state = ref<'idle' | 'loading' | 'errored'>('idle'); const signIn = async () => { diff --git a/src/storage/genesis-storage/index.ts b/src/storage/genesis-storage/index.ts index 1daf53da..cdf0aabe 100644 --- a/src/storage/genesis-storage/index.ts +++ b/src/storage/genesis-storage/index.ts @@ -1,60 +1,42 @@ import { computed, nextTick, ref, shallowReactive, watch } from 'vue'; import { MigratableState } from 'yuppee'; -import { createKeyStorage } from '@storage/key-storage'; import { AppStorage, StorageAuthenticationState, StorageSync } from '@storage/types'; import { debounce } from '@utils'; - -const CACHE_KEY = 'GENESIS_STORAGE_KEY'; +import { createGenesisStore } from './sdk'; export const createGenesisStorage = (): AppStorage => { - const { register, unregister, token } = createKeyStorage(CACHE_KEY); + const store = createGenesisStore('/api'); const storesToInitialize = shallowReactive(new Set()); const syncsActive = ref(0); - const loggedIn = ref(!!token.value); + const loggedIn = ref(store.isLoggedIn()); const login = async (user: string, password: string): Promise => - fetch('/api/login', { - method: 'POST', - body: JSON.stringify({ user, password }) - }) - .then((res) => (res.ok ? res.json() : Promise.reject(res))) - .then((body) => { - register(body.token, body.expiresAt); - loggedIn.value = true; - return true; - }) + store + .login({ user, password }) + .then(() => (loggedIn.value = true)) .catch(() => false); - const upsert = async (name: string, json: string) => - fetch(`/api/data/${name}`, { - headers: { Authorization: `Bearer ${token.value}` }, - method: 'POST', - body: json - }); - - const get = async (name: string) => - fetch(`/api/data/${name}`, { - headers: { Authorization: `Bearer ${token.value}` } - }).then((res) => (res.ok ? (res.status === 204 ? undefined : res.json()) : Promise.reject(res))); - - const logout = () => (loggedIn.value = false); + const logout = () => { + loggedIn.value = false; + }; const sync = (config: StorageSync) => { const initialSyncRequired = ref(true); const syncing = ref(false); storesToInitialize.add(config.name); - const change = debounce(async (json: string) => { - await upsert(config.name, json); - syncing.value = false; + const change = debounce(async () => { + await store + .setDataByKey(config.name, config.state()) + .catch(logout) + .then(() => (syncing.value = false)); }, 1000); watch( - [token, initialSyncRequired], - async ([token, sync]) => { - if (token && sync) { - const data = await get(config.name); - data && config.push(data); + [loggedIn, initialSyncRequired], + async ([loggedIn, sync]) => { + if (loggedIn && sync) { + await store.getDataByKey(config.name).catch(logout); await nextTick(() => { initialSyncRequired.value = false; @@ -67,11 +49,11 @@ export const createGenesisStorage = (): AppStorage => { // push data watch( - [token, () => JSON.stringify(config.state())], - ([key, state]) => { - if (key && !initialSyncRequired.value) { + [loggedIn, () => JSON.stringify(config.state())], + ([loggedIn]) => { + if (loggedIn && !initialSyncRequired.value) { syncing.value = true; - void change(state); + void change(); } }, { immediate: true } @@ -81,8 +63,8 @@ export const createGenesisStorage = (): AppStorage => { watch([loggedIn, syncing, initialSyncRequired], ([loggedIn, syncing, initializing]) => { if (!loggedIn && !syncing && !initializing) { initialSyncRequired.value = true; + store.logout(); config.clear(); - unregister(); } }); @@ -91,7 +73,7 @@ export const createGenesisStorage = (): AppStorage => { }; const status = computed((): StorageAuthenticationState => { - if (token.value) { + if (loggedIn.value) { if (storesToInitialize.size) { return 'loading'; } else if (syncsActive.value) { diff --git a/src/storage/genesis-storage/sdk.ts b/src/storage/genesis-storage/sdk.ts new file mode 100644 index 00000000..5cf49e24 --- /dev/null +++ b/src/storage/genesis-storage/sdk.ts @@ -0,0 +1,157 @@ +export interface User { + user: string; + password: string; +} + +export interface GenesisToken { + expiresAt: number; + token: string; +} + +export interface UpdatePasswordRequest { + newPassword: string; + currentPassword: string; +} + +export const createGenesisStore = (baseUrl: string) => { + const LOCAL_STORAGE_KEY = 'GENESIS_STORE'; + let tokenDataObj: GenesisToken | undefined = undefined; + let tokenTimeout = -1; + + const setToken = (token: string, expiresAt: number) => { + tokenDataObj = { token, expiresAt }; + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(tokenDataObj)); + scheduleTokenClear(expiresAt); + }; + + const removeToken = () => { + tokenDataObj = undefined; + localStorage.removeItem(LOCAL_STORAGE_KEY); + clearTimeout(tokenTimeout); + }; + + const scheduleTokenClear = (expiresAt: number) => { + const timeRemaining = expiresAt - Date.now(); + clearTimeout(tokenTimeout); + + if (timeRemaining > 0) { + tokenTimeout = window.setTimeout(() => refreshToken().catch(removeToken), timeRemaining); + } else removeToken(); + }; + + const login = async (user: User): Promise => { + const response = await fetch(`${baseUrl}/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(user) + }); + + if (response.status === 200) { + const data = await response.json(); + setToken(data.token, data.expiresAt); + } else if (response.status === 401) { + throw new Error('Invalid user or password.'); + } else { + throw new Error('An error occurred during login.'); + } + }; + + const refreshToken = async (): Promise => { + const response = await fetch(`${baseUrl}/login/refresh`, { method: 'POST' }); + + if (response.status === 200) { + const data = await response.json(); + setToken(data.token, data.expiresAt); + } else if (response.status === 401) { + throw new Error('Token expired.'); + } else { + throw new Error('An error occurred during login.'); + } + }; + + const updatePassword = async (request: UpdatePasswordRequest): Promise => { + const response = await fetch(`${baseUrl}/account/update`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${tokenDataObj?.token}` + }, + body: JSON.stringify(request) + }); + + if (response.status !== 200) { + throw new Error('Failed to update password.'); + } + }; + + const getData = async (): Promise => { + const response = await fetch(`${baseUrl}/data`, { + headers: { Authorization: `Bearer ${tokenDataObj?.token}` } + }); + + if (response.status === 200) { + return response.json(); + } else { + throw new Error('Failed to retrieve data.'); + } + }; + + const getDataByKey = async (key: string): Promise => { + const response = await fetch(`${baseUrl}/data/${key}`, { + headers: { Authorization: `Bearer ${tokenDataObj?.token}` } + }); + + if (response.status === 204) { + return undefined; + } else if (response.status === 200) { + return response.json(); + } else { + throw new Error('Failed to retrieve data by key.'); + } + }; + + const setDataByKey = async (key: string, data: unknown): Promise => { + const response = await fetch(`${baseUrl}/data/${key}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${tokenDataObj?.token}` + }, + body: JSON.stringify(data) + }); + + if (response.status !== 200) { + throw new Error('Failed to store data.'); + } + }; + + const deleteDataByKey = async (key: string): Promise => { + await fetch(`${baseUrl}/data/${key}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${tokenDataObj?.token}` } + }); + }; + + // Load from localstorage + const tokenData = localStorage.getItem(LOCAL_STORAGE_KEY); + if (tokenData) { + tokenDataObj = JSON.parse(tokenData) as GenesisToken; + scheduleTokenClear(tokenDataObj.expiresAt); + refreshToken().catch(removeToken); + } + + const logout = () => removeToken(); + + const isLoggedIn = () => !!tokenDataObj; + + return { + login, + logout, + isLoggedIn, + updatePassword, + getData, + getDataByKey, + setDataByKey, + deleteDataByKey + }; +}; diff --git a/src/storage/key-storage/index.ts b/src/storage/key-storage/index.ts deleted file mode 100644 index eff6a76b..00000000 --- a/src/storage/key-storage/index.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { computed, ref, watch } from 'vue'; - -interface KeyStoreData { - key: string; - expiresAt: number; -} - -export const createKeyStorage = (name: string) => { - const store = ref(); - const cached = localStorage.getItem(name); - - if (cached) { - const data = JSON.parse(cached) as KeyStoreData; - - if (Date.now() > data.expiresAt) { - store.value = undefined; - localStorage.removeItem(name); - } else { - store.value = data; - } - } - - let timeout = -1; - watch(store, (data) => { - clearTimeout(timeout); - - if (data) { - localStorage.setItem(name, JSON.stringify(data)); - - timeout = setTimeout(() => { - store.value = undefined; - localStorage.removeItem(name); - }, data.expiresAt - Date.now()) as unknown as number; - } - }); - - return { - token: computed(() => store.value?.key), - unregister() { - store.value = undefined; - localStorage.removeItem(name); - }, - register(key: string, expiresAt: number) { - store.value = { key, expiresAt }; - } - }; -}; diff --git a/src/types/env.d.ts b/src/types/env.d.ts index cb0364e2..a4bc9918 100644 --- a/src/types/env.d.ts +++ b/src/types/env.d.ts @@ -22,12 +22,11 @@ declare interface HTMLDialogElement { } interface ImportMetaEnv { - readonly OAUTH_URI: string; - readonly OAUTH_CLIENT_ID: string; - readonly OAUTH_SCOPE: string; + readonly ACKEE_HOST?: string; + readonly ACKEE_DOMAIN_ID?: string; - readonly ACKEE_HOST: string; - readonly ACKEE_DOMAIN_ID: string; + readonly APP_USERNAME?: string; + readonly APP_PASSWORD?: string; readonly APP_BUILD_TIMESTAMP: string; } diff --git a/vite.config.ts b/vite.config.ts index 96c4e567..93e8277b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,7 +6,7 @@ import tsconfigPaths from 'vite-tsconfig-paths'; import manifest from './assets/manifest.json'; export default defineConfig({ - envPrefix: ['OAUTH', 'ACKEE'], + envPrefix: ['APP', 'ACKEE'], server: { port: 3000, proxy: { From 3265da546db5a97285867198e7ee4a730d950daa Mon Sep 17 00:00:00 2001 From: Simon Reinisch Date: Sat, 29 Jul 2023 16:50:20 +0200 Subject: [PATCH 03/15] feat(core): add change password dialog and logout function --- package.json | 2 +- src/app/components/base/alert/Alert.vue | 36 ++++++++++ src/app/components/base/icon/Icon.types.ts | 1 + .../base/input-field/InputField.vue | 2 +- .../base/input-field/InputFields.vue | 41 ++++++++++++ src/app/pages/dashboard/summary/Summary.vue | 1 - src/app/pages/navigation/auth/LoginDialog.vue | 41 ++++-------- .../pages/navigation/tools/ToolsButton.vue | 2 + .../change-password/ChangePasswordButton.vue | 15 +++++ .../change-password/ChangePasswordDialog.vue | 67 +++++++++++++++++++ src/i18n/locales/de.json | 12 ++++ src/i18n/locales/en.json | 12 ++++ src/icons/key-2-line.svg | 1 + .../sdk.ts => createGenesisStore.ts} | 7 +- .../index.ts => createStorage.ts} | 59 +++++++++------- src/storage/index.ts | 13 ++-- src/storage/types.ts | 8 --- src/store/settings/index.ts | 4 +- src/store/state/index.ts | 4 +- 19 files changed, 251 insertions(+), 77 deletions(-) create mode 100644 src/app/components/base/alert/Alert.vue create mode 100644 src/app/components/base/input-field/InputFields.vue create mode 100644 src/app/pages/navigation/tools/change-password/ChangePasswordButton.vue create mode 100644 src/app/pages/navigation/tools/change-password/ChangePasswordDialog.vue create mode 100644 src/icons/key-2-line.svg rename src/storage/{genesis-storage/sdk.ts => createGenesisStore.ts} (97%) rename src/storage/{genesis-storage/index.ts => createStorage.ts} (52%) diff --git a/package.json b/package.json index 5e6fb451..c539e483 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "lint:i18n:fix": "pnpm run lint:i18n --fix", "lint": "pnpm run lint:i18n && pnpm run lint:src", "lint:fix": "pnpm run lint:i18n:fix && pnpm run lint:src:fix", - "test:ci": "pnpm run build && pnpm run lint:fix", + "test:ci": "pnpm run lint:fix && pnpm run build", "gen:icons": "node scripts/icons.js" }, "dependencies": { diff --git a/src/app/components/base/alert/Alert.vue b/src/app/components/base/alert/Alert.vue new file mode 100644 index 00000000..4aa9f2dd --- /dev/null +++ b/src/app/components/base/alert/Alert.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/src/app/components/base/icon/Icon.types.ts b/src/app/components/base/icon/Icon.types.ts index 9bb60bbc..c96bb7dd 100644 --- a/src/app/components/base/icon/Icon.types.ts +++ b/src/app/components/base/icon/Icon.types.ts @@ -22,6 +22,7 @@ export type AppIcon = | 'google-fill' | 'grid-line' | 'hand-coin' + | 'key-2-line' | 'magic-line' | 'menu-line' | 'moon-fill' diff --git a/src/app/components/base/input-field/InputField.vue b/src/app/components/base/input-field/InputField.vue index cef2ec19..ff30541b 100644 --- a/src/app/components/base/input-field/InputField.vue +++ b/src/app/components/base/input-field/InputField.vue @@ -111,7 +111,7 @@ const passwordBarColor = computed(() => { .label { font-weight: var(--font-weight-l); - font-size: var(--font-size-s); + font-size: var(--font-size-xs); } .passwordStrength { diff --git a/src/app/components/base/input-field/InputFields.vue b/src/app/components/base/input-field/InputFields.vue new file mode 100644 index 00000000..9a922089 --- /dev/null +++ b/src/app/components/base/input-field/InputFields.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/src/app/pages/dashboard/summary/Summary.vue b/src/app/pages/dashboard/summary/Summary.vue index f24f92d0..47a713ba 100644 --- a/src/app/pages/dashboard/summary/Summary.vue +++ b/src/app/pages/dashboard/summary/Summary.vue @@ -36,7 +36,6 @@ const expenses = computed(() => totals(state.expenses)); flex-direction: column; grid-gap: 20px; flex-grow: 1; - height: 100%; padding-bottom: 10px; } diff --git a/src/app/pages/navigation/auth/LoginDialog.vue b/src/app/pages/navigation/auth/LoginDialog.vue index 788f82a6..037485ad 100644 --- a/src/app/pages/navigation/auth/LoginDialog.vue +++ b/src/app/pages/navigation/auth/LoginDialog.vue @@ -1,19 +1,20 @@ - - diff --git a/src/app/pages/navigation/tools/ToolsButton.vue b/src/app/pages/navigation/tools/ToolsButton.vue index b6050c53..d1a3b178 100644 --- a/src/app/pages/navigation/tools/ToolsButton.vue +++ b/src/app/pages/navigation/tools/ToolsButton.vue @@ -9,6 +9,7 @@