bewcloud/pages/settings.ts
Bruno Bernardino c26cae625e
Remove fresh
This implements a huge change, where Fresh is removed as a framework and serving files, allowing more control over importing, bundling, and serving files and components.

The biggest challenge was to continue making sure that there weren't too many places to look into for import versions, and `PasswordlessPasskeyLogin.tsx` became a prototype in migrating a component to fully SSR, no need for frontend parsing (via Babel) or bundling (via a custom-script, downloading frontend dependencies from esm.sh). Still, there are too many components to migrate like that, and it's all working, so I likely won't even attempt it unless there's some bug, new feature, or security vulnerability to address that warrants a rewrite of those.

This also updates all dependencies (except `@libs/xml` because that still causes some breaking in DAV endpoints), including Deno!

All other advantages can be seen in the related issues, and the breaking change this (v4.0.0) introduces is related simply to `config.email.tlsMode` (which had a deprecation warning throughout v3), and because, while I tested many things exhaustively, it's not impossible something broke that I didn't see.

Closes #141
Closes #132
2026-02-20 10:54:31 +00:00

300 lines
9.1 KiB
TypeScript

import page, { RequestHandlerParams } from '/lib/page.ts';
import { SupportedCurrencySymbol, User } from '/lib/types.ts';
import { PASSWORD_SALT } from '/lib/auth.ts';
import { UserModel, VerificationCodeModel } from '/lib/models/user.ts';
import { convertFormDataToObject, currencyMap, generateHash, html, validateEmail } from '/public/ts/utils/misc.ts';
import { getFormDataField } from '/public/ts/utils/form.ts';
import { EmailModel } from '/lib/models/email.ts';
import { AppConfig } from '/lib/config.ts';
import { getTimeZones } from '/public/ts/utils/calendar.ts';
import { basicLayoutResponse } from '/lib/utils/layout.tsx';
import Settings, { Action, actionWords } from '/components/Settings.ts';
const titlePrefix = 'Settings';
async function get({ request, user, match, session, isRunningLocally }: RequestHandlerParams): Promise<Response> {
const isExpensesAppEnabled = await AppConfig.isAppEnabled('expenses');
const helpEmail = (await AppConfig.getConfig()).visuals.helpEmail;
const isMultiFactorAuthEnabled = await AppConfig.isMultiFactorAuthEnabled();
const isCalendarAppEnabled = await AppConfig.isAppEnabled('calendar');
const htmlContent = defaultHtmlContent({
formData: {},
currency: user!.extra.expenses_currency,
timezoneId: user!.extra.timezone?.id || 'UTC',
isExpensesAppEnabled,
helpEmail,
isMultiFactorAuthEnabled,
isCalendarAppEnabled,
user: user!,
});
return basicLayoutResponse(htmlContent, {
currentPath: match.pathname.input,
titlePrefix,
match,
request,
user,
session,
isRunningLocally,
});
}
async function post({ request, user, match, session, isRunningLocally }: RequestHandlerParams): Promise<Response> {
const isExpensesAppEnabled = await AppConfig.isAppEnabled('expenses');
const helpEmail = (await AppConfig.getConfig()).visuals.helpEmail;
const isMultiFactorAuthEnabled = await AppConfig.isMultiFactorAuthEnabled();
const isCalendarAppEnabled = await AppConfig.isAppEnabled('calendar');
let action: Action = 'change-email';
let errorTitle = '';
let errorMessage = '';
let successTitle = '';
let successMessage = '';
const formData = await request.clone().formData();
try {
action = getFormDataField(formData, 'action') as Action;
if (action !== 'change-email' && action !== 'verify-change-email') {
formData.set('email', user!.email);
}
if ((action === 'change-email' || action === 'verify-change-email')) {
const email = getFormDataField(formData, 'email');
if (!validateEmail(email)) {
throw new Error(`Invalid email.`);
}
if (email === user!.email) {
throw new Error(`New email is the same as the current email.`);
}
const matchingUser = await UserModel.getByEmail(email);
if (matchingUser) {
throw new Error('Email is already in use.');
}
if (action === 'change-email' && (await AppConfig.isEmailVerificationEnabled())) {
const verificationCode = await VerificationCodeModel.create(user!, email, 'email');
await EmailModel.sendVerificationEmail(email, verificationCode);
successTitle = 'Verify your email!';
successMessage = 'You have received a code in your new email. Use it to verify it here.';
} else {
if (await AppConfig.isEmailVerificationEnabled()) {
const code = getFormDataField(formData, 'verification-code');
await VerificationCodeModel.validate(user!, email, code, 'email');
}
user!.email = email;
await UserModel.update(user!);
successTitle = 'Email updated!';
successMessage = 'Email updated successfully.';
}
} else if (action === 'change-password') {
const currentPassword = getFormDataField(formData, 'current-password');
const newPassword = getFormDataField(formData, 'new-password');
if (newPassword.length < 6) {
throw new Error(`New password is too short`);
}
const hashedCurrentPassword = await generateHash(`${currentPassword}:${PASSWORD_SALT}`, 'SHA-256');
const hashedNewPassword = await generateHash(`${newPassword}:${PASSWORD_SALT}`, 'SHA-256');
if (user!.hashed_password !== hashedCurrentPassword) {
throw new Error('Invalid current password.');
}
if (hashedCurrentPassword === hashedNewPassword) {
throw new Error(`New password is the same as the current password.`);
}
user!.hashed_password = hashedNewPassword;
await UserModel.update(user!);
successTitle = 'Password changed!';
successMessage = 'Password changed successfully.';
} else if (action === 'change-dav-password') {
const newDavPassword = getFormDataField(formData, 'new-dav-password');
if (newDavPassword.length < 6) {
throw new Error(`New DAV password is too short`);
}
const hashedNewDavPassword = await generateHash(`${newDavPassword}:${PASSWORD_SALT}`, 'SHA-256');
if (user!.extra.dav_hashed_password === hashedNewDavPassword) {
throw new Error(`New DAV password is the same as the current password.`);
}
user!.extra.dav_hashed_password = hashedNewDavPassword;
await UserModel.update(user!);
successTitle = 'DAV Password changed!';
successMessage = 'DAV Password changed successfully.';
} else if (action === 'delete-account') {
const currentPassword = getFormDataField(formData, 'current-password');
const hashedCurrentPassword = await generateHash(`${currentPassword}:${PASSWORD_SALT}`, 'SHA-256');
if (user!.hashed_password !== hashedCurrentPassword) {
throw new Error('Invalid current password.');
}
await UserModel.delete(user!.id);
return new Response('Account deleted successfully', {
status: 303,
headers: { 'location': `/signup?success=delete` },
});
} else if (action === 'change-currency') {
const newCurrencySymbol = getFormDataField(formData, 'currency') as SupportedCurrencySymbol;
if (!currencyMap.has(newCurrencySymbol)) {
throw new Error(`Invalid currency.`);
}
user!.extra.expenses_currency = newCurrencySymbol;
await UserModel.update(user!);
successTitle = 'Currency changed!';
successMessage = 'Currency changed successfully.';
} else if (action === 'change-timezone') {
const timezones = getTimeZones();
const newTimezoneId = getFormDataField(formData, 'timezone');
const matchingTimezone = timezones.find((timezone) => timezone.id === newTimezoneId);
if (!matchingTimezone) {
throw new Error(`Invalid timezone.`);
}
user!.extra.timezone = {
id: newTimezoneId,
utcOffset: matchingTimezone.utcOffset,
};
await UserModel.update(user!);
successTitle = 'Timezone changed!';
successMessage = 'Timezone changed successfully.';
}
const notice = successTitle
? {
title: successTitle,
message: successMessage,
}
: undefined;
const htmlContent = defaultHtmlContent({
notice,
formData: convertFormDataToObject(formData),
currency: user!.extra.expenses_currency,
timezoneId: user!.extra.timezone?.id || 'UTC',
isExpensesAppEnabled,
helpEmail,
isMultiFactorAuthEnabled,
isCalendarAppEnabled,
user: user!,
});
return basicLayoutResponse(htmlContent, {
currentPath: match.pathname.input,
titlePrefix,
match,
request,
user,
session,
isRunningLocally,
});
} catch (error) {
console.error(error);
errorMessage = (error as Error).toString();
errorTitle = `Failed to ${actionWords.get(action) || action}!`;
const htmlContent = defaultHtmlContent({
error: { title: errorTitle, message: errorMessage },
formData: convertFormDataToObject(formData),
currency: user!.extra.expenses_currency,
timezoneId: user!.extra.timezone?.id || 'UTC',
isExpensesAppEnabled,
helpEmail,
isMultiFactorAuthEnabled,
isCalendarAppEnabled,
user: user!,
});
return basicLayoutResponse(htmlContent, {
currentPath: match.pathname.input,
titlePrefix,
match,
request,
user,
session,
isRunningLocally,
});
}
}
function defaultHtmlContent({
formData,
error,
notice,
currency,
timezoneId,
isExpensesAppEnabled,
isMultiFactorAuthEnabled,
isCalendarAppEnabled,
helpEmail,
user,
}: {
formData: Record<string, any>;
error?: { title: string; message: string };
notice?: { title: string; message: string };
currency?: SupportedCurrencySymbol;
timezoneId?: string;
isExpensesAppEnabled: boolean;
isMultiFactorAuthEnabled: boolean;
isCalendarAppEnabled: boolean;
helpEmail: string;
user: { extra: Pick<User['extra'], 'multi_factor_auth_methods'> };
}) {
const settingsHtml = Settings({
formData,
error,
notice,
currency,
timezoneId,
isExpensesAppEnabled,
isMultiFactorAuthEnabled,
isCalendarAppEnabled,
helpEmail,
user,
});
const htmlContent = html`
<main id="main">
${settingsHtml}
</main>
`;
return htmlContent;
}
export default page({
get,
post,
accessMode: 'user',
});