This implements a basic CalDav UI, titled "Calendar". It allows creating new calendars and events with a start and end date, URL, location, and description. You can also import and export ICS (VCALENDAR + VEVENT) files. It allows editing the ICS directly, for power users. Additionally, you can hide/display events from certain calendars, change their names and their colors. If there's no calendar created yet in your CalDav server (first-time setup), it'll automatically create one, titled "Calendar". You can also change the display timezone for the calendar from the settings. Finally, there's some minor documentation fixes and some other minor tweaks. Closes #56 Closes #89
269 lines
9.0 KiB
TypeScript
269 lines
9.0 KiB
TypeScript
import { Handlers, PageProps } from 'fresh/server.ts';
|
|
|
|
import { currencyMap, FreshContextState, SupportedCurrencySymbol, User } 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 { EmailModel } from '/lib/models/email.ts';
|
|
import { AppConfig } from '/lib/config.ts';
|
|
import Settings, { Action, actionWords } from '/islands/Settings.tsx';
|
|
import { getTimeZones } from '/lib/utils/calendar.ts';
|
|
|
|
interface Data {
|
|
error?: {
|
|
title: string;
|
|
message: string;
|
|
};
|
|
notice?: {
|
|
title: string;
|
|
message: string;
|
|
};
|
|
formData: Record<string, any>;
|
|
currency?: SupportedCurrencySymbol;
|
|
timezoneId?: string;
|
|
isExpensesAppEnabled: boolean;
|
|
isMultiFactorAuthEnabled: boolean;
|
|
isCalendarAppEnabled: boolean;
|
|
helpEmail: string;
|
|
user: {
|
|
extra: Pick<User['extra'], 'multi_factor_auth_methods'>;
|
|
};
|
|
}
|
|
|
|
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;
|
|
const isMultiFactorAuthEnabled = await AppConfig.isMultiFactorAuthEnabled();
|
|
const isCalendarAppEnabled = await AppConfig.isAppEnabled('calendar');
|
|
|
|
return await context.render({
|
|
formData: {},
|
|
currency: context.state.user.extra.expenses_currency,
|
|
timezoneId: context.state.user.extra.timezone?.id || 'UTC',
|
|
isExpensesAppEnabled,
|
|
helpEmail,
|
|
isMultiFactorAuthEnabled,
|
|
isCalendarAppEnabled,
|
|
user: context.state.user,
|
|
});
|
|
},
|
|
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;
|
|
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();
|
|
|
|
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 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;
|
|
|
|
return await context.render({
|
|
notice,
|
|
formData: convertFormDataToObject(formData),
|
|
currency: user.extra.expenses_currency,
|
|
timezoneId: user.extra.timezone?.id || 'UTC',
|
|
isExpensesAppEnabled,
|
|
helpEmail,
|
|
isMultiFactorAuthEnabled,
|
|
isCalendarAppEnabled,
|
|
user: user,
|
|
});
|
|
} 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,
|
|
timezoneId: user.extra.timezone?.id || 'UTC',
|
|
isExpensesAppEnabled,
|
|
helpEmail,
|
|
isMultiFactorAuthEnabled,
|
|
isCalendarAppEnabled,
|
|
user: user,
|
|
});
|
|
}
|
|
},
|
|
};
|
|
|
|
export default function SettingsPage({ data }: PageProps<Data, FreshContextState>) {
|
|
return (
|
|
<main>
|
|
<Settings
|
|
formData={data?.formData}
|
|
error={data?.error}
|
|
notice={data?.notice}
|
|
currency={data?.currency}
|
|
timezoneId={data?.timezoneId}
|
|
isExpensesAppEnabled={data?.isExpensesAppEnabled}
|
|
isMultiFactorAuthEnabled={data?.isMultiFactorAuthEnabled}
|
|
isCalendarAppEnabled={data?.isCalendarAppEnabled}
|
|
helpEmail={data?.helpEmail}
|
|
user={data?.user}
|
|
/>
|
|
</main>
|
|
);
|
|
}
|