mirror of
https://github.com/bewcloud/bewcloud.git
synced 2026-03-11 08:54:49 +00:00
Compare commits
No commits in common. "main" and "v3.2.0" have entirely different histories.
465 changed files with 10168 additions and 20733 deletions
|
|
@ -1,11 +1,14 @@
|
||||||
|
.do
|
||||||
.github
|
.github
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
docker-compose*
|
docker-compose*
|
||||||
Dockerfile
|
Dockerfile
|
||||||
|
render.yaml
|
||||||
LICENSE
|
LICENSE
|
||||||
README.md
|
README.md
|
||||||
.env.sample
|
.env.sample
|
||||||
node_modules
|
node_modules
|
||||||
|
_fresh
|
||||||
data-files
|
data-files
|
||||||
bewcloud.config.sample.ts
|
bewcloud.config.sample.ts
|
||||||
|
|
|
||||||
2
.dvmrc
2
.dvmrc
|
|
@ -1 +1 @@
|
||||||
2.6.10
|
2.5.6
|
||||||
|
|
|
||||||
|
|
@ -16,5 +16,5 @@ MFA_SALT="fake" # optional, if you want to enable multi-factor authentication
|
||||||
OIDC_CLIENT_ID="fake" # optional, if you want to enable SSO (Single Sign-On)
|
OIDC_CLIENT_ID="fake" # optional, if you want to enable SSO (Single Sign-On)
|
||||||
OIDC_CLIENT_SECRET="fake" # optional, if you want to enable SSO (Single Sign-On)
|
OIDC_CLIENT_SECRET="fake" # optional, if you want to enable SSO (Single Sign-On)
|
||||||
|
|
||||||
#SMTP_USERNAME="" # optional, if you want to use signup email verification or multi-factor with an email service requiring authentication
|
SMTP_USERNAME="fake" # optional, if you want to enable signup email verification or multi-factor authentication via email
|
||||||
#SMTP_PASSWORD="" # optional, if you want to use signup email verification or multi-factor with an email service requiring authentication
|
SMTP_PASSWORD="fake" # optional, if you want to enable signup email verification or multi-factor authentication via email
|
||||||
|
|
|
||||||
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
|
|
@ -1,7 +1,7 @@
|
||||||
github: [bewcloud, BrunoBernardino]
|
github: [bewcloud, BrunoBernardino]
|
||||||
custom:
|
custom:
|
||||||
[
|
[
|
||||||
'https://payment-links.mollie.com/payment/wUS9dvewvjEPvseZVHEi5',
|
'https://donate.stripe.com/bIYeWBbw00Ape5iaFi',
|
||||||
'https://paypal.me/brunobernardino',
|
'https://paypal.me/brunobernardino',
|
||||||
'https://gist.github.com/BrunoBernardino/ff5b54c13dd96ac7f9fee6fbfd825b09',
|
'https://gist.github.com/BrunoBernardino/ff5b54c13dd96ac7f9fee6fbfd825b09',
|
||||||
]
|
]
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,3 +1,6 @@
|
||||||
|
# Fresh build directory
|
||||||
|
_fresh/
|
||||||
|
|
||||||
# npm dependencies
|
# npm dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
|
|
||||||
16
Dockerfile
16
Dockerfile
|
|
@ -1,25 +1,23 @@
|
||||||
FROM denoland/deno:ubuntu-2.6.10
|
FROM denoland/deno:ubuntu-2.5.6
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y make zip coreutils
|
RUN apt-get update && apt-get install -y make zip
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# These steps will be re-run upon each file change in your working directory:
|
||||||
ADD . /app
|
ADD . /app
|
||||||
|
|
||||||
# Prepare for any npm modules required "on the fly"
|
# Build fresh
|
||||||
RUN mkdir -p /app/node_modules/.deno
|
RUN deno task build
|
||||||
|
|
||||||
RUN chown -R deno:deno /app
|
RUN chown -R deno:deno /app /deno-dir
|
||||||
|
|
||||||
# Prefer not to run as root.
|
# Prefer not to run as root.
|
||||||
USER deno
|
USER deno
|
||||||
|
|
||||||
# Build frontend components and CSS
|
|
||||||
RUN deno task build
|
|
||||||
|
|
||||||
# Compile the main app so that it doesn't need to be compiled each startup/entry.
|
# Compile the main app so that it doesn't need to be compiled each startup/entry.
|
||||||
RUN deno cache --reload main.ts
|
RUN deno cache --reload main.ts
|
||||||
|
|
||||||
CMD ["task", "preview"]
|
CMD ["run", "--allow-all", "main.ts"]
|
||||||
|
|
|
||||||
4
FAQ.md
4
FAQ.md
|
|
@ -215,7 +215,3 @@ BEWCLOUD_CALENDAR_CALDAV_URL=http://127.0.0.1:5232
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## Wasn't this made with Fresh?
|
|
||||||
|
|
||||||
Up until [v4.0.0](https://github.com/bewcloud/bewcloud/releases/tag/v4.0.0), bewCloud was made with [Fresh](https://fresh.deno.dev/), but as per [#141](https://github.com/bewcloud/bewcloud/issues/141) it has since been rewritten in standard Deno to use a more web-standards-based backend, easier to maintain and extend, without requiring breaking updates.
|
|
||||||
|
|
|
||||||
32
Makefile
32
Makefile
|
|
@ -15,40 +15,12 @@ test:
|
||||||
|
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
build:
|
build:
|
||||||
make download-frontend-imports
|
deno task build
|
||||||
make build-babel
|
|
||||||
make build-tailwind
|
|
||||||
|
|
||||||
.PHONY: download-frontend-imports
|
|
||||||
download-frontend-imports:
|
|
||||||
deno task download-frontend-imports
|
|
||||||
|
|
||||||
.PHONY: build-babel
|
|
||||||
build-babel:
|
|
||||||
deno run --allow-env --allow-ffi --allow-sys --allow-read --allow-write=public/components npm:@babel/cli@7.28.6/babel ./components --out-dir ./public/components --extensions ".ts,.tsx"
|
|
||||||
|
|
||||||
.PHONY: watch-babel
|
|
||||||
watch-babel:
|
|
||||||
deno run --allow-env --allow-ffi --allow-sys --allow-read --allow-write=public/components npm:@babel/cli@7.28.6/babel ./components --out-dir ./public/components --extensions ".ts,.tsx" --watch
|
|
||||||
|
|
||||||
.PHONY: migrate-db
|
.PHONY: migrate-db
|
||||||
migrate-db:
|
migrate-db:
|
||||||
deno task migrate-db
|
deno run --allow-net --allow-read --allow-env migrate-db.ts
|
||||||
|
|
||||||
.PHONY: exec-db
|
.PHONY: exec-db
|
||||||
exec-db:
|
exec-db:
|
||||||
docker exec -it -u postgres $(shell basename $(CURDIR))-postgresql-1 psql
|
docker exec -it -u postgres $(shell basename $(CURDIR))-postgresql-1 psql
|
||||||
|
|
||||||
.PHONY: build-tailwind
|
|
||||||
build-tailwind:
|
|
||||||
deno install --allow-scripts npm:tailwindcss@4.2.0 npm:@tailwindcss/cli@4.2.0
|
|
||||||
deno run --allow-env --allow-read --allow-sys --allow-ffi --vendor --unstable-detect-cjs --allow-write=public/css,/var/folders --allow-scripts npm:@tailwindcss/cli@4.2.0 -i ./public/css/tailwind-input.css -o ./public/css/tailwind.css
|
|
||||||
|
|
||||||
.PHONY: watch-tailwind
|
|
||||||
watch-tailwind:
|
|
||||||
deno install --allow-scripts npm:tailwindcss@4.2.0 npm:@tailwindcss/cli@4.2.0
|
|
||||||
deno run --allow-env --allow-read --allow-sys --allow-ffi --vendor --unstable-detect-cjs --allow-write=public/css,/var/folders --allow-scripts npm:@tailwindcss/cli@4.2.0 -w -i ./public/css/tailwind-input.css -o ./public/css/tailwind.css
|
|
||||||
|
|
||||||
.PHONY: preview
|
|
||||||
preview:
|
|
||||||
deno task preview
|
|
||||||
|
|
|
||||||
28
README.md
28
README.md
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
[](https://github.com/bewcloud/bewcloud/actions?workflow=Run+Tests)
|
[](https://github.com/bewcloud/bewcloud/actions?workflow=Run+Tests)
|
||||||
|
|
||||||
This is the [bewCloud app](https://bewcloud.com) built with [Deno](https://deno.land/) and deployed using [docker compose](https://docs.docker.com/compose/).
|
This is the [bewCloud app](https://bewcloud.com) built using [Fresh](https://fresh.deno.dev) and deployed using [docker compose](https://docs.docker.com/compose/).
|
||||||
|
|
||||||
If you're looking for the desktop sync app, it's at [`bewcloud-desktop`](https://github.com/bewcloud/bewcloud-desktop).
|
If you're looking for the desktop sync app, it's at [`bewcloud-desktop`](https://github.com/bewcloud/bewcloud-desktop).
|
||||||
|
|
||||||
|
|
@ -10,7 +10,9 @@ If you're looking for the mobile app, it's at [`bewcloud-mobile`](https://github
|
||||||
|
|
||||||
## Self-host it!
|
## Self-host it!
|
||||||
|
|
||||||
[-51a4fb?style=for-the-badge)](https://payment-links.mollie.com/payment/AiEHa5EpD6wkZN5r6Vs3c)
|
[-51a4fb?style=for-the-badge)](https://buy.stripe.com/eVa01HgQk0Ap0eseVz)
|
||||||
|
|
||||||
|
[-51a4fb?style=for-the-badge)](https://buy.stripe.com/fZu8wOb5RfIydj56FA1gs0J)
|
||||||
|
|
||||||
Or, to run on your own machine, start with these commands:
|
Or, to run on your own machine, start with these commands:
|
||||||
|
|
||||||
|
|
@ -46,7 +48,7 @@ See the [Community Links](#community-links) section for alternative ways of runn
|
||||||
|
|
||||||
## Sponsors
|
## Sponsors
|
||||||
|
|
||||||
These are the amazing entities or individuals who are sponsoring this project for this current month. If you'd like to show up here, [check the GitHub Sponsors page](https://github.com/sponsors/bewcloud) or [make a donation](https://payment-links.mollie.com/payment/wUS9dvewvjEPvseZVHEi5) above $50 ($100 to show up on the website)!
|
These are the amazing entities or individuals who are sponsoring this project for this current month. If you'd like to show up here, [check the GitHub Sponsors page](https://github.com/sponsors/bewcloud) or [make a donation](https://donate.stripe.com/bIYeWBbw00Ape5iaFi) above $50 ($100 to show up on the website)!
|
||||||
|
|
||||||
<p align="center" width="100%">
|
<p align="center" width="100%">
|
||||||
<a href="https://nlnet.nl/project/bewCloud/" title="NLnet Foundation">
|
<a href="https://nlnet.nl/project/bewCloud/" title="NLnet Foundation">
|
||||||
|
|
@ -70,25 +72,24 @@ These are the amazing entities or individuals who are sponsoring this project fo
|
||||||
```sh
|
```sh
|
||||||
docker compose -f docker-compose.dev.yml up # (optional) runs docker with postgres, locally
|
docker compose -f docker-compose.dev.yml up # (optional) runs docker with postgres, locally
|
||||||
make migrate-db # runs any missing database migrations
|
make migrate-db # runs any missing database migrations
|
||||||
make start # runs the app in development mode (watches for CSS file changes and recompiles the CSS)
|
make start # runs the app
|
||||||
make format # (optional) formats the code (if you're interested in contributing)
|
make format # (optional) formats the code (if you're interested in contributing)
|
||||||
make test # (optional) runs tests (if you're interested in contributing)
|
make test # (optional) runs tests (if you're interested in contributing)
|
||||||
make build # (optional) generates CSS for production, if you've made changes
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Other less-used commands (mostly for development)
|
### Other less-used commands (mostly for development)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
make preview # runs the app in production mode (serves the app from the built files)
|
|
||||||
make exec-db # runs psql inside the postgres container, useful for running direct development queries like `DROP DATABASE "bewcloud"; CREATE DATABASE "bewcloud";`
|
make exec-db # runs psql inside the postgres container, useful for running direct development queries like `DROP DATABASE "bewcloud"; CREATE DATABASE "bewcloud";`
|
||||||
|
make build # generates all static files for production deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
## File/Directory Structure
|
## File/Directory Structure
|
||||||
|
|
||||||
- Backend routes are defined at `routes.ts`
|
- Routes are defined at `routes/`.
|
||||||
- Publicly-available files are defined at `public/`
|
- Static files are defined at `static/`.
|
||||||
- Pages are defined at `pages/`.
|
- Frontend-only components are defined at `components/`.
|
||||||
- JSX/TSX components are defined at `components/`.
|
- Isomorphic components are defined at `islands/`.
|
||||||
- Cron jobs are defined at `crons/`.
|
- Cron jobs are defined at `crons/`.
|
||||||
- Reusable bits of code are defined at `lib/`.
|
- Reusable bits of code are defined at `lib/`.
|
||||||
- Database migrations are defined at `db-migrations/`.
|
- Database migrations are defined at `db-migrations/`.
|
||||||
|
|
@ -109,8 +110,5 @@ Just push to the `main` branch.
|
||||||
|
|
||||||
These are not officially endorsed, but are alternative ways of running bewCloud.
|
These are not officially endorsed, but are alternative ways of running bewCloud.
|
||||||
|
|
||||||
- [`bewcloud-nixos`](https://codeberg.org/ntninja/bewcloud-nixos/) by [@ntninja](https://codeberg.org/ntninja/) exposes bewCloud as an easy-to-use NixOS integration as an alternative to using Docker or running the app locally.
|
- [`bewcloud-nixos`](https://gitlab.com/ntninja/bewcloud-nixos/) by [@ntninja](https://github.com/ntninja) exposes bewCloud as a NixOS integration as an alternative to using Docker or running the app locally.
|
||||||
- For installation, please see the [README](https://codeberg.org/ntninja/bewcloud-nixos/src/branch/main/README.md).
|
- For installation and known limitations, please see its [README](https://gitlab.com/ntninja/bewcloud-nixos/-/blob/main/README.md).
|
||||||
- [`bewcloud-helm`](https://github.com/loboda4450/charts/tree/main/charts/bewcloud) by [@loboda4450](https://github.com/loboda4450/) exposes bewCloud as a Helm chart.
|
|
||||||
- For installation, please see the [README](https://github.com/loboda4450/charts/blob/main/charts/bewcloud/README.md)
|
|
||||||
- Supports automatic migrations, Radicale installation (as a dependency chart) and streamlined, fully yaml-based configuration.
|
|
||||||
|
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
// Converts local TSX imports to JS imports, used in the components
|
|
||||||
const rewriteLocalTsxImports = () => {
|
|
||||||
const isLocal = (value) => typeof value === 'string' && (value.startsWith('./') || value.startsWith('../'));
|
|
||||||
const isComponent = (value) => typeof value === 'string' && value.startsWith('/components/');
|
|
||||||
|
|
||||||
const rewrite = (source) => {
|
|
||||||
if (source && isLocal(source.value) && source.value.endsWith('.tsx')) {
|
|
||||||
source.value = source.value.replace('.tsx', '.js');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (source && isComponent(source.value) && source.value.endsWith('.tsx')) {
|
|
||||||
source.value = source.value.replace('/components/', '/public/components/').replace('.tsx', '.js');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
visitor: {
|
|
||||||
ImportDeclaration(path) {
|
|
||||||
rewrite(path.node.source);
|
|
||||||
},
|
|
||||||
ExportAllDeclaration(path) {
|
|
||||||
rewrite(path.node.source);
|
|
||||||
},
|
|
||||||
ExportNamedDeclaration(path) {
|
|
||||||
if (path.node.source) rewrite(path.node.source);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const presets = [
|
|
||||||
['@babel/preset-react'],
|
|
||||||
['@babel/preset-typescript', { jsxPragma: 'h' }],
|
|
||||||
];
|
|
||||||
|
|
||||||
const plugins = [
|
|
||||||
rewriteLocalTsxImports,
|
|
||||||
[
|
|
||||||
'@babel/plugin-transform-react-jsx',
|
|
||||||
{
|
|
||||||
'pragma': 'h',
|
|
||||||
'pragmaFrag': 'Fragment',
|
|
||||||
'jsxImportSource': 'preact',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
export default {
|
|
||||||
sourceType: 'module',
|
|
||||||
targets: '> 0.5%, not dead',
|
|
||||||
presets,
|
|
||||||
plugins,
|
|
||||||
comments: false,
|
|
||||||
compact: false,
|
|
||||||
minified: false,
|
|
||||||
};
|
|
||||||
|
|
@ -11,7 +11,6 @@ const config: PartialDeep<Config> = {
|
||||||
// allowedCookieDomains: ['example.com', 'example.net'], // Can be set to allow more than the baseUrl's domain for session cookies
|
// allowedCookieDomains: ['example.com', 'example.net'], // Can be set to allow more than the baseUrl's domain for session cookies
|
||||||
// skipCookieDomainSecurity: true, // If true, the cookie domain will not be strictly set and checked against. This skipping slightly reduces security, but is usually necessary for reverse proxies like Cloudflare Tunnel
|
// skipCookieDomainSecurity: true, // If true, the cookie domain will not be strictly set and checked against. This skipping slightly reduces security, but is usually necessary for reverse proxies like Cloudflare Tunnel
|
||||||
// enableSingleSignOn: false, // If true, single sign-on will be enabled
|
// enableSingleSignOn: false, // If true, single sign-on will be enabled
|
||||||
// allowSignupsViaSingleSignOn: false, // If true, signups via single sign-on will be allowed, overriding allowSignups
|
|
||||||
// singleSignOnUrl: '', // The Discovery URL (AKA Issuer) of the identity/single sign-on provider
|
// singleSignOnUrl: '', // The Discovery URL (AKA Issuer) of the identity/single sign-on provider
|
||||||
// singleSignOnEmailAttribute: 'email', // The attribute to prefer as email of the identity/single sign-on provider
|
// singleSignOnEmailAttribute: 'email', // The attribute to prefer as email of the identity/single sign-on provider
|
||||||
// singleSignOnScopes: ['openid', 'email'], // The scopes to request from the identity/single sign-on provider
|
// singleSignOnScopes: ['openid', 'email'], // The scopes to request from the identity/single sign-on provider
|
||||||
|
|
@ -20,11 +19,9 @@ const config: PartialDeep<Config> = {
|
||||||
// rootPath: 'data-files',
|
// rootPath: 'data-files',
|
||||||
// allowPublicSharing: false, // If true, public file sharing will be allowed (still requires a user to enable sharing for a given file or directory)
|
// allowPublicSharing: false, // If true, public file sharing will be allowed (still requires a user to enable sharing for a given file or directory)
|
||||||
// allowDirectoryDownloads: false, // If true, directories can be downloaded as zip files
|
// allowDirectoryDownloads: false, // If true, directories can be downloaded as zip files
|
||||||
// maxUploadSizeInMegabytes: 100, // The maximum upload size in megabytes. Overrides the core.maxRequestSizeInMegabytes setting on /dav and /api/files/upload endpoints.
|
|
||||||
// },
|
// },
|
||||||
// core: {
|
// core: {
|
||||||
// enabledApps: ['dashboard', 'files', 'news', 'notes', 'photos', 'expenses', 'contacts', 'calendar'], // The apps to show, in order of appearance in the header. The first app will be the default one shown after logging in. At least one is required.
|
// enabledApps: ['dashboard', 'files', 'news', 'notes', 'photos', 'expenses', 'contacts', 'calendar'], // The apps to show, in order of appearance in the header. The first app will be the default one shown after logging in. At least one is required.
|
||||||
// maxRequestSizeInMegabytes: 12, // The maximum request size in megabytes.
|
|
||||||
// },
|
// },
|
||||||
// visuals: {
|
// visuals: {
|
||||||
// title: 'My own cloud',
|
// title: 'My own cloud',
|
||||||
|
|
@ -35,8 +32,6 @@ const config: PartialDeep<Config> = {
|
||||||
// from: 'help@bewcloud.com',
|
// from: 'help@bewcloud.com',
|
||||||
// host: 'localhost',
|
// host: 'localhost',
|
||||||
// port: 465,
|
// port: 465,
|
||||||
// tlsMode: 'auto', // "auto" means "immediate" on port 465, "starttls" otherwise.
|
|
||||||
// tlsVerify: true, // Whether to verify the TLS certificate. If a string is used the hostname will be verified using that name.
|
|
||||||
// },
|
// },
|
||||||
// contacts: {
|
// contacts: {
|
||||||
// enableCardDavServer: true,
|
// enableCardDavServer: true,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
|
import { Head } from 'fresh/runtime.ts';
|
||||||
|
|
||||||
import { OptionalApp, User } from '/lib/types.ts';
|
import { OptionalApp, User } from '/lib/types.ts';
|
||||||
import { capitalizeWord } from '/public/ts/utils/misc.ts';
|
import { capitalizeWord } from '/lib/utils/misc.ts';
|
||||||
|
|
||||||
interface Data {
|
interface Data {
|
||||||
route: string;
|
route: string;
|
||||||
user?: User | null;
|
user?: User;
|
||||||
enabledApps: OptionalApp[];
|
enabledApps: OptionalApp[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,17 +58,16 @@ export default function Header({ route, user, enabledApps }: Data) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>{pageLabel} - bewCloud</title>
|
||||||
|
</Head>
|
||||||
<nav class='bg-slate-950'>
|
<nav class='bg-slate-950'>
|
||||||
<div class='mx-auto max-w-7xl px-4 sm:px-6 lg:px-8'>
|
<div class='mx-auto max-w-7xl px-4 sm:px-6 lg:px-8'>
|
||||||
<div class='flex h-16 items-center justify-between'>
|
<div class='flex h-16 items-center justify-between'>
|
||||||
<div class='flex items-center'>
|
<div class='flex items-center'>
|
||||||
<div class='shrink-0'>
|
<div class='flex-shrink-0'>
|
||||||
<a href='/'>
|
<a href='/'>
|
||||||
<img
|
<img class='h-12 w-12 drop-shadow-md' src='/images/logomark.svg' alt='a stylized blue cloud' />
|
||||||
class='h-12 w-12 drop-shadow-md'
|
|
||||||
src='/public/images/logomark.svg'
|
|
||||||
alt='a stylized blue cloud'
|
|
||||||
/>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class='hidden md:block'>
|
<div class='hidden md:block'>
|
||||||
|
|
@ -74,7 +75,7 @@ export default function Header({ route, user, enabledApps }: Data) {
|
||||||
{menuItems.map((menu) => (
|
{menuItems.map((menu) => (
|
||||||
<a href={menu.url} class={route.startsWith(menu.url) ? activeClass : defaultClass}>
|
<a href={menu.url} class={route.startsWith(menu.url) ? activeClass : defaultClass}>
|
||||||
<img
|
<img
|
||||||
src={`/public/images${menu.url}${'.svg'}`}
|
src={`/images${menu.url}${'.svg'}`}
|
||||||
alt={menu.label}
|
alt={menu.label}
|
||||||
title={menu.label}
|
title={menu.label}
|
||||||
width={iconWidthAndHeightInPixels}
|
width={iconWidthAndHeightInPixels}
|
||||||
|
|
@ -95,7 +96,7 @@ export default function Header({ route, user, enabledApps }: Data) {
|
||||||
class={route.startsWith('/settings') ? activeClass : defaultClass}
|
class={route.startsWith('/settings') ? activeClass : defaultClass}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/settings.svg'
|
src='/images/settings.svg'
|
||||||
alt='Settings'
|
alt='Settings'
|
||||||
title='Settings'
|
title='Settings'
|
||||||
width={iconWidthAndHeightInPixels}
|
width={iconWidthAndHeightInPixels}
|
||||||
|
|
@ -108,7 +109,7 @@ export default function Header({ route, user, enabledApps }: Data) {
|
||||||
class={defaultClass}
|
class={defaultClass}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/logout.svg'
|
src='/images/logout.svg'
|
||||||
alt='Logout'
|
alt='Logout'
|
||||||
title='Logout'
|
title='Logout'
|
||||||
width={iconWidthAndHeightInPixels}
|
width={iconWidthAndHeightInPixels}
|
||||||
|
|
@ -144,11 +145,11 @@ export default function Header({ route, user, enabledApps }: Data) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header class='px-4 pt-8 pb-2 max-w-3xl mx-auto flex flex-col items-center justify-center'>
|
<header class='px-4 pt-8 pb-2 max-w-screen-md mx-auto flex flex-col items-center justify-center'>
|
||||||
<a href='/'>
|
<a href='/'>
|
||||||
<img
|
<img
|
||||||
class='mt-6 mb-2 drop-shadow-md'
|
class='mt-6 mb-2 drop-shadow-md'
|
||||||
src='/public/images/logo-white.svg'
|
src='/images/logo-white.svg'
|
||||||
width='250'
|
width='250'
|
||||||
height='50'
|
height='50'
|
||||||
alt='the bewCloud logo: a stylized logo'
|
alt='the bewCloud logo: a stylized logo'
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import { html } from '/public/ts/utils/misc.ts';
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return html`
|
|
||||||
<section role="status" id="loading" class="flex justify-center items-center">
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
class="w-8 h-8 text-slate-700 animate-spin fill-sky-400"
|
|
||||||
viewBox="0 0 100 101"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
|
||||||
fill="currentFill"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="sr-only">Loading...</span>
|
|
||||||
</section>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
@ -1,295 +0,0 @@
|
||||||
import { FormField, generateFieldHtml, getFormDataField } from '/public/ts/utils/form.ts';
|
|
||||||
import { convertObjectToFormData, currencyMap, escapeHtml, html } from '/public/ts/utils/misc.ts';
|
|
||||||
import { SupportedCurrencySymbol, User } from '/lib/types.ts';
|
|
||||||
import { getEnabledMultiFactorAuthMethodsFromUser } from '/public/ts/utils/multi-factor-auth.ts';
|
|
||||||
import { getTimeZones } from '/public/ts/utils/calendar.ts';
|
|
||||||
import Loading from '/components/Loading.ts';
|
|
||||||
|
|
||||||
interface SettingsProps {
|
|
||||||
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'>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Action =
|
|
||||||
| 'change-email'
|
|
||||||
| 'verify-change-email'
|
|
||||||
| 'change-password'
|
|
||||||
| 'change-dav-password'
|
|
||||||
| 'delete-account'
|
|
||||||
| 'change-currency'
|
|
||||||
| 'change-timezone';
|
|
||||||
|
|
||||||
export const actionWords = new Map<Action, string>([
|
|
||||||
['change-email', 'change email'],
|
|
||||||
['verify-change-email', 'change email'],
|
|
||||||
['change-password', 'change password'],
|
|
||||||
['change-dav-password', 'change WebDav password'],
|
|
||||||
['delete-account', 'delete account'],
|
|
||||||
['change-currency', 'change currency'],
|
|
||||||
['change-timezone', 'change timezone'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
function formFields(action: Action, formData: FormData, currency?: SupportedCurrencySymbol, timezoneId?: string) {
|
|
||||||
const fields: FormField[] = [
|
|
||||||
{
|
|
||||||
name: 'action',
|
|
||||||
label: '',
|
|
||||||
type: 'hidden',
|
|
||||||
value: action,
|
|
||||||
overrideValue: action,
|
|
||||||
required: true,
|
|
||||||
readOnly: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (action === 'change-email') {
|
|
||||||
fields.push({
|
|
||||||
name: 'email',
|
|
||||||
label: 'Email',
|
|
||||||
type: 'email',
|
|
||||||
placeholder: 'jane.doe@example.com',
|
|
||||||
value: getFormDataField(formData, 'email'),
|
|
||||||
required: true,
|
|
||||||
});
|
|
||||||
} else if (action === 'verify-change-email') {
|
|
||||||
fields.push({
|
|
||||||
name: 'email',
|
|
||||||
label: 'Email',
|
|
||||||
type: 'email',
|
|
||||||
placeholder: 'jane.doe@example.com',
|
|
||||||
value: getFormDataField(formData, 'email'),
|
|
||||||
required: true,
|
|
||||||
}, {
|
|
||||||
name: 'verification-code',
|
|
||||||
label: 'Verification Code',
|
|
||||||
description: `The verification code to validate your new email.`,
|
|
||||||
type: 'text',
|
|
||||||
placeholder: '000000',
|
|
||||||
required: true,
|
|
||||||
});
|
|
||||||
} else if (action === 'change-password') {
|
|
||||||
fields.push({
|
|
||||||
name: 'current-password',
|
|
||||||
label: 'Current Password',
|
|
||||||
type: 'password',
|
|
||||||
placeholder: 'super-SECRET-passphrase',
|
|
||||||
required: true,
|
|
||||||
}, {
|
|
||||||
name: 'new-password',
|
|
||||||
label: 'New Password',
|
|
||||||
type: 'password',
|
|
||||||
placeholder: 'super-SECRET-passphrase',
|
|
||||||
required: true,
|
|
||||||
});
|
|
||||||
} else if (action === 'change-dav-password') {
|
|
||||||
fields.push({
|
|
||||||
name: 'new-dav-password',
|
|
||||||
label: 'New WebDav Password',
|
|
||||||
type: 'password',
|
|
||||||
placeholder: 'super-SECRET-passphrase',
|
|
||||||
required: true,
|
|
||||||
description: 'Alternative password used for WebDav access and/or HTTP Basic Auth.',
|
|
||||||
});
|
|
||||||
} else if (action === 'delete-account') {
|
|
||||||
fields.push({
|
|
||||||
name: 'current-password',
|
|
||||||
label: 'Password',
|
|
||||||
type: 'password',
|
|
||||||
placeholder: 'super-SECRET-passphrase',
|
|
||||||
description: 'You need to input your password in order to delete your account.',
|
|
||||||
required: true,
|
|
||||||
});
|
|
||||||
} else if (action === 'change-currency') {
|
|
||||||
fields.push({
|
|
||||||
name: 'currency',
|
|
||||||
label: 'Currency',
|
|
||||||
type: 'select',
|
|
||||||
options: Array.from(currencyMap.keys()).map((currencySymbol) => ({
|
|
||||||
value: currencySymbol,
|
|
||||||
label: `${currencySymbol} (${currencyMap.get(currencySymbol)})`,
|
|
||||||
})),
|
|
||||||
value: getFormDataField(formData, 'currency') || currency,
|
|
||||||
required: true,
|
|
||||||
});
|
|
||||||
} else if (action === 'change-timezone') {
|
|
||||||
const timezones = getTimeZones();
|
|
||||||
|
|
||||||
fields.push({
|
|
||||||
name: 'timezone',
|
|
||||||
label: 'Timezone',
|
|
||||||
type: 'select',
|
|
||||||
options: timezones.map((timezone) => ({
|
|
||||||
value: timezone.id,
|
|
||||||
label: timezone.label,
|
|
||||||
})),
|
|
||||||
value: getFormDataField(formData, 'timezone') || timezoneId,
|
|
||||||
required: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return fields;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Settings(
|
|
||||||
{
|
|
||||||
formData: formDataObject,
|
|
||||||
error,
|
|
||||||
notice,
|
|
||||||
currency,
|
|
||||||
timezoneId,
|
|
||||||
isExpensesAppEnabled,
|
|
||||||
isMultiFactorAuthEnabled,
|
|
||||||
isCalendarAppEnabled,
|
|
||||||
helpEmail,
|
|
||||||
user,
|
|
||||||
}: SettingsProps,
|
|
||||||
): string {
|
|
||||||
const formData = convertObjectToFormData(formDataObject);
|
|
||||||
|
|
||||||
const multiFactorAuthMethods = getEnabledMultiFactorAuthMethodsFromUser(user);
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<section class="mx-auto max-w-7xl my-8">
|
|
||||||
${error
|
|
||||||
? html`
|
|
||||||
<section class="notification-error">
|
|
||||||
<h3>${escapeHtml(error.title)}</h3>
|
|
||||||
<p>${escapeHtml(error.message)}</p>
|
|
||||||
</section>
|
|
||||||
`
|
|
||||||
: ''} ${notice
|
|
||||||
? html`
|
|
||||||
<section class="notification-success">
|
|
||||||
<h3>${escapeHtml(notice.title)}</h3>
|
|
||||||
<p>${escapeHtml(notice.message)}</p>
|
|
||||||
</section>
|
|
||||||
`
|
|
||||||
: ''}
|
|
||||||
|
|
||||||
<h2 class="text-2xl mb-4 text-left px-4 max-w-3xl mx-auto lg:min-w-96">Change your email</h2>
|
|
||||||
|
|
||||||
<form method="POST" class="mb-12">
|
|
||||||
${formFields('change-email', formData).map((field) => generateFieldHtml(field, formData)).join('')}
|
|
||||||
<section class="flex justify-end mt-8 mb-4">
|
|
||||||
<button class="button-secondary" type="submit">Change email</button>
|
|
||||||
</section>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<h2 class="text-2xl mb-4 text-left px-4 max-w-3xl mx-auto lg:min-w-96">Change your password</h2>
|
|
||||||
|
|
||||||
<form method="POST" class="mb-12">
|
|
||||||
${formFields('change-password', formData).map((field) => generateFieldHtml(field, formData)).join('')}
|
|
||||||
<section class="flex justify-end mt-8 mb-4">
|
|
||||||
<button class="button-secondary" type="submit">Change password</button>
|
|
||||||
</section>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<h2 class="text-2xl mb-4 text-left px-4 max-w-3xl mx-auto lg:min-w-96">Change your WebDav password</h2>
|
|
||||||
|
|
||||||
<form method="POST" class="mb-12">
|
|
||||||
${formFields('change-dav-password', formData).map((field) => generateFieldHtml(field, formData)).join('')}
|
|
||||||
<section class="flex justify-end mt-8 mb-4">
|
|
||||||
<button class="button-secondary" type="submit">Change WebDav password</button>
|
|
||||||
</section>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
${isExpensesAppEnabled
|
|
||||||
? html`
|
|
||||||
<h2 class="text-2xl mb-4 text-left px-4 max-w-3xl mx-auto lg:min-w-96">Change your currency</h2>
|
|
||||||
<p class="text-left mt-2 mb-6 px-4 max-w-3xl mx-auto lg:min-w-96">
|
|
||||||
This is only used in the expenses app, visually. It changes nothing about the stored data or values.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form method="POST" class="mb-12">
|
|
||||||
${formFields('change-currency', formData, currency, timezoneId).map((field) =>
|
|
||||||
generateFieldHtml(field, formData)
|
|
||||||
).join('')}
|
|
||||||
<section class="flex justify-end mt-8 mb-4">
|
|
||||||
<button class="button-secondary" type="submit">Change currency</button>
|
|
||||||
</section>
|
|
||||||
</form>
|
|
||||||
`
|
|
||||||
: ''} ${isCalendarAppEnabled
|
|
||||||
? html`
|
|
||||||
<h2 class="text-2xl mb-4 text-left px-4 max-w-3xl mx-auto lg:min-w-96">Change your timezone</h2>
|
|
||||||
<p class="text-left mt-2 mb-6 px-4 max-w-3xl mx-auto lg:min-w-96">
|
|
||||||
This is only used in the calendar app.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form method="POST" class="mb-12">
|
|
||||||
${formFields('change-timezone', formData, currency, timezoneId).map((field) =>
|
|
||||||
generateFieldHtml(field, formData)
|
|
||||||
).join('')}
|
|
||||||
<section class="flex justify-end mt-8 mb-4">
|
|
||||||
<button class="button-secondary" type="submit">Change timezone</button>
|
|
||||||
</section>
|
|
||||||
</form>
|
|
||||||
`
|
|
||||||
: ''} ${isMultiFactorAuthEnabled
|
|
||||||
? html`
|
|
||||||
<section id="multi-factor-auth-settings">
|
|
||||||
${Loading()}
|
|
||||||
</section>
|
|
||||||
`
|
|
||||||
: ''}
|
|
||||||
|
|
||||||
<h2 class="text-2xl mb-4 text-left px-4 max-w-3xl mx-auto lg:min-w-96">Delete your account</h2>
|
|
||||||
<p class="text-left mt-2 mb-6 px-4 max-w-3xl mx-auto lg:min-w-96">
|
|
||||||
Deleting your account is instant and deletes all your data. ${helpEmail !== ''
|
|
||||||
? html`
|
|
||||||
If you need help, please <a href="${`mailto:${helpEmail}`}">reach out</a>.
|
|
||||||
`
|
|
||||||
: ''}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form method="POST" class="mb-12">
|
|
||||||
${formFields('delete-account', formData).map((field) => generateFieldHtml(field, formData)).join('')}
|
|
||||||
<section class="flex justify-end mt-8 mb-4">
|
|
||||||
<button class="button-danger" type="submit">Delete account</button>
|
|
||||||
</section>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<script type="module">
|
|
||||||
import { h, render } from 'preact';
|
|
||||||
|
|
||||||
// Imported files need some preact globals to work
|
|
||||||
window.h = h;
|
|
||||||
|
|
||||||
import MultiFactorAuthSettings from '/public/components/auth/MultiFactorAuthSettings.js';
|
|
||||||
|
|
||||||
const multiFactorAuthSettingsElement = document.getElementById('multi-factor-auth-settings');
|
|
||||||
|
|
||||||
if (multiFactorAuthSettingsElement) {
|
|
||||||
const multiFactorAuthSettingsApp = h(MultiFactorAuthSettings, {
|
|
||||||
methods: ${JSON.stringify(multiFactorAuthMethods.map((method) => ({
|
|
||||||
type: method.type,
|
|
||||||
id: method.id,
|
|
||||||
name: method.name,
|
|
||||||
enabled: method.enabled,
|
|
||||||
backupCodesCount: method.metadata.totp?.hashed_backup_codes?.length,
|
|
||||||
})))},
|
|
||||||
});
|
|
||||||
|
|
||||||
render(multiFactorAuthSettingsApp, multiFactorAuthSettingsElement);
|
|
||||||
|
|
||||||
document.getElementById('loading')?.remove();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { MultiFactorAuthMethodType } from '/lib/types.ts';
|
import { MultiFactorAuthMethodType } from '/lib/types.ts';
|
||||||
import PasswordlessPasskeyLogin from '/components/auth/PasswordlessPasskeyLogin.tsx';
|
import PasswordlessPasskeyLogin from '/islands/auth/PasswordlessPasskeyLogin.tsx';
|
||||||
|
|
||||||
interface MultiFactorAuthVerifyFormProps {
|
interface MultiFactorAuthVerifyFormProps {
|
||||||
email: string;
|
email: string;
|
||||||
|
|
@ -16,7 +16,7 @@ export default function MultiFactorAuthVerifyForm(
|
||||||
const hasEmail = availableMethods.includes('email');
|
const hasEmail = availableMethods.includes('email');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section class='max-w-md w-full mb-12 mx-auto'>
|
<section class='max-w-md w-full mb-12'>
|
||||||
<section class='mb-6'>
|
<section class='mb-6'>
|
||||||
<h2 class='mt-6 text-center text-3xl font-extrabold text-white'>
|
<h2 class='mt-6 text-center text-3xl font-extrabold text-white'>
|
||||||
Multi-Factor Authentication
|
Multi-Factor Authentication
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
export interface PasswordlessPasskeyLoginProps {
|
|
||||||
email?: string;
|
|
||||||
redirectUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PasswordlessPasskeyLogin({ email, redirectUrl }: PasswordlessPasskeyLoginProps) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<section class='space-y-4'>
|
|
||||||
<section class='flex justify-center mt-2 mb-4'>
|
|
||||||
<button
|
|
||||||
id='passwordless-passkey-login-button'
|
|
||||||
type='button'
|
|
||||||
class='button-secondary'
|
|
||||||
data-email={email}
|
|
||||||
data-redirect-url={redirectUrl}
|
|
||||||
>
|
|
||||||
Login with Passkey
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class='notification-error hidden' id='passwordless-passkey-login-error'></section>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<script src='/public/js/simplewebauthn.js'></script>
|
|
||||||
<script type='module' src='/public/ts/passwordless-passkey-login.ts'></script>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -52,7 +52,7 @@ export default function AddEventModal(
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section
|
<section
|
||||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
|
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||||
>
|
>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -79,7 +79,7 @@ export default function AddEventModal(
|
||||||
<label class='text-slate-300 block pb-1' for='event_calendar'>Calendar</label>
|
<label class='text-slate-300 block pb-1' for='event_calendar'>Calendar</label>
|
||||||
<section class='flex items-center justify-between'>
|
<section class='flex items-center justify-between'>
|
||||||
<select
|
<select
|
||||||
class='input-field mr-2 w-5/6!'
|
class='input-field mr-2 !w-5/6'
|
||||||
name='event_calendar'
|
name='event_calendar'
|
||||||
id='event_calendar'
|
id='event_calendar'
|
||||||
value={newEvent.value?.calendarId || ''}
|
value={newEvent.value?.calendarId || ''}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
||||||
import { getCalendarEventStyle } from '/public/ts/utils/calendar.ts';
|
import { getCalendarEventStyle } from '/lib/utils/calendar.ts';
|
||||||
|
|
||||||
interface CalendarViewDayProps {
|
interface CalendarViewDayProps {
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
||||||
import { getCalendarEventStyle, getWeeksForMonth } from '/public/ts/utils/calendar.ts';
|
import { getCalendarEventStyle, getWeeksForMonth } from '/lib/utils/calendar.ts';
|
||||||
|
|
||||||
interface CalendarViewWeekProps {
|
interface CalendarViewWeekProps {
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
||||||
import { getCalendarEventStyle, getDaysForWeek } from '/public/ts/utils/calendar.ts';
|
import { getCalendarEventStyle, getDaysForWeek } from '/lib/utils/calendar.ts';
|
||||||
|
|
||||||
interface CalendarViewWeekProps {
|
interface CalendarViewWeekProps {
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ export default function ImportEventsModal(
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section
|
<section
|
||||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
|
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||||
>
|
>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -41,7 +41,7 @@ export default function ImportEventsModal(
|
||||||
<label class='text-slate-300 block pb-1' for='event_calendar'>Calendar</label>
|
<label class='text-slate-300 block pb-1' for='event_calendar'>Calendar</label>
|
||||||
<section class='flex items-center justify-between'>
|
<section class='flex items-center justify-between'>
|
||||||
<select
|
<select
|
||||||
class='input-field mr-2 w-5/6!'
|
class='input-field mr-2 !w-5/6'
|
||||||
name='event_calendar'
|
name='event_calendar'
|
||||||
id='event_calendar'
|
id='event_calendar'
|
||||||
value={newCalendarId.value || ''}
|
value={newCalendarId.value || ''}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
import { useSignal } from '@preact/signals';
|
import { useSignal } from '@preact/signals';
|
||||||
|
|
||||||
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
||||||
import { capitalizeWord } from '/public/ts/utils/misc.ts';
|
import { capitalizeWord } from '/lib/utils/misc.ts';
|
||||||
import { generateVCalendar } from '/public/ts/utils/calendar.ts';
|
import { generateVCalendar } from '/lib/utils/calendar.ts';
|
||||||
import {
|
import {
|
||||||
RequestBody as ExportRequestBody,
|
RequestBody as ExportRequestBody,
|
||||||
ResponseBody as ExportResponseBody,
|
ResponseBody as ExportResponseBody,
|
||||||
} from '/pages/api/calendar/export-events.ts';
|
} from '/routes/api/calendar/export-events.tsx';
|
||||||
import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/pages/api/calendar/add-event.ts';
|
import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/calendar/add-event.tsx';
|
||||||
import {
|
import {
|
||||||
RequestBody as DeleteRequestBody,
|
RequestBody as DeleteRequestBody,
|
||||||
ResponseBody as DeleteResponseBody,
|
ResponseBody as DeleteResponseBody,
|
||||||
} from '/pages/api/calendar/delete-event.ts';
|
} from '/routes/api/calendar/delete-event.tsx';
|
||||||
import { RequestBody as ImportRequestBody, ResponseBody as ImportResponseBody } from '/pages/api/calendar/import.ts';
|
import { RequestBody as ImportRequestBody, ResponseBody as ImportResponseBody } from '/routes/api/calendar/import.tsx';
|
||||||
import CalendarViewDay from './CalendarViewDay.tsx';
|
import CalendarViewDay from './CalendarViewDay.tsx';
|
||||||
import CalendarViewWeek from './CalendarViewWeek.tsx';
|
import CalendarViewWeek from './CalendarViewWeek.tsx';
|
||||||
import CalendarViewMonth from './CalendarViewMonth.tsx';
|
import CalendarViewMonth from './CalendarViewMonth.tsx';
|
||||||
|
|
@ -32,7 +32,8 @@ interface MainCalendarProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MainCalendar(
|
export default function MainCalendar(
|
||||||
{ initialCalendars, initialCalendarEvents, view, startDate, baseUrl, timezoneId }: MainCalendarProps,
|
{ initialCalendars, initialCalendarEvents, view, startDate, baseUrl, timezoneId, timezoneUtcOffset }:
|
||||||
|
MainCalendarProps,
|
||||||
) {
|
) {
|
||||||
const isAdding = useSignal<boolean>(false);
|
const isAdding = useSignal<boolean>(false);
|
||||||
const isDeleting = useSignal<boolean>(false);
|
const isDeleting = useSignal<boolean>(false);
|
||||||
|
|
@ -448,7 +449,7 @@ export default function MainCalendar(
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
|
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||||
!isViewOptionsDropdownOpen.value ? 'hidden' : ''
|
!isViewOptionsDropdownOpen.value ? 'hidden' : ''
|
||||||
}`}
|
}`}
|
||||||
role='menu'
|
role='menu'
|
||||||
|
|
@ -509,7 +510,7 @@ export default function MainCalendar(
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
|
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||||
!isImportExportOptionsDropdownOpen.value ? 'hidden' : ''
|
!isImportExportOptionsDropdownOpen.value ? 'hidden' : ''
|
||||||
}`}
|
}`}
|
||||||
role='menu'
|
role='menu'
|
||||||
|
|
@ -542,7 +543,7 @@ export default function MainCalendar(
|
||||||
onClick={() => onClickAddEvent()}
|
onClick={() => onClickAddEvent()}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/add.svg'
|
src='/images/add.svg'
|
||||||
alt='Add new event'
|
alt='Add new event'
|
||||||
class={`white ${isAdding.value ? 'animate-spin' : ''}`}
|
class={`white ${isAdding.value ? 'animate-spin' : ''}`}
|
||||||
width={20}
|
width={20}
|
||||||
|
|
@ -596,21 +597,21 @@ export default function MainCalendar(
|
||||||
{isDeleting.value
|
{isDeleting.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
{isExporting.value
|
{isExporting.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting...
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting...
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
{isImporting.value
|
{isImporting.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Importing...
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Importing...
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,8 @@ import { useSignal } from '@preact/signals';
|
||||||
import { useEffect } from 'preact/hooks';
|
import { useEffect } from 'preact/hooks';
|
||||||
|
|
||||||
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
||||||
import { RequestBody, ResponseBody } from '/pages/api/calendar/search-events.ts';
|
import { RequestBody, ResponseBody } from '/routes/api/calendar/search-events.tsx';
|
||||||
import { getColorAsHex } from '/public/ts/utils/calendar.ts';
|
import { getColorAsHex } from '/lib/utils/calendar.ts';
|
||||||
|
|
||||||
interface SearchEventsProps {
|
interface SearchEventsProps {
|
||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
onClickOpenEvent: (calendarEvent: CalendarEvent) => void;
|
onClickOpenEvent: (calendarEvent: CalendarEvent) => void;
|
||||||
|
|
@ -106,12 +105,12 @@ export default function SearchEvents({ calendars, onClickOpenEvent }: SearchEven
|
||||||
onFocus={() => onFocus()}
|
onFocus={() => onFocus()}
|
||||||
onBlur={() => onBlur()}
|
onBlur={() => onBlur()}
|
||||||
/>
|
/>
|
||||||
{isSearching.value ? <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} /> : null}
|
{isSearching.value ? <img src='/images/loading.svg' class='white mr-2' width={18} height={18} /> : null}
|
||||||
{areResultsVisible.value
|
{areResultsVisible.value
|
||||||
? (
|
? (
|
||||||
<section class='relative inline-block text-left ml-2 text-xs'>
|
<section class='relative inline-block text-left ml-2 text-xs'>
|
||||||
<section
|
<section
|
||||||
class={`absolute right-0 z-10 mt-2 w-56 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none`}
|
class={`absolute right-0 z-10 mt-2 w-56 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none`}
|
||||||
role='menu'
|
role='menu'
|
||||||
aria-orientation='vertical'
|
aria-orientation='vertical'
|
||||||
aria-labelledby='view-button'
|
aria-labelledby='view-button'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
||||||
import { convertRRuleToWords } from '/public/ts/utils/calendar.ts';
|
import { convertRRuleToWords } from '/lib/utils/calendar.ts';
|
||||||
|
|
||||||
interface ViewEventModalProps {
|
interface ViewEventModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -33,7 +33,7 @@ export default function ViewEventModal(
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section
|
<section
|
||||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
|
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||||
>
|
>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useSignal } from '@preact/signals';
|
||||||
import { useEffect } from 'preact/hooks';
|
import { useEffect } from 'preact/hooks';
|
||||||
|
|
||||||
import { Budget } from '/lib/types.ts';
|
import { Budget } from '/lib/types.ts';
|
||||||
import { formatInputToNumber } from '/public/ts/utils/misc.ts';
|
import { formatInputToNumber } from '/lib/utils/misc.ts';
|
||||||
|
|
||||||
interface BudgetModalProps {
|
interface BudgetModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -41,7 +41,7 @@ export default function BudgetModal(
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section
|
<section
|
||||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
|
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||||
>
|
>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@ import { useSignal } from '@preact/signals';
|
||||||
import { useEffect } from 'preact/hooks';
|
import { useEffect } from 'preact/hooks';
|
||||||
|
|
||||||
import { Budget, Expense } from '/lib/types.ts';
|
import { Budget, Expense } from '/lib/types.ts';
|
||||||
import { formatInputToNumber } from '/public/ts/utils/misc.ts';
|
import { formatInputToNumber } from '/lib/utils/misc.ts';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
RequestBody as SuggestionsRequestBody,
|
RequestBody as SuggestionsRequestBody,
|
||||||
ResponseBody as SuggestionsResponse,
|
ResponseBody as SuggestionsResponse,
|
||||||
} from '/pages/api/expenses/auto-complete.ts';
|
} from '/routes/api/expenses/auto-complete.tsx';
|
||||||
|
|
||||||
interface ExpenseModalProps {
|
interface ExpenseModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -98,7 +98,7 @@ export default function ExpenseModal(
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section
|
<section
|
||||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
|
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||||
>
|
>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useSignal } from '@preact/signals';
|
||||||
import { useEffect, useRef } from 'preact/hooks';
|
import { useEffect, useRef } from 'preact/hooks';
|
||||||
import { Chart } from 'chart.js/auto';
|
import { Chart } from 'chart.js/auto';
|
||||||
|
|
||||||
import { formatNumber } from '/public/ts/utils/misc.ts';
|
import { formatNumber } from '/lib/utils/misc.ts';
|
||||||
import { Budget, SupportedCurrencySymbol } from '/lib/types.ts';
|
import { Budget, SupportedCurrencySymbol } from '/lib/types.ts';
|
||||||
|
|
||||||
interface ListBudgetsProps {
|
interface ListBudgetsProps {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { Expense, SupportedCurrencySymbol } from '/lib/types.ts';
|
import { Expense, SupportedCurrencySymbol } from '/lib/types.ts';
|
||||||
import { formatNumber } from '/public/ts/utils/misc.ts';
|
import { formatNumber } from '/lib/utils/misc.ts';
|
||||||
|
|
||||||
interface ListExpensesProps {
|
interface ListExpensesProps {
|
||||||
expenses: Expense[];
|
expenses: Expense[];
|
||||||
currency: SupportedCurrencySymbol;
|
currency: SupportedCurrencySymbol;
|
||||||
|
|
@ -48,14 +47,14 @@ export default function ListExpenses(
|
||||||
class='text-white border-t border-slate-700 hover:bg-slate-600 transition-colors even:bg-slate-700 odd:bg-slate-800 cursor-pointer flex md:table-row flex-row flex-wrap my-4 mx-4 md:my-0 md:mx-0 rounded md:rounded-none shadow-md md:shadow-none relative py-4 md:py-0 px-6 md:px-0'
|
class='text-white border-t border-slate-700 hover:bg-slate-600 transition-colors even:bg-slate-700 odd:bg-slate-800 cursor-pointer flex md:table-row flex-row flex-wrap my-4 mx-4 md:my-0 md:mx-0 rounded md:rounded-none shadow-md md:shadow-none relative py-4 md:py-0 px-6 md:px-0'
|
||||||
onClick={() => onClickEditExpense(expense.id)}
|
onClick={() => onClickEditExpense(expense.id)}
|
||||||
>
|
>
|
||||||
<td class='md:px-6 md:py-3 flex-50 mx-2 md:mx-0'>{expense.description}</td>
|
<td class='md:px-6 md:py-3 flex-[50] mx-2 md:mx-0'>{expense.description}</td>
|
||||||
<td class='md:px-6 md:py-3 flex-20 mx-2 md:mx-0 text-gray-400 md:text-gray-300'>
|
<td class='md:px-6 md:py-3 flex-[20] mx-2 md:mx-0 text-gray-400 md:text-gray-300'>
|
||||||
{expense.budget}
|
{expense.budget}
|
||||||
</td>
|
</td>
|
||||||
<td class='md:px-6 md:py-3 flex-15 mx-2 md:mx-0 text-gray-400 md:text-gray-300'>
|
<td class='md:px-6 md:py-3 flex-[15] mx-2 md:mx-0 text-gray-400 md:text-gray-300'>
|
||||||
{dateFormat.format(new Date(expense.date))}
|
{dateFormat.format(new Date(expense.date))}
|
||||||
</td>
|
</td>
|
||||||
<td class='md:px-6 md:py-3 flex-15 mx-2 md:mx-0 font-bold md:font-semibold'>
|
<td class='md:px-6 md:py-3 flex-[15] mx-2 md:mx-0 font-bold md:font-semibold'>
|
||||||
{formatNumber(currency, expense.cost)}
|
{formatNumber(currency, expense.cost)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -5,32 +5,35 @@ import { Budget, Expense, SupportedCurrencySymbol } from '/lib/types.ts';
|
||||||
import {
|
import {
|
||||||
RequestBody as ImportRequestBody,
|
RequestBody as ImportRequestBody,
|
||||||
ResponseBody as ImportResponseBody,
|
ResponseBody as ImportResponseBody,
|
||||||
} from '/pages/api/expenses/import-expenses.ts';
|
} from '/routes/api/expenses/import-expenses.tsx';
|
||||||
import { ResponseBody as ExportResponseBody } from '/pages/api/expenses/export-expenses.ts';
|
import {
|
||||||
|
RequestBody as ExportRequestBody,
|
||||||
|
ResponseBody as ExportResponseBody,
|
||||||
|
} from '/routes/api/expenses/export-expenses.tsx';
|
||||||
import {
|
import {
|
||||||
RequestBody as AddExpenseRequestBody,
|
RequestBody as AddExpenseRequestBody,
|
||||||
ResponseBody as AddExpenseResponseBody,
|
ResponseBody as AddExpenseResponseBody,
|
||||||
} from '/pages/api/expenses/add-expense.ts';
|
} from '/routes/api/expenses/add-expense.tsx';
|
||||||
import {
|
import {
|
||||||
RequestBody as AddBudgetRequestBody,
|
RequestBody as AddBudgetRequestBody,
|
||||||
ResponseBody as AddBudgetResponseBody,
|
ResponseBody as AddBudgetResponseBody,
|
||||||
} from '/pages/api/expenses/add-budget.ts';
|
} from '/routes/api/expenses/add-budget.tsx';
|
||||||
import {
|
import {
|
||||||
RequestBody as UpdateExpenseRequestBody,
|
RequestBody as UpdateExpenseRequestBody,
|
||||||
ResponseBody as UpdateExpenseResponseBody,
|
ResponseBody as UpdateExpenseResponseBody,
|
||||||
} from '/pages/api/expenses/update-expense.ts';
|
} from '/routes/api/expenses/update-expense.tsx';
|
||||||
import {
|
import {
|
||||||
RequestBody as UpdateBudgetRequestBody,
|
RequestBody as UpdateBudgetRequestBody,
|
||||||
ResponseBody as UpdateBudgetResponseBody,
|
ResponseBody as UpdateBudgetResponseBody,
|
||||||
} from '/pages/api/expenses/update-budget.ts';
|
} from '/routes/api/expenses/update-budget.tsx';
|
||||||
import {
|
import {
|
||||||
RequestBody as DeleteExpenseRequestBody,
|
RequestBody as DeleteExpenseRequestBody,
|
||||||
ResponseBody as DeleteExpenseResponseBody,
|
ResponseBody as DeleteExpenseResponseBody,
|
||||||
} from '/pages/api/expenses/delete-expense.ts';
|
} from '/routes/api/expenses/delete-expense.tsx';
|
||||||
import {
|
import {
|
||||||
RequestBody as DeleteBudgetRequestBody,
|
RequestBody as DeleteBudgetRequestBody,
|
||||||
ResponseBody as DeleteBudgetResponseBody,
|
ResponseBody as DeleteBudgetResponseBody,
|
||||||
} from '/pages/api/expenses/delete-budget.ts';
|
} from '/routes/api/expenses/delete-budget.tsx';
|
||||||
import ListBudgets from '/components/expenses/ListBudgets.tsx';
|
import ListBudgets from '/components/expenses/ListBudgets.tsx';
|
||||||
import ListExpenses from '/components/expenses/ListExpenses.tsx';
|
import ListExpenses from '/components/expenses/ListExpenses.tsx';
|
||||||
import ExpenseModal from './ExpenseModal.tsx';
|
import ExpenseModal from './ExpenseModal.tsx';
|
||||||
|
|
@ -164,9 +167,10 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
|
||||||
const fileName = `expenses-data-export-${new Date().toISOString().substring(0, 19).replace(/:/g, '-')}.json`;
|
const fileName = `expenses-data-export-${new Date().toISOString().substring(0, 19).replace(/:/g, '-')}.json`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const requestBody: ExportRequestBody = {};
|
||||||
const response = await fetch(`/api/expenses/export-expenses`, {
|
const response = await fetch(`/api/expenses/export-expenses`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify(requestBody),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -659,9 +663,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
|
||||||
placeholder='Filter expenses...'
|
placeholder='Filter expenses...'
|
||||||
onInput={(event) => searchExpenses(event.currentTarget.value)}
|
onInput={(event) => searchExpenses(event.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
{isSearching.value
|
{isSearching.value ? <img src='/images/loading.svg' class='white mr-2' width={18} height={18} /> : null}
|
||||||
? <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />
|
|
||||||
: null}
|
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
<section class='flex items-center justify-end w-full'>
|
<section class='flex items-center justify-end w-full'>
|
||||||
|
|
@ -717,7 +719,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
|
||||||
onClick={() => toggleNewOptionsDropdown()}
|
onClick={() => toggleNewOptionsDropdown()}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/add.svg'
|
src='/images/add.svg'
|
||||||
alt='Add new expense or budget'
|
alt='Add new expense or budget'
|
||||||
class={`white ${isSaving.value || isImporting.value ? 'animate-spin' : ''}`}
|
class={`white ${isSaving.value || isImporting.value ? 'animate-spin' : ''}`}
|
||||||
width={20}
|
width={20}
|
||||||
|
|
@ -727,7 +729,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
|
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||||
!areNewOptionsOption.value ? 'hidden' : ''
|
!areNewOptionsOption.value ? 'hidden' : ''
|
||||||
}`}
|
}`}
|
||||||
role='menu'
|
role='menu'
|
||||||
|
|
@ -795,21 +797,21 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
|
||||||
{isSaving.value
|
{isSaving.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
{isImporting.value
|
{isImporting.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Importing...
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Importing...
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
{isExporting.value
|
{isExporting.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting...
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting...
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export default function CreateDirectoryModal(
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section
|
<section
|
||||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
|
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||||
>
|
>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export default function CreateShareModal(
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section
|
<section
|
||||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
|
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||||
>
|
>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { join } from '@std/path';
|
import { join } from '@std/path';
|
||||||
|
|
||||||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||||
import { humanFileSize, TRASH_PATH } from '/public/ts/utils/files.ts';
|
import { humanFileSize, TRASH_PATH } from '/lib/utils/files.ts';
|
||||||
|
|
||||||
interface ListFilesProps {
|
interface ListFilesProps {
|
||||||
directories: Directory[];
|
directories: Directory[];
|
||||||
|
|
@ -143,7 +143,7 @@ export default function ListFiles(
|
||||||
class='flex items-center font-normal text-white'
|
class='flex items-center font-normal text-white'
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`/public/images/${fullPath === TRASH_PATH ? 'trash.svg' : 'directory.svg'}`}
|
src={`/images/${fullPath === TRASH_PATH ? 'trash.svg' : 'directory.svg'}`}
|
||||||
class='white drop-shadow-md mr-2'
|
class='white drop-shadow-md mr-2'
|
||||||
width={18}
|
width={18}
|
||||||
height={18}
|
height={18}
|
||||||
|
|
@ -158,7 +158,7 @@ export default function ListFiles(
|
||||||
</td>
|
</td>
|
||||||
{isShowingNotes || isShowingPhotos ? null : (
|
{isShowingNotes || isShowingPhotos ? null : (
|
||||||
<td class='px-6 py-4 text-slate-200'>
|
<td class='px-6 py-4 text-slate-200'>
|
||||||
{humanFileSize(directory.size_in_bytes)}
|
-
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
{isShowingPhotos || fileShareId ? null : (
|
{isShowingPhotos || fileShareId ? null : (
|
||||||
|
|
@ -174,7 +174,7 @@ export default function ListFiles(
|
||||||
onClick={() => onClickDownloadDirectory(directory.parent_path, directory.directory_name)}
|
onClick={() => onClickDownloadDirectory(directory.parent_path, directory.directory_name)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/download.svg'
|
src='/images/download.svg'
|
||||||
class='white drop-shadow-md'
|
class='white drop-shadow-md'
|
||||||
width={18}
|
width={18}
|
||||||
height={18}
|
height={18}
|
||||||
|
|
@ -189,7 +189,7 @@ export default function ListFiles(
|
||||||
onClickOpenRenameDirectory(directory.parent_path, directory.directory_name)}
|
onClickOpenRenameDirectory(directory.parent_path, directory.directory_name)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/rename.svg'
|
src='/images/rename.svg'
|
||||||
class='white drop-shadow-md'
|
class='white drop-shadow-md'
|
||||||
width={18}
|
width={18}
|
||||||
height={18}
|
height={18}
|
||||||
|
|
@ -202,7 +202,7 @@ export default function ListFiles(
|
||||||
onClick={() => onClickOpenMoveDirectory(directory.parent_path, directory.directory_name)}
|
onClick={() => onClickOpenMoveDirectory(directory.parent_path, directory.directory_name)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/move.svg'
|
src='/images/move.svg'
|
||||||
class='white drop-shadow-md'
|
class='white drop-shadow-md'
|
||||||
width={18}
|
width={18}
|
||||||
height={18}
|
height={18}
|
||||||
|
|
@ -216,7 +216,7 @@ export default function ListFiles(
|
||||||
onClick={() => onClickDeleteDirectory(directory.parent_path, directory.directory_name)}
|
onClick={() => onClickDeleteDirectory(directory.parent_path, directory.directory_name)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/delete.svg'
|
src='/images/delete.svg'
|
||||||
class='red drop-shadow-md'
|
class='red drop-shadow-md'
|
||||||
width={20}
|
width={20}
|
||||||
height={20}
|
height={20}
|
||||||
|
|
@ -231,7 +231,7 @@ export default function ListFiles(
|
||||||
onClick={() => onClickCreateShare(join(directory.parent_path, directory.directory_name))}
|
onClick={() => onClickCreateShare(join(directory.parent_path, directory.directory_name))}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/share.svg'
|
src='/images/share.svg'
|
||||||
class='white drop-shadow-md'
|
class='white drop-shadow-md'
|
||||||
width={18}
|
width={18}
|
||||||
height={18}
|
height={18}
|
||||||
|
|
@ -246,7 +246,7 @@ export default function ListFiles(
|
||||||
onClick={() => onClickOpenManageShare(directory.file_share_id!)}
|
onClick={() => onClickOpenManageShare(directory.file_share_id!)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/share.svg'
|
src='/images/share.svg'
|
||||||
class='white drop-shadow-md'
|
class='white drop-shadow-md'
|
||||||
width={18}
|
width={18}
|
||||||
height={18}
|
height={18}
|
||||||
|
|
@ -288,7 +288,7 @@ export default function ListFiles(
|
||||||
rel='noopener noreferrer'
|
rel='noopener noreferrer'
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/file.svg'
|
src='/images/file.svg'
|
||||||
class='white drop-shadow-md mr-2'
|
class='white drop-shadow-md mr-2'
|
||||||
width={18}
|
width={18}
|
||||||
height={18}
|
height={18}
|
||||||
|
|
@ -315,7 +315,7 @@ export default function ListFiles(
|
||||||
onClick={() => onClickOpenRenameFile(file.parent_path, file.file_name)}
|
onClick={() => onClickOpenRenameFile(file.parent_path, file.file_name)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/rename.svg'
|
src='/images/rename.svg'
|
||||||
class='white drop-shadow-md'
|
class='white drop-shadow-md'
|
||||||
width={18}
|
width={18}
|
||||||
height={18}
|
height={18}
|
||||||
|
|
@ -330,7 +330,7 @@ export default function ListFiles(
|
||||||
onClick={() => onClickOpenMoveFile(file.parent_path, file.file_name)}
|
onClick={() => onClickOpenMoveFile(file.parent_path, file.file_name)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/move.svg'
|
src='/images/move.svg'
|
||||||
class='white drop-shadow-md'
|
class='white drop-shadow-md'
|
||||||
width={18}
|
width={18}
|
||||||
height={18}
|
height={18}
|
||||||
|
|
@ -345,7 +345,7 @@ export default function ListFiles(
|
||||||
onClick={() => onClickDeleteFile(file.parent_path, file.file_name)}
|
onClick={() => onClickDeleteFile(file.parent_path, file.file_name)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/delete.svg'
|
src='/images/delete.svg'
|
||||||
class='red drop-shadow-md'
|
class='red drop-shadow-md'
|
||||||
width={20}
|
width={20}
|
||||||
height={20}
|
height={20}
|
||||||
|
|
@ -360,7 +360,7 @@ export default function ListFiles(
|
||||||
onClick={() => onClickCreateShare(join(file.parent_path, file.file_name))}
|
onClick={() => onClickCreateShare(join(file.parent_path, file.file_name))}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/share.svg'
|
src='/images/share.svg'
|
||||||
class='white drop-shadow-md'
|
class='white drop-shadow-md'
|
||||||
width={18}
|
width={18}
|
||||||
height={18}
|
height={18}
|
||||||
|
|
@ -375,7 +375,7 @@ export default function ListFiles(
|
||||||
onClick={() => onClickOpenManageShare(file.file_share_id!)}
|
onClick={() => onClickOpenManageShare(file.file_share_id!)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/share.svg'
|
src='/images/share.svg'
|
||||||
class='white drop-shadow-md'
|
class='white drop-shadow-md'
|
||||||
width={18}
|
width={18}
|
||||||
height={18}
|
height={18}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||||
import { humanFileSize, TRASH_PATH } from '/public/ts/utils/files.ts';
|
import { humanFileSize, TRASH_PATH } from '/lib/utils/files.ts';
|
||||||
|
|
||||||
interface ListPhotosProps {
|
interface ListPhotosProps {
|
||||||
directories: Directory[];
|
directories: Directory[];
|
||||||
|
|
@ -64,7 +64,7 @@ export default function ListPhotos(
|
||||||
class='flex items-center font-normal text-white'
|
class='flex items-center font-normal text-white'
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`/public/images/${fullPath === TRASH_PATH ? 'trash.svg' : 'directory.svg'}`}
|
src={`/images/${fullPath === TRASH_PATH ? 'trash.svg' : 'directory.svg'}`}
|
||||||
class='white drop-shadow-md mr-2'
|
class='white drop-shadow-md mr-2'
|
||||||
width={18}
|
width={18}
|
||||||
height={18}
|
height={18}
|
||||||
|
|
@ -79,7 +79,7 @@ export default function ListPhotos(
|
||||||
</td>
|
</td>
|
||||||
{isShowingNotes ? null : (
|
{isShowingNotes ? null : (
|
||||||
<td class='px-6 py-4 text-slate-200'>
|
<td class='px-6 py-4 text-slate-200'>
|
||||||
{humanFileSize(directory.size_in_bytes)}
|
-
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
<td class='px-6 py-4'>
|
<td class='px-6 py-4'>
|
||||||
|
|
@ -93,7 +93,7 @@ export default function ListPhotos(
|
||||||
onClick={() => onClickOpenRenameDirectory(directory.parent_path, directory.directory_name)}
|
onClick={() => onClickOpenRenameDirectory(directory.parent_path, directory.directory_name)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/rename.svg'
|
src='/images/rename.svg'
|
||||||
class='white drop-shadow-md'
|
class='white drop-shadow-md'
|
||||||
width={18}
|
width={18}
|
||||||
height={18}
|
height={18}
|
||||||
|
|
@ -106,7 +106,7 @@ export default function ListPhotos(
|
||||||
onClick={() => onClickOpenMoveDirectory(directory.parent_path, directory.directory_name)}
|
onClick={() => onClickOpenMoveDirectory(directory.parent_path, directory.directory_name)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/move.svg'
|
src='/images/move.svg'
|
||||||
class='white drop-shadow-md'
|
class='white drop-shadow-md'
|
||||||
width={18}
|
width={18}
|
||||||
height={18}
|
height={18}
|
||||||
|
|
@ -119,7 +119,7 @@ export default function ListPhotos(
|
||||||
onClick={() => onClickDeleteDirectory(directory.parent_path, directory.directory_name)}
|
onClick={() => onClickDeleteDirectory(directory.parent_path, directory.directory_name)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/delete.svg'
|
src='/images/delete.svg'
|
||||||
class='red drop-shadow-md'
|
class='red drop-shadow-md'
|
||||||
width={20}
|
width={20}
|
||||||
height={20}
|
height={20}
|
||||||
|
|
@ -145,7 +145,7 @@ export default function ListPhotos(
|
||||||
rel='noopener noreferrer'
|
rel='noopener noreferrer'
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/file.svg'
|
src='/images/file.svg'
|
||||||
class='white drop-shadow-md mr-2'
|
class='white drop-shadow-md mr-2'
|
||||||
width={18}
|
width={18}
|
||||||
height={18}
|
height={18}
|
||||||
|
|
@ -171,7 +171,7 @@ export default function ListPhotos(
|
||||||
onClick={() => onClickOpenRenameFile(file.parent_path, file.file_name)}
|
onClick={() => onClickOpenRenameFile(file.parent_path, file.file_name)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/rename.svg'
|
src='/images/rename.svg'
|
||||||
class='white drop-shadow-md'
|
class='white drop-shadow-md'
|
||||||
width={18}
|
width={18}
|
||||||
height={18}
|
height={18}
|
||||||
|
|
@ -186,7 +186,7 @@ export default function ListPhotos(
|
||||||
onClick={() => onClickOpenMoveFile(file.parent_path, file.file_name)}
|
onClick={() => onClickOpenMoveFile(file.parent_path, file.file_name)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/move.svg'
|
src='/images/move.svg'
|
||||||
class='white drop-shadow-md'
|
class='white drop-shadow-md'
|
||||||
width={18}
|
width={18}
|
||||||
height={18}
|
height={18}
|
||||||
|
|
@ -200,7 +200,7 @@ export default function ListPhotos(
|
||||||
onClick={() => onClickDeleteFile(file.parent_path, file.file_name)}
|
onClick={() => onClickDeleteFile(file.parent_path, file.file_name)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/delete.svg'
|
src='/images/delete.svg'
|
||||||
class='red drop-shadow-md'
|
class='red drop-shadow-md'
|
||||||
width={20}
|
width={20}
|
||||||
height={20}
|
height={20}
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,38 @@
|
||||||
import { useSignal } from '@preact/signals';
|
import { useSignal } from '@preact/signals';
|
||||||
|
|
||||||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||||
import { ResponseBody as UploadResponseBody } from '/pages/api/files/upload.ts';
|
import { ResponseBody as UploadResponseBody } from '/routes/api/files/upload.tsx';
|
||||||
import { RequestBody as RenameRequestBody, ResponseBody as RenameResponseBody } from '/pages/api/files/rename.ts';
|
import { RequestBody as RenameRequestBody, ResponseBody as RenameResponseBody } from '/routes/api/files/rename.tsx';
|
||||||
import { RequestBody as MoveRequestBody, ResponseBody as MoveResponseBody } from '/pages/api/files/move.ts';
|
import { RequestBody as MoveRequestBody, ResponseBody as MoveResponseBody } from '/routes/api/files/move.tsx';
|
||||||
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/pages/api/files/delete.ts';
|
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/files/delete.tsx';
|
||||||
import {
|
import {
|
||||||
RequestBody as CreateDirectoryRequestBody,
|
RequestBody as CreateDirectoryRequestBody,
|
||||||
ResponseBody as CreateDirectoryResponseBody,
|
ResponseBody as CreateDirectoryResponseBody,
|
||||||
} from '/pages/api/files/create-directory.ts';
|
} from '/routes/api/files/create-directory.tsx';
|
||||||
import {
|
import {
|
||||||
RequestBody as RenameDirectoryRequestBody,
|
RequestBody as RenameDirectoryRequestBody,
|
||||||
ResponseBody as RenameDirectoryResponseBody,
|
ResponseBody as RenameDirectoryResponseBody,
|
||||||
} from '/pages/api/files/rename-directory.ts';
|
} from '/routes/api/files/rename-directory.tsx';
|
||||||
import {
|
import {
|
||||||
RequestBody as MoveDirectoryRequestBody,
|
RequestBody as MoveDirectoryRequestBody,
|
||||||
ResponseBody as MoveDirectoryResponseBody,
|
ResponseBody as MoveDirectoryResponseBody,
|
||||||
} from '/pages/api/files/move-directory.ts';
|
} from '/routes/api/files/move-directory.tsx';
|
||||||
import {
|
import {
|
||||||
RequestBody as DeleteDirectoryRequestBody,
|
RequestBody as DeleteDirectoryRequestBody,
|
||||||
ResponseBody as DeleteDirectoryResponseBody,
|
ResponseBody as DeleteDirectoryResponseBody,
|
||||||
} from '/pages/api/files/delete-directory.ts';
|
} from '/routes/api/files/delete-directory.tsx';
|
||||||
import {
|
import {
|
||||||
RequestBody as CreateShareRequestBody,
|
RequestBody as CreateShareRequestBody,
|
||||||
ResponseBody as CreateShareResponseBody,
|
ResponseBody as CreateShareResponseBody,
|
||||||
} from '/pages/api/files/create-share.ts';
|
} from '/routes/api/files/create-share.tsx';
|
||||||
import {
|
import {
|
||||||
RequestBody as UpdateShareRequestBody,
|
RequestBody as UpdateShareRequestBody,
|
||||||
ResponseBody as UpdateShareResponseBody,
|
ResponseBody as UpdateShareResponseBody,
|
||||||
} from '/pages/api/files/update-share.ts';
|
} from '/routes/api/files/update-share.tsx';
|
||||||
import {
|
import {
|
||||||
RequestBody as DeleteShareRequestBody,
|
RequestBody as DeleteShareRequestBody,
|
||||||
ResponseBody as DeleteShareResponseBody,
|
ResponseBody as DeleteShareResponseBody,
|
||||||
} from '/pages/api/files/delete-share.ts';
|
} from '/routes/api/files/delete-share.tsx';
|
||||||
import SearchFiles from './SearchFiles.tsx';
|
import SearchFiles from './SearchFiles.tsx';
|
||||||
import ListFiles from './ListFiles.tsx';
|
import ListFiles from './ListFiles.tsx';
|
||||||
import FilesBreadcrumb from './FilesBreadcrumb.tsx';
|
import FilesBreadcrumb from './FilesBreadcrumb.tsx';
|
||||||
|
|
@ -741,7 +741,7 @@ export default function MainFiles(
|
||||||
onClick={() => toggleBulkOptionsDropdown()}
|
onClick={() => toggleBulkOptionsDropdown()}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`/public/images/${areBulkOptionsOpen.value ? 'hide-options' : 'show-options'}.svg`}
|
src={`/images/${areBulkOptionsOpen.value ? 'hide-options' : 'show-options'}.svg`}
|
||||||
alt='Bulk actions'
|
alt='Bulk actions'
|
||||||
class={`white w-5 max-w-5`}
|
class={`white w-5 max-w-5`}
|
||||||
width={20}
|
width={20}
|
||||||
|
|
@ -751,7 +751,7 @@ export default function MainFiles(
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={`absolute left-0 z-10 mt-2 w-44 origin-top-left rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
|
class={`absolute left-0 z-10 mt-2 w-44 origin-top-left rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||||
!areBulkOptionsOpen.value ? 'hidden' : ''
|
!areBulkOptionsOpen.value ? 'hidden' : ''
|
||||||
}`}
|
}`}
|
||||||
role='menu'
|
role='menu'
|
||||||
|
|
@ -792,7 +792,7 @@ export default function MainFiles(
|
||||||
onClick={() => toggleNewOptionsDropdown()}
|
onClick={() => toggleNewOptionsDropdown()}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/add.svg'
|
src='/images/add.svg'
|
||||||
alt='Add new file or directory'
|
alt='Add new file or directory'
|
||||||
class={`white ${isAdding.value || isUploading.value ? 'animate-spin' : ''}`}
|
class={`white ${isAdding.value || isUploading.value ? 'animate-spin' : ''}`}
|
||||||
width={20}
|
width={20}
|
||||||
|
|
@ -802,7 +802,7 @@ export default function MainFiles(
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
|
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||||
!areNewOptionsOpen.value ? 'hidden' : ''
|
!areNewOptionsOpen.value ? 'hidden' : ''
|
||||||
}`}
|
}`}
|
||||||
role='menu'
|
role='menu'
|
||||||
|
|
@ -866,28 +866,28 @@ export default function MainFiles(
|
||||||
{isDeleting.value
|
{isDeleting.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
{isAdding.value
|
{isAdding.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Creating...
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Creating...
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
{isUploading.value
|
{isUploading.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Uploading...
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Uploading...
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
{isUpdating.value
|
{isUpdating.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Updating...
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Updating...
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useSignal } from '@preact/signals';
|
import { useSignal } from '@preact/signals';
|
||||||
import { useEffect } from 'preact/hooks';
|
import { useEffect } from 'preact/hooks';
|
||||||
|
|
||||||
import { RequestBody, ResponseBody } from '/pages/api/files/get-share.ts';
|
import { RequestBody, ResponseBody } from '/routes/api/files/get-share.tsx';
|
||||||
import { FileShare } from '/lib/types.ts';
|
import { FileShare } from '/lib/types.ts';
|
||||||
|
|
||||||
interface ManageShareModalProps {
|
interface ManageShareModalProps {
|
||||||
|
|
@ -62,7 +62,7 @@ export default function ManageShareModal(
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section
|
<section
|
||||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
|
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||||
>
|
>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useSignal } from '@preact/signals';
|
import { useSignal } from '@preact/signals';
|
||||||
import { useEffect } from 'preact/hooks';
|
import { useEffect } from 'preact/hooks';
|
||||||
|
|
||||||
import { RequestBody, ResponseBody } from '/pages/api/files/get-directories.ts';
|
import { RequestBody, ResponseBody } from '/routes/api/files/get-directories.tsx';
|
||||||
import { Directory } from '/lib/types.ts';
|
import { Directory } from '/lib/types.ts';
|
||||||
|
|
||||||
interface MoveDirectoryOrFileModalProps {
|
interface MoveDirectoryOrFileModalProps {
|
||||||
|
|
@ -78,7 +78,7 @@ export default function MoveDirectoryOrFileModal(
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section
|
<section
|
||||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
|
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||||
>
|
>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -123,7 +123,7 @@ export default function MoveDirectoryOrFileModal(
|
||||||
{isLoading.value
|
{isLoading.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Loading...
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Loading...
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export default function RenameDirectoryOrFileModal(
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section
|
<section
|
||||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
|
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||||
>
|
>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@ import { useSignal } from '@preact/signals';
|
||||||
import { useEffect } from 'preact/hooks';
|
import { useEffect } from 'preact/hooks';
|
||||||
|
|
||||||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||||
import { RequestBody, ResponseBody } from '/pages/api/files/search.ts';
|
import { RequestBody, ResponseBody } from '/routes/api/files/search.tsx';
|
||||||
|
interface SearchFilesProps {}
|
||||||
|
|
||||||
export default function SearchFiles() {
|
export default function SearchFiles({}: SearchFilesProps) {
|
||||||
const isSearching = useSignal<boolean>(false);
|
const isSearching = useSignal<boolean>(false);
|
||||||
const areResultsVisible = useSignal<boolean>(false);
|
const areResultsVisible = useSignal<boolean>(false);
|
||||||
const matchingDirectories = useSignal<Directory[]>([]);
|
const matchingDirectories = useSignal<Directory[]>([]);
|
||||||
|
|
@ -106,12 +107,12 @@ export default function SearchFiles() {
|
||||||
onFocus={() => onFocus()}
|
onFocus={() => onFocus()}
|
||||||
onBlur={() => onBlur()}
|
onBlur={() => onBlur()}
|
||||||
/>
|
/>
|
||||||
{isSearching.value ? <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} /> : null}
|
{isSearching.value ? <img src='/images/loading.svg' class='white mr-2' width={18} height={18} /> : null}
|
||||||
{areResultsVisible.value
|
{areResultsVisible.value
|
||||||
? (
|
? (
|
||||||
<section class='relative inline-block text-left ml-2 text-sm'>
|
<section class='relative inline-block text-left ml-2 text-sm'>
|
||||||
<section
|
<section
|
||||||
class={`absolute right-0 z-10 mt-2 w-80 origin-top-right rounded-md bg-slate-600 shadow-lg ring-1 ring-black/15 focus:outline-none overflow-y-scroll max-h-[80%] min-h-56`}
|
class={`absolute right-0 z-10 mt-2 w-80 origin-top-right rounded-md bg-slate-600 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none overflow-y-scroll max-h-[80%] min-h-56`}
|
||||||
role='menu'
|
role='menu'
|
||||||
aria-orientation='vertical'
|
aria-orientation='vertical'
|
||||||
aria-labelledby='view-button'
|
aria-labelledby='view-button'
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ export default function ShareVerifyForm(
|
||||||
{ error }: ShareVerifyFormProps,
|
{ error }: ShareVerifyFormProps,
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<section class='max-w-md w-full mb-12 mx-auto'>
|
<section class='max-w-md w-full mb-12'>
|
||||||
<section class='mb-6'>
|
<section class='mb-6'>
|
||||||
<h2 class='mt-6 text-center text-3xl font-extrabold text-white'>
|
<h2 class='mt-6 text-center text-3xl font-extrabold text-white'>
|
||||||
File Share Authentication
|
File Share Authentication
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export default function CreateNoteModal(
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section
|
<section
|
||||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
|
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||||
>
|
>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
import { useSignal } from '@preact/signals';
|
import { useSignal } from '@preact/signals';
|
||||||
|
|
||||||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||||
import { ResponseBody as UploadResponseBody } from '/pages/api/files/upload.ts';
|
import { ResponseBody as UploadResponseBody } from '/routes/api/files/upload.tsx';
|
||||||
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/pages/api/files/delete.ts';
|
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/files/delete.tsx';
|
||||||
import {
|
import {
|
||||||
RequestBody as CreateDirectoryRequestBody,
|
RequestBody as CreateDirectoryRequestBody,
|
||||||
ResponseBody as CreateDirectoryResponseBody,
|
ResponseBody as CreateDirectoryResponseBody,
|
||||||
} from '/pages/api/files/create-directory.ts';
|
} from '/routes/api/files/create-directory.tsx';
|
||||||
import {
|
import {
|
||||||
RequestBody as DeleteDirectoryRequestBody,
|
RequestBody as DeleteDirectoryRequestBody,
|
||||||
ResponseBody as DeleteDirectoryResponseBody,
|
ResponseBody as DeleteDirectoryResponseBody,
|
||||||
} from '/pages/api/files/delete-directory.ts';
|
} from '/routes/api/files/delete-directory.tsx';
|
||||||
import ListFiles from '/components/files/ListFiles.tsx';
|
import ListFiles from '/components/files/ListFiles.tsx';
|
||||||
import FilesBreadcrumb from '/components/files/FilesBreadcrumb.tsx';
|
import FilesBreadcrumb from '/components/files/FilesBreadcrumb.tsx';
|
||||||
import CreateDirectoryModal from '/components/files/CreateDirectoryModal.tsx';
|
import CreateDirectoryModal from '/components/files/CreateDirectoryModal.tsx';
|
||||||
|
|
@ -240,7 +240,7 @@ export default function MainNotes({ initialDirectories, initialFiles, initialPat
|
||||||
onClick={() => toggleNewOptionsDropdown()}
|
onClick={() => toggleNewOptionsDropdown()}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/add.svg'
|
src='/images/add.svg'
|
||||||
alt='Add new note or directory'
|
alt='Add new note or directory'
|
||||||
class={`white ${isAdding.value ? 'animate-spin' : ''}`}
|
class={`white ${isAdding.value ? 'animate-spin' : ''}`}
|
||||||
width={20}
|
width={20}
|
||||||
|
|
@ -250,7 +250,7 @@ export default function MainNotes({ initialDirectories, initialFiles, initialPat
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
|
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||||
!areNewOptionsOption.value ? 'hidden' : ''
|
!areNewOptionsOption.value ? 'hidden' : ''
|
||||||
}`}
|
}`}
|
||||||
role='menu'
|
role='menu'
|
||||||
|
|
@ -294,14 +294,14 @@ export default function MainNotes({ initialDirectories, initialFiles, initialPat
|
||||||
{isDeleting.value
|
{isDeleting.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
{isAdding.value
|
{isAdding.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Creating...
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Creating...
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { DirectoryFile } from '/lib/types.ts';
|
import { DirectoryFile } from '/lib/types.ts';
|
||||||
import { PHOTO_IMAGE_EXTENSIONS, PHOTO_VIDEO_EXTENSIONS } from '/public/ts/utils/photos.ts';
|
import { PHOTO_IMAGE_EXTENSIONS, PHOTO_VIDEO_EXTENSIONS } from '/lib/utils/photos.ts';
|
||||||
|
|
||||||
interface ListPhotosProps {
|
interface ListPhotosProps {
|
||||||
files: DirectoryFile[];
|
files: DirectoryFile[];
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { useSignal } from '@preact/signals';
|
import { useSignal } from '@preact/signals';
|
||||||
|
|
||||||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||||
import { ResponseBody as UploadResponseBody } from '/pages/api/files/upload.ts';
|
import { ResponseBody as UploadResponseBody } from '/routes/api/files/upload.tsx';
|
||||||
import {
|
import {
|
||||||
RequestBody as CreateDirectoryRequestBody,
|
RequestBody as CreateDirectoryRequestBody,
|
||||||
ResponseBody as CreateDirectoryResponseBody,
|
ResponseBody as CreateDirectoryResponseBody,
|
||||||
} from '/pages/api/files/create-directory.ts';
|
} from '/routes/api/files/create-directory.tsx';
|
||||||
import CreateDirectoryModal from '/components/files/CreateDirectoryModal.tsx';
|
import CreateDirectoryModal from '/components/files/CreateDirectoryModal.tsx';
|
||||||
import ListFiles from '/components/files/ListFiles.tsx';
|
import ListFiles from '/components/files/ListFiles.tsx';
|
||||||
import FilesBreadcrumb from '/components/files/FilesBreadcrumb.tsx';
|
import FilesBreadcrumb from '/components/files/FilesBreadcrumb.tsx';
|
||||||
|
|
@ -156,7 +156,7 @@ export default function MainPhotos({ initialDirectories, initialFiles, initialPa
|
||||||
onClick={() => toggleNewOptionsDropdown()}
|
onClick={() => toggleNewOptionsDropdown()}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/add.svg'
|
src='/images/add.svg'
|
||||||
alt='Add new file or directory'
|
alt='Add new file or directory'
|
||||||
class={`white ${isAdding.value || isUploading.value ? 'animate-spin' : ''}`}
|
class={`white ${isAdding.value || isUploading.value ? 'animate-spin' : ''}`}
|
||||||
width={20}
|
width={20}
|
||||||
|
|
@ -166,7 +166,7 @@ export default function MainPhotos({ initialDirectories, initialFiles, initialPa
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
|
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||||
!areNewOptionsOption.value ? 'hidden' : ''
|
!areNewOptionsOption.value ? 'hidden' : ''
|
||||||
}`}
|
}`}
|
||||||
role='menu'
|
role='menu'
|
||||||
|
|
@ -212,14 +212,14 @@ export default function MainPhotos({ initialDirectories, initialFiles, initialPa
|
||||||
{isAdding.value
|
{isAdding.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Creating...
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Creating...
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
{isUploading.value
|
{isUploading.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Uploading...
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Uploading...
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import Database, { sql } from '/lib/interfaces/database.ts';
|
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||||
import { NewsFeed } from '/lib/types.ts';
|
import { NewsFeed } from '/lib/types.ts';
|
||||||
import { concurrentPromises } from '/public/ts/utils/misc.ts';
|
import { concurrentPromises } from '/lib/utils/misc.ts';
|
||||||
import { FeedModel } from '/lib/models/news.ts';
|
import { FeedModel } from '/lib/models/news.ts';
|
||||||
|
|
||||||
const db = new Database();
|
const db = new Database();
|
||||||
|
|
|
||||||
99
deno.json
99
deno.json
|
|
@ -1,19 +1,13 @@
|
||||||
{
|
{
|
||||||
"lock": true,
|
"lock": true,
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"execute-with-permissions": "deno run --allow-env --allow-net --allow-sys=networkInterfaces,hostname,cpus,homedir --allow-read=.,/ --allow-write=data-files,/ --allow-run",
|
|
||||||
"check": "deno fmt --check && deno lint && deno check .",
|
"check": "deno fmt --check && deno lint && deno check .",
|
||||||
"build": "make build",
|
"cli": "echo \"import '\\$fresh/src/dev/cli.ts'\" | deno run --unstable -A -",
|
||||||
"preview": "deno task execute-with-permissions ./main.ts",
|
"manifest": "deno task cli manifest $(pwd)",
|
||||||
"test": "IS_TESTING=true deno test --allow-all --check",
|
"start": "deno run -A --watch=static/,routes/,lib/,components/,islands/ dev.ts",
|
||||||
"migrate-db": "deno task execute-with-permissions ./migrate-db.ts",
|
"build": "deno run -A dev.ts build",
|
||||||
"watch-app": "deno task execute-with-permissions --watch --watch-exclude=./public/css/tailwind.css,./public/components ./main.ts",
|
"preview": "deno run -A main.ts",
|
||||||
"watch-babel": "make watch-babel",
|
"test": "deno test -A --check"
|
||||||
"watch-tailwind": "make watch-tailwind",
|
|
||||||
"download-frontend-imports": "deno run --allow-read=./deno.json,./public/js --allow-write=./public/js --allow-net=esm.sh ./download-frontend-imports.ts",
|
|
||||||
"start": {
|
|
||||||
"dependencies": ["watch-tailwind", "watch-babel", "watch-app"]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"fmt": {
|
"fmt": {
|
||||||
"useTabs": false,
|
"useTabs": false,
|
||||||
|
|
@ -21,78 +15,51 @@
|
||||||
"indentWidth": 2,
|
"indentWidth": 2,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"proseWrap": "preserve",
|
"proseWrap": "preserve",
|
||||||
"exclude": ["README.md", "lib/models/dav.js", "public/css/tailwind.css"]
|
"exclude": ["README.md", "lib/models/dav.js"]
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"rules": {
|
"rules": {
|
||||||
"tags": ["recommended"],
|
"tags": ["fresh", "recommended"],
|
||||||
"exclude": ["no-explicit-any", "no-window"]
|
"exclude": ["no-explicit-any", "no-empty-interface", "no-window", "no-unused-vars"]
|
||||||
},
|
},
|
||||||
"exclude": ["lib/models/dav.js"]
|
"exclude": ["lib/models/dav.js"]
|
||||||
},
|
},
|
||||||
"exclude": ["./node_modules/*", "./public/js/*", "./public/components/*"],
|
"exclude": ["./_fresh/*", "./node_modules/*", "**/_fresh/*"],
|
||||||
"compilerOptions": {
|
"compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "preact" },
|
||||||
"jsx": "react-jsx",
|
|
||||||
"jsxImportSource": "preact",
|
|
||||||
"lib": [
|
|
||||||
"dom",
|
|
||||||
"dom.iterable",
|
|
||||||
"dom.asynciterable",
|
|
||||||
"deno.ns"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nodeModulesDir": "auto",
|
"nodeModulesDir": "auto",
|
||||||
"imports": {
|
"imports": {
|
||||||
"/": "./",
|
"/": "./",
|
||||||
"./": "./",
|
"./": "./",
|
||||||
|
|
||||||
"sass": "https://deno.land/x/denosass@1.0.6/mod.ts",
|
"fresh/": "https://deno.land/x/fresh@1.7.3/",
|
||||||
|
"$fresh/": "https://deno.land/x/fresh@1.7.3/",
|
||||||
"@libs/xml": "https://deno.land/x/xml@2.1.3/mod.ts",
|
"@libs/xml": "https://deno.land/x/xml@2.1.3/mod.ts",
|
||||||
|
|
||||||
"chart.js": "https://esm.sh/chart.js@4.5.1",
|
|
||||||
"chart.js/auto": "https://esm.sh/chart.js@4.5.1/auto",
|
|
||||||
"preact": "https://esm.sh/preact@10.28.4",
|
|
||||||
"preact/jsx-runtime": "https://esm.sh/preact@10.28.4/jsx-runtime",
|
|
||||||
"preact/hooks": "https://esm.sh/preact@10.28.4/hooks",
|
|
||||||
"preact-render-to-string": "https://esm.sh/preact-render-to-string@6.6.5?deps=preact@10.28.4",
|
|
||||||
"@preact/signals": "https://esm.sh/*@preact/signals@2.8.1?deps=preact@10.28.4",
|
|
||||||
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.13.0?deps=preact@10.28.4",
|
|
||||||
"@simplewebauthn/browser": "https://esm.sh/@simplewebauthn/browser@13.2.2",
|
|
||||||
"@std/path": "https://esm.sh/jsr/@std/path@1.1.4",
|
|
||||||
|
|
||||||
"deno/emit": "jsr:@deno/emit@0.46.0",
|
|
||||||
"postgres": "jsr:@db/postgres@0.19.5",
|
"postgres": "jsr:@db/postgres@0.19.5",
|
||||||
"@b-fuze/deno-dom": "jsr:@b-fuze/deno-dom@0.1.56",
|
"@b-fuze/deno-dom": "jsr:@b-fuze/deno-dom@0.1.56",
|
||||||
"@hexagon/croner": "jsr:@hexagon/croner@10.0.1",
|
"@fresh/plugin-vite": "jsr:@fresh/plugin-vite@1.0.4",
|
||||||
|
"@hexagon/croner": "jsr:@hexagon/croner@9.1.0",
|
||||||
"@mikaelporttila/rss": "jsr:@mikaelporttila/rss@1.1.3",
|
"@mikaelporttila/rss": "jsr:@mikaelporttila/rss@1.1.3",
|
||||||
"@libs/qrcode": "jsr:@libs/qrcode@3.0.1",
|
"@libs/qrcode": "jsr:@libs/qrcode@3.0.0",
|
||||||
"@simplewebauthn/server": "jsr:@simplewebauthn/server@13.2.2",
|
"@simplewebauthn/server": "jsr:@simplewebauthn/server@13.2.1",
|
||||||
"@std/assert": "jsr:@std/assert@1.0.18",
|
"@simplewebauthn/browser": "jsr:@simplewebauthn/browser@13.2.0",
|
||||||
"@std/dotenv": "jsr:@std/dotenv@0.225.6",
|
"@std/assert": "jsr:@std/assert@1.0.14",
|
||||||
|
"@std/dotenv": "jsr:@std/dotenv@0.225.5",
|
||||||
"@std/encoding": "jsr:@std/encoding@1.0.10",
|
"@std/encoding": "jsr:@std/encoding@1.0.10",
|
||||||
"@std/http": "jsr:@std/http@1.0.24",
|
"@std/http": "jsr:@std/http@1.0.20",
|
||||||
|
"@std/path": "jsr:@std/path@1.1.2",
|
||||||
|
|
||||||
|
"chart.js": "npm:chart.js@4.5.0",
|
||||||
"jimp": "npm:jimp@1.6.0",
|
"jimp": "npm:jimp@1.6.0",
|
||||||
"mrmime": "npm:mrmime@2.0.1",
|
"mrmime": "npm:mrmime@2.0.1",
|
||||||
"nodemailer": "npm:nodemailer@8.0.1",
|
"nodemailer": "npm:nodemailer@7.0.6",
|
||||||
"openid-client": "npm:openid-client@6.8.2",
|
"openid-client": "npm:openid-client@6.8.0",
|
||||||
"otpauth": "npm:otpauth@9.5.0",
|
"otpauth": "npm:otpauth@9.4.1",
|
||||||
"tailwindcss": "npm:tailwindcss@4.2.0",
|
"preact": "npm:preact@10.27.2",
|
||||||
"@tailwindcss/cli": "npm:@tailwindcss/cli@4.2.0",
|
"tailwindcss": "npm:tailwindcss@3.4.17",
|
||||||
"@babel/cli": "npm:@babel/cli@7.28.6",
|
"tailwindcss/": "npm:/tailwindcss@3.4.17/",
|
||||||
"@babel/core": "npm:@babel/core@7.29.0",
|
"tailwindcss/plugin": "npm:/tailwindcss@3.4.17/plugin.js",
|
||||||
"@babel/plugin-transform-react-jsx": "npm:@babel/plugin-transform-react-jsx@7.28.6",
|
"vite": "npm:vite@7.1.7",
|
||||||
"@babel/preset-react": "npm:@babel/preset-react@7.28.5",
|
"@preact/signals": "npm:@preact/signals@2.3.1"
|
||||||
"@babel/preset-typescript": "npm:@babel/preset-typescript@7.28.5"
|
}
|
||||||
},
|
|
||||||
"frontendImports": [
|
|
||||||
"chart.js",
|
|
||||||
"chart.js/auto",
|
|
||||||
"preact",
|
|
||||||
"preact/hooks",
|
|
||||||
"@preact/signals",
|
|
||||||
"@preact/signals-core",
|
|
||||||
"@simplewebauthn/browser",
|
|
||||||
"@std/path"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
8
dev.ts
Executable file
8
dev.ts
Executable file
|
|
@ -0,0 +1,8 @@
|
||||||
|
#!/usr/bin/env -S deno run -A --watch=static/,routes/,lib/,components/,islands/
|
||||||
|
|
||||||
|
import dev from 'fresh/dev.ts';
|
||||||
|
import config from './fresh.config.ts';
|
||||||
|
|
||||||
|
import '@std/dotenv/load';
|
||||||
|
|
||||||
|
await dev(import.meta.url, './main.ts', config);
|
||||||
|
|
@ -18,7 +18,7 @@ services:
|
||||||
|
|
||||||
# NOTE: If you don't want to use the CardDav/CalDav servers, you can comment/remove this service.
|
# NOTE: If you don't want to use the CardDav/CalDav servers, you can comment/remove this service.
|
||||||
radicale:
|
radicale:
|
||||||
image: tomsquest/docker-radicale:3.6.0.0
|
image: tomsquest/docker-radicale:3.5.8.2
|
||||||
ports:
|
ports:
|
||||||
- 5232:5232
|
- 5232:5232
|
||||||
init: true
|
init: true
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
services:
|
services:
|
||||||
website:
|
website:
|
||||||
image: ghcr.io/bewcloud/bewcloud:v4.1.2
|
image: ghcr.io/bewcloud/bewcloud:v3.2.0
|
||||||
# NOTE: uncomment below (and comment above) only if you pulled the repo and want to build the image locally
|
|
||||||
# build:
|
|
||||||
# context: .
|
|
||||||
# dockerfile: Dockerfile
|
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- 127.0.0.1:8000:8000
|
- 127.0.0.1:8000:8000
|
||||||
|
|
@ -36,7 +32,7 @@ services:
|
||||||
|
|
||||||
# NOTE: If you don't want to use the CardDav/CalDav servers, you can comment/remove this service.
|
# NOTE: If you don't want to use the CardDav/CalDav servers, you can comment/remove this service.
|
||||||
radicale:
|
radicale:
|
||||||
image: tomsquest/docker-radicale:3.6.0.0
|
image: tomsquest/docker-radicale:3.5.8.2
|
||||||
# NOTE: uncomment below only if you need to connect to the CardDav/CalDav servers from outside the container
|
# NOTE: uncomment below only if you need to connect to the CardDav/CalDav servers from outside the container
|
||||||
# ports:
|
# ports:
|
||||||
# - 127.0.0.1:5232:5232
|
# - 127.0.0.1:5232:5232
|
||||||
|
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
import denoConfig from '/deno.json' with { type: 'json' };
|
|
||||||
import { dirname } from '@std/path';
|
|
||||||
|
|
||||||
const downloadDirectory = `${Deno.cwd()}/public/js`;
|
|
||||||
|
|
||||||
function extractFileNameFromUrl(url: string) {
|
|
||||||
let fileName = url.replace('https://esm.sh/', '').split('?')[0].trim();
|
|
||||||
if (!fileName.endsWith('.mjs') && !fileName.includes('color@^0.3.0')) {
|
|
||||||
fileName = `${fileName}.mjs`;
|
|
||||||
}
|
|
||||||
return fileName;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureDirectoryExistsForFileName(fileName: string) {
|
|
||||||
const directory = dirname(fileName);
|
|
||||||
try {
|
|
||||||
const stat = await Deno.stat(directory);
|
|
||||||
if (!stat.isDirectory) {
|
|
||||||
throw new Error(`Directory ${directory} is not a directory`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as Error).toString().includes('NotFound')) {
|
|
||||||
await Deno.mkdir(directory, { recursive: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUrlsToDownload(): Map<string, { url: string; fileName: string }> {
|
|
||||||
const urlMap = new Map<string, { url: string; fileName: string }>();
|
|
||||||
const { imports, frontendImports } = denoConfig;
|
|
||||||
|
|
||||||
for (const importName of frontendImports) {
|
|
||||||
const url = new URL(imports[importName as keyof typeof imports]);
|
|
||||||
// url.searchParams.append('standalone', ''); // This should be easier, but it results in broken bundles (probably because of the missing peerDependencies)
|
|
||||||
|
|
||||||
const finalUrl = url.toString();
|
|
||||||
|
|
||||||
urlMap.set(importName, { url: finalUrl, fileName: extractFileNameFromUrl(finalUrl) });
|
|
||||||
}
|
|
||||||
|
|
||||||
return urlMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadUrls(urlMap: Map<string, { url: string; fileName: string }>) {
|
|
||||||
for (const { url, fileName } of urlMap.values()) {
|
|
||||||
console.log(`Fetching source file ${fileName} (${url})...`);
|
|
||||||
const response = await fetch(url);
|
|
||||||
const sourceContent = await response.text();
|
|
||||||
|
|
||||||
const sourceLines = sourceContent.split('\n');
|
|
||||||
for (const sourceLine of sourceLines) {
|
|
||||||
const dependencyUrlsMap = new Map<string, { url: string; fileName: string }>();
|
|
||||||
const importUrlInSourceContent = sourceLine.split('import "')[1]?.split('"')[0];
|
|
||||||
const exportUrlInSourceContent = sourceLine.split('export * from "')[1]?.split('"')[0];
|
|
||||||
const defaultExportUrlInSourceContent = sourceLine.split('export { default } from "')[1]?.split('"')[0];
|
|
||||||
|
|
||||||
if (importUrlInSourceContent) {
|
|
||||||
const importUrl = new URL(`https://esm.sh${importUrlInSourceContent}`).toString();
|
|
||||||
dependencyUrlsMap.set(importUrlInSourceContent, {
|
|
||||||
url: importUrl,
|
|
||||||
fileName: extractFileNameFromUrl(importUrl),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exportUrlInSourceContent) {
|
|
||||||
const exportUrl = new URL(`https://esm.sh${exportUrlInSourceContent}`).toString();
|
|
||||||
dependencyUrlsMap.set(exportUrlInSourceContent, {
|
|
||||||
url: exportUrl,
|
|
||||||
fileName: extractFileNameFromUrl(exportUrl),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (defaultExportUrlInSourceContent) {
|
|
||||||
const defaultExportUrl = new URL(`https://esm.sh${defaultExportUrlInSourceContent}`).toString();
|
|
||||||
dependencyUrlsMap.set(defaultExportUrlInSourceContent, {
|
|
||||||
url: defaultExportUrl,
|
|
||||||
fileName: extractFileNameFromUrl(defaultExportUrl),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await downloadUrls(dependencyUrlsMap);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Downloading bundle file ${fileName} (${url})...`);
|
|
||||||
|
|
||||||
const bundleResponse = await fetch(url);
|
|
||||||
let bundleFileContent = await bundleResponse.text();
|
|
||||||
|
|
||||||
// Update absolute import paths
|
|
||||||
bundleFileContent = bundleFileContent.replaceAll('import "/', 'import "/public/js/');
|
|
||||||
bundleFileContent = bundleFileContent.replaceAll('import"/', 'import"/public/js/');
|
|
||||||
// Update absolute export paths
|
|
||||||
bundleFileContent = bundleFileContent.replaceAll('export * from "/', 'export * from "/public/js/');
|
|
||||||
bundleFileContent = bundleFileContent.replaceAll('from"/', 'from"/public/js/'); // minified files
|
|
||||||
// Update absolute default export paths
|
|
||||||
bundleFileContent = bundleFileContent.replaceAll(
|
|
||||||
'export { default } from "/',
|
|
||||||
'export { default } from "/public/js/',
|
|
||||||
);
|
|
||||||
// Remove sourcemap URLs (they're not downloaded)
|
|
||||||
bundleFileContent = bundleFileContent.replaceAll('//# sourceMappingURL=', '//');
|
|
||||||
|
|
||||||
const fullFilePath = `${downloadDirectory}/${fileName}`;
|
|
||||||
|
|
||||||
console.log(`Writing bundle file ${fileName} (${fullFilePath})...`);
|
|
||||||
|
|
||||||
await ensureDirectoryExistsForFileName(fullFilePath);
|
|
||||||
|
|
||||||
await Deno.writeTextFile(fullFilePath, bundleFileContent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const urlMap = getUrlsToDownload();
|
|
||||||
|
|
||||||
// These are some extra imports that are harder to match from analyzing the imported files (just related to @std/path)
|
|
||||||
urlMap.set('@std/path/internals', {
|
|
||||||
url: 'https://esm.sh/@jsr/std__internal@^1.0.12/os',
|
|
||||||
fileName: '@jsr/std__internal@^1.0.12/os',
|
|
||||||
});
|
|
||||||
|
|
||||||
const osPaths = [
|
|
||||||
'basename',
|
|
||||||
'constants',
|
|
||||||
'dirname',
|
|
||||||
'extname',
|
|
||||||
'format',
|
|
||||||
'from-file-url',
|
|
||||||
'glob-to-regexp',
|
|
||||||
'is-absolute',
|
|
||||||
'join-globs',
|
|
||||||
'join',
|
|
||||||
'normalize-glob',
|
|
||||||
'normalize',
|
|
||||||
'parse',
|
|
||||||
'relative',
|
|
||||||
'resolve',
|
|
||||||
'to-file-url',
|
|
||||||
'to-namespaced-path',
|
|
||||||
'_util',
|
|
||||||
];
|
|
||||||
for (const path of osPaths) {
|
|
||||||
urlMap.set(`@std/path/posix/${path}`, {
|
|
||||||
url: `https://esm.sh/@jsr/std__path@1.1.4/denonext/posix/${path}.mjs`,
|
|
||||||
fileName: `@jsr/std__path@1.1.4/denonext/posix/${path}.mjs`,
|
|
||||||
});
|
|
||||||
urlMap.set(`@std/path/windows/${path}`, {
|
|
||||||
url: `https://esm.sh/@jsr/std__path@1.1.4/denonext/windows/${path}.mjs`,
|
|
||||||
fileName: `@jsr/std__path@1.1.4/denonext/windows/${path}.mjs`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const commonPaths = [
|
|
||||||
'common',
|
|
||||||
'basename',
|
|
||||||
'assert_path',
|
|
||||||
'strip_trailing_separators',
|
|
||||||
'dirname',
|
|
||||||
'constants',
|
|
||||||
'format',
|
|
||||||
'from_file_url',
|
|
||||||
'glob_to_reg_exp',
|
|
||||||
'normalize',
|
|
||||||
'normalize_string',
|
|
||||||
'relative',
|
|
||||||
'to_file_url',
|
|
||||||
];
|
|
||||||
for (const path of commonPaths) {
|
|
||||||
urlMap.set(`@std/path/common/${path}`, {
|
|
||||||
url: `https://esm.sh/@jsr/std__path@1.1.4/denonext/_common/${path}.mjs`,
|
|
||||||
fileName: `@jsr/std__path@1.1.4/denonext/_common/${path}.mjs`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await downloadUrls(urlMap);
|
|
||||||
|
|
||||||
console.log('Done');
|
|
||||||
Deno.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
14
fresh.config.ts
Normal file
14
fresh.config.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { defineConfig } from 'fresh/server.ts';
|
||||||
|
import tailwind from 'fresh/plugins/tailwind.ts';
|
||||||
|
|
||||||
|
import { startCrons } from '/crons/index.ts';
|
||||||
|
|
||||||
|
const isBuildMode = Deno.args.includes('build');
|
||||||
|
|
||||||
|
if (!isBuildMode) {
|
||||||
|
await startCrons();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tailwind()],
|
||||||
|
});
|
||||||
227
fresh.gen.ts
Normal file
227
fresh.gen.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
// DO NOT EDIT. This file is generated by Fresh.
|
||||||
|
// This file SHOULD be checked into source version control.
|
||||||
|
// This file is automatically updated during development when running `dev.ts`.
|
||||||
|
|
||||||
|
import * as $_well_known_caldav from './routes/.well-known/caldav.tsx';
|
||||||
|
import * as $_well_known_carddav from './routes/.well-known/carddav.tsx';
|
||||||
|
import * as $_404 from './routes/_404.tsx';
|
||||||
|
import * as $_app from './routes/_app.tsx';
|
||||||
|
import * as $_middleware from './routes/_middleware.tsx';
|
||||||
|
import * as $api_auth_multi_factor_disable from './routes/api/auth/multi-factor/disable.ts';
|
||||||
|
import * as $api_auth_multi_factor_email_setup from './routes/api/auth/multi-factor/email/setup.ts';
|
||||||
|
import * as $api_auth_multi_factor_enable from './routes/api/auth/multi-factor/enable.ts';
|
||||||
|
import * as $api_auth_multi_factor_passkey_begin from './routes/api/auth/multi-factor/passkey/begin.ts';
|
||||||
|
import * as $api_auth_multi_factor_passkey_setup_begin from './routes/api/auth/multi-factor/passkey/setup-begin.ts';
|
||||||
|
import * as $api_auth_multi_factor_passkey_setup_complete from './routes/api/auth/multi-factor/passkey/setup-complete.ts';
|
||||||
|
import * as $api_auth_multi_factor_passkey_verify from './routes/api/auth/multi-factor/passkey/verify.ts';
|
||||||
|
import * as $api_auth_multi_factor_totp_setup from './routes/api/auth/multi-factor/totp/setup.ts';
|
||||||
|
import * as $api_calendar_add_event from './routes/api/calendar/add-event.tsx';
|
||||||
|
import * as $api_calendar_add from './routes/api/calendar/add.tsx';
|
||||||
|
import * as $api_calendar_delete_event from './routes/api/calendar/delete-event.tsx';
|
||||||
|
import * as $api_calendar_delete from './routes/api/calendar/delete.tsx';
|
||||||
|
import * as $api_calendar_export_events from './routes/api/calendar/export-events.tsx';
|
||||||
|
import * as $api_calendar_import from './routes/api/calendar/import.tsx';
|
||||||
|
import * as $api_calendar_search_events from './routes/api/calendar/search-events.tsx';
|
||||||
|
import * as $api_calendar_update from './routes/api/calendar/update.tsx';
|
||||||
|
import * as $api_contacts_add_addressbook from './routes/api/contacts/add-addressbook.tsx';
|
||||||
|
import * as $api_contacts_add from './routes/api/contacts/add.tsx';
|
||||||
|
import * as $api_contacts_delete_addressbook from './routes/api/contacts/delete-addressbook.tsx';
|
||||||
|
import * as $api_contacts_delete from './routes/api/contacts/delete.tsx';
|
||||||
|
import * as $api_contacts_get_addressbooks from './routes/api/contacts/get-addressbooks.tsx';
|
||||||
|
import * as $api_contacts_get from './routes/api/contacts/get.tsx';
|
||||||
|
import * as $api_contacts_import from './routes/api/contacts/import.tsx';
|
||||||
|
import * as $api_dashboard_save_links from './routes/api/dashboard/save-links.tsx';
|
||||||
|
import * as $api_dashboard_save_notes from './routes/api/dashboard/save-notes.tsx';
|
||||||
|
import * as $api_expenses_add_budget from './routes/api/expenses/add-budget.tsx';
|
||||||
|
import * as $api_expenses_add_expense from './routes/api/expenses/add-expense.tsx';
|
||||||
|
import * as $api_expenses_auto_complete from './routes/api/expenses/auto-complete.tsx';
|
||||||
|
import * as $api_expenses_delete_budget from './routes/api/expenses/delete-budget.tsx';
|
||||||
|
import * as $api_expenses_delete_expense from './routes/api/expenses/delete-expense.tsx';
|
||||||
|
import * as $api_expenses_export_expenses from './routes/api/expenses/export-expenses.tsx';
|
||||||
|
import * as $api_expenses_import_expenses from './routes/api/expenses/import-expenses.tsx';
|
||||||
|
import * as $api_expenses_update_budget from './routes/api/expenses/update-budget.tsx';
|
||||||
|
import * as $api_expenses_update_expense from './routes/api/expenses/update-expense.tsx';
|
||||||
|
import * as $api_files_create_directory from './routes/api/files/create-directory.tsx';
|
||||||
|
import * as $api_files_create_share from './routes/api/files/create-share.tsx';
|
||||||
|
import * as $api_files_delete_directory from './routes/api/files/delete-directory.tsx';
|
||||||
|
import * as $api_files_delete_share from './routes/api/files/delete-share.tsx';
|
||||||
|
import * as $api_files_delete from './routes/api/files/delete.tsx';
|
||||||
|
import * as $api_files_download_directory from './routes/api/files/download-directory.tsx';
|
||||||
|
import * as $api_files_get_directories from './routes/api/files/get-directories.tsx';
|
||||||
|
import * as $api_files_get_share from './routes/api/files/get-share.tsx';
|
||||||
|
import * as $api_files_get from './routes/api/files/get.tsx';
|
||||||
|
import * as $api_files_move_directory from './routes/api/files/move-directory.tsx';
|
||||||
|
import * as $api_files_move from './routes/api/files/move.tsx';
|
||||||
|
import * as $api_files_rename_directory from './routes/api/files/rename-directory.tsx';
|
||||||
|
import * as $api_files_rename from './routes/api/files/rename.tsx';
|
||||||
|
import * as $api_files_search from './routes/api/files/search.tsx';
|
||||||
|
import * as $api_files_update_share from './routes/api/files/update-share.tsx';
|
||||||
|
import * as $api_files_upload from './routes/api/files/upload.tsx';
|
||||||
|
import * as $api_news_add_feed from './routes/api/news/add-feed.tsx';
|
||||||
|
import * as $api_news_delete_feed from './routes/api/news/delete-feed.tsx';
|
||||||
|
import * as $api_news_import_feeds from './routes/api/news/import-feeds.tsx';
|
||||||
|
import * as $api_news_mark_read from './routes/api/news/mark-read.tsx';
|
||||||
|
import * as $api_news_refresh_articles from './routes/api/news/refresh-articles.tsx';
|
||||||
|
import * as $api_notes_save from './routes/api/notes/save.tsx';
|
||||||
|
import * as $caldav from './routes/caldav.tsx';
|
||||||
|
import * as $calendar from './routes/calendar.tsx';
|
||||||
|
import * as $calendar_calendarEventId_ from './routes/calendar/[calendarEventId].tsx';
|
||||||
|
import * as $calendars from './routes/calendars.tsx';
|
||||||
|
import * as $carddav from './routes/carddav.tsx';
|
||||||
|
import * as $contacts from './routes/contacts.tsx';
|
||||||
|
import * as $contacts_contactId_ from './routes/contacts/[contactId].tsx';
|
||||||
|
import * as $dashboard from './routes/dashboard.tsx';
|
||||||
|
import * as $dav from './routes/dav.tsx';
|
||||||
|
import * as $expenses from './routes/expenses.tsx';
|
||||||
|
import * as $file_share_fileShareId_ from './routes/file-share/[fileShareId].tsx';
|
||||||
|
import * as $file_share_fileShareId_open_fileName_ from './routes/file-share/[fileShareId]/open/[fileName].tsx';
|
||||||
|
import * as $file_share_fileShareId_verify from './routes/file-share/[fileShareId]/verify.tsx';
|
||||||
|
import * as $files from './routes/files.tsx';
|
||||||
|
import * as $files_open_fileName_ from './routes/files/open/[fileName].tsx';
|
||||||
|
import * as $index from './routes/index.tsx';
|
||||||
|
import * as $login from './routes/login.tsx';
|
||||||
|
import * as $logout from './routes/logout.tsx';
|
||||||
|
import * as $mfa_verify from './routes/mfa-verify.tsx';
|
||||||
|
import * as $news from './routes/news.tsx';
|
||||||
|
import * as $news_feeds from './routes/news/feeds.tsx';
|
||||||
|
import * as $notes from './routes/notes.tsx';
|
||||||
|
import * as $notes_open_fileName_ from './routes/notes/open/[fileName].tsx';
|
||||||
|
import * as $oidc_callback from './routes/oidc/callback.tsx';
|
||||||
|
import * as $photos from './routes/photos.tsx';
|
||||||
|
import * as $photos_thumbnail_fileName_ from './routes/photos/thumbnail/[fileName].tsx';
|
||||||
|
import * as $settings from './routes/settings.tsx';
|
||||||
|
import * as $signup from './routes/signup.tsx';
|
||||||
|
import * as $Settings from './islands/Settings.tsx';
|
||||||
|
import * as $auth_MultiFactorAuthSettings from './islands/auth/MultiFactorAuthSettings.tsx';
|
||||||
|
import * as $auth_PasswordlessPasskeyLogin from './islands/auth/PasswordlessPasskeyLogin.tsx';
|
||||||
|
import * as $calendar_CalendarWrapper from './islands/calendar/CalendarWrapper.tsx';
|
||||||
|
import * as $calendar_Calendars from './islands/calendar/Calendars.tsx';
|
||||||
|
import * as $calendar_ViewCalendarEvent from './islands/calendar/ViewCalendarEvent.tsx';
|
||||||
|
import * as $contacts_Contacts from './islands/contacts/Contacts.tsx';
|
||||||
|
import * as $contacts_ViewContact from './islands/contacts/ViewContact.tsx';
|
||||||
|
import * as $dashboard_Links from './islands/dashboard/Links.tsx';
|
||||||
|
import * as $dashboard_Notes from './islands/dashboard/Notes.tsx';
|
||||||
|
import * as $expenses_ExpensesWrapper from './islands/expenses/ExpensesWrapper.tsx';
|
||||||
|
import * as $files_FilesWrapper from './islands/files/FilesWrapper.tsx';
|
||||||
|
import * as $news_Articles from './islands/news/Articles.tsx';
|
||||||
|
import * as $news_Feeds from './islands/news/Feeds.tsx';
|
||||||
|
import * as $notes_Note from './islands/notes/Note.tsx';
|
||||||
|
import * as $notes_NotesWrapper from './islands/notes/NotesWrapper.tsx';
|
||||||
|
import * as $photos_PhotosWrapper from './islands/photos/PhotosWrapper.tsx';
|
||||||
|
import type { Manifest } from '$fresh/server.ts';
|
||||||
|
|
||||||
|
const manifest = {
|
||||||
|
routes: {
|
||||||
|
'./routes/.well-known/caldav.tsx': $_well_known_caldav,
|
||||||
|
'./routes/.well-known/carddav.tsx': $_well_known_carddav,
|
||||||
|
'./routes/_404.tsx': $_404,
|
||||||
|
'./routes/_app.tsx': $_app,
|
||||||
|
'./routes/_middleware.tsx': $_middleware,
|
||||||
|
'./routes/api/auth/multi-factor/disable.ts': $api_auth_multi_factor_disable,
|
||||||
|
'./routes/api/auth/multi-factor/email/setup.ts': $api_auth_multi_factor_email_setup,
|
||||||
|
'./routes/api/auth/multi-factor/enable.ts': $api_auth_multi_factor_enable,
|
||||||
|
'./routes/api/auth/multi-factor/passkey/begin.ts': $api_auth_multi_factor_passkey_begin,
|
||||||
|
'./routes/api/auth/multi-factor/passkey/setup-begin.ts': $api_auth_multi_factor_passkey_setup_begin,
|
||||||
|
'./routes/api/auth/multi-factor/passkey/setup-complete.ts': $api_auth_multi_factor_passkey_setup_complete,
|
||||||
|
'./routes/api/auth/multi-factor/passkey/verify.ts': $api_auth_multi_factor_passkey_verify,
|
||||||
|
'./routes/api/auth/multi-factor/totp/setup.ts': $api_auth_multi_factor_totp_setup,
|
||||||
|
'./routes/api/calendar/add-event.tsx': $api_calendar_add_event,
|
||||||
|
'./routes/api/calendar/add.tsx': $api_calendar_add,
|
||||||
|
'./routes/api/calendar/delete-event.tsx': $api_calendar_delete_event,
|
||||||
|
'./routes/api/calendar/delete.tsx': $api_calendar_delete,
|
||||||
|
'./routes/api/calendar/export-events.tsx': $api_calendar_export_events,
|
||||||
|
'./routes/api/calendar/import.tsx': $api_calendar_import,
|
||||||
|
'./routes/api/calendar/search-events.tsx': $api_calendar_search_events,
|
||||||
|
'./routes/api/calendar/update.tsx': $api_calendar_update,
|
||||||
|
'./routes/api/contacts/add-addressbook.tsx': $api_contacts_add_addressbook,
|
||||||
|
'./routes/api/contacts/add.tsx': $api_contacts_add,
|
||||||
|
'./routes/api/contacts/delete-addressbook.tsx': $api_contacts_delete_addressbook,
|
||||||
|
'./routes/api/contacts/delete.tsx': $api_contacts_delete,
|
||||||
|
'./routes/api/contacts/get-addressbooks.tsx': $api_contacts_get_addressbooks,
|
||||||
|
'./routes/api/contacts/get.tsx': $api_contacts_get,
|
||||||
|
'./routes/api/contacts/import.tsx': $api_contacts_import,
|
||||||
|
'./routes/api/dashboard/save-links.tsx': $api_dashboard_save_links,
|
||||||
|
'./routes/api/dashboard/save-notes.tsx': $api_dashboard_save_notes,
|
||||||
|
'./routes/api/expenses/add-budget.tsx': $api_expenses_add_budget,
|
||||||
|
'./routes/api/expenses/add-expense.tsx': $api_expenses_add_expense,
|
||||||
|
'./routes/api/expenses/auto-complete.tsx': $api_expenses_auto_complete,
|
||||||
|
'./routes/api/expenses/delete-budget.tsx': $api_expenses_delete_budget,
|
||||||
|
'./routes/api/expenses/delete-expense.tsx': $api_expenses_delete_expense,
|
||||||
|
'./routes/api/expenses/export-expenses.tsx': $api_expenses_export_expenses,
|
||||||
|
'./routes/api/expenses/import-expenses.tsx': $api_expenses_import_expenses,
|
||||||
|
'./routes/api/expenses/update-budget.tsx': $api_expenses_update_budget,
|
||||||
|
'./routes/api/expenses/update-expense.tsx': $api_expenses_update_expense,
|
||||||
|
'./routes/api/files/create-directory.tsx': $api_files_create_directory,
|
||||||
|
'./routes/api/files/create-share.tsx': $api_files_create_share,
|
||||||
|
'./routes/api/files/delete-directory.tsx': $api_files_delete_directory,
|
||||||
|
'./routes/api/files/delete-share.tsx': $api_files_delete_share,
|
||||||
|
'./routes/api/files/delete.tsx': $api_files_delete,
|
||||||
|
'./routes/api/files/download-directory.tsx': $api_files_download_directory,
|
||||||
|
'./routes/api/files/get-directories.tsx': $api_files_get_directories,
|
||||||
|
'./routes/api/files/get-share.tsx': $api_files_get_share,
|
||||||
|
'./routes/api/files/get.tsx': $api_files_get,
|
||||||
|
'./routes/api/files/move-directory.tsx': $api_files_move_directory,
|
||||||
|
'./routes/api/files/move.tsx': $api_files_move,
|
||||||
|
'./routes/api/files/rename-directory.tsx': $api_files_rename_directory,
|
||||||
|
'./routes/api/files/rename.tsx': $api_files_rename,
|
||||||
|
'./routes/api/files/search.tsx': $api_files_search,
|
||||||
|
'./routes/api/files/update-share.tsx': $api_files_update_share,
|
||||||
|
'./routes/api/files/upload.tsx': $api_files_upload,
|
||||||
|
'./routes/api/news/add-feed.tsx': $api_news_add_feed,
|
||||||
|
'./routes/api/news/delete-feed.tsx': $api_news_delete_feed,
|
||||||
|
'./routes/api/news/import-feeds.tsx': $api_news_import_feeds,
|
||||||
|
'./routes/api/news/mark-read.tsx': $api_news_mark_read,
|
||||||
|
'./routes/api/news/refresh-articles.tsx': $api_news_refresh_articles,
|
||||||
|
'./routes/api/notes/save.tsx': $api_notes_save,
|
||||||
|
'./routes/caldav.tsx': $caldav,
|
||||||
|
'./routes/calendar.tsx': $calendar,
|
||||||
|
'./routes/calendar/[calendarEventId].tsx': $calendar_calendarEventId_,
|
||||||
|
'./routes/calendars.tsx': $calendars,
|
||||||
|
'./routes/carddav.tsx': $carddav,
|
||||||
|
'./routes/contacts.tsx': $contacts,
|
||||||
|
'./routes/contacts/[contactId].tsx': $contacts_contactId_,
|
||||||
|
'./routes/dashboard.tsx': $dashboard,
|
||||||
|
'./routes/dav.tsx': $dav,
|
||||||
|
'./routes/expenses.tsx': $expenses,
|
||||||
|
'./routes/file-share/[fileShareId].tsx': $file_share_fileShareId_,
|
||||||
|
'./routes/file-share/[fileShareId]/open/[fileName].tsx': $file_share_fileShareId_open_fileName_,
|
||||||
|
'./routes/file-share/[fileShareId]/verify.tsx': $file_share_fileShareId_verify,
|
||||||
|
'./routes/files.tsx': $files,
|
||||||
|
'./routes/files/open/[fileName].tsx': $files_open_fileName_,
|
||||||
|
'./routes/index.tsx': $index,
|
||||||
|
'./routes/login.tsx': $login,
|
||||||
|
'./routes/logout.tsx': $logout,
|
||||||
|
'./routes/mfa-verify.tsx': $mfa_verify,
|
||||||
|
'./routes/news.tsx': $news,
|
||||||
|
'./routes/news/feeds.tsx': $news_feeds,
|
||||||
|
'./routes/notes.tsx': $notes,
|
||||||
|
'./routes/notes/open/[fileName].tsx': $notes_open_fileName_,
|
||||||
|
'./routes/oidc/callback.tsx': $oidc_callback,
|
||||||
|
'./routes/photos.tsx': $photos,
|
||||||
|
'./routes/photos/thumbnail/[fileName].tsx': $photos_thumbnail_fileName_,
|
||||||
|
'./routes/settings.tsx': $settings,
|
||||||
|
'./routes/signup.tsx': $signup,
|
||||||
|
},
|
||||||
|
islands: {
|
||||||
|
'./islands/Settings.tsx': $Settings,
|
||||||
|
'./islands/auth/MultiFactorAuthSettings.tsx': $auth_MultiFactorAuthSettings,
|
||||||
|
'./islands/auth/PasswordlessPasskeyLogin.tsx': $auth_PasswordlessPasskeyLogin,
|
||||||
|
'./islands/calendar/CalendarWrapper.tsx': $calendar_CalendarWrapper,
|
||||||
|
'./islands/calendar/Calendars.tsx': $calendar_Calendars,
|
||||||
|
'./islands/calendar/ViewCalendarEvent.tsx': $calendar_ViewCalendarEvent,
|
||||||
|
'./islands/contacts/Contacts.tsx': $contacts_Contacts,
|
||||||
|
'./islands/contacts/ViewContact.tsx': $contacts_ViewContact,
|
||||||
|
'./islands/dashboard/Links.tsx': $dashboard_Links,
|
||||||
|
'./islands/dashboard/Notes.tsx': $dashboard_Notes,
|
||||||
|
'./islands/expenses/ExpensesWrapper.tsx': $expenses_ExpensesWrapper,
|
||||||
|
'./islands/files/FilesWrapper.tsx': $files_FilesWrapper,
|
||||||
|
'./islands/news/Articles.tsx': $news_Articles,
|
||||||
|
'./islands/news/Feeds.tsx': $news_Feeds,
|
||||||
|
'./islands/notes/Note.tsx': $notes_Note,
|
||||||
|
'./islands/notes/NotesWrapper.tsx': $notes_NotesWrapper,
|
||||||
|
'./islands/photos/PhotosWrapper.tsx': $photos_PhotosWrapper,
|
||||||
|
},
|
||||||
|
baseUrl: import.meta.url,
|
||||||
|
} satisfies Manifest;
|
||||||
|
|
||||||
|
export default manifest;
|
||||||
290
islands/Settings.tsx
Normal file
290
islands/Settings.tsx
Normal file
|
|
@ -0,0 +1,290 @@
|
||||||
|
import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx';
|
||||||
|
import { convertObjectToFormData } from '/lib/utils/misc.ts';
|
||||||
|
import { currencyMap, SupportedCurrencySymbol, User } from '/lib/types.ts';
|
||||||
|
import MultiFactorAuthSettings from '/islands/auth/MultiFactorAuthSettings.tsx';
|
||||||
|
import { getEnabledMultiFactorAuthMethodsFromUser } from '/lib/utils/multi-factor-auth.ts';
|
||||||
|
import { getTimeZones } from '/lib/utils/calendar.ts';
|
||||||
|
|
||||||
|
interface SettingsProps {
|
||||||
|
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'>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Action =
|
||||||
|
| 'change-email'
|
||||||
|
| 'verify-change-email'
|
||||||
|
| 'change-password'
|
||||||
|
| 'change-dav-password'
|
||||||
|
| 'delete-account'
|
||||||
|
| 'change-currency'
|
||||||
|
| 'change-timezone';
|
||||||
|
|
||||||
|
export const actionWords = new Map<Action, string>([
|
||||||
|
['change-email', 'change email'],
|
||||||
|
['verify-change-email', 'change email'],
|
||||||
|
['change-password', 'change password'],
|
||||||
|
['change-dav-password', 'change WebDav password'],
|
||||||
|
['delete-account', 'delete account'],
|
||||||
|
['change-currency', 'change currency'],
|
||||||
|
['change-timezone', 'change timezone'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
function formFields(action: Action, formData: FormData, currency?: SupportedCurrencySymbol, timezoneId?: string) {
|
||||||
|
const fields: FormField[] = [
|
||||||
|
{
|
||||||
|
name: 'action',
|
||||||
|
label: '',
|
||||||
|
type: 'hidden',
|
||||||
|
value: action,
|
||||||
|
overrideValue: action,
|
||||||
|
required: true,
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (action === 'change-email') {
|
||||||
|
fields.push({
|
||||||
|
name: 'email',
|
||||||
|
label: 'Email',
|
||||||
|
type: 'email',
|
||||||
|
placeholder: 'jane.doe@example.com',
|
||||||
|
value: getFormDataField(formData, 'email'),
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
} else if (action === 'verify-change-email') {
|
||||||
|
fields.push({
|
||||||
|
name: 'email',
|
||||||
|
label: 'Email',
|
||||||
|
type: 'email',
|
||||||
|
placeholder: 'jane.doe@example.com',
|
||||||
|
value: getFormDataField(formData, 'email'),
|
||||||
|
required: true,
|
||||||
|
}, {
|
||||||
|
name: 'verification-code',
|
||||||
|
label: 'Verification Code',
|
||||||
|
description: `The verification code to validate your new email.`,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: '000000',
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
} else if (action === 'change-password') {
|
||||||
|
fields.push({
|
||||||
|
name: 'current-password',
|
||||||
|
label: 'Current Password',
|
||||||
|
type: 'password',
|
||||||
|
placeholder: 'super-SECRET-passphrase',
|
||||||
|
required: true,
|
||||||
|
}, {
|
||||||
|
name: 'new-password',
|
||||||
|
label: 'New Password',
|
||||||
|
type: 'password',
|
||||||
|
placeholder: 'super-SECRET-passphrase',
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
} else if (action === 'change-dav-password') {
|
||||||
|
fields.push({
|
||||||
|
name: 'new-dav-password',
|
||||||
|
label: 'New WebDav Password',
|
||||||
|
type: 'password',
|
||||||
|
placeholder: 'super-SECRET-passphrase',
|
||||||
|
required: true,
|
||||||
|
description: 'Alternative password used for WebDav access and/or HTTP Basic Auth.',
|
||||||
|
});
|
||||||
|
} else if (action === 'delete-account') {
|
||||||
|
fields.push({
|
||||||
|
name: 'current-password',
|
||||||
|
label: 'Password',
|
||||||
|
type: 'password',
|
||||||
|
placeholder: 'super-SECRET-passphrase',
|
||||||
|
description: 'You need to input your password in order to delete your account.',
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
} else if (action === 'change-currency') {
|
||||||
|
fields.push({
|
||||||
|
name: 'currency',
|
||||||
|
label: 'Currency',
|
||||||
|
type: 'select',
|
||||||
|
options: Array.from(currencyMap.keys()).map((currencySymbol) => ({
|
||||||
|
value: currencySymbol,
|
||||||
|
label: `${currencySymbol} (${currencyMap.get(currencySymbol)})`,
|
||||||
|
})),
|
||||||
|
value: getFormDataField(formData, 'currency') || currency,
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
} else if (action === 'change-timezone') {
|
||||||
|
const timezones = getTimeZones();
|
||||||
|
|
||||||
|
fields.push({
|
||||||
|
name: 'timezone',
|
||||||
|
label: 'Timezone',
|
||||||
|
type: 'select',
|
||||||
|
options: timezones.map((timezone) => ({
|
||||||
|
value: timezone.id,
|
||||||
|
label: timezone.label,
|
||||||
|
})),
|
||||||
|
value: getFormDataField(formData, 'timezone') || timezoneId,
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Settings(
|
||||||
|
{
|
||||||
|
formData: formDataObject,
|
||||||
|
error,
|
||||||
|
notice,
|
||||||
|
currency,
|
||||||
|
timezoneId,
|
||||||
|
isExpensesAppEnabled,
|
||||||
|
isMultiFactorAuthEnabled,
|
||||||
|
isCalendarAppEnabled,
|
||||||
|
helpEmail,
|
||||||
|
user,
|
||||||
|
}: SettingsProps,
|
||||||
|
) {
|
||||||
|
const formData = convertObjectToFormData(formDataObject);
|
||||||
|
|
||||||
|
const multiFactorAuthMethods = getEnabledMultiFactorAuthMethodsFromUser(user);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section class='mx-auto max-w-7xl my-8'>
|
||||||
|
{error
|
||||||
|
? (
|
||||||
|
<section class='notification-error'>
|
||||||
|
<h3>{error.title}</h3>
|
||||||
|
<p>{error.message}</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{notice
|
||||||
|
? (
|
||||||
|
<section class='notification-success'>
|
||||||
|
<h3>{notice.title}</h3>
|
||||||
|
<p>{notice.message}</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
|
||||||
|
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Change your email</h2>
|
||||||
|
|
||||||
|
<form method='POST' class='mb-12'>
|
||||||
|
{formFields(
|
||||||
|
'change-email',
|
||||||
|
formData,
|
||||||
|
).map((field) => generateFieldHtml(field, formData))}
|
||||||
|
<section class='flex justify-end mt-8 mb-4'>
|
||||||
|
<button class='button-secondary' type='submit'>Change email</button>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Change your password</h2>
|
||||||
|
|
||||||
|
<form method='POST' class='mb-12'>
|
||||||
|
{formFields('change-password', formData).map((field) => generateFieldHtml(field, formData))}
|
||||||
|
<section class='flex justify-end mt-8 mb-4'>
|
||||||
|
<button class='button-secondary' type='submit'>Change password</button>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Change your WebDav password</h2>
|
||||||
|
|
||||||
|
<form method='POST' class='mb-12'>
|
||||||
|
{formFields('change-dav-password', formData).map((field) => generateFieldHtml(field, formData))}
|
||||||
|
<section class='flex justify-end mt-8 mb-4'>
|
||||||
|
<button class='button-secondary' type='submit'>Change WebDav password</button>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{isExpensesAppEnabled
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Change your currency</h2>
|
||||||
|
<p class='text-left mt-2 mb-6 px-4 max-w-screen-md mx-auto lg:min-w-96'>
|
||||||
|
This is only used in the expenses app, visually. It changes nothing about the stored data or values.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method='POST' class='mb-12'>
|
||||||
|
{formFields('change-currency', formData, currency, timezoneId).map((field) =>
|
||||||
|
generateFieldHtml(field, formData)
|
||||||
|
)}
|
||||||
|
<section class='flex justify-end mt-8 mb-4'>
|
||||||
|
<button class='button-secondary' type='submit'>Change currency</button>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
|
||||||
|
{isCalendarAppEnabled
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Change your timezone</h2>
|
||||||
|
<p class='text-left mt-2 mb-6 px-4 max-w-screen-md mx-auto lg:min-w-96'>
|
||||||
|
This is only used in the calendar app.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method='POST' class='mb-12'>
|
||||||
|
{formFields('change-timezone', formData, currency, timezoneId).map((field) =>
|
||||||
|
generateFieldHtml(field, formData)
|
||||||
|
)}
|
||||||
|
<section class='flex justify-end mt-8 mb-4'>
|
||||||
|
<button class='button-secondary' type='submit'>Change timezone</button>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
|
||||||
|
{isMultiFactorAuthEnabled
|
||||||
|
? (
|
||||||
|
<MultiFactorAuthSettings
|
||||||
|
methods={multiFactorAuthMethods.map((method) => ({
|
||||||
|
type: method.type,
|
||||||
|
id: method.id,
|
||||||
|
name: method.name,
|
||||||
|
enabled: method.enabled,
|
||||||
|
backupCodesCount: method.metadata.totp?.hashed_backup_codes?.length,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
|
||||||
|
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Delete your account</h2>
|
||||||
|
<p class='text-left mt-2 mb-6 px-4 max-w-screen-md mx-auto lg:min-w-96'>
|
||||||
|
Deleting your account is instant and deletes all your data. {helpEmail !== ''
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
If you need help, please <a href={`mailto:${helpEmail}`}>reach out</a>.
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method='POST' class='mb-12'>
|
||||||
|
{formFields('delete-account', formData).map((field) => generateFieldHtml(field, formData))}
|
||||||
|
<section class='flex justify-end mt-8 mb-4'>
|
||||||
|
<button class='button-danger' type='submit'>Delete account</button>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,21 +2,30 @@ import { useSignal } from '@preact/signals';
|
||||||
import { startRegistration } from '@simplewebauthn/browser';
|
import { startRegistration } from '@simplewebauthn/browser';
|
||||||
|
|
||||||
import { MultiFactorAuthMethodType } from '/lib/types.ts';
|
import { MultiFactorAuthMethodType } from '/lib/types.ts';
|
||||||
import { ResponseBody as PasskeySetupBeginResponseBody } from '/pages/api/auth/multi-factor/passkey/setup-begin.ts';
|
import {
|
||||||
|
RequestBody as PasskeySetupBeginRequestBody,
|
||||||
|
ResponseBody as PasskeySetupBeginResponseBody,
|
||||||
|
} from '/routes/api/auth/multi-factor/passkey/setup-begin.ts';
|
||||||
import {
|
import {
|
||||||
RequestBody as PasskeySetupCompleteRequestBody,
|
RequestBody as PasskeySetupCompleteRequestBody,
|
||||||
ResponseBody as PasskeySetupCompleteResponseBody,
|
ResponseBody as PasskeySetupCompleteResponseBody,
|
||||||
} from '/pages/api/auth/multi-factor/passkey/setup-complete.ts';
|
} from '/routes/api/auth/multi-factor/passkey/setup-complete.ts';
|
||||||
import { ResponseBody as TOTPSetupResponseBody } from '/pages/api/auth/multi-factor/totp/setup.ts';
|
import {
|
||||||
import { ResponseBody as EmailSetupResponseBody } from '/pages/api/auth/multi-factor/email/setup.ts';
|
RequestBody as TOTPSetupRequestBody,
|
||||||
|
ResponseBody as TOTPSetupResponseBody,
|
||||||
|
} from '/routes/api/auth/multi-factor/totp/setup.ts';
|
||||||
|
import {
|
||||||
|
RequestBody as EmailSetupRequestBody,
|
||||||
|
ResponseBody as EmailSetupResponseBody,
|
||||||
|
} from '/routes/api/auth/multi-factor/email/setup.ts';
|
||||||
import {
|
import {
|
||||||
RequestBody as MultiFactorAuthEnableRequestBody,
|
RequestBody as MultiFactorAuthEnableRequestBody,
|
||||||
ResponseBody as MultiFactorAuthEnableResponseBody,
|
ResponseBody as MultiFactorAuthEnableResponseBody,
|
||||||
} from '/pages/api/auth/multi-factor/enable.ts';
|
} from '/routes/api/auth/multi-factor/enable.ts';
|
||||||
import {
|
import {
|
||||||
RequestBody as MultiFactorAuthDisableRequestBody,
|
RequestBody as MultiFactorAuthDisableRequestBody,
|
||||||
ResponseBody as MultiFactorAuthDisableResponseBody,
|
ResponseBody as MultiFactorAuthDisableResponseBody,
|
||||||
} from '/pages/api/auth/multi-factor/disable.ts';
|
} from '/routes/api/auth/multi-factor/disable.ts';
|
||||||
|
|
||||||
interface MultiFactorAuthMethod {
|
interface MultiFactorAuthMethod {
|
||||||
type: MultiFactorAuthMethodType;
|
type: MultiFactorAuthMethodType;
|
||||||
|
|
@ -75,9 +84,11 @@ export default function MultiFactorAuthSettings({ methods }: MultiFactorAuthSett
|
||||||
const hasMultiFactorAuthEnabled = enabledMethods.length > 0;
|
const hasMultiFactorAuthEnabled = enabledMethods.length > 0;
|
||||||
|
|
||||||
const setupPasskey = async () => {
|
const setupPasskey = async () => {
|
||||||
|
const beginRequestBody: PasskeySetupBeginRequestBody = {};
|
||||||
|
|
||||||
const beginResponse = await fetch('/api/auth/multi-factor/passkey/setup-begin', {
|
const beginResponse = await fetch('/api/auth/multi-factor/passkey/setup-begin', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify(beginRequestBody),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!beginResponse.ok) {
|
if (!beginResponse.ok) {
|
||||||
|
|
@ -124,9 +135,11 @@ export default function MultiFactorAuthSettings({ methods }: MultiFactorAuthSett
|
||||||
};
|
};
|
||||||
|
|
||||||
const setupTOTP = async () => {
|
const setupTOTP = async () => {
|
||||||
|
const requestBody: TOTPSetupRequestBody = {};
|
||||||
|
|
||||||
const response = await fetch('/api/auth/multi-factor/totp/setup', {
|
const response = await fetch('/api/auth/multi-factor/totp/setup', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify(requestBody),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -151,9 +164,11 @@ export default function MultiFactorAuthSettings({ methods }: MultiFactorAuthSett
|
||||||
};
|
};
|
||||||
|
|
||||||
const setupEmail = async () => {
|
const setupEmail = async () => {
|
||||||
|
const requestBody: EmailSetupRequestBody = {};
|
||||||
|
|
||||||
const response = await fetch('/api/auth/multi-factor/email/setup', {
|
const response = await fetch('/api/auth/multi-factor/email/setup', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify(requestBody),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -306,11 +321,11 @@ export default function MultiFactorAuthSettings({ methods }: MultiFactorAuthSett
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section class='mb-16'>
|
<section class='mb-16'>
|
||||||
<h2 class='text-2xl mb-4 text-left px-4 max-w-3xl mx-auto lg:min-w-96'>
|
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>
|
||||||
Multi-Factor Authentication (MFA)
|
Multi-Factor Authentication (MFA)
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<section class='px-4 max-w-3xl mx-auto lg:min-w-96'>
|
<section class='px-4 max-w-screen-md mx-auto lg:min-w-96'>
|
||||||
{error.value
|
{error.value
|
||||||
? (
|
? (
|
||||||
<section class='notification-error mb-4'>
|
<section class='notification-error mb-4'>
|
||||||
116
islands/auth/PasswordlessPasskeyLogin.tsx
Normal file
116
islands/auth/PasswordlessPasskeyLogin.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { useSignal } from '@preact/signals';
|
||||||
|
import { startAuthentication } from '@simplewebauthn/browser';
|
||||||
|
|
||||||
|
import {
|
||||||
|
RequestBody as PasskeyBeginRequestBody,
|
||||||
|
ResponseBody as PasskeyBeginResponseBody,
|
||||||
|
} from '/routes/api/auth/multi-factor/passkey/begin.ts';
|
||||||
|
|
||||||
|
import {
|
||||||
|
RequestBody as PasskeyVerifyRequestBody,
|
||||||
|
ResponseBody as PasskeyVerifyResponseBody,
|
||||||
|
} from '/routes/api/auth/multi-factor/passkey/verify.ts';
|
||||||
|
|
||||||
|
interface PasswordlessPasskeyLoginProps {
|
||||||
|
email?: string;
|
||||||
|
redirectUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PasswordlessPasskeyLogin({ email: providedEmail, redirectUrl }: PasswordlessPasskeyLoginProps) {
|
||||||
|
const isLoading = useSignal(false);
|
||||||
|
const email = useSignal<string | null>(providedEmail || null);
|
||||||
|
const error = useSignal<string | null>(null);
|
||||||
|
|
||||||
|
const handlePasswordlessLogin = async () => {
|
||||||
|
if (isLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email.value) {
|
||||||
|
const promptEmail = prompt('Please enter your email');
|
||||||
|
if (!promptEmail) {
|
||||||
|
throw new Error('Email is required to login with Passkey');
|
||||||
|
}
|
||||||
|
email.value = promptEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const beginRequestBody: PasskeyBeginRequestBody = {
|
||||||
|
email: email.value,
|
||||||
|
};
|
||||||
|
|
||||||
|
const beginResponse = await fetch('/api/auth/multi-factor/passkey/begin', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(beginRequestBody),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!beginResponse.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to begin passwordless login! ${beginResponse.statusText} ${await beginResponse.text()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const beginData = await beginResponse.json() as PasskeyBeginResponseBody;
|
||||||
|
|
||||||
|
if (!beginData.success) {
|
||||||
|
throw new Error(beginData.error || 'Failed to begin passwordless login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const authenticationResponse = await startAuthentication({
|
||||||
|
optionsJSON: beginData.options!,
|
||||||
|
});
|
||||||
|
|
||||||
|
const verifyRequestBody: PasskeyVerifyRequestBody = {
|
||||||
|
email: email.value,
|
||||||
|
challenge: beginData.sessionData!.challenge,
|
||||||
|
authenticationResponse,
|
||||||
|
redirectUrl: redirectUrl || '/',
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyResponse = await fetch('/api/auth/multi-factor/passkey/verify', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(verifyRequestBody),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (verifyResponse.ok) {
|
||||||
|
window.location.href = redirectUrl || '/';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifyData = await verifyResponse.json() as PasskeyVerifyResponseBody;
|
||||||
|
throw new Error(
|
||||||
|
verifyData.error || `Authentication failed! ${verifyResponse.statusText} ${await verifyResponse.text()}`,
|
||||||
|
);
|
||||||
|
} catch (handleError) {
|
||||||
|
console.error('Passwordless passkey login failed:', handleError);
|
||||||
|
error.value = (handleError as Error).message;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section class='space-y-4'>
|
||||||
|
<section class='flex justify-center mt-2 mb-4'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={handlePasswordlessLogin}
|
||||||
|
class='button-secondary'
|
||||||
|
>
|
||||||
|
{isLoading.value ? 'Authenticating...' : 'Login with Passkey'}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error.value
|
||||||
|
? (
|
||||||
|
<section class='notification-error'>
|
||||||
|
<p>{error.value}</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
islands/calendar/CalendarWrapper.tsx
Normal file
30
islands/calendar/CalendarWrapper.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
||||||
|
import MainCalendar from '/components/calendar/MainCalendar.tsx';
|
||||||
|
|
||||||
|
interface CalendarWrapperProps {
|
||||||
|
initialCalendars: Calendar[];
|
||||||
|
initialCalendarEvents: CalendarEvent[];
|
||||||
|
view: 'day' | 'week' | 'month';
|
||||||
|
startDate: string;
|
||||||
|
baseUrl: string;
|
||||||
|
timezoneId: string;
|
||||||
|
timezoneUtcOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This wrapper is necessary because islands need to be the first frontend component, but they don't support functions as props, so the more complex logic needs to live in the component itself
|
||||||
|
export default function CalendarWrapper(
|
||||||
|
{ initialCalendars, initialCalendarEvents, view, startDate, baseUrl, timezoneId, timezoneUtcOffset }:
|
||||||
|
CalendarWrapperProps,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<MainCalendar
|
||||||
|
initialCalendars={initialCalendars}
|
||||||
|
initialCalendarEvents={initialCalendarEvents}
|
||||||
|
view={view}
|
||||||
|
startDate={startDate}
|
||||||
|
baseUrl={baseUrl}
|
||||||
|
timezoneId={timezoneId}
|
||||||
|
timezoneUtcOffset={timezoneUtcOffset}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { useSignal } from '@preact/signals';
|
import { useSignal } from '@preact/signals';
|
||||||
|
|
||||||
import { Calendar } from '/lib/models/calendar.ts';
|
import { Calendar } from '/lib/models/calendar.ts';
|
||||||
import { CALENDAR_COLOR_OPTIONS, getColorAsHex } from '/public/ts/utils/calendar.ts';
|
import { CALENDAR_COLOR_OPTIONS, getColorAsHex } from '/lib/utils/calendar.ts';
|
||||||
import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/pages/api/calendar/add.ts';
|
import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/calendar/add.tsx';
|
||||||
import { RequestBody as UpdateRequestBody, ResponseBody as UpdateResponseBody } from '/pages/api/calendar/update.ts';
|
import { RequestBody as UpdateRequestBody, ResponseBody as UpdateResponseBody } from '/routes/api/calendar/update.tsx';
|
||||||
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/pages/api/calendar/delete.ts';
|
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/calendar/delete.tsx';
|
||||||
|
|
||||||
interface CalendarsProps {
|
interface CalendarsProps {
|
||||||
initialCalendars: Calendar[];
|
initialCalendars: Calendar[];
|
||||||
|
|
@ -150,7 +150,7 @@ export default function Calendars({ initialCalendars }: CalendarsProps) {
|
||||||
onClick={() => onClickAddCalendar()}
|
onClick={() => onClickAddCalendar()}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/add.svg'
|
src='/images/add.svg'
|
||||||
alt='Add new calendar'
|
alt='Add new calendar'
|
||||||
class={`white ${isAdding.value ? 'animate-spin' : ''}`}
|
class={`white ${isAdding.value ? 'animate-spin' : ''}`}
|
||||||
width={20}
|
width={20}
|
||||||
|
|
@ -194,7 +194,7 @@ export default function Calendars({ initialCalendars }: CalendarsProps) {
|
||||||
onClick={() => onClickDeleteCalendar(calendar.uid!)}
|
onClick={() => onClickDeleteCalendar(calendar.uid!)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/delete.svg'
|
src='/images/delete.svg'
|
||||||
class='red drop-shadow-md'
|
class='red drop-shadow-md'
|
||||||
width={24}
|
width={24}
|
||||||
height={24}
|
height={24}
|
||||||
|
|
@ -225,14 +225,14 @@ export default function Calendars({ initialCalendars }: CalendarsProps) {
|
||||||
{isDeleting.value
|
{isDeleting.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
{isSaving.value
|
{isSaving.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
|
|
@ -241,7 +241,9 @@ export default function Calendars({ initialCalendars }: CalendarsProps) {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
class={`fixed ${openCalendar.value ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
|
class={`fixed ${
|
||||||
|
openCalendar.value ? 'block' : 'hidden'
|
||||||
|
} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||||
>
|
>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -269,7 +271,7 @@ export default function Calendars({ initialCalendars }: CalendarsProps) {
|
||||||
<label class='text-slate-300 block pb-1' for='calendar_color'>Color</label>
|
<label class='text-slate-300 block pb-1' for='calendar_color'>Color</label>
|
||||||
<section class='flex items-center justify-between'>
|
<section class='flex items-center justify-between'>
|
||||||
<select
|
<select
|
||||||
class='input-field mr-2 w-5/6!'
|
class='input-field mr-2 !w-5/6'
|
||||||
name='calendar_color'
|
name='calendar_color'
|
||||||
id='calendar_color'
|
id='calendar_color'
|
||||||
value={openCalendar.value?.calendarColor || ''}
|
value={openCalendar.value?.calendarColor || ''}
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { useSignal } from '@preact/signals';
|
import { useSignal } from '@preact/signals';
|
||||||
|
|
||||||
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
||||||
import { capitalizeWord, convertObjectToFormData } from '/public/ts/utils/misc.ts';
|
import { capitalizeWord, convertObjectToFormData } from '/lib/utils/misc.ts';
|
||||||
import { FormField, generateFieldHtml } from '/public/ts/utils/form.ts';
|
import { FormField, generateFieldHtml } from '/lib/form-utils.tsx';
|
||||||
import {
|
import {
|
||||||
RequestBody as DeleteRequestBody,
|
RequestBody as DeleteRequestBody,
|
||||||
ResponseBody as DeleteResponseBody,
|
ResponseBody as DeleteResponseBody,
|
||||||
} from '/pages/api/calendar/delete-event.ts';
|
} from '/routes/api/calendar/delete-event.tsx';
|
||||||
|
|
||||||
interface ViewCalendarEventProps {
|
interface ViewCalendarEventProps {
|
||||||
initialCalendarEvent: CalendarEvent;
|
initialCalendarEvent: CalendarEvent;
|
||||||
|
|
@ -183,7 +183,7 @@ export default function ViewCalendarEvent(
|
||||||
onClick={() => onClickDeleteEvent()}
|
onClick={() => onClickDeleteEvent()}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/delete.svg'
|
src='/images/delete.svg'
|
||||||
alt='Delete event'
|
alt='Delete event'
|
||||||
class={`white ${isDeleting.value ? 'animate-spin' : ''}`}
|
class={`white ${isDeleting.value ? 'animate-spin' : ''}`}
|
||||||
width={20}
|
width={20}
|
||||||
|
|
@ -212,13 +212,7 @@ export default function ViewCalendarEvent(
|
||||||
: null}
|
: null}
|
||||||
|
|
||||||
<form method='POST' class='mb-12'>
|
<form method='POST' class='mb-12'>
|
||||||
<div
|
{formFields(calendarEvent.peek(), calendars, 'ui').map((field) => generateFieldHtml(field, formData))}
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: formFields(calendarEvent.peek(), calendars, 'ui').map((field) =>
|
|
||||||
generateFieldHtml(field, formData)
|
|
||||||
).join(''),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section class='flex justify-end items-center mt-8 mb-4'>
|
<section class='flex justify-end items-center mt-8 mb-4'>
|
||||||
{calendarEvent.peek().isRecurring
|
{calendarEvent.peek().isRecurring
|
||||||
|
|
@ -238,18 +232,12 @@ export default function ViewCalendarEvent(
|
||||||
<summary class='text-slate-100 flex items-center font-bold cursor-pointer text-center justify-center mx-auto hover:text-sky-400'>
|
<summary class='text-slate-100 flex items-center font-bold cursor-pointer text-center justify-center mx-auto hover:text-sky-400'>
|
||||||
Edit Raw ICS{' '}
|
Edit Raw ICS{' '}
|
||||||
<span class='ml-2 text-slate-400 group-open:rotate-90 transition-transform duration-200'>
|
<span class='ml-2 text-slate-400 group-open:rotate-90 transition-transform duration-200'>
|
||||||
<img src='/public/images/right.svg' alt='Expand' width={16} height={16} class='white' />
|
<img src='/images/right.svg' alt='Expand' width={16} height={16} class='white' />
|
||||||
</span>
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<form method='POST' class='mb-12'>
|
<form method='POST' class='mb-12'>
|
||||||
<div
|
{formFields(calendarEvent.peek(), calendars, 'raw').map((field) => generateFieldHtml(field, formData))}
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: formFields(calendarEvent.peek(), calendars, 'raw').map((field) =>
|
|
||||||
generateFieldHtml(field, formData)
|
|
||||||
).join(''),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section class='flex justify-end mt-8 mb-4'>
|
<section class='flex justify-end mt-8 mb-4'>
|
||||||
<button class='button' type='submit'>Update ICS</button>
|
<button class='button' type='submit'>Update ICS</button>
|
||||||
|
|
@ -263,7 +251,7 @@ export default function ViewCalendarEvent(
|
||||||
{isDeleting.value
|
{isDeleting.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
import { useSignal } from '@preact/signals';
|
import { useSignal } from '@preact/signals';
|
||||||
|
|
||||||
import { AddressBook, Contact } from '/lib/models/contacts.ts';
|
import { AddressBook, Contact } from '/lib/models/contacts.ts';
|
||||||
import { RequestBody as GetRequestBody, ResponseBody as GetResponseBody } from '/pages/api/contacts/get.ts';
|
import { RequestBody as GetRequestBody, ResponseBody as GetResponseBody } from '/routes/api/contacts/get.tsx';
|
||||||
import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/pages/api/contacts/add.ts';
|
import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/contacts/add.tsx';
|
||||||
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/pages/api/contacts/delete.ts';
|
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/contacts/delete.tsx';
|
||||||
import { RequestBody as ImportRequestBody, ResponseBody as ImportResponseBody } from '/pages/api/contacts/import.ts';
|
import { RequestBody as ImportRequestBody, ResponseBody as ImportResponseBody } from '/routes/api/contacts/import.tsx';
|
||||||
import {
|
import {
|
||||||
RequestBody as AddAddressBookRequestBody,
|
RequestBody as AddAddressBookRequestBody,
|
||||||
ResponseBody as AddAddressBookResponseBody,
|
ResponseBody as AddAddressBookResponseBody,
|
||||||
} from '/pages/api/contacts/add-addressbook.ts';
|
} from '/routes/api/contacts/add-addressbook.tsx';
|
||||||
import {
|
import {
|
||||||
RequestBody as DeleteAddressBookRequestBody,
|
RequestBody as DeleteAddressBookRequestBody,
|
||||||
ResponseBody as DeleteAddressBookResponseBody,
|
ResponseBody as DeleteAddressBookResponseBody,
|
||||||
} from '/pages/api/contacts/delete-addressbook.ts';
|
} from '/routes/api/contacts/delete-addressbook.tsx';
|
||||||
|
|
||||||
interface ContactsProps {
|
interface ContactsProps {
|
||||||
initialAddressBookId: string;
|
initialAddressBookId: string;
|
||||||
|
|
@ -359,7 +359,7 @@ export default function Contacts(
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right divide-y divide-slate-600 rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
|
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right divide-y divide-slate-600 rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||||
!isAddressBooksDropdownOpen.value ? 'hidden' : ''
|
!isAddressBooksDropdownOpen.value ? 'hidden' : ''
|
||||||
}`}
|
}`}
|
||||||
role='menu'
|
role='menu'
|
||||||
|
|
@ -424,7 +424,7 @@ export default function Contacts(
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
|
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||||
!isOptionsDropdownOpen.value ? 'hidden' : ''
|
!isOptionsDropdownOpen.value ? 'hidden' : ''
|
||||||
}`}
|
}`}
|
||||||
role='menu'
|
role='menu'
|
||||||
|
|
@ -457,7 +457,7 @@ export default function Contacts(
|
||||||
onClick={() => onClickAddContact()}
|
onClick={() => onClickAddContact()}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/add.svg'
|
src='/images/add.svg'
|
||||||
alt='Add new contact'
|
alt='Add new contact'
|
||||||
class={`white ${isAdding.value ? 'animate-spin' : ''}`}
|
class={`white ${isAdding.value ? 'animate-spin' : ''}`}
|
||||||
width={20}
|
width={20}
|
||||||
|
|
@ -493,7 +493,7 @@ export default function Contacts(
|
||||||
onClick={() => onClickDeleteContact(contact.uid!)}
|
onClick={() => onClickDeleteContact(contact.uid!)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/delete.svg'
|
src='/images/delete.svg'
|
||||||
class='red drop-shadow-md'
|
class='red drop-shadow-md'
|
||||||
width={24}
|
width={24}
|
||||||
height={24}
|
height={24}
|
||||||
|
|
@ -524,21 +524,21 @@ export default function Contacts(
|
||||||
{isDeleting.value
|
{isDeleting.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
{isExporting.value
|
{isExporting.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting...
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting...
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
{isImporting.value
|
{isImporting.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Importing...
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Importing...
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { useSignal } from '@preact/signals';
|
import { useSignal } from '@preact/signals';
|
||||||
|
|
||||||
import { Contact } from '/lib/models/contacts.ts';
|
import { Contact } from '/lib/models/contacts.ts';
|
||||||
import { convertObjectToFormData } from '/public/ts/utils/misc.ts';
|
import { convertObjectToFormData } from '/lib/utils/misc.ts';
|
||||||
import { FormField, generateFieldHtml } from '/public/ts/utils/form.ts';
|
import { FormField, generateFieldHtml } from '/lib/form-utils.tsx';
|
||||||
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/pages/api/contacts/delete.ts';
|
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/contacts/delete.tsx';
|
||||||
|
|
||||||
interface ViewContactProps {
|
interface ViewContactProps {
|
||||||
addressBookId: string;
|
addressBookId: string;
|
||||||
|
|
@ -131,7 +131,7 @@ export default function ViewContact(
|
||||||
onClick={() => onClickDeleteContact()}
|
onClick={() => onClickDeleteContact()}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/delete.svg'
|
src='/images/delete.svg'
|
||||||
alt='Delete contact'
|
alt='Delete contact'
|
||||||
class={`white ${isDeleting.value ? 'animate-spin' : ''}`}
|
class={`white ${isDeleting.value ? 'animate-spin' : ''}`}
|
||||||
width={20}
|
width={20}
|
||||||
|
|
@ -160,11 +160,7 @@ export default function ViewContact(
|
||||||
: null}
|
: null}
|
||||||
|
|
||||||
<form method='POST' class='mb-12'>
|
<form method='POST' class='mb-12'>
|
||||||
<div
|
{formFields(contact.peek(), 'ui').map((field) => generateFieldHtml(field, formData))}
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: formFields(contact.peek(), 'ui').map((field) => generateFieldHtml(field, formData)).join(''),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section class='flex justify-end mt-8 mb-4'>
|
<section class='flex justify-end mt-8 mb-4'>
|
||||||
<button class='button' type='submit'>Update contact</button>
|
<button class='button' type='submit'>Update contact</button>
|
||||||
|
|
@ -177,16 +173,12 @@ export default function ViewContact(
|
||||||
<summary class='text-slate-100 flex items-center font-bold cursor-pointer text-center justify-center mx-auto hover:text-sky-400'>
|
<summary class='text-slate-100 flex items-center font-bold cursor-pointer text-center justify-center mx-auto hover:text-sky-400'>
|
||||||
Edit Raw vCard{' '}
|
Edit Raw vCard{' '}
|
||||||
<span class='ml-2 text-slate-400 group-open:rotate-90 transition-transform duration-200'>
|
<span class='ml-2 text-slate-400 group-open:rotate-90 transition-transform duration-200'>
|
||||||
<img src='/public/images/right.svg' alt='Expand' width={16} height={16} class='white' />
|
<img src='/images/right.svg' alt='Expand' width={16} height={16} class='white' />
|
||||||
</span>
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<form method='POST' class='mb-12'>
|
<form method='POST' class='mb-12'>
|
||||||
<div
|
{formFields(contact.peek(), 'raw').map((field) => generateFieldHtml(field, formData))}
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: formFields(contact.peek(), 'raw').map((field) => generateFieldHtml(field, formData)).join(''),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section class='flex justify-end mt-8 mb-4'>
|
<section class='flex justify-end mt-8 mb-4'>
|
||||||
<button class='button' type='submit'>Update vCard</button>
|
<button class='button' type='submit'>Update vCard</button>
|
||||||
|
|
@ -200,7 +192,7 @@ export default function ViewContact(
|
||||||
{isDeleting.value
|
{isDeleting.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
|
|
@ -2,8 +2,8 @@ import { useSignal } from '@preact/signals';
|
||||||
import { useEffect } from 'preact/hooks';
|
import { useEffect } from 'preact/hooks';
|
||||||
|
|
||||||
import { DashboardLink } from '/lib/types.ts';
|
import { DashboardLink } from '/lib/types.ts';
|
||||||
import { validateUrl } from '/public/ts/utils/misc.ts';
|
import { validateUrl } from '/lib/utils/misc.ts';
|
||||||
import { RequestBody, ResponseBody } from '/pages/api/dashboard/save-links.ts';
|
import { RequestBody, ResponseBody } from '/routes/api/dashboard/save-links.tsx';
|
||||||
|
|
||||||
interface LinksProps {
|
interface LinksProps {
|
||||||
initialLinks: DashboardLink[];
|
initialLinks: DashboardLink[];
|
||||||
|
|
@ -118,13 +118,13 @@ export default function Links({ initialLinks }: LinksProps) {
|
||||||
<section class='flex flex-row items-center justify-end mb-4'>
|
<section class='flex flex-row items-center justify-end mb-4'>
|
||||||
<section class='flex items-center'>
|
<section class='flex items-center'>
|
||||||
<button
|
<button
|
||||||
class='inline-block justify-center gap-x-1.5 rounded-md bg-[#51A4FB] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-400 ml-2 cursor-pointer'
|
class='inline-block justify-center gap-x-1.5 rounded-md bg-[#51A4FB] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-400 ml-2'
|
||||||
type='button'
|
type='button'
|
||||||
title='Add new link'
|
title='Add new link'
|
||||||
onClick={() => onClickAddLink()}
|
onClick={() => onClickAddLink()}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/add.svg'
|
src='/images/add.svg'
|
||||||
alt='Add new link'
|
alt='Add new link'
|
||||||
class={`white`}
|
class={`white`}
|
||||||
width={20}
|
width={20}
|
||||||
|
|
@ -148,7 +148,7 @@ export default function Links({ initialLinks }: LinksProps) {
|
||||||
onClick={() => onClickDeleteLink(index)}
|
onClick={() => onClickDeleteLink(index)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/delete.svg'
|
src='/images/delete.svg'
|
||||||
class='red drop-shadow-md'
|
class='red drop-shadow-md'
|
||||||
width={24}
|
width={24}
|
||||||
height={24}
|
height={24}
|
||||||
|
|
@ -163,7 +163,7 @@ export default function Links({ initialLinks }: LinksProps) {
|
||||||
onClick={() => onClickMoveLeftLink(index)}
|
onClick={() => onClickMoveLeftLink(index)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/left-circle.svg'
|
src='/images/left-circle.svg'
|
||||||
class='gray'
|
class='gray'
|
||||||
width={24}
|
width={24}
|
||||||
height={24}
|
height={24}
|
||||||
|
|
@ -185,14 +185,14 @@ export default function Links({ initialLinks }: LinksProps) {
|
||||||
{isSaving.value
|
{isSaving.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
{hasSaved.value
|
{hasSaved.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/check.svg' class='green mr-2' width={18} height={18} />Saved!
|
<img src='/images/check.svg' class='green mr-2' width={18} height={18} />Saved!
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useSignal, useSignalEffect } from '@preact/signals';
|
import { useSignal, useSignalEffect } from '@preact/signals';
|
||||||
import { useEffect } from 'preact/hooks';
|
import { useEffect } from 'preact/hooks';
|
||||||
|
|
||||||
import { RequestBody, ResponseBody } from '/pages/api/dashboard/save-notes.ts';
|
import { RequestBody, ResponseBody } from '/routes/api/dashboard/save-notes.tsx';
|
||||||
|
|
||||||
interface NotesProps {
|
interface NotesProps {
|
||||||
initialNotes: string;
|
initialNotes: string;
|
||||||
|
|
@ -85,14 +85,14 @@ export default function Notes({ initialNotes }: NotesProps) {
|
||||||
{isSaving.value
|
{isSaving.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
{hasSaved.value
|
{hasSaved.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/check.svg' class='green mr-2' width={18} height={18} />Saved!
|
<img src='/images/check.svg' class='green mr-2' width={18} height={18} />Saved!
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
23
islands/expenses/ExpensesWrapper.tsx
Normal file
23
islands/expenses/ExpensesWrapper.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { Budget, Expense, SupportedCurrencySymbol } from '/lib/types.ts';
|
||||||
|
import MainExpenses from '/components/expenses/MainExpenses.tsx';
|
||||||
|
|
||||||
|
interface ExpensesWrapperProps {
|
||||||
|
initialBudgets: Budget[];
|
||||||
|
initialExpenses: Expense[];
|
||||||
|
initialMonth: string;
|
||||||
|
currency: SupportedCurrencySymbol;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This wrapper is necessary because islands need to be the first frontend component, but they don't support functions as props, so the more complex logic needs to live in the component itself
|
||||||
|
export default function ExpensesWrapper(
|
||||||
|
{ initialBudgets, initialExpenses, initialMonth, currency }: ExpensesWrapperProps,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<MainExpenses
|
||||||
|
initialBudgets={initialBudgets}
|
||||||
|
initialExpenses={initialExpenses}
|
||||||
|
initialMonth={initialMonth}
|
||||||
|
currency={currency}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
islands/files/FilesWrapper.tsx
Normal file
37
islands/files/FilesWrapper.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||||
|
import MainFiles from '/components/files/MainFiles.tsx';
|
||||||
|
|
||||||
|
interface FilesWrapperProps {
|
||||||
|
initialDirectories: Directory[];
|
||||||
|
initialFiles: DirectoryFile[];
|
||||||
|
initialPath: string;
|
||||||
|
baseUrl: string;
|
||||||
|
isFileSharingAllowed: boolean;
|
||||||
|
areDirectoryDownloadsAllowed: boolean;
|
||||||
|
fileShareId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This wrapper is necessary because islands need to be the first frontend component, but they don't support functions as props, so the more complex logic needs to live in the component itself
|
||||||
|
export default function FilesWrapper(
|
||||||
|
{
|
||||||
|
initialDirectories,
|
||||||
|
initialFiles,
|
||||||
|
initialPath,
|
||||||
|
baseUrl,
|
||||||
|
isFileSharingAllowed,
|
||||||
|
areDirectoryDownloadsAllowed,
|
||||||
|
fileShareId,
|
||||||
|
}: FilesWrapperProps,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<MainFiles
|
||||||
|
initialDirectories={initialDirectories}
|
||||||
|
initialFiles={initialFiles}
|
||||||
|
initialPath={initialPath}
|
||||||
|
baseUrl={baseUrl}
|
||||||
|
isFileSharingAllowed={isFileSharingAllowed}
|
||||||
|
areDirectoryDownloadsAllowed={areDirectoryDownloadsAllowed}
|
||||||
|
fileShareId={fileShareId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import { useSignal } from '@preact/signals';
|
import { useSignal } from '@preact/signals';
|
||||||
|
|
||||||
import { NewsFeedArticle } from '/lib/types.ts';
|
import { NewsFeedArticle } from '/lib/types.ts';
|
||||||
import { ResponseBody as RefreshResponseBody } from '/pages/api/news/refresh-articles.ts';
|
import {
|
||||||
import { RequestBody as ReadRequestBody, ResponseBody as ReadResponseBody } from '/pages/api/news/mark-read.ts';
|
RequestBody as RefreshRequestBody,
|
||||||
|
ResponseBody as RefreshResponseBody,
|
||||||
|
} from '/routes/api/news/refresh-articles.tsx';
|
||||||
|
import { RequestBody as ReadRequestBody, ResponseBody as ReadResponseBody } from '/routes/api/news/mark-read.tsx';
|
||||||
|
|
||||||
interface ArticlesProps {
|
interface ArticlesProps {
|
||||||
initialArticles: NewsFeedArticle[];
|
initialArticles: NewsFeedArticle[];
|
||||||
|
|
@ -33,9 +36,10 @@ export default function Articles({ initialArticles }: ArticlesProps) {
|
||||||
isRefreshing.value = true;
|
isRefreshing.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const requestBody: RefreshRequestBody = {};
|
||||||
const response = await fetch(`/api/news/refresh-articles`, {
|
const response = await fetch(`/api/news/refresh-articles`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify(requestBody),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -181,7 +185,7 @@ export default function Articles({ initialArticles }: ArticlesProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
|
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||||
!isFilterDropdownOpen.value ? 'hidden' : ''
|
!isFilterDropdownOpen.value ? 'hidden' : ''
|
||||||
}`}
|
}`}
|
||||||
role='menu'
|
role='menu'
|
||||||
|
|
@ -219,7 +223,7 @@ export default function Articles({ initialArticles }: ArticlesProps) {
|
||||||
onClick={() => onClickMarkAllRead()}
|
onClick={() => onClickMarkAllRead()}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/check-all.svg'
|
src='/images/check-all.svg'
|
||||||
alt='Mark all read'
|
alt='Mark all read'
|
||||||
class={`white`}
|
class={`white`}
|
||||||
width={20}
|
width={20}
|
||||||
|
|
@ -234,7 +238,7 @@ export default function Articles({ initialArticles }: ArticlesProps) {
|
||||||
onClick={() => refreshArticles()}
|
onClick={() => refreshArticles()}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/refresh.svg'
|
src='/images/refresh.svg'
|
||||||
alt='Fetch new articles'
|
alt='Fetch new articles'
|
||||||
class={`white ${isRefreshing.value ? 'animate-spin' : ''}`}
|
class={`white ${isRefreshing.value ? 'animate-spin' : ''}`}
|
||||||
width={20}
|
width={20}
|
||||||
|
|
@ -254,7 +258,7 @@ export default function Articles({ initialArticles }: ArticlesProps) {
|
||||||
class={`group order-first mx-auto max-w-full relative bg-slate-700 duration-150 first:rounded-tl-md first:rounded-tr-md last:rounded-bl-md last:rounded-br-md`}
|
class={`group order-first mx-auto max-w-full relative bg-slate-700 duration-150 first:rounded-tl-md first:rounded-tr-md last:rounded-bl-md last:rounded-br-md`}
|
||||||
>
|
>
|
||||||
<summary
|
<summary
|
||||||
class={`bg-slate-700 hover:bg-slate-600 px-4 py-4 cursor-pointer flex justify-between group-first:rounded-tl-md group-first:rounded-tr-md ${
|
class={`bg-slate-700 hover:bg-slate-600 px-4 py-4 cursor-pointer flex justify-between group-[:first-child]:rounded-tl-md group-[:first-child]:rounded-tr-md ${
|
||||||
article.is_read ? 'opacity-50' : 'font-semibold'
|
article.is_read ? 'opacity-50' : 'font-semibold'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => onClickView(article.id)}
|
onClick={() => onClickView(article.id)}
|
||||||
|
|
@ -269,7 +273,7 @@ export default function Articles({ initialArticles }: ArticlesProps) {
|
||||||
</article>
|
</article>
|
||||||
<a
|
<a
|
||||||
href={article.article_url}
|
href={article.article_url}
|
||||||
class='py-4 px-8 flex justify-between text-right hover:bg-slate-600 group-last:rounded-bl-md group-last:rounded-br-md'
|
class='py-4 px-8 flex justify-between text-right hover:bg-slate-600 group-[:last-child]:rounded-bl-md group-[:last-child]:rounded-br-md'
|
||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noreferrer noopener'
|
rel='noreferrer noopener'
|
||||||
onClick={() => onClickView(article.id)}
|
onClick={() => onClickView(article.id)}
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import { useSignal } from '@preact/signals';
|
import { useSignal } from '@preact/signals';
|
||||||
|
|
||||||
import { NewsFeed } from '/lib/types.ts';
|
import { NewsFeed } from '/lib/types.ts';
|
||||||
import { escapeHtml, validateUrl } from '/public/ts/utils/misc.ts';
|
import { escapeHtml, validateUrl } from '/lib/utils/misc.ts';
|
||||||
import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/pages/api/news/add-feed.ts';
|
import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/news/add-feed.tsx';
|
||||||
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/pages/api/news/delete-feed.ts';
|
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/news/delete-feed.tsx';
|
||||||
import { RequestBody as ImportRequestBody, ResponseBody as ImportResponseBody } from '/pages/api/news/import-feeds.ts';
|
import {
|
||||||
|
RequestBody as ImportRequestBody,
|
||||||
|
ResponseBody as ImportResponseBody,
|
||||||
|
} from '/routes/api/news/import-feeds.tsx';
|
||||||
|
|
||||||
interface FeedsProps {
|
interface FeedsProps {
|
||||||
initialFeeds: NewsFeed[];
|
initialFeeds: NewsFeed[];
|
||||||
|
|
@ -254,7 +257,7 @@ export default function Feeds({ initialFeeds }: FeedsProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
|
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||||
!isOptionsDropdownOpen.value ? 'hidden' : ''
|
!isOptionsDropdownOpen.value ? 'hidden' : ''
|
||||||
}`}
|
}`}
|
||||||
role='menu'
|
role='menu'
|
||||||
|
|
@ -287,7 +290,7 @@ export default function Feeds({ initialFeeds }: FeedsProps) {
|
||||||
onClick={() => onClickAddFeed()}
|
onClick={() => onClickAddFeed()}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/add.svg'
|
src='/images/add.svg'
|
||||||
alt='Add new feed'
|
alt='Add new feed'
|
||||||
class={`white ${isAdding.value ? 'animate-spin' : ''}`}
|
class={`white ${isAdding.value ? 'animate-spin' : ''}`}
|
||||||
width={20}
|
width={20}
|
||||||
|
|
@ -330,7 +333,7 @@ export default function Feeds({ initialFeeds }: FeedsProps) {
|
||||||
onClick={() => onClickDeleteFeed(newsFeed.id)}
|
onClick={() => onClickDeleteFeed(newsFeed.id)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/public/images/delete.svg'
|
src='/images/delete.svg'
|
||||||
class='red drop-shadow-md'
|
class='red drop-shadow-md'
|
||||||
width={24}
|
width={24}
|
||||||
height={24}
|
height={24}
|
||||||
|
|
@ -361,21 +364,21 @@ export default function Feeds({ initialFeeds }: FeedsProps) {
|
||||||
{isDeleting.value
|
{isDeleting.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
{isExporting.value
|
{isExporting.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting...
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting...
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
{isImporting.value
|
{isImporting.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Importing...
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Importing...
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useSignal, useSignalEffect } from '@preact/signals';
|
import { useSignal, useSignalEffect } from '@preact/signals';
|
||||||
import { useEffect } from 'preact/hooks';
|
import { useEffect } from 'preact/hooks';
|
||||||
|
|
||||||
import { RequestBody, ResponseBody } from '/pages/api/notes/save.ts';
|
import { RequestBody, ResponseBody } from '/routes/api/notes/save.tsx';
|
||||||
import FilesBreadcrumb from '/components/files/FilesBreadcrumb.tsx';
|
import FilesBreadcrumb from '/components/files/FilesBreadcrumb.tsx';
|
||||||
|
|
||||||
interface NoteProps {
|
interface NoteProps {
|
||||||
|
|
@ -96,14 +96,14 @@ export default function Note({ fileName, currentPath, contents }: NoteProps) {
|
||||||
{isSaving.value
|
{isSaving.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
{hasSaved.value
|
{hasSaved.value
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<img src='/public/images/check.svg' class='green mr-2' width={18} height={18} />Saved!
|
<img src='/images/check.svg' class='green mr-2' width={18} height={18} />Saved!
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
21
islands/notes/NotesWrapper.tsx
Normal file
21
islands/notes/NotesWrapper.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||||
|
import MainNotes from '/components/notes/MainNotes.tsx';
|
||||||
|
|
||||||
|
interface NotesWrapperProps {
|
||||||
|
initialDirectories: Directory[];
|
||||||
|
initialFiles: DirectoryFile[];
|
||||||
|
initialPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This wrapper is necessary because islands need to be the first frontend component, but they don't support functions as props, so the more complex logic needs to live in the component itself
|
||||||
|
export default function NotesWrapper(
|
||||||
|
{ initialDirectories, initialFiles, initialPath }: NotesWrapperProps,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<MainNotes
|
||||||
|
initialDirectories={initialDirectories}
|
||||||
|
initialFiles={initialFiles}
|
||||||
|
initialPath={initialPath}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
islands/photos/PhotosWrapper.tsx
Normal file
21
islands/photos/PhotosWrapper.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||||
|
import MainPhotos from '/components/photos/MainPhotos.tsx';
|
||||||
|
|
||||||
|
interface PhotosWrapperProps {
|
||||||
|
initialDirectories: Directory[];
|
||||||
|
initialFiles: DirectoryFile[];
|
||||||
|
initialPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This wrapper is necessary because islands need to be the first frontend component, but they don't support functions as props, so the more complex logic needs to live in the component itself
|
||||||
|
export default function PhotosWrapper(
|
||||||
|
{ initialDirectories, initialFiles, initialPath }: PhotosWrapperProps,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<MainPhotos
|
||||||
|
initialDirectories={initialDirectories}
|
||||||
|
initialFiles={initialFiles}
|
||||||
|
initialPath={initialPath}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,10 +2,10 @@ import { decodeBase64, decodeBase64Url, encodeBase64Url } from '@std/encoding';
|
||||||
import { Cookie, getCookies, setCookie } from '@std/http';
|
import { Cookie, getCookies, setCookie } from '@std/http';
|
||||||
import '@std/dotenv/load';
|
import '@std/dotenv/load';
|
||||||
|
|
||||||
import { generateHash, isRunningLocally } from '/public/ts/utils/misc.ts';
|
import { generateHash, isRunningLocally } from './utils/misc.ts';
|
||||||
import { User, UserSession } from '/lib/types.ts';
|
import { User, UserSession } from './types.ts';
|
||||||
import { UserModel, UserSessionModel, validateUserAndSession } from '/lib/models/user.ts';
|
import { UserModel, UserSessionModel, validateUserAndSession } from './models/user.ts';
|
||||||
import { AppConfig } from '/lib/config.ts';
|
import { AppConfig } from './config.ts';
|
||||||
|
|
||||||
export const JWT_SECRET = Deno.env.get('JWT_SECRET') || '';
|
export const JWT_SECRET = Deno.env.get('JWT_SECRET') || '';
|
||||||
export const PASSWORD_SALT = Deno.env.get('PASSWORD_SALT') || '';
|
export const PASSWORD_SALT = Deno.env.get('PASSWORD_SALT') || '';
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ export class AppConfig {
|
||||||
allowedCookieDomains: [],
|
allowedCookieDomains: [],
|
||||||
skipCookieDomainSecurity: false,
|
skipCookieDomainSecurity: false,
|
||||||
enableSingleSignOn: false,
|
enableSingleSignOn: false,
|
||||||
allowSignupsViaSingleSignOn: false,
|
|
||||||
singleSignOnUrl: '',
|
singleSignOnUrl: '',
|
||||||
singleSignOnEmailAttribute: 'email',
|
singleSignOnEmailAttribute: 'email',
|
||||||
singleSignOnScopes: ['openid', 'email'],
|
singleSignOnScopes: ['openid', 'email'],
|
||||||
|
|
@ -25,11 +24,9 @@ export class AppConfig {
|
||||||
rootPath: 'data-files',
|
rootPath: 'data-files',
|
||||||
allowPublicSharing: false,
|
allowPublicSharing: false,
|
||||||
allowDirectoryDownloads: false,
|
allowDirectoryDownloads: false,
|
||||||
maxUploadSizeInMegabytes: 100,
|
|
||||||
},
|
},
|
||||||
core: {
|
core: {
|
||||||
enabledApps: ['dashboard', 'files', 'news', 'notes', 'photos', 'expenses', 'contacts', 'calendar'],
|
enabledApps: ['dashboard', 'files', 'news', 'notes', 'photos', 'expenses', 'contacts', 'calendar'],
|
||||||
maxRequestSizeInMegabytes: 12,
|
|
||||||
},
|
},
|
||||||
visuals: {
|
visuals: {
|
||||||
title: '',
|
title: '',
|
||||||
|
|
@ -40,8 +37,6 @@ export class AppConfig {
|
||||||
from: 'help@bewcloud.com',
|
from: 'help@bewcloud.com',
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 465,
|
port: 465,
|
||||||
tlsMode: 'auto',
|
|
||||||
tlsVerify: true,
|
|
||||||
},
|
},
|
||||||
contacts: {
|
contacts: {
|
||||||
enableCardDavServer: true,
|
enableCardDavServer: true,
|
||||||
|
|
@ -120,12 +115,11 @@ export class AppConfig {
|
||||||
return this.config;
|
return this.config;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async isSignupAllowed({ viaSingleSignOn = false }: { viaSingleSignOn?: boolean } = {}): Promise<boolean> {
|
static async isSignupAllowed(): Promise<boolean> {
|
||||||
await this.loadConfig();
|
await this.loadConfig();
|
||||||
|
|
||||||
const areSignupsAllowed = viaSingleSignOn && !this.config.auth.allowSignups
|
const areSignupsAllowed = this.config.auth.allowSignups;
|
||||||
? this.config.auth.allowSignupsViaSingleSignOn
|
|
||||||
: this.config.auth.allowSignups;
|
|
||||||
const areThereAdmins = await UserModel.isThereAnAdmin();
|
const areThereAdmins = await UserModel.isThereAnAdmin();
|
||||||
|
|
||||||
if (areSignupsAllowed || !areThereAdmins) {
|
if (areSignupsAllowed || !areThereAdmins) {
|
||||||
|
|
|
||||||
16
lib/feed.ts
16
lib/feed.ts
|
|
@ -1,8 +1,7 @@
|
||||||
import { DOMParser, initParser } from '@b-fuze/deno-dom/wasm-noinit';
|
import { DOMParser, initParser } from '@b-fuze/deno-dom/wasm-noinit';
|
||||||
import { Feed, parseFeed } from '@mikaelporttila/rss';
|
import { Feed, parseFeed } from '@mikaelporttila/rss';
|
||||||
|
import { fetchUrl, fetchUrlAsGooglebot, fetchUrlWithProxy, fetchUrlWithRetries } from './utils/misc.ts';
|
||||||
import { fetchUrl, fetchUrlAsGooglebot, fetchUrlWithProxy, fetchUrlWithRetries } from '/public/ts/utils/misc.ts';
|
import { NewsFeed, NewsFeedCrawlType, NewsFeedType } from './types.ts';
|
||||||
import { NewsFeed, NewsFeedCrawlType, NewsFeedType } from '/lib/types.ts';
|
|
||||||
|
|
||||||
export interface JsonFeedItem {
|
export interface JsonFeedItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -222,20 +221,13 @@ export async function getUrlInfo(url: string): Promise<{ title: string; htmlBody
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function parseTextFromHtml(html: string): Promise<string> {
|
export async function parseTextFromHtml(html: string): Promise<string> {
|
||||||
if (!html || !html.trim()) {
|
let text = '';
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
await initParser();
|
await initParser();
|
||||||
|
|
||||||
const document = new DOMParser().parseFromString(html, 'text/html');
|
const document = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
|
||||||
// Extract text from body to avoid any artifacts from the document wrapper
|
text = document!.textContent;
|
||||||
const text = (document?.querySelector('body')?.textContent || document?.textContent || '')
|
|
||||||
// Collapse runs of 2+ whitespace/newline characters, preserving single line breaks
|
|
||||||
.replace(/[^\S\n]{2,}/g, ' ')
|
|
||||||
.replace(/\n{3,}/g, '\n\n')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
186
lib/form-utils.tsx
Normal file
186
lib/form-utils.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
export interface FormField {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
value?: string | null;
|
||||||
|
overrideValue?: string;
|
||||||
|
description?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
type:
|
||||||
|
| 'text'
|
||||||
|
| 'email'
|
||||||
|
| 'tel'
|
||||||
|
| 'url'
|
||||||
|
| 'date'
|
||||||
|
| 'datetime-local'
|
||||||
|
| 'number'
|
||||||
|
| 'range'
|
||||||
|
| 'select'
|
||||||
|
| 'textarea'
|
||||||
|
| 'checkbox'
|
||||||
|
| 'hidden'
|
||||||
|
| 'password';
|
||||||
|
step?: string;
|
||||||
|
max?: string;
|
||||||
|
min?: string;
|
||||||
|
rows?: string;
|
||||||
|
options?: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}[];
|
||||||
|
checked?: boolean;
|
||||||
|
multiple?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
|
extraClasses?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFormDataField(formData: FormData, field: string) {
|
||||||
|
return ((formData.get(field) || '') as string).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFormDataFieldArray(formData: FormData, field: string) {
|
||||||
|
return ((formData.getAll(field) || []) as string[]).map((value) => value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateFieldHtml(
|
||||||
|
field: FormField,
|
||||||
|
formData: FormData,
|
||||||
|
) {
|
||||||
|
let value = field.overrideValue ||
|
||||||
|
(field.multiple ? getFormDataFieldArray(formData, field.name) : getFormDataField(formData, field.name)) ||
|
||||||
|
field.value;
|
||||||
|
|
||||||
|
if (typeof field.overrideValue !== 'undefined') {
|
||||||
|
value = field.overrideValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'hidden') {
|
||||||
|
return generateInputHtml(field, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset class={`block mb-4 ${field.extraClasses || ''}`}>
|
||||||
|
<label class='text-slate-300 block pb-1' for={`field_${field.name}`}>{field.label}</label>
|
||||||
|
{generateInputHtml(field, value)}
|
||||||
|
{field.description
|
||||||
|
? (
|
||||||
|
<aside class={`text-sm text-slate-400 p-2 ${field.type === 'checkbox' ? 'inline' : ''}`}>
|
||||||
|
{field.description}
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateInputHtml(
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
placeholder,
|
||||||
|
type,
|
||||||
|
options,
|
||||||
|
step,
|
||||||
|
max,
|
||||||
|
min,
|
||||||
|
rows,
|
||||||
|
checked,
|
||||||
|
multiple,
|
||||||
|
disabled,
|
||||||
|
required,
|
||||||
|
readOnly,
|
||||||
|
}: FormField,
|
||||||
|
value?: string | string[] | null,
|
||||||
|
) {
|
||||||
|
const additionalAttributes: Record<string, string | number | boolean> = {};
|
||||||
|
|
||||||
|
if (typeof step !== 'undefined') {
|
||||||
|
additionalAttributes.step = parseInt(step, 10);
|
||||||
|
}
|
||||||
|
if (typeof max !== 'undefined') {
|
||||||
|
additionalAttributes.max = parseInt(max, 10);
|
||||||
|
}
|
||||||
|
if (typeof min !== 'undefined') {
|
||||||
|
additionalAttributes.min = parseInt(min, 10);
|
||||||
|
}
|
||||||
|
if (typeof rows !== 'undefined') {
|
||||||
|
additionalAttributes.rows = parseInt(rows, 10);
|
||||||
|
}
|
||||||
|
if (checked === true && type === 'checkbox' && value) {
|
||||||
|
additionalAttributes.checked = true;
|
||||||
|
}
|
||||||
|
if (multiple === true) {
|
||||||
|
additionalAttributes.multiple = true;
|
||||||
|
}
|
||||||
|
if (required === true) {
|
||||||
|
additionalAttributes.required = true;
|
||||||
|
}
|
||||||
|
if (disabled === true) {
|
||||||
|
additionalAttributes.disabled = true;
|
||||||
|
}
|
||||||
|
if (readOnly === true) {
|
||||||
|
additionalAttributes.readonly = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'select') {
|
||||||
|
return (
|
||||||
|
<select class='mt-1 input-field' id={`field_${name}`} name={name} {...additionalAttributes}>
|
||||||
|
{options?.map((option) => (
|
||||||
|
<option
|
||||||
|
value={option.value}
|
||||||
|
selected={option.value === value || (multiple && (value || [])?.includes(option.value))}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'textarea') {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
class='mt-1 input-field'
|
||||||
|
id={`field_${name}`}
|
||||||
|
name={name}
|
||||||
|
rows={6}
|
||||||
|
placeholder={placeholder}
|
||||||
|
{...additionalAttributes}
|
||||||
|
>
|
||||||
|
{(value as string) || ''}
|
||||||
|
</textarea>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'checkbox') {
|
||||||
|
return (
|
||||||
|
<input id={`field_${name}`} name={name} type={type} value={value as string || ''} {...additionalAttributes} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'password') {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
class='mt-1 input-field'
|
||||||
|
id={`field_${name}`}
|
||||||
|
name={name}
|
||||||
|
type={type}
|
||||||
|
placeholder={placeholder || ''}
|
||||||
|
value=''
|
||||||
|
{...additionalAttributes}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
class='mt-1 input-field'
|
||||||
|
id={`field_${name}`}
|
||||||
|
name={name}
|
||||||
|
type={type}
|
||||||
|
placeholder={placeholder || ''}
|
||||||
|
value={value as string || ''}
|
||||||
|
{...additionalAttributes}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { createDAVClient } from '/lib/models/dav.js';
|
import { createDAVClient } from '/lib/models/dav.js';
|
||||||
import { AppConfig } from '/lib/config.ts';
|
import { AppConfig } from '/lib/config.ts';
|
||||||
import { getColorAsHex, parseVCalendar } from '/public/ts/utils/calendar.ts';
|
import { getColorAsHex, parseVCalendar } from '/lib/utils/calendar.ts';
|
||||||
import { concurrentPromises } from '/public/ts/utils/misc.ts';
|
import { concurrentPromises } from '/lib/utils/misc.ts';
|
||||||
import { UserModel } from '/lib/models/user.ts';
|
import { UserModel } from '/lib/models/user.ts';
|
||||||
|
|
||||||
interface DAVObject extends Record<string, any> {
|
interface DAVObject extends Record<string, any> {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { createDAVClient } from '/lib/models/dav.js';
|
import { createDAVClient } from '/lib/models/dav.js';
|
||||||
import { AppConfig } from '/lib/config.ts';
|
import { AppConfig } from '/lib/config.ts';
|
||||||
import { parseVCard } from '/public/ts/utils/contacts.ts';
|
import { parseVCard } from '/lib/utils/contacts.ts';
|
||||||
|
|
||||||
interface DAVObject extends Record<string, any> {
|
interface DAVObject extends Record<string, any> {
|
||||||
data?: string;
|
data?: string;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
import '@std/dotenv/load';
|
import '@std/dotenv/load';
|
||||||
|
|
||||||
import { escapeHtml } from '/public/ts/utils/misc.ts';
|
import { escapeHtml } from '/lib/utils/misc.ts';
|
||||||
import { AppConfig } from '/lib/config.ts';
|
import { AppConfig } from '/lib/config.ts';
|
||||||
|
|
||||||
const SMTP_USERNAME = Deno.env.get('SMTP_USERNAME') || '';
|
const SMTP_USERNAME = Deno.env.get('SMTP_USERNAME') || '';
|
||||||
|
|
@ -15,30 +15,14 @@ export class EmailModel {
|
||||||
throw new Error('config.email.from, config.email.host, or config.email.port is not set');
|
throw new Error('config.email.from, config.email.host, or config.email.port is not set');
|
||||||
}
|
}
|
||||||
|
|
||||||
let tlsMode = emailConfig.tlsMode;
|
|
||||||
if (tlsMode === 'auto') {
|
|
||||||
tlsMode = Number(emailConfig.port) === 465 ? 'immediate' : 'starttls';
|
|
||||||
}
|
|
||||||
|
|
||||||
const transporterConfig = {
|
const transporterConfig = {
|
||||||
host: emailConfig.host,
|
host: emailConfig.host,
|
||||||
port: emailConfig.port,
|
port: emailConfig.port,
|
||||||
|
secure: Number(emailConfig.port) === 465,
|
||||||
secure: tlsMode === 'immediate',
|
auth: {
|
||||||
requireTLS: tlsMode === 'starttls',
|
user: SMTP_USERNAME,
|
||||||
ignoreTLS: tlsMode === 'none',
|
pass: SMTP_PASSWORD,
|
||||||
tls: emailConfig.tlsVerify === false
|
},
|
||||||
? { rejectUnauthorized: false }
|
|
||||||
: emailConfig.tlsVerify !== true
|
|
||||||
? { servername: emailConfig.tlsVerify }
|
|
||||||
: {},
|
|
||||||
|
|
||||||
auth: (SMTP_USERNAME || SMTP_PASSWORD)
|
|
||||||
? {
|
|
||||||
user: SMTP_USERNAME,
|
|
||||||
pass: SMTP_PASSWORD,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport(transporterConfig);
|
const transporter = nodemailer.createTransport(transporterConfig);
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,7 @@ import { Cookie, getCookies, setCookie } from '@std/http';
|
||||||
|
|
||||||
import { AppConfig } from '/lib/config.ts';
|
import { AppConfig } from '/lib/config.ts';
|
||||||
import { Directory, DirectoryFile, FileShare } from '/lib/types.ts';
|
import { Directory, DirectoryFile, FileShare } from '/lib/types.ts';
|
||||||
import {
|
import { sortDirectoriesByName, sortEntriesByName, sortFilesByName, TRASH_PATH } from '/lib/utils/files.ts';
|
||||||
bytesFromHumanFileSize,
|
|
||||||
sortDirectoriesByName,
|
|
||||||
sortEntriesByName,
|
|
||||||
sortFilesByName,
|
|
||||||
TRASH_PATH,
|
|
||||||
} from '/public/ts/utils/files.ts';
|
|
||||||
import Database, { sql } from '/lib/interfaces/database.ts';
|
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||||
import {
|
import {
|
||||||
COOKIE_NAME as AUTH_COOKIE_NAME,
|
COOKIE_NAME as AUTH_COOKIE_NAME,
|
||||||
|
|
@ -20,7 +14,7 @@ import {
|
||||||
resolveCookieDomain,
|
resolveCookieDomain,
|
||||||
verifyAuthJwt,
|
verifyAuthJwt,
|
||||||
} from '/lib/auth.ts';
|
} from '/lib/auth.ts';
|
||||||
import { isRunningLocally } from '/public/ts/utils/misc.ts';
|
import { isRunningLocally } from '/lib/utils/misc.ts';
|
||||||
|
|
||||||
const COOKIE_NAME = `${AUTH_COOKIE_NAME}-file-share`;
|
const COOKIE_NAME = `${AUTH_COOKIE_NAME}-file-share`;
|
||||||
|
|
||||||
|
|
@ -45,14 +39,12 @@ export class DirectoryModel {
|
||||||
for (const entry of directoryEntries) {
|
for (const entry of directoryEntries) {
|
||||||
const stat = await Deno.stat(join(rootPath, entry.name));
|
const stat = await Deno.stat(join(rootPath, entry.name));
|
||||||
|
|
||||||
const directorySize = await getDirectorySize(join(rootPath, entry.name));
|
|
||||||
|
|
||||||
const directory: Directory = {
|
const directory: Directory = {
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
parent_path: path,
|
parent_path: path,
|
||||||
directory_name: entry.name,
|
directory_name: entry.name,
|
||||||
has_write_access: true,
|
has_write_access: true,
|
||||||
size_in_bytes: directorySize || stat.size,
|
size_in_bytes: stat.size,
|
||||||
file_share_id: fileShares.find((fileShare) => fileShare.file_path === `${join(path, entry.name)}/`)?.id || null,
|
file_share_id: fileShares.find((fileShare) => fileShare.file_path === `${join(path, entry.name)}/`)?.id || null,
|
||||||
updated_at: stat.mtime || new Date(),
|
updated_at: stat.mtime || new Date(),
|
||||||
created_at: stat.birthtime || new Date(),
|
created_at: stat.birthtime || new Date(),
|
||||||
|
|
@ -149,14 +141,12 @@ export class DirectoryModel {
|
||||||
parentPath = '/';
|
parentPath = '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
const directorySize = await getDirectorySize(join(rootPath, relativeDirectoryPath));
|
|
||||||
|
|
||||||
const directory: Directory = {
|
const directory: Directory = {
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
parent_path: parentPath,
|
parent_path: parentPath,
|
||||||
directory_name: directoryName,
|
directory_name: directoryName,
|
||||||
has_write_access: true,
|
has_write_access: true,
|
||||||
size_in_bytes: directorySize || stat.size,
|
size_in_bytes: stat.size,
|
||||||
file_share_id: fileShares.find((fileShare) =>
|
file_share_id: fileShares.find((fileShare) =>
|
||||||
fileShare.file_path === `${join(relativeDirectoryPath, directoryName)}/`
|
fileShare.file_path === `${join(relativeDirectoryPath, directoryName)}/`
|
||||||
)?.id || null,
|
)?.id || null,
|
||||||
|
|
@ -774,45 +764,3 @@ export async function getPathInfo(userId: string, path: string): Promise<{ isDir
|
||||||
isFile: stat.isFile,
|
isFile: stat.isFile,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: We're using `-h` (human readable) and parsing the output because that's more stable than `-B 1B` across different systems for a reliable byte size.
|
|
||||||
async function getDirectorySize(path: string): Promise<number> {
|
|
||||||
try {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const commandTimeout = setTimeout(() => controller.abort(), 5_000);
|
|
||||||
|
|
||||||
const command = new Deno.Command(`du`, {
|
|
||||||
args: [
|
|
||||||
`-sh`,
|
|
||||||
path,
|
|
||||||
],
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { code, stdout, stderr } = await command.output();
|
|
||||||
|
|
||||||
if (commandTimeout) {
|
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (code !== 0) {
|
|
||||||
if (stderr) {
|
|
||||||
throw new Error(new TextDecoder().decode(stderr));
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Unknown error running "du"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = new TextDecoder().decode(stdout);
|
|
||||||
|
|
||||||
const value = output.split('\t')[0].trim();
|
|
||||||
|
|
||||||
const number = Number.parseFloat(value.match(/\d+(\.\d+)?/)?.[0] || '0');
|
|
||||||
const unit = value.match(/[A-Z]+/)?.[0] || 'B'.toUpperCase();
|
|
||||||
|
|
||||||
return bytesFromHumanFileSize(`${number} ${unit}B`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { MultiFactorAuthMethod, User } from '/lib/types.ts';
|
||||||
import {
|
import {
|
||||||
getEnabledMultiFactorAuthMethodsFromUser,
|
getEnabledMultiFactorAuthMethodsFromUser,
|
||||||
getMultiFactorAuthMethodByIdFromUser,
|
getMultiFactorAuthMethodByIdFromUser,
|
||||||
} from '/public/ts/utils/multi-factor-auth.ts';
|
} from '/lib/utils/multi-factor-auth.ts';
|
||||||
import {
|
import {
|
||||||
COOKIE_NAME as AUTH_COOKIE_NAME,
|
COOKIE_NAME as AUTH_COOKIE_NAME,
|
||||||
generateKey,
|
generateKey,
|
||||||
|
|
@ -14,7 +14,7 @@ import {
|
||||||
resolveCookieDomain,
|
resolveCookieDomain,
|
||||||
verifyAuthJwt,
|
verifyAuthJwt,
|
||||||
} from '/lib/auth.ts';
|
} from '/lib/auth.ts';
|
||||||
import { isRunningLocally } from '/public/ts/utils/misc.ts';
|
import { isRunningLocally } from '/lib/utils/misc.ts';
|
||||||
import { AppConfig } from '/lib/config.ts';
|
import { AppConfig } from '/lib/config.ts';
|
||||||
import { UserModel } from './user.ts';
|
import { UserModel } from './user.ts';
|
||||||
import { EmailModel } from './multi-factor-auth/email.ts';
|
import { EmailModel } from './multi-factor-auth/email.ts';
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { decodeBase64, encodeBase32, encodeBase64 } from '@std/encoding';
|
||||||
|
|
||||||
import { MultiFactorAuthMethod } from '/lib/types.ts';
|
import { MultiFactorAuthMethod } from '/lib/types.ts';
|
||||||
import { MFA_KEY, MFA_SALT } from '/lib/auth.ts';
|
import { MFA_KEY, MFA_SALT } from '/lib/auth.ts';
|
||||||
import { generateHash } from '/public/ts/utils/misc.ts';
|
import { generateHash } from '/lib/utils/misc.ts';
|
||||||
import { MultiFactorAuthSetup } from '/lib/models/multi-factor-auth.ts';
|
import { MultiFactorAuthSetup } from '/lib/models/multi-factor-auth.ts';
|
||||||
|
|
||||||
export class TOTPModel {
|
export class TOTPModel {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import '@std/dotenv/load';
|
||||||
|
|
||||||
import { createSessionResponse, dataToText } from '/lib/auth.ts';
|
import { createSessionResponse, dataToText } from '/lib/auth.ts';
|
||||||
import { UserModel } from '/lib/models/user.ts';
|
import { UserModel } from '/lib/models/user.ts';
|
||||||
import { generateRandomCode } from '/public/ts/utils/misc.ts';
|
import { generateRandomCode } from '/lib/utils/misc.ts';
|
||||||
import { AppConfig } from '/lib/config.ts';
|
import { AppConfig } from '/lib/config.ts';
|
||||||
import SimpleCache from '/lib/interfaces/simple-cache.ts';
|
import SimpleCache from '/lib/interfaces/simple-cache.ts';
|
||||||
|
|
||||||
|
|
@ -169,7 +169,7 @@ export class OidcModel {
|
||||||
throw new Error(`Missing user/${emailAttribute}`);
|
throw new Error(`Missing user/${emailAttribute}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSignupAllowed = await AppConfig.isSignupAllowed({ viaSingleSignOn: true });
|
const isSignupAllowed = await AppConfig.isSignupAllowed();
|
||||||
const isThereAnAdmin = await UserModel.isThereAnAdmin();
|
const isThereAnAdmin = await UserModel.isThereAnAdmin();
|
||||||
|
|
||||||
// Confirm the user exists (or signup if allowed)
|
// Confirm the user exists (or signup if allowed)
|
||||||
|
|
@ -181,10 +181,6 @@ export class OidcModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
if (!config.auth.allowSignupsViaSingleSignOn) {
|
|
||||||
throw new Error('Sign up via SSO is not allowed!');
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('There was a problem signing up or logging in!');
|
throw new Error('There was a problem signing up or logging in!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import Database, { sql } from '/lib/interfaces/database.ts';
|
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||||
import { User, UserSession, VerificationCode } from '/lib/types.ts';
|
import { User, UserSession, VerificationCode } from '/lib/types.ts';
|
||||||
import { generateRandomCode } from '/public/ts/utils/misc.ts';
|
import { generateRandomCode } from '/lib/utils/misc.ts';
|
||||||
import { AppConfig } from '/lib/config.ts';
|
import { AppConfig } from '/lib/config.ts';
|
||||||
|
|
||||||
const db = new Database();
|
const db = new Database();
|
||||||
|
|
|
||||||
69
lib/page.ts
69
lib/page.ts
|
|
@ -1,69 +0,0 @@
|
||||||
import { User, UserSession } from './types.ts';
|
|
||||||
import { JwtData } from './auth.ts';
|
|
||||||
|
|
||||||
export type RequestHandlerParams = {
|
|
||||||
request: Request;
|
|
||||||
match: URLPatternResult;
|
|
||||||
user?: User | null;
|
|
||||||
session?: { userSession?: UserSession; tokenData?: JwtData['data'] } | null;
|
|
||||||
isRunningLocally: boolean;
|
|
||||||
};
|
|
||||||
export type RequestHandler<T = Response> = (params: RequestHandlerParams) => T | Promise<T>;
|
|
||||||
|
|
||||||
export interface Page {
|
|
||||||
get?: RequestHandler;
|
|
||||||
post?: RequestHandler;
|
|
||||||
put?: RequestHandler;
|
|
||||||
patch?: RequestHandler;
|
|
||||||
delete?: RequestHandler;
|
|
||||||
options?: RequestHandler;
|
|
||||||
catchAll?: RequestHandler;
|
|
||||||
}
|
|
||||||
|
|
||||||
type AccessMode = 'public' | 'user';
|
|
||||||
interface PermissionsParams {
|
|
||||||
accessMode: AccessMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Params = Page & PermissionsParams;
|
|
||||||
|
|
||||||
function permissioned(handler: RequestHandler, accessMode: AccessMode) {
|
|
||||||
return ({ request, match, user, session, isRunningLocally }: RequestHandlerParams) => {
|
|
||||||
if (accessMode !== 'public') {
|
|
||||||
if (!user) {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const redirectTo = encodeURIComponent(`${url.pathname}${url.search}`);
|
|
||||||
return new Response('Redirect', { status: 302, headers: { 'Location': `/login?redirectTo=${redirectTo}` } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!handler) {
|
|
||||||
return new Response('Not Implemented', { status: 501 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return handler({ request, match, user, session, isRunningLocally });
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function page(
|
|
||||||
{
|
|
||||||
get,
|
|
||||||
post,
|
|
||||||
put,
|
|
||||||
patch,
|
|
||||||
delete: deleteAction,
|
|
||||||
options,
|
|
||||||
catchAll,
|
|
||||||
accessMode,
|
|
||||||
}: Params,
|
|
||||||
): Page {
|
|
||||||
return {
|
|
||||||
get: get ? permissioned(get, accessMode) : undefined,
|
|
||||||
post: post ? permissioned(post, accessMode) : undefined,
|
|
||||||
put: put ? permissioned(put, accessMode) : undefined,
|
|
||||||
patch: patch ? permissioned(patch, accessMode) : undefined,
|
|
||||||
delete: deleteAction ? permissioned(deleteAction, accessMode) : undefined,
|
|
||||||
options: options ? permissioned(options, accessMode) : undefined,
|
|
||||||
catchAll: catchAll ? permissioned(catchAll, accessMode) : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
25
lib/types.ts
25
lib/types.ts
|
|
@ -31,6 +31,11 @@ export interface UserSession {
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FreshContextState {
|
||||||
|
user?: User;
|
||||||
|
session?: UserSession;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VerificationCode {
|
export interface VerificationCode {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
|
|
@ -135,7 +140,15 @@ export interface Expense {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SupportedCurrencySymbol = '$' | '€' | '£' | '¥' | '₹';
|
export type SupportedCurrencySymbol = '$' | '€' | '£' | '¥' | '₹';
|
||||||
export type SupportedCurrency = 'USD' | 'EUR' | 'GBP' | 'JPY' | 'INR';
|
type SupportedCurrency = 'USD' | 'EUR' | 'GBP' | 'JPY' | 'INR';
|
||||||
|
|
||||||
|
export const currencyMap = new Map<SupportedCurrencySymbol, SupportedCurrency>([
|
||||||
|
['$', 'USD'],
|
||||||
|
['€', 'EUR'],
|
||||||
|
['£', 'GBP'],
|
||||||
|
['¥', 'JPY'],
|
||||||
|
['₹', 'INR'],
|
||||||
|
]);
|
||||||
|
|
||||||
export type PartialDeep<T> = (T extends (infer U)[] ? PartialDeep<U>[] : { [P in keyof T]?: PartialDeep<T[P]> }) | T;
|
export type PartialDeep<T> = (T extends (infer U)[] ? PartialDeep<U>[] : { [P in keyof T]?: PartialDeep<T[P]> }) | T;
|
||||||
|
|
||||||
|
|
@ -159,8 +172,6 @@ export interface Config {
|
||||||
skipCookieDomainSecurity: boolean;
|
skipCookieDomainSecurity: boolean;
|
||||||
/** If true, single sign-on will be enabled */
|
/** If true, single sign-on will be enabled */
|
||||||
enableSingleSignOn: boolean;
|
enableSingleSignOn: boolean;
|
||||||
/** If true, signups via single sign-on will be allowed, overriding allowSignups */
|
|
||||||
allowSignupsViaSingleSignOn: boolean;
|
|
||||||
/** The Discovery URL (AKA Issuer) of the identity/single sign-on provider */
|
/** The Discovery URL (AKA Issuer) of the identity/single sign-on provider */
|
||||||
singleSignOnUrl: string;
|
singleSignOnUrl: string;
|
||||||
/** The attribute to prefer as email of the identity/single sign-on provider */
|
/** The attribute to prefer as email of the identity/single sign-on provider */
|
||||||
|
|
@ -175,14 +186,10 @@ export interface Config {
|
||||||
allowPublicSharing: boolean;
|
allowPublicSharing: boolean;
|
||||||
/** If true, directories can be downloaded as zip files */
|
/** If true, directories can be downloaded as zip files */
|
||||||
allowDirectoryDownloads: boolean;
|
allowDirectoryDownloads: boolean;
|
||||||
/** The maximum upload size in megabytes. Overrides the core.maxRequestSizeInMegabytes setting on /dav and /api/files/upload endpoints. */
|
|
||||||
maxUploadSizeInMegabytes: number;
|
|
||||||
};
|
};
|
||||||
core: {
|
core: {
|
||||||
/** The apps to show, in order of appearance in the header. The first app will be the default one shown after logging in. At least one is required. */
|
/** The apps to show, in order of appearance in the header. The first app will be the default one shown after logging in. At least one is required. */
|
||||||
enabledApps: OptionalApp[];
|
enabledApps: OptionalApp[];
|
||||||
/** The maximum request size in megabytes. */
|
|
||||||
maxRequestSizeInMegabytes: number;
|
|
||||||
};
|
};
|
||||||
visuals: {
|
visuals: {
|
||||||
/** An override title of the application. Empty shows the default title. */
|
/** An override title of the application. Empty shows the default title. */
|
||||||
|
|
@ -199,10 +206,6 @@ export interface Config {
|
||||||
host: string;
|
host: string;
|
||||||
/** The SMTP port to send emails from */
|
/** The SMTP port to send emails from */
|
||||||
port: number;
|
port: number;
|
||||||
/** "auto" means "immediate" on port 465, "starttls" otherwise. */
|
|
||||||
tlsMode: 'auto' | 'immediate' | 'starttls' | 'none';
|
|
||||||
/** Whether to verify the TLS certificate. If a string is used the hostname will be verified using that name. */
|
|
||||||
tlsVerify: boolean | string;
|
|
||||||
};
|
};
|
||||||
contacts: {
|
contacts: {
|
||||||
/** If true, the CardDAV server will be enabled (proxied) */
|
/** If true, the CardDAV server will be enabled (proxied) */
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ export function getColorAsHex(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getIdFromVEvent(vEvent: string): string {
|
export function getIdFromVEvent(vEvent: string): string {
|
||||||
const lines = vEvent.split('\n').map((line) => line.trimEnd()).filter(Boolean);
|
const lines = vEvent.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||||
|
|
||||||
// Loop through every line and find the UID line
|
// Loop through every line and find the UID line
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
|
|
@ -71,7 +71,7 @@ export function getIdFromVEvent(vEvent: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function splitTextIntoVEvents(text: string): string[] {
|
export function splitTextIntoVEvents(text: string): string[] {
|
||||||
const lines = text.split('\n').map((line) => line.trimEnd()).filter(Boolean);
|
const lines = text.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||||
const vEvents: string[] = [];
|
const vEvents: string[] = [];
|
||||||
const currentVEvent: string[] = [];
|
const currentVEvent: string[] = [];
|
||||||
let hasFoundBeginVEvent = false;
|
let hasFoundBeginVEvent = false;
|
||||||
|
|
@ -154,7 +154,7 @@ export function generateVCalendar(
|
||||||
const vCalendarText = events.map((event) => generateVEvent(event, createdDate)).join('\n');
|
const vCalendarText = events.map((event) => generateVEvent(event, createdDate)).join('\n');
|
||||||
|
|
||||||
return `BEGIN:VCALENDAR\nVERSION:2.0\nCALSCALE:GREGORIAN\n${vCalendarText}\nEND:VCALENDAR`.split('\n').map((line) =>
|
return `BEGIN:VCALENDAR\nVERSION:2.0\nCALSCALE:GREGORIAN\n${vCalendarText}\nEND:VCALENDAR`.split('\n').map((line) =>
|
||||||
line.trimEnd()
|
line.trim()
|
||||||
).filter(
|
).filter(
|
||||||
Boolean,
|
Boolean,
|
||||||
).join('\n');
|
).join('\n');
|
||||||
|
|
@ -212,14 +212,14 @@ END:VALARM`
|
||||||
}
|
}
|
||||||
END:VEVENT`;
|
END:VEVENT`;
|
||||||
|
|
||||||
return vEventText.split('\n').map((line) => line.trimEnd()).filter(Boolean).join('\n');
|
return vEventText.split('\n').map((line) => line.trim()).filter(Boolean).join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateIcs(
|
export function updateIcs(
|
||||||
ics: string,
|
ics: string,
|
||||||
event: CalendarEvent,
|
event: CalendarEvent,
|
||||||
): string {
|
): string {
|
||||||
const lines = ics.split('\n').map((line) => line.trimEnd()).filter(Boolean);
|
const lines = ics.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||||
|
|
||||||
let replacedTitle = false;
|
let replacedTitle = false;
|
||||||
let replacedStartDate = false;
|
let replacedStartDate = false;
|
||||||
|
|
@ -313,7 +313,7 @@ export function updateIcs(
|
||||||
// Put the final lines back
|
// Put the final lines back
|
||||||
updatedIcsLines.push(...endLines);
|
updatedIcsLines.push(...endLines);
|
||||||
|
|
||||||
const updatedIcs = updatedIcsLines.map((line) => line.trimEnd()).filter(Boolean).join('\n');
|
const updatedIcs = updatedIcsLines.map((line) => line.trim()).filter(Boolean).join('\n');
|
||||||
|
|
||||||
return updatedIcs;
|
return updatedIcs;
|
||||||
}
|
}
|
||||||
|
|
@ -346,7 +346,7 @@ export function parseVCalendar(text: string): CalendarEvent[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
return previousLines;
|
return previousLines;
|
||||||
}, [] as string[]).map((line) => line.trimEnd()).filter(Boolean);
|
}, [] as string[]).map((line) => line.trim()).filter(Boolean);
|
||||||
|
|
||||||
const partialCalendarEvents: Partial<CalendarEvent>[] = [];
|
const partialCalendarEvents: Partial<CalendarEvent>[] = [];
|
||||||
|
|
||||||
|
|
@ -136,44 +136,6 @@ UID:incomplete
|
||||||
SUMMARY:Incomplete Event`,
|
SUMMARY:Incomplete Event`,
|
||||||
expected: [],
|
expected: [],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
input: `BEGIN:VEVENT
|
|
||||||
DTSTAMP:20250804T155354Z
|
|
||||||
DTSTART;VALUE=DATE:00000000
|
|
||||||
DTEND;VALUE=DATE:00000000
|
|
||||||
UID:f2476b38-d62f-4d02-a975-b54d97a629f0
|
|
||||||
RRULE:FREQ=YEARLY
|
|
||||||
SUMMARY:🎂 First Middle Last
|
|
||||||
TRANSP:TRANSPARENT
|
|
||||||
X-NEXTCLOUD-BC-FIELD-TYPE:BDAY
|
|
||||||
X-NEXTCLOUD-BC-UNKNOWN-YEAR:1
|
|
||||||
BEGIN:VALARM
|
|
||||||
TRIGGER;VALUE=DURATION:PT9H
|
|
||||||
ACTION:DISPLAY
|
|
||||||
DESCRIPTION:🎂 First Middle Last
|
|
||||||
And another line
|
|
||||||
END:VALARM
|
|
||||||
END:VEVENT`,
|
|
||||||
expected: [
|
|
||||||
`BEGIN:VEVENT
|
|
||||||
DTSTAMP:20250804T155354Z
|
|
||||||
DTSTART;VALUE=DATE:00000000
|
|
||||||
DTEND;VALUE=DATE:00000000
|
|
||||||
UID:f2476b38-d62f-4d02-a975-b54d97a629f0
|
|
||||||
RRULE:FREQ=YEARLY
|
|
||||||
SUMMARY:🎂 First Middle Last
|
|
||||||
TRANSP:TRANSPARENT
|
|
||||||
X-NEXTCLOUD-BC-FIELD-TYPE:BDAY
|
|
||||||
X-NEXTCLOUD-BC-UNKNOWN-YEAR:1
|
|
||||||
BEGIN:VALARM
|
|
||||||
TRIGGER;VALUE=DURATION:PT9H
|
|
||||||
ACTION:DISPLAY
|
|
||||||
DESCRIPTION:🎂 First Middle Last
|
|
||||||
And another line
|
|
||||||
END:VALARM
|
|
||||||
END:VEVENT`,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const test of tests) {
|
for (const test of tests) {
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Contact } from '/lib/models/contacts.ts';
|
import { Contact } from '/lib/models/contacts.ts';
|
||||||
|
|
||||||
export function getIdFromVCard(vCard: string): string {
|
export function getIdFromVCard(vCard: string): string {
|
||||||
const lines = vCard.split('\n').map((line) => line.trimEnd()).filter(Boolean);
|
const lines = vCard.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||||
|
|
||||||
// Loop through every line and find the UID line
|
// Loop through every line and find the UID line
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
|
|
@ -15,7 +15,7 @@ export function getIdFromVCard(vCard: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function splitTextIntoVCards(text: string): string[] {
|
export function splitTextIntoVCards(text: string): string[] {
|
||||||
const lines = text.split('\n').map((line) => line.trimEnd()).filter(Boolean);
|
const lines = text.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||||
const vCards: string[] = [];
|
const vCards: string[] = [];
|
||||||
const currentVCard: string[] = [];
|
const currentVCard: string[] = [];
|
||||||
|
|
||||||
|
|
@ -52,7 +52,7 @@ export function updateVCard(
|
||||||
notes?: string;
|
notes?: string;
|
||||||
},
|
},
|
||||||
): string {
|
): string {
|
||||||
const lines = vCard.split('\n').map((line) => line.trimEnd()).filter(Boolean);
|
const lines = vCard.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||||
|
|
||||||
let replacedName = false;
|
let replacedName = false;
|
||||||
let replacedFormattedName = false;
|
let replacedFormattedName = false;
|
||||||
|
|
@ -105,7 +105,7 @@ export function updateVCard(
|
||||||
|
|
||||||
updatedVCardLines.push('END:VCARD');
|
updatedVCardLines.push('END:VCARD');
|
||||||
|
|
||||||
const updatedVCard = updatedVCardLines.map((line) => line.trimEnd()).filter(Boolean).join('\n');
|
const updatedVCard = updatedVCardLines.map((line) => line.trim()).filter(Boolean).join('\n');
|
||||||
|
|
||||||
return updatedVCard;
|
return updatedVCard;
|
||||||
}
|
}
|
||||||
|
|
@ -121,7 +121,7 @@ function getSafelyUnescapedTextFromVCard(text: string): string {
|
||||||
type VCardVersion = '2.1' | '3.0' | '4.0';
|
type VCardVersion = '2.1' | '3.0' | '4.0';
|
||||||
|
|
||||||
export function parseVCard(text: string): Partial<Contact>[] {
|
export function parseVCard(text: string): Partial<Contact>[] {
|
||||||
const lines = text.split('\n').map((line) => line.trimEnd()).filter(Boolean);
|
const lines = text.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||||
|
|
||||||
const partialContacts: Partial<Contact>[] = [];
|
const partialContacts: Partial<Contact>[] = [];
|
||||||
|
|
||||||
502
lib/utils/contacts_test.ts
Normal file
502
lib/utils/contacts_test.ts
Normal file
|
|
@ -0,0 +1,502 @@
|
||||||
|
import { assertEquals, assertMatch } from '@std/assert';
|
||||||
|
|
||||||
|
import { generateVCard, getIdFromVCard, parseVCard, splitTextIntoVCards, updateVCard } from './contacts.ts';
|
||||||
|
|
||||||
|
Deno.test('that getIdFromVCard works', () => {
|
||||||
|
const tests: { input: string; expected?: string; shouldBeUUID?: boolean }[] = [
|
||||||
|
{
|
||||||
|
input: `BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
UID:12345-abcde-67890
|
||||||
|
FN:John Doe
|
||||||
|
END:VCARD`,
|
||||||
|
expected: '12345-abcde-67890',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: `BEGIN:VCARD
|
||||||
|
VERSION:3.0
|
||||||
|
FN:Jane Smith
|
||||||
|
UID:jane-smith-uuid
|
||||||
|
EMAIL:jane@example.com
|
||||||
|
END:VCARD`,
|
||||||
|
expected: 'jane-smith-uuid',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: `BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
FN:No UID Contact
|
||||||
|
EMAIL:nouid@example.com
|
||||||
|
END:VCARD`,
|
||||||
|
shouldBeUUID: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: `BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
UID: spaced-uid
|
||||||
|
FN:Spaced UID
|
||||||
|
END:VCARD`,
|
||||||
|
expected: 'spaced-uid',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const test of tests) {
|
||||||
|
const output = getIdFromVCard(test.input);
|
||||||
|
if (test.expected) {
|
||||||
|
assertEquals(output, test.expected);
|
||||||
|
} else if (test.shouldBeUUID) {
|
||||||
|
// Check that it's a valid UUID format
|
||||||
|
assertMatch(output, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('that splitTextIntoVCards works', () => {
|
||||||
|
const tests: { input: string; expected: string[] }[] = [
|
||||||
|
{
|
||||||
|
input: `BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
UID:1
|
||||||
|
FN:John Doe
|
||||||
|
END:VCARD
|
||||||
|
BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
UID:2
|
||||||
|
FN:Jane Smith
|
||||||
|
END:VCARD`,
|
||||||
|
expected: [
|
||||||
|
`BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
UID:1
|
||||||
|
FN:John Doe
|
||||||
|
END:VCARD`,
|
||||||
|
`BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
UID:2
|
||||||
|
FN:Jane Smith
|
||||||
|
END:VCARD`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: `BEGIN:VCARD
|
||||||
|
VERSION:3.0
|
||||||
|
FN:Single Contact
|
||||||
|
EMAIL:single@example.com
|
||||||
|
END:VCARD`,
|
||||||
|
expected: [
|
||||||
|
`BEGIN:VCARD
|
||||||
|
VERSION:3.0
|
||||||
|
FN:Single Contact
|
||||||
|
EMAIL:single@example.com
|
||||||
|
END:VCARD`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: '',
|
||||||
|
expected: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: `BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
FN:Incomplete Contact`,
|
||||||
|
expected: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const test of tests) {
|
||||||
|
const output = splitTextIntoVCards(test.input);
|
||||||
|
assertEquals(output, test.expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('that generateVCard works', () => {
|
||||||
|
const tests: { input: { contactId: string; firstName: string; lastName?: string }; expected: string }[] = [
|
||||||
|
{
|
||||||
|
input: { contactId: 'test-123', firstName: 'John', lastName: 'Doe' },
|
||||||
|
expected: `BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
N:Doe;John;
|
||||||
|
FN:John Doe
|
||||||
|
UID:test-123
|
||||||
|
END:VCARD`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: { contactId: 'single-name', firstName: 'Madonna' },
|
||||||
|
expected: `BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
N:;Madonna;
|
||||||
|
FN:Madonna
|
||||||
|
UID:single-name
|
||||||
|
END:VCARD`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: { contactId: 'special-chars', firstName: 'John,Test', lastName: 'Doe\nSmith' },
|
||||||
|
expected: `BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
N:Doe\\nSmith;John\\,Test;
|
||||||
|
FN:John\\,Test Doe\\nSmith
|
||||||
|
UID:special-chars
|
||||||
|
END:VCARD`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const test of tests) {
|
||||||
|
const output = generateVCard(test.input.contactId, test.input.firstName, test.input.lastName);
|
||||||
|
assertEquals(output, test.expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('that updateVCard works', () => {
|
||||||
|
const tests: {
|
||||||
|
input: {
|
||||||
|
vCard: string;
|
||||||
|
updates: {
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
notes?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
expected: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
input: {
|
||||||
|
vCard: `BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
UID:test-123
|
||||||
|
N:Doe;John;
|
||||||
|
FN:John Doe
|
||||||
|
END:VCARD`,
|
||||||
|
updates: { firstName: 'Jane', lastName: 'Smith' },
|
||||||
|
},
|
||||||
|
expected: `BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
UID:test-123
|
||||||
|
N:Smith;Jane;
|
||||||
|
FN:Jane Smith
|
||||||
|
END:VCARD`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: {
|
||||||
|
vCard: `BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
UID:test-456
|
||||||
|
N:Doe;John;
|
||||||
|
FN:John Doe
|
||||||
|
EMAIL:old@example.com
|
||||||
|
TEL:+1234567890
|
||||||
|
END:VCARD`,
|
||||||
|
updates: { email: 'new@example.com', phone: '+9876543210' },
|
||||||
|
},
|
||||||
|
expected: `BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
UID:test-456
|
||||||
|
N:Doe;John;
|
||||||
|
FN:John Doe
|
||||||
|
EMAIL:new@example.com
|
||||||
|
TEL:+9876543210
|
||||||
|
END:VCARD`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: {
|
||||||
|
vCard: `BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
UID:test-789
|
||||||
|
N:Doe;John;
|
||||||
|
FN:John Doe
|
||||||
|
END:VCARD`,
|
||||||
|
updates: { email: 'added@example.com', phone: '+1111111111', notes: 'Test notes' },
|
||||||
|
},
|
||||||
|
expected: `BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
UID:test-789
|
||||||
|
N:Doe;John;
|
||||||
|
FN:John Doe
|
||||||
|
EMAIL;TYPE=HOME:added@example.com
|
||||||
|
TEL;TYPE=HOME:+1111111111
|
||||||
|
NOTE:Test notes
|
||||||
|
END:VCARD`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: {
|
||||||
|
vCard: `BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
UID:test-special
|
||||||
|
N:Doe;John;
|
||||||
|
FN:John Doe
|
||||||
|
NOTE:Old notes
|
||||||
|
END:VCARD`,
|
||||||
|
updates: { notes: 'New notes\nwith newlines, and commas' },
|
||||||
|
},
|
||||||
|
expected: `BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
UID:test-special
|
||||||
|
N:Doe;John;
|
||||||
|
FN:John Doe
|
||||||
|
NOTE:New notes\\nwith newlines\\, and commas
|
||||||
|
END:VCARD`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: {
|
||||||
|
vCard: `BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
UID:test-carriage
|
||||||
|
N:Doe;John;
|
||||||
|
FN:John Doe
|
||||||
|
END:VCARD`,
|
||||||
|
updates: { notes: 'Notes with\r\ncarriage returns' },
|
||||||
|
},
|
||||||
|
expected: `BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
UID:test-carriage
|
||||||
|
N:Doe;John;
|
||||||
|
FN:John Doe
|
||||||
|
NOTE:Notes with\\ncarriage returns
|
||||||
|
END:VCARD`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const test of tests) {
|
||||||
|
const output = updateVCard(test.input.vCard, test.input.updates);
|
||||||
|
assertEquals(output, test.expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('that parseVCard works', () => {
|
||||||
|
const tests: {
|
||||||
|
input: string;
|
||||||
|
expected: Array<{
|
||||||
|
uid?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
middleNames?: string[];
|
||||||
|
title?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
notes?: string;
|
||||||
|
}>;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
input: `BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
UID:test-123
|
||||||
|
N:Doe;John;Middle;Jr
|
||||||
|
FN:John Middle Doe Jr
|
||||||
|
EMAIL;TYPE=HOME:john@example.com
|
||||||
|
TEL;TYPE=HOME:+1234567890
|
||||||
|
NOTE:Test contact notes
|
||||||
|
END:VCARD`,
|
||||||
|
expected: [{
|
||||||
|
uid: 'test-123',
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
middleNames: ['Middle'],
|
||||||
|
title: 'Jr',
|
||||||
|
email: 'john@example.com',
|
||||||
|
phone: '+1234567890',
|
||||||
|
notes: 'Test contact notes',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: `BEGIN:VCARD
|
||||||
|
VERSION:3.0
|
||||||
|
UID:test-456
|
||||||
|
N:Smith;Jane;;
|
||||||
|
FN:Jane Smith
|
||||||
|
EMAIL:jane@example.com
|
||||||
|
TEL:+9876543210
|
||||||
|
END:VCARD`,
|
||||||
|
expected: [{
|
||||||
|
uid: 'test-456',
|
||||||
|
firstName: 'Jane',
|
||||||
|
lastName: 'Smith',
|
||||||
|
middleNames: [],
|
||||||
|
title: '',
|
||||||
|
email: 'jane@example.com',
|
||||||
|
phone: '+9876543210',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: `BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
UID:multi-1
|
||||||
|
N:Doe;John;
|
||||||
|
FN:John Doe
|
||||||
|
END:VCARD
|
||||||
|
BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
UID:multi-2
|
||||||
|
N:Smith;Jane;
|
||||||
|
FN:Jane Smith
|
||||||
|
EMAIL;PREF=1:jane@example.com
|
||||||
|
END:VCARD`,
|
||||||
|
expected: [
|
||||||
|
{
|
||||||
|
uid: 'multi-1',
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
middleNames: [],
|
||||||
|
title: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: 'multi-2',
|
||||||
|
firstName: 'Jane',
|
||||||
|
lastName: 'Smith',
|
||||||
|
middleNames: [],
|
||||||
|
title: '',
|
||||||
|
email: 'jane@example.com',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: `BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
UID:escaped-contact
|
||||||
|
N:Test;Contact;
|
||||||
|
FN:Contact Test
|
||||||
|
NOTE:Notes with\\nescaped newlines\\, and commas
|
||||||
|
END:VCARD`,
|
||||||
|
expected: [{
|
||||||
|
uid: 'escaped-contact',
|
||||||
|
firstName: 'Contact',
|
||||||
|
lastName: 'Test',
|
||||||
|
middleNames: [],
|
||||||
|
title: '',
|
||||||
|
notes: 'Notes with\nescaped newlines, and commas',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: `BEGIN:VCARD
|
||||||
|
VERSION:2.1
|
||||||
|
UID:version-21
|
||||||
|
N:Old;Format;
|
||||||
|
FN:Format Old
|
||||||
|
EMAIL:old@example.com
|
||||||
|
TEL:+1111111111
|
||||||
|
END:VCARD`,
|
||||||
|
expected: [{
|
||||||
|
uid: 'version-21',
|
||||||
|
firstName: 'Format',
|
||||||
|
lastName: 'Old',
|
||||||
|
middleNames: [],
|
||||||
|
title: '',
|
||||||
|
email: 'old@example.com',
|
||||||
|
phone: '+1111111111',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: `BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
UID:email-variations
|
||||||
|
N:Test;Email;
|
||||||
|
FN:Email Test
|
||||||
|
EMAIL:direct@example.com
|
||||||
|
EMAIL;TYPE=WORK:work@example.com
|
||||||
|
TEL:+1234567890
|
||||||
|
TEL;TYPE=WORK:+9876543210
|
||||||
|
END:VCARD`,
|
||||||
|
expected: [{
|
||||||
|
uid: 'email-variations',
|
||||||
|
firstName: 'Email',
|
||||||
|
lastName: 'Test',
|
||||||
|
middleNames: [],
|
||||||
|
title: '',
|
||||||
|
email: 'direct@example.com', // Only first email is captured
|
||||||
|
phone: '+1234567890', // Only first phone is captured
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: `BEGIN:VCARD
|
||||||
|
VERSION:5.0
|
||||||
|
UID:invalid-version
|
||||||
|
N:Invalid;Version;
|
||||||
|
FN:Version Invalid
|
||||||
|
END:VCARD`,
|
||||||
|
expected: [{
|
||||||
|
uid: 'invalid-version',
|
||||||
|
firstName: 'Version',
|
||||||
|
lastName: 'Invalid',
|
||||||
|
middleNames: [],
|
||||||
|
title: '',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: `BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
UID:no-first-name
|
||||||
|
N:LastOnly;;
|
||||||
|
FN:LastOnly
|
||||||
|
END:VCARD`,
|
||||||
|
expected: [{ uid: 'no-first-name' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: `BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
UID:empty-uid
|
||||||
|
N:Test;Empty;
|
||||||
|
FN:Empty Test
|
||||||
|
END:VCARD`,
|
||||||
|
expected: [{
|
||||||
|
firstName: 'Empty',
|
||||||
|
lastName: 'Test',
|
||||||
|
middleNames: [],
|
||||||
|
title: '',
|
||||||
|
uid: 'empty-uid',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const test of tests) {
|
||||||
|
const output = parseVCard(test.input);
|
||||||
|
assertEquals(output, test.expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('that parseVCard handles edge cases', () => {
|
||||||
|
const edgeCases: { input: string; description: string; expected: any[] }[] = [
|
||||||
|
{
|
||||||
|
input: '',
|
||||||
|
description: 'empty string',
|
||||||
|
expected: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: 'Not a vCard at all',
|
||||||
|
description: 'invalid format',
|
||||||
|
expected: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: `BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
UID:incomplete`,
|
||||||
|
description: 'incomplete vCard without END',
|
||||||
|
expected: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: `BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
UID:missing-required
|
||||||
|
FN:Missing Required Fields
|
||||||
|
END:VCARD`,
|
||||||
|
description: 'vCard without N field',
|
||||||
|
expected: [{ uid: 'missing-required' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: `BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
UID:empty-fields
|
||||||
|
N:;;;
|
||||||
|
FN:Empty Fields
|
||||||
|
EMAIL:
|
||||||
|
TEL:
|
||||||
|
NOTE:
|
||||||
|
END:VCARD`,
|
||||||
|
description: 'vCard with empty field values',
|
||||||
|
expected: [{ uid: 'empty-fields', notes: '' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const test of edgeCases) {
|
||||||
|
const output = parseVCard(test.input);
|
||||||
|
assertEquals(output, test.expected, `Failed for: ${test.description}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -17,21 +17,9 @@ export function humanFileSize(bytes: number) {
|
||||||
++unitIndex;
|
++unitIndex;
|
||||||
} while (Math.round(Math.abs(bytes) * roundedPower) / roundedPower >= 1024 && unitIndex < units.length - 1);
|
} while (Math.round(Math.abs(bytes) * roundedPower) / roundedPower >= 1024 && unitIndex < units.length - 1);
|
||||||
|
|
||||||
if (bytes.toFixed(2).split('.')[1] === '00') {
|
|
||||||
return `${bytes.toFixed(0)} ${units[unitIndex]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${bytes.toFixed(2)} ${units[unitIndex]}`;
|
return `${bytes.toFixed(2)} ${units[unitIndex]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bytesFromHumanFileSize(humanFileSize: string) {
|
|
||||||
const [numberString, unit] = humanFileSize.split(' ');
|
|
||||||
const number = Number.parseFloat(numberString);
|
|
||||||
|
|
||||||
return number *
|
|
||||||
(unit === 'B' ? 1 : 1024 ** (['KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'].indexOf(unit) + 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sortEntriesByName(entryA: Deno.DirEntry, entryB: Deno.DirEntry) {
|
export function sortEntriesByName(entryA: Deno.DirEntry, entryB: Deno.DirEntry) {
|
||||||
const nameA = entryA.name.toLocaleLowerCase();
|
const nameA = entryA.name.toLocaleLowerCase();
|
||||||
const nameB = entryB.name.toLocaleLowerCase();
|
const nameB = entryB.name.toLocaleLowerCase();
|
||||||
37
lib/utils/files_test.ts
Normal file
37
lib/utils/files_test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { assertEquals } from '@std/assert';
|
||||||
|
|
||||||
|
import { humanFileSize } from './files.ts';
|
||||||
|
|
||||||
|
Deno.test('that humanFileSize works', () => {
|
||||||
|
const tests: { input: number; expected: string }[] = [
|
||||||
|
{
|
||||||
|
input: 1000,
|
||||||
|
expected: '1000 B',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: 1024,
|
||||||
|
expected: '1.00 KB',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: 10000,
|
||||||
|
expected: '9.77 KB',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: 1,
|
||||||
|
expected: '1 B',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: 1048576,
|
||||||
|
expected: '1.00 MB',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: 1073741824,
|
||||||
|
expected: '1.00 GB',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const test of tests) {
|
||||||
|
const output = humanFileSize(test.input);
|
||||||
|
assertEquals(output, test.expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
import { renderToString } from 'preact-render-to-string';
|
|
||||||
|
|
||||||
import denoConfig from '/deno.json' with { type: 'json' };
|
|
||||||
import { RequestHandlerParams } from '/lib/page.ts';
|
|
||||||
import { AppConfig } from '/lib/config.ts';
|
|
||||||
import { escapeHtml, html } from '/public/ts/utils/misc.ts';
|
|
||||||
|
|
||||||
import Header from '/components/Header.tsx';
|
|
||||||
|
|
||||||
interface BasicLayoutOptions
|
|
||||||
extends Pick<RequestHandlerParams, 'user' | 'session' | 'request' | 'match' | 'isRunningLocally'> {
|
|
||||||
currentPath: string;
|
|
||||||
titlePrefix: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function basicLayout(
|
|
||||||
htmlContent: string,
|
|
||||||
{ currentPath, titlePrefix, description, user }: BasicLayoutOptions,
|
|
||||||
) {
|
|
||||||
const config = await AppConfig.getConfig();
|
|
||||||
|
|
||||||
const defaultTitle = config.visuals.title || 'bewCloud is a modern and simpler alternative to Nextcloud and ownCloud';
|
|
||||||
const defaultDescription = config.visuals.description || `Have your files under your own control.`;
|
|
||||||
const enabledApps = config.core.enabledApps;
|
|
||||||
|
|
||||||
let title = defaultTitle;
|
|
||||||
|
|
||||||
if (titlePrefix) {
|
|
||||||
title = `${titlePrefix} - bewCloud`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const headerReactNode = <Header route={currentPath} user={user} enabledApps={enabledApps} />;
|
|
||||||
|
|
||||||
const headerHtml = renderToString(headerReactNode);
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" dir="ltr" class="h-full bg-slate-800">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>${escapeHtml(title)}</title>
|
|
||||||
<meta name="description" content="${escapeHtml(description || defaultDescription)}">
|
|
||||||
<meta name="author" content="Bruno Bernardino">
|
|
||||||
<meta property="og:title" content="${escapeHtml(defaultTitle)}" />
|
|
||||||
<link rel="icon" href="/public/images/favicon-dark.png" type="image/png" />
|
|
||||||
<link rel="apple-touch-icon" href="/public/images/favicon-dark.png" />
|
|
||||||
<link rel="manifest" href="/public/manifest.json" />
|
|
||||||
<link rel="stylesheet" href="/public/scss/style.scss" />
|
|
||||||
<link rel="stylesheet" href="/public/css/tailwind.css" />
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<script type="importmap">
|
|
||||||
${JSON.stringify(importMap)}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<body class="h-full">
|
|
||||||
${headerHtml} ${htmlContent}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const importMap = denoConfig.frontendImports.reduce(
|
|
||||||
(importsObject: { imports: Record<string, string> }, importName: string) => {
|
|
||||||
const url = new URL(denoConfig.imports[importName as keyof typeof denoConfig.imports]).toString();
|
|
||||||
let fileName = url.replace('https://esm.sh/', '').split('?')[0].trim();
|
|
||||||
if (!fileName.endsWith('.mjs')) {
|
|
||||||
fileName = `${fileName}.mjs`;
|
|
||||||
}
|
|
||||||
importsObject.imports[importName] = `/public/js/${fileName}`;
|
|
||||||
return importsObject;
|
|
||||||
},
|
|
||||||
{ imports: {} },
|
|
||||||
);
|
|
||||||
|
|
||||||
export async function basicLayoutResponse(htmlContent: string, options: BasicLayoutOptions) {
|
|
||||||
const headers: HeadersInit = {
|
|
||||||
'content-type': 'text/html; charset=utf-8',
|
|
||||||
'content-security-policy':
|
|
||||||
`default-src 'self'; child-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'`,
|
|
||||||
'x-frame-options': 'DENY',
|
|
||||||
'x-content-type-options': 'nosniff',
|
|
||||||
'strict-transport-security': 'max-age=31536000; includeSubDomains; preload',
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Response(await basicLayout(htmlContent, options), {
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,64 +1,270 @@
|
||||||
import { serveFile } from '@std/http/file-server';
|
import { currencyMap } from '/lib/types.ts';
|
||||||
import { transpile } from 'deno/emit';
|
import { SupportedCurrencySymbol } from '/lib/types.ts';
|
||||||
import sass from 'sass';
|
|
||||||
|
|
||||||
async function transpileTs(content: string, specifier: URL) {
|
export function isRunningLocally(request: Request): boolean {
|
||||||
const urlStr = specifier.toString();
|
try {
|
||||||
const result = await transpile(specifier, {
|
const url = new URL(request.url);
|
||||||
load(specifier: string) {
|
const hostname = url.hostname;
|
||||||
if (specifier !== urlStr) {
|
|
||||||
return Promise.resolve({ kind: 'module', specifier, content: '' });
|
// Local hostnames check
|
||||||
|
if (['localhost', '127.0.0.1', '0.0.0.0'].includes(hostname)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private IP ranges check
|
||||||
|
const ipParts = hostname.split('.').map(Number);
|
||||||
|
|
||||||
|
// Check if the IP address is valid
|
||||||
|
if (ipParts.length !== 4 || ipParts.some((part) => isNaN(part) || part < 0 || part > 255)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10.0.0.0 - 10.255.255.255
|
||||||
|
if (ipParts[0] === 10) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 172.16.0.0 - 172.31.255.255
|
||||||
|
if (ipParts[0] === 172 && ipParts[1] >= 16 && ipParts[1] <= 31) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 192.168.0.0 - 192.168.255.255
|
||||||
|
if (ipParts[0] === 192 && ipParts[1] === 168) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
console.info('Failed to check if the request is running locally', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeHtml(unsafe: string) {
|
||||||
|
return unsafe.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeXml(unsafe: string) {
|
||||||
|
return escapeHtml(unsafe).replaceAll('\r', ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateRandomCode(length = 6) {
|
||||||
|
const getRandomDigit = () => Math.floor(Math.random() * (10)); // 0-9
|
||||||
|
|
||||||
|
const codeDigits = Array.from({ length }).map(getRandomDigit);
|
||||||
|
|
||||||
|
return codeDigits.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** This generates a random string, including unicode characters. */
|
||||||
|
export function generateRandomString(length = 10) {
|
||||||
|
const array = new Uint8Array(length * 2);
|
||||||
|
crypto.getRandomValues(array);
|
||||||
|
|
||||||
|
return Array.from(array)
|
||||||
|
.map((byte) => String.fromCharCode(byte))
|
||||||
|
.join('')
|
||||||
|
.slice(0, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateHash(value: string, algorithm: AlgorithmIdentifier) {
|
||||||
|
const hashedValueData = await crypto.subtle.digest(
|
||||||
|
algorithm,
|
||||||
|
new TextEncoder().encode(value),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hashedValue = Array.from(new Uint8Array(hashedValueData)).map(
|
||||||
|
(byte) => byte.toString(16).padStart(2, '0'),
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
return hashedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitArrayInChunks<T = any>(array: T[], chunkLength: number) {
|
||||||
|
const chunks = [];
|
||||||
|
let chunkIndex = 0;
|
||||||
|
const arrayLength = array.length;
|
||||||
|
|
||||||
|
while (chunkIndex < arrayLength) {
|
||||||
|
chunks.push(array.slice(chunkIndex, chunkIndex += chunkLength));
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateEmail(email: string) {
|
||||||
|
const trimmedEmail = (email || '').trim().toLocaleLowerCase();
|
||||||
|
if (!trimmedEmail) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredCharsNotInEdges = ['@', '.'];
|
||||||
|
return requiredCharsNotInEdges.every((char) =>
|
||||||
|
trimmedEmail.includes(char) && !trimmedEmail.startsWith(char) && !trimmedEmail.endsWith(char)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateUrl(url: string) {
|
||||||
|
const trimmedUrl = (url || '').trim().toLocaleLowerCase();
|
||||||
|
if (!trimmedUrl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmedUrl.includes('://')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocolIndex = trimmedUrl.indexOf('://');
|
||||||
|
const urlAfterProtocol = trimmedUrl.substring(protocolIndex + 3);
|
||||||
|
|
||||||
|
if (!urlAfterProtocol) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adapted from https://gist.github.com/fasiha/7f20043a12ce93401d8473aee037d90a
|
||||||
|
export async function concurrentPromises<T>(
|
||||||
|
generators: (() => Promise<T>)[],
|
||||||
|
maxConcurrency: number,
|
||||||
|
): Promise<T[]> {
|
||||||
|
const iterator = generators.entries();
|
||||||
|
|
||||||
|
const results: T[] = [];
|
||||||
|
|
||||||
|
let hasFailed = false;
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(Array(maxConcurrency), async () => {
|
||||||
|
for (const [index, promiseToExecute] of iterator) {
|
||||||
|
if (hasFailed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
results[index] = await promiseToExecute();
|
||||||
|
} catch (error) {
|
||||||
|
hasFailed = true;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Promise.resolve({ kind: 'module', specifier, content });
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_RESPONSE_TIME_IN_MS = 10_000;
|
||||||
|
|
||||||
|
export async function fetchUrl(url: string) {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const requestCancelTimeout = setTimeout(() => {
|
||||||
|
abortController.abort();
|
||||||
|
}, MAX_RESPONSE_TIME_IN_MS);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (requestCancelTimeout) {
|
||||||
|
clearTimeout(requestCancelTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlContents = await response.text();
|
||||||
|
return urlContents;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUrlAsGooglebot(url: string) {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const requestCancelTimeout = setTimeout(() => {
|
||||||
|
abortController.abort();
|
||||||
|
}, MAX_RESPONSE_TIME_IN_MS);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
|
||||||
},
|
},
|
||||||
|
signal: abortController.signal,
|
||||||
});
|
});
|
||||||
return result.get(urlStr) || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function serveFileWithTs(request: Request, filePath: string, extraHeaders?: ResponseInit['headers']) {
|
if (requestCancelTimeout) {
|
||||||
const response = await serveFile(request, filePath);
|
clearTimeout(requestCancelTimeout);
|
||||||
|
|
||||||
if (response.status !== 200) {
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tsCode = await response.text();
|
const urlContents = await response.text();
|
||||||
const jsCode = await transpileTs(tsCode, new URL('file:///src.ts'));
|
return urlContents;
|
||||||
const { headers } = response;
|
}
|
||||||
headers.set('content-type', 'application/javascript; charset=utf-8');
|
|
||||||
headers.delete('content-length');
|
|
||||||
|
|
||||||
return new Response(jsCode, {
|
export async function fetchUrlWithProxy(url: string) {
|
||||||
status: response.status,
|
const abortController = new AbortController();
|
||||||
statusText: response.statusText,
|
const requestCancelTimeout = setTimeout(() => {
|
||||||
headers,
|
abortController.abort();
|
||||||
...(extraHeaders || {}),
|
}, MAX_RESPONSE_TIME_IN_MS);
|
||||||
|
|
||||||
|
const response = await fetch(`https://api.allorigins.win/raw?url=${url}`, {
|
||||||
|
signal: abortController.signal,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
function transpileSass(content: string) {
|
if (requestCancelTimeout) {
|
||||||
const compiler = sass(content);
|
clearTimeout(requestCancelTimeout);
|
||||||
|
|
||||||
return compiler.to_string('compressed') as string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function serveFileWithSass(request: Request, filePath: string, extraHeaders?: ResponseInit['headers']) {
|
|
||||||
const response = await serveFile(request, filePath);
|
|
||||||
|
|
||||||
if (response.status !== 200) {
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sassCode = await response.text();
|
const urlContents = await response.text();
|
||||||
const cssCode = transpileSass(sassCode);
|
return urlContents;
|
||||||
const { headers } = response;
|
}
|
||||||
headers.set('content-type', 'text/css; charset=utf-8');
|
|
||||||
headers.delete('content-length');
|
export async function fetchUrlWithRetries(url: string) {
|
||||||
|
try {
|
||||||
return new Response(cssCode, {
|
const text = await fetchUrl(url);
|
||||||
status: response.status,
|
return text;
|
||||||
statusText: response.statusText,
|
} catch (_error) {
|
||||||
headers,
|
try {
|
||||||
...(extraHeaders || {}),
|
const text = await fetchUrlAsGooglebot(url);
|
||||||
});
|
return text;
|
||||||
|
} catch (_error) {
|
||||||
|
const text = await fetchUrlWithProxy(url);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertFormDataToObject(formData: FormData): Record<string, any> {
|
||||||
|
return JSON.parse(JSON.stringify(Object.fromEntries(formData)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertObjectToFormData(formDataObject: Record<string, any>): FormData {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
for (const key of Object.keys(formDataObject || {})) {
|
||||||
|
if (Array.isArray(formDataObject[key])) {
|
||||||
|
formData.append(key, formDataObject[key].join(','));
|
||||||
|
} else {
|
||||||
|
formData.append(key, formDataObject[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return formData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const capitalizeWord = (string: string) => {
|
||||||
|
return `${string.charAt(0).toLocaleUpperCase()}${string.slice(1)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getRandomItem<T>(items: Readonly<Array<T>>): T {
|
||||||
|
return items[Math.floor(Math.random() * items.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNumber(currency: SupportedCurrencySymbol, number: number) {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currencyMap.get(currency) || 'USD',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(Number.parseFloat(`${number}`.replace(',', '.')));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatInputToNumber(numberInput: number | string): number {
|
||||||
|
return Number.parseFloat(`${numberInput}`.replace(',', '.'));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// This file contains some multi-factor authentication utilities that are isomorphic.
|
// This file contains some multi-factor authentication utilities that are isomorphic.
|
||||||
|
|
||||||
import { MultiFactorAuthMethod, User } from '/lib/types.ts';
|
import { MultiFactorAuthMethod, MultiFactorAuthMethodType, User } from '/lib/types.ts';
|
||||||
|
|
||||||
function getMultiFactorAuthMethodsFromUser(
|
function getMultiFactorAuthMethodsFromUser(
|
||||||
user: { extra: Pick<User['extra'], 'multi_factor_auth_methods'> },
|
user: { extra: Pick<User['extra'], 'multi_factor_auth_methods'> },
|
||||||
151
main.ts
151
main.ts
|
|
@ -1,144 +1,13 @@
|
||||||
import routes, { Route } from './routes.ts';
|
/// <reference no-default-lib="true" />
|
||||||
import { startCrons } from './crons/index.ts';
|
/// <reference lib="dom" />
|
||||||
import { Page } from './lib/page.ts';
|
/// <reference lib="dom.iterable" />
|
||||||
import { AppConfig } from './lib/config.ts';
|
/// <reference lib="dom.asynciterable" />
|
||||||
|
/// <reference lib="deno.ns" />
|
||||||
|
|
||||||
const config = await AppConfig.getConfig();
|
import '@std/dotenv/load';
|
||||||
const MAX_REQUEST_SIZE_IN_MEGABYTES = config.core.maxRequestSizeInMegabytes;
|
|
||||||
const MAX_UPLOAD_SIZE_IN_MEGABYTES = config.files.maxUploadSizeInMegabytes;
|
|
||||||
const MAX_REQUEST_SIZE_IN_BYTES = MAX_REQUEST_SIZE_IN_MEGABYTES * 1024 * 1024;
|
|
||||||
const MAX_UPLOAD_SIZE_IN_BYTES = MAX_UPLOAD_SIZE_IN_MEGABYTES * 1024 * 1024;
|
|
||||||
|
|
||||||
function applyCorsHeadersToResponse(origin: string, response: Response) {
|
import { start } from 'fresh/server.ts';
|
||||||
const headers = response.headers;
|
import manifest from './fresh.gen.ts';
|
||||||
headers.set('Access-Control-Allow-Origin', origin);
|
import config from './fresh.config.ts';
|
||||||
headers.set('Access-Control-Allow-Credentials', 'true');
|
|
||||||
headers.set(
|
|
||||||
'Access-Control-Allow-Headers',
|
|
||||||
'Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With',
|
|
||||||
);
|
|
||||||
headers.set(
|
|
||||||
'Access-Control-Allow-Methods',
|
|
||||||
'POST, OPTIONS, GET, PUT, DELETE',
|
|
||||||
);
|
|
||||||
if (!headers.get('content-security-policy')) {
|
|
||||||
headers.set(
|
|
||||||
'Content-Security-Policy',
|
|
||||||
"default-src 'self'; child-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!headers.get('x-frame-options')) {
|
|
||||||
headers.set('X-Frame-Options', 'DENY');
|
|
||||||
}
|
|
||||||
if (!headers.get('x-content-type-options')) {
|
|
||||||
headers.set('X-Content-Type-Options', 'nosniff');
|
|
||||||
}
|
|
||||||
if (!headers.get('strict-transport-security')) {
|
|
||||||
headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleLogging(request: Request, response: Response) {
|
await start(manifest, config);
|
||||||
console.info(`${new Date().toISOString()} - [${response.status}] ${request.method} ${request.url}`);
|
|
||||||
// NOTE: Uncomment when debugging WebDav/CardDav/CalDav stuff
|
|
||||||
// if (request.url.includes('/dav') || request.url.includes('/carddav') || request.url.includes('/caldav')) {
|
|
||||||
// console.info(`Request`, request.headers);
|
|
||||||
// try {
|
|
||||||
// console.info((await request.clone().text()) || '<No Body>');
|
|
||||||
// } catch (_error) {
|
|
||||||
// console.info('<No Body>');
|
|
||||||
// }
|
|
||||||
// console.info(`Response`, response.headers);
|
|
||||||
// console.info(`Status`, response.status);
|
|
||||||
// }
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handler(request: Request) {
|
|
||||||
const contentLength = request.headers.get('content-length');
|
|
||||||
const path = new URL(request.url).pathname;
|
|
||||||
|
|
||||||
const isUploadRequest = path.startsWith('/api/files/upload') || path.startsWith('/dav');
|
|
||||||
const maxSizeInBytes = isUploadRequest ? MAX_UPLOAD_SIZE_IN_BYTES : MAX_REQUEST_SIZE_IN_BYTES;
|
|
||||||
|
|
||||||
if (contentLength && parseInt(contentLength, 10) > maxSizeInBytes) {
|
|
||||||
return new Response('Payload too large', { status: 413 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const origin = request.headers.get('Origin') || '*';
|
|
||||||
|
|
||||||
// CORS headers for non-DAV routes
|
|
||||||
if (
|
|
||||||
request.method == 'OPTIONS' && path !== '/dav' && !path.startsWith('/dav/') && path !== '/carddav' &&
|
|
||||||
!path.startsWith('/carddav/') && path !== '/caldav' && !path.startsWith('/caldav/')
|
|
||||||
) {
|
|
||||||
const response = new Response(null, {
|
|
||||||
status: 204,
|
|
||||||
});
|
|
||||||
const headers = response.headers;
|
|
||||||
headers.set('Access-Control-Allow-Origin', origin);
|
|
||||||
headers.set('Access-Control-Allow-Credentials', 'true');
|
|
||||||
headers.set('Access-Control-Allow-Methods', 'POST, OPTIONS, GET, PUT, DELETE');
|
|
||||||
headers.set(
|
|
||||||
'Access-Control-Allow-Headers',
|
|
||||||
'Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With',
|
|
||||||
);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
const routeKeys = Object.keys(routes);
|
|
||||||
|
|
||||||
for (const routeKey of routeKeys) {
|
|
||||||
const route: Route = routes[routeKey];
|
|
||||||
const match = route.pattern.exec(request.url);
|
|
||||||
|
|
||||||
if (match) {
|
|
||||||
const response = await route.handler(request, match);
|
|
||||||
|
|
||||||
applyCorsHeadersToResponse(origin, response);
|
|
||||||
handleLogging(request, response);
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const notFoundPage: Page = (await import(`/pages/404.ts`)).default;
|
|
||||||
|
|
||||||
const response = await notFoundPage.get!({
|
|
||||||
request,
|
|
||||||
match: new URLPattern({ pathname: '/' }).exec(request.url) as URLPatternResult,
|
|
||||||
isRunningLocally: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
handleLogging(request, response);
|
|
||||||
|
|
||||||
return new Response(response.body, {
|
|
||||||
status: 404,
|
|
||||||
headers: response.headers,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function notifyServiceManagerReady() {
|
|
||||||
const socketAddress = Deno.env.get('NOTIFY_SOCKET');
|
|
||||||
if (typeof socketAddress !== 'string') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await (new Deno.Command('systemd-notify', {
|
|
||||||
args: ['--ready', `MESSAGE=bewCloud is ready`],
|
|
||||||
})).output();
|
|
||||||
if (!result.success) {
|
|
||||||
const output = new TextDecoder().decode(result.stderr);
|
|
||||||
throw new Deno.errors.NotCapable(`Failed to execute “systemd-notify”: ${output} (code ${result.code})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const abortController = new AbortController();
|
|
||||||
|
|
||||||
const PORT = Deno.env.get('PORT') || 8000;
|
|
||||||
|
|
||||||
Deno.serve({ port: PORT as number, signal: abortController.signal }, handler);
|
|
||||||
|
|
||||||
startCrons();
|
|
||||||
notifyServiceManagerReady();
|
|
||||||
|
|
|
||||||
58
main_test.ts
58
main_test.ts
|
|
@ -1,44 +1,46 @@
|
||||||
import { assertEquals } from '@std/assert';
|
import { assertEquals } from '@std/assert';
|
||||||
|
import { createHandler, ServeHandlerInfo } from 'fresh/server.ts';
|
||||||
|
|
||||||
import { abortController } from './main.ts';
|
import manifest from './fresh.gen.ts';
|
||||||
|
import config from './fresh.config.ts';
|
||||||
|
|
||||||
const baseUrl = 'http://localhost:8000';
|
// @ts-ignore Deno's newer types seem to have messed this up
|
||||||
|
const CONN_INFO: ServeHandlerInfo = {
|
||||||
|
remoteAddr: { hostname: '127.0.0.1', port: 53496, transport: 'tcp' },
|
||||||
|
};
|
||||||
|
|
||||||
Deno.test({
|
Deno.test('Basic routes', async (testContext) => {
|
||||||
name: 'HTTP Server',
|
const handler = await createHandler(manifest, config);
|
||||||
fn: async () => {
|
|
||||||
let response = await fetch(`${baseUrl}/login`);
|
|
||||||
assertEquals(response.status, 200);
|
|
||||||
|
|
||||||
let responseText = await response.text();
|
await testContext.step('#1 GET /', async () => {
|
||||||
assertEquals(responseText.includes('bewCloud'), true);
|
const response = await handler(new Request('http://127.0.0.1/'), CONN_INFO);
|
||||||
|
|
||||||
response = await fetch(`${baseUrl}/`, {
|
|
||||||
redirect: 'manual',
|
|
||||||
});
|
|
||||||
assertEquals(response.status, 303);
|
assertEquals(response.status, 303);
|
||||||
assertEquals(response.headers.get('Location'), `/login`);
|
});
|
||||||
|
|
||||||
response = await fetch(`${baseUrl}/blah`);
|
await testContext.step('#2 GET /login', async () => {
|
||||||
|
const response = await handler(new Request('http://127.0.0.1/login'), CONN_INFO);
|
||||||
|
const text = await response.text();
|
||||||
|
assertEquals(text.includes('bewCloud'), true);
|
||||||
|
assertEquals(response.status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
await testContext.step('#3 GET /blah', async () => {
|
||||||
|
const response = await handler(new Request('http://127.0.0.1/blah'), CONN_INFO);
|
||||||
|
const text = await response.text();
|
||||||
|
assertEquals(text.includes('404 - Page not found'), true);
|
||||||
assertEquals(response.status, 404);
|
assertEquals(response.status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
responseText = await response.text();
|
await testContext.step('#4 POST /login', async () => {
|
||||||
assertEquals(responseText.includes('404 - Page not found'), true);
|
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('email', 'user@example.com');
|
formData.append('email', 'user@example.com');
|
||||||
|
const request = new Request('http://127.0.0.1/login', {
|
||||||
response = await fetch(`${baseUrl}/login`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
const response = await handler(request, CONN_INFO);
|
||||||
|
const text = await response.text();
|
||||||
|
assertEquals(text.includes('Error: Password is too short'), true);
|
||||||
assertEquals(response.status, 200);
|
assertEquals(response.status, 200);
|
||||||
|
});
|
||||||
responseText = await response.text();
|
|
||||||
assertEquals(responseText.includes('Error: Password is too short'), true);
|
|
||||||
|
|
||||||
abortController.abort('Test finished');
|
|
||||||
},
|
|
||||||
sanitizeResources: false,
|
|
||||||
sanitizeOps: false,
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import page from '/lib/page.ts';
|
|
||||||
|
|
||||||
function get() {
|
|
||||||
return new Response('Redirecting...', { status: 301, headers: { 'Location': '/caldav' } });
|
|
||||||
}
|
|
||||||
|
|
||||||
export default page({
|
|
||||||
get,
|
|
||||||
accessMode: 'public',
|
|
||||||
});
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import page from '/lib/page.ts';
|
|
||||||
|
|
||||||
function get() {
|
|
||||||
return new Response('Redirecting...', { status: 301, headers: { 'Location': '/carddav' } });
|
|
||||||
}
|
|
||||||
|
|
||||||
export default page({
|
|
||||||
get,
|
|
||||||
accessMode: 'public',
|
|
||||||
});
|
|
||||||
47
pages/404.ts
47
pages/404.ts
|
|
@ -1,47 +0,0 @@
|
||||||
import { basicLayoutResponse } from '/lib/utils/layout.tsx';
|
|
||||||
import { html } from '/public/ts/utils/misc.ts';
|
|
||||||
import page, { RequestHandlerParams } from '/lib/page.ts';
|
|
||||||
import { User } from '/lib/types.ts';
|
|
||||||
|
|
||||||
const titlePrefix = '404 - Page not found';
|
|
||||||
|
|
||||||
function get({ request, match, user, session, isRunningLocally }: RequestHandlerParams) {
|
|
||||||
const htmlContent = defaultHtmlContent(user);
|
|
||||||
|
|
||||||
return basicLayoutResponse(htmlContent, {
|
|
||||||
currentPath: match?.pathname.input || '/',
|
|
||||||
titlePrefix,
|
|
||||||
match,
|
|
||||||
request,
|
|
||||||
user,
|
|
||||||
session,
|
|
||||||
isRunningLocally,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultHtmlContent(user?: User | null) {
|
|
||||||
const htmlContent = html`
|
|
||||||
<main>
|
|
||||||
<section class="max-w-3xl mx-auto flex flex-col items-center justify-center">
|
|
||||||
${!user
|
|
||||||
? (
|
|
||||||
html`
|
|
||||||
<h1>404 - Page not found</h1>
|
|
||||||
`
|
|
||||||
)
|
|
||||||
: ''}
|
|
||||||
<p class="my-4">
|
|
||||||
The page you were looking for doesn't exist.
|
|
||||||
</p>
|
|
||||||
<a href="/">Go back home</a>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
`;
|
|
||||||
|
|
||||||
return htmlContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default page({
|
|
||||||
get,
|
|
||||||
accessMode: 'public',
|
|
||||||
});
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
import page, { RequestHandlerParams } from '/lib/page.ts';
|
|
||||||
|
|
||||||
import { PASSWORD_SALT } from '/lib/auth.ts';
|
|
||||||
import { generateHash } from '/public/ts/utils/misc.ts';
|
|
||||||
import { UserModel } from '/lib/models/user.ts';
|
|
||||||
import { getMultiFactorAuthMethodByIdFromUser } from '/public/ts/utils/multi-factor-auth.ts';
|
|
||||||
import { AppConfig } from '/lib/config.ts';
|
|
||||||
import { MultiFactorAuthModel } from '/lib/models/multi-factor-auth.ts';
|
|
||||||
|
|
||||||
export interface RequestBody {
|
|
||||||
methodId?: string;
|
|
||||||
password: string;
|
|
||||||
disableAll?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResponseBody {
|
|
||||||
success: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function post({ request, user }: RequestHandlerParams) {
|
|
||||||
const isMultiFactorAuthEnabled = await AppConfig.isMultiFactorAuthEnabled();
|
|
||||||
|
|
||||||
if (!isMultiFactorAuthEnabled) {
|
|
||||||
const responseBody: ResponseBody = {
|
|
||||||
success: false,
|
|
||||||
error: 'Multi-factor authentication is not enabled on this server',
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Response(JSON.stringify(responseBody), { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.clone().json() as RequestBody;
|
|
||||||
const { methodId, password, disableAll } = body;
|
|
||||||
|
|
||||||
if (!password) {
|
|
||||||
const responseBody: ResponseBody = {
|
|
||||||
success: false,
|
|
||||||
error: 'Password is required',
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Response(JSON.stringify(responseBody), { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const hashedPassword = await generateHash(`${password}:${PASSWORD_SALT}`, 'SHA-256');
|
|
||||||
|
|
||||||
if (user!.hashed_password !== hashedPassword) {
|
|
||||||
const responseBody: ResponseBody = {
|
|
||||||
success: false,
|
|
||||||
error: 'Invalid password',
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Response(JSON.stringify(responseBody), { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (disableAll) {
|
|
||||||
user!.extra.multi_factor_auth_methods = [];
|
|
||||||
|
|
||||||
await UserModel.update(user!);
|
|
||||||
|
|
||||||
const responseBody: ResponseBody = {
|
|
||||||
success: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Response(JSON.stringify(responseBody));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!methodId) {
|
|
||||||
const responseBody: ResponseBody = {
|
|
||||||
success: false,
|
|
||||||
error: 'Method ID is required when not disabling all methods',
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Response(JSON.stringify(responseBody), { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const method = getMultiFactorAuthMethodByIdFromUser(user!, methodId);
|
|
||||||
|
|
||||||
if (!method) {
|
|
||||||
const responseBody: ResponseBody = {
|
|
||||||
success: false,
|
|
||||||
error: 'Multi-factor authentication method not found',
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Response(JSON.stringify(responseBody), { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!method.enabled) {
|
|
||||||
const responseBody: ResponseBody = {
|
|
||||||
success: false,
|
|
||||||
error: 'Multi-factor authentication method is not enabled',
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Response(JSON.stringify(responseBody), { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
MultiFactorAuthModel.disableMethodFromUser(user!, methodId);
|
|
||||||
|
|
||||||
await UserModel.update(user!);
|
|
||||||
|
|
||||||
const responseBody: ResponseBody = {
|
|
||||||
success: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Response(JSON.stringify(responseBody));
|
|
||||||
}
|
|
||||||
|
|
||||||
export default page({
|
|
||||||
post,
|
|
||||||
accessMode: 'user',
|
|
||||||
});
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue