diff --git a/Makefile b/Makefile index ac5b86a..e667bc3 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .PHONY: start start: - deno run --watch --allow-net --allow-read --allow-env main.ts + deno run --watch --allow-net --allow-read --allow-env --allow-write main.ts .PHONY: format format: diff --git a/components/header.ts b/components/header.ts index 35584fa..75554f1 100644 --- a/components/header.ts +++ b/components/header.ts @@ -20,6 +20,11 @@ export default function header(currentPath: string) {
  • Pricing
  • + diff --git a/docker-compose.yml b/docker-compose.yml index 38fd405..5bf0e8d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,17 +15,6 @@ services: soft: -1 hard: -1 - # NOTE: This would be nice to develop with https:// locally, but it doesn't work, for whatever reason, so we need a system caddy instead - # caddy: - # image: caddy:2-alpine - # restart: unless-stopped - # command: caddy reverse-proxy --from https://localhost:443 --to http://localhost:8000 - # network_mode: "host" - # volumes: - # - caddy:/data - volumes: pgdata: driver: local - # caddy: - # driver: local diff --git a/lib/utils.ts b/lib/utils.ts index 2c1e388..2d7eeb6 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,5 +1,5 @@ import 'std/dotenv/load.ts'; -import { emit } from 'https://deno.land/x/emit@0.15.0/mod.ts'; +import { transpile } from 'https://deno.land/x/emit@0.33.0/mod.ts'; import sass from 'https://deno.land/x/denosass@1.0.6/mod.ts'; import { serveFile } from 'std/http/file_server.ts'; @@ -106,7 +106,7 @@ export function escapeHtml(unsafe: string) { async function transpileTs(content: string, specifier: URL) { const urlStr = specifier.toString(); - const result = await emit(specifier, { + const result = await transpile(specifier, { load(specifier: string) { if (specifier !== urlStr) { return Promise.resolve({ kind: 'module', specifier, content: '' }); @@ -114,7 +114,7 @@ async function transpileTs(content: string, specifier: URL) { return Promise.resolve({ kind: 'module', specifier, content }); }, }); - return result[urlStr]; + return result.get(urlStr) || ''; } export async function serveFileWithTs(request: Request, filePath: string, extraHeaders?: ResponseInit['headers']) { diff --git a/public/css/style.css b/public/css/style.css index e455b55..c1b17f1 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -367,7 +367,7 @@ a.button.secondary:focus { .input-wrapper input[type="url"], .input-wrapper input[type="password"], .input-wrapper textarea, -.input-wrapper select +.input-wrapper select { box-sizing: border-box; width: 100%; @@ -394,7 +394,7 @@ a.button.secondary:focus { .input-wrapper input[type="url"]:focus, .input-wrapper input[type="password"]:focus, .input-wrapper textarea:focus, -.input-wrapper select:focus +.input-wrapper select:focus { border-color: var(--color-link-hover); } diff --git a/public/scss/style.scss b/public/scss/style.scss index 7bd0d4e..e2d7f16 100644 --- a/public/scss/style.scss +++ b/public/scss/style.scss @@ -179,3 +179,20 @@ } } } + +#swap-accounts-select { + box-sizing: border-box; + width: auto; + max-width: 120px; + display: block; + outline: none; + border: none; + font-size: 0.9rem; + padding: 0.25rem 0.25rem; + border: 1px solid #fff; + background: #fff; + border-radius: 3px; + transition: all 80ms ease-in-out; + text-overflow: ellipsis; + margin: 0 0.5rem; +} diff --git a/public/ts/billing.ts b/public/ts/billing.ts index 007a007..d624d27 100644 --- a/public/ts/billing.ts +++ b/public/ts/billing.ts @@ -1,4 +1,10 @@ -import { checkForValidSession, commonRequestHeaders, dateDiffInDays, showNotification } from './utils.ts'; +import { + checkForValidSession, + commonInitializer, + commonRequestHeaders, + dateDiffInDays, + showNotification, +} from './utils.ts'; import LocalData from './local-data.ts'; document.addEventListener('app-loaded', async () => { @@ -191,6 +197,7 @@ document.addEventListener('app-loaded', async () => { function initializePage() { updateUI(); + commonInitializer(); } if (window.app.isLoggedIn) { diff --git a/public/ts/index.ts b/public/ts/index.ts index d0c413e..54ced44 100644 --- a/public/ts/index.ts +++ b/public/ts/index.ts @@ -2,6 +2,7 @@ import { Budget, Expense } from '/lib/types.ts'; import { BudgetToShow, checkForValidSession, + commonInitializer, copyBudgetsAndExpenses, createAccount, debounce, @@ -362,6 +363,7 @@ document.addEventListener('app-loaded', async () => { function initializePage() { showData(); + commonInitializer(); } let isAddingExpense = false; diff --git a/public/ts/local-data.ts b/public/ts/local-data.ts index 9b31a39..ec042e4 100644 --- a/public/ts/local-data.ts +++ b/public/ts/local-data.ts @@ -6,6 +6,7 @@ export interface StoredSession { userId: string; email: string; keyPair: KeyPair; + otherSessions?: Omit[]; } export default class LocalData { diff --git a/public/ts/pricing.ts b/public/ts/pricing.ts index 38c9032..a08feb0 100644 --- a/public/ts/pricing.ts +++ b/public/ts/pricing.ts @@ -1,4 +1,10 @@ -import { checkForValidSession, commonRequestHeaders, dateDiffInDays, showNotification } from './utils.ts'; +import { + checkForValidSession, + commonInitializer, + commonRequestHeaders, + dateDiffInDays, + showNotification, +} from './utils.ts'; import LocalData from './local-data.ts'; document.addEventListener('app-loaded', async () => { @@ -127,6 +133,7 @@ document.addEventListener('app-loaded', async () => { function initializePage() { updateUI(); + commonInitializer(); } if (window.app.isLoggedIn) { diff --git a/public/ts/settings.ts b/public/ts/settings.ts index 06f9049..16b95e6 100644 --- a/public/ts/settings.ts +++ b/public/ts/settings.ts @@ -1,6 +1,7 @@ import { Budget, Expense } from '/lib/types.ts'; import { checkForValidSession, + commonInitializer, commonRequestHeaders, exportAllData, importData, @@ -370,6 +371,7 @@ document.addEventListener('app-loaded', async () => { function initializePage() { newCurrencySelect.value = user?.extra.currency || '$'; + commonInitializer(); } if (window.app.isLoggedIn) { diff --git a/public/ts/utils.ts b/public/ts/utils.ts index 5132043..ec9ed25 100644 --- a/public/ts/utils.ts +++ b/public/ts/utils.ts @@ -164,9 +164,60 @@ async function getUser() { return null; } +export function getOtherAccounts() { + try { + const session = LocalData.get('session')!; + + return session.otherSessions?.map((otherSession) => ({ + email: otherSession.email, + })) || []; + } catch (_error) { + // Do nothing + } + + return []; +} + +export function swapAccount(newEmail: string) { + try { + const session = LocalData.get('session')!; + + const foundSession = session.otherSessions?.find((otherSession) => otherSession.email === newEmail); + + if (foundSession) { + const otherSessions = [...(session.otherSessions || [])].filter((otherSession) => + otherSession.email !== foundSession.email + ); + + otherSessions.unshift(session); + + const newSession: StoredSession = { + ...foundSession, + otherSessions, + }; + + LocalData.set('session', newSession); + + window.location.reload(); + } + } catch (_error) { + // Do nothing + } + + return []; +} + export async function validateLogin(email: string, password: string) { const { Swal } = window; + let existingSession: StoredSession | null = null; + + try { + existingSession = LocalData.get('session'); + } catch (_error) { + // Do nothing + } + try { const headers = commonRequestHeaders; @@ -230,11 +281,18 @@ export async function validateLogin(email: string, password: string) { await fetch('/api/session', { method: 'PATCH', headers, body: JSON.stringify(verificationBody) }); + const otherSessions = [...(existingSession?.otherSessions || [])]; + + if (existingSession && existingSession.email !== lowercaseEmail) { + otherSessions.unshift(existingSession); + } + const session: StoredSession = { sessionId, userId: user.id, email: lowercaseEmail, keyPair, + otherSessions, }; LocalData.set('session', session); @@ -252,6 +310,14 @@ export async function validateLogin(email: string, password: string) { } export async function createAccount(email: string, password: string) { + let existingSession: StoredSession | null = null; + + try { + existingSession = LocalData.get('session'); + } catch (_error) { + // Do nothing + } + try { const headers = commonRequestHeaders; @@ -273,11 +339,18 @@ export async function createAccount(email: string, password: string) { throw new Error('Failed to create user. Try logging in instead.'); } + const otherSessions = [...(existingSession?.otherSessions || [])]; + + if (existingSession && existingSession.email !== lowercaseEmail) { + otherSessions.unshift(existingSession); + } + const session: StoredSession = { sessionId, userId: user.id, email: lowercaseEmail, keyPair, + otherSessions, }; LocalData.set('session', session); @@ -867,6 +940,57 @@ export async function importData(replaceData: boolean, budgets: Budget[], expens return false; } +export async function commonInitializer() { + const user = await checkForValidSession(); + const swapAccountsSelect = document.getElementById('swap-accounts-select') as HTMLSelectElement; + + function populateSwapAccountsSelect() { + if (user) { + const otherSessions = getOtherAccounts(); + otherSessions.sort(sortByEmail); + + const currentUserOptionHtml = ``; + const newLoginOptionHtml = ``; + const fullSelectHtmlStrings: string[] = [currentUserOptionHtml]; + + for (const otherSession of otherSessions) { + const optionHtml = ``; + fullSelectHtmlStrings.push(optionHtml); + } + + fullSelectHtmlStrings.push(newLoginOptionHtml); + + swapAccountsSelect.innerHTML = fullSelectHtmlStrings.join('\n'); + } + } + + function chooseAnotherAccount() { + const currentEmail = user?.email; + const chosenEmail = swapAccountsSelect.value; + + if (!chosenEmail) { + return; + } + + if (chosenEmail === currentEmail) { + return; + } + + if (chosenEmail === 'new') { + // Show login form again + hideValidSessionElements(); + return; + } + + swapAccount(chosenEmail); + } + + populateSwapAccountsSelect(); + + swapAccountsSelect.removeEventListener('change', chooseAnotherAccount); + swapAccountsSelect.addEventListener('change', chooseAnotherAccount); +} + const months = [ 'January', 'February', @@ -935,6 +1059,22 @@ export function sortByName( return 0; } +type SortableByEmail = { email: string }; +export function sortByEmail( + objectA: SortableByEmail, + objectB: SortableByEmail, +) { + const emailA = objectA.email.toLowerCase(); + const emailB = objectB.email.toLowerCase(); + if (emailA < emailB) { + return -1; + } + if (emailA > emailB) { + return 1; + } + return 0; +} + export interface BudgetToShow extends Omit { expensesCost: number; }