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
+
+
+
Logout
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;
}