Files
bewcloud/routes/settings.tsx
Bruno Bernardino e337859a22 Implement a more robust Config (#60)
* Implement a more robust Config

This moves the configuration variables from the `.env` file to a new `bewcloud.config.ts` file. Note that DB connection and secrets are still in the `.env` file.

This will allow for more reliable and easier personalized configurations, and was a requirement to start working on adding SSO (#13).

For now, `.env`-based config will still be allowed and respected (overriden by `bewcloud.config.ts`), but in the future I'll probably remove it (some major upgrade).

* Update deploy script to also copy the new config file
2025-05-25 15:48:53 +01:00

224 lines
7.2 KiB
TypeScript

import { Handlers, PageProps } from 'fresh/server.ts';
import { currencyMap, FreshContextState, SupportedCurrencySymbol } from '/lib/types.ts';
import { PASSWORD_SALT } from '/lib/auth.ts';
import { UserModel, VerificationCodeModel } from '/lib/models/user.ts';
import { convertFormDataToObject, generateHash, validateEmail } from '/lib/utils/misc.ts';
import { getFormDataField } from '/lib/form-utils.tsx';
import { sendVerifyEmailEmail } from '/lib/providers/brevo.ts';
import { AppConfig } from '/lib/config.ts';
import Settings, { Action, actionWords } from '/islands/Settings.tsx';
interface Data {
error?: {
title: string;
message: string;
};
notice?: {
title: string;
message: string;
};
formData: Record<string, any>;
currency?: SupportedCurrencySymbol;
isExpensesAppEnabled: boolean;
helpEmail: string;
}
export const handler: Handlers<Data, FreshContextState> = {
async GET(request, context) {
if (!context.state.user) {
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
}
const isExpensesAppEnabled = await AppConfig.isAppEnabled('expenses');
const helpEmail = (await AppConfig.getConfig()).visuals.helpEmail;
return await context.render({
formData: {},
currency: context.state.user.extra.expenses_currency,
isExpensesAppEnabled,
helpEmail,
});
},
async POST(request, context) {
if (!context.state.user) {
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
}
const isExpensesAppEnabled = await AppConfig.isAppEnabled('expenses');
const helpEmail = (await AppConfig.getConfig()).visuals.helpEmail;
let action: Action = 'change-email';
let errorTitle = '';
let errorMessage = '';
let successTitle = '';
let successMessage = '';
const formData = await request.clone().formData();
const { user } = context.state;
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 sendVerifyEmailEmail(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.';
}
const notice = successTitle
? {
title: successTitle,
message: successMessage,
}
: undefined;
return await context.render({
notice,
formData: convertFormDataToObject(formData),
currency: user.extra.expenses_currency,
isExpensesAppEnabled,
helpEmail,
});
} catch (error) {
console.error(error);
errorMessage = (error as Error).toString();
errorTitle = `Failed to ${actionWords.get(action) || action}!`;
return await context.render({
error: { title: errorTitle, message: errorMessage },
formData: convertFormDataToObject(formData),
currency: user.extra.expenses_currency,
isExpensesAppEnabled,
helpEmail,
});
}
},
};
export default function SettingsPage({ data }: PageProps<Data, FreshContextState>) {
return (
<main>
<Settings
formData={data?.formData}
error={data?.error}
notice={data?.notice}
currency={data?.currency}
isExpensesAppEnabled={data?.isExpensesAppEnabled}
helpEmail={data?.helpEmail}
/>
</main>
);
}