From 2c18a613155309aa6caded1a449df8071c56f5ca Mon Sep 17 00:00:00 2001 From: Linus Miller Date: Mon, 15 Dec 2025 16:02:51 +0100 Subject: [PATCH] WIP auth and admin --- .env.testing | 17 + client/admin/client.ts | 7 + client/admin/components/admission_form.tsx | 88 +++ client/admin/components/admissions_page.tsx | 83 +++ client/admin/components/admissions_table.tsx | 50 ++ client/admin/components/app.module.scss | 53 ++ client/admin/components/app.tsx | 55 ++ client/admin/components/button.module.scss | 120 ++++ client/admin/components/button.tsx | 17 + .../admin/components/change_password_form.tsx | 52 ++ .../admin/components/change_password_page.tsx | 19 + client/admin/components/checkbox.module.scss | 15 + client/admin/components/checkbox.tsx | 4 + .../admin/components/current_user.module.scss | 25 + client/admin/components/current_user.tsx | 32 + .../components/error_details.module.scss | 12 + client/admin/components/error_details.tsx | 41 ++ .../admin/components/errors_page.module.scss | 20 + client/admin/components/errors_page.tsx | 150 ++++ .../components/errors_search_form.module.scss | 7 + .../admin/components/errors_search_form.tsx | 31 + .../admin/components/errors_table.module.scss | 31 + client/admin/components/errors_table.tsx | 76 ++ .../admin/components/forgot_password_form.tsx | 47 ++ .../admin/components/forgot_password_page.tsx | 19 + client/admin/components/form.module.scss | 21 + client/admin/components/git_log.tsx | 37 + client/admin/components/input.module.scss | 70 ++ client/admin/components/input.tsx | 5 + client/admin/components/invite_form.tsx | 54 ++ .../admin/components/invites_page.module.scss | 11 + client/admin/components/invites_page.tsx | 74 ++ client/admin/components/invites_table.tsx | 52 ++ .../admin/components/login_form.module.scss | 11 + client/admin/components/login_form.tsx | 69 ++ client/admin/components/login_page.tsx | 18 + client/admin/components/message.module.scss | 32 + client/admin/components/message.tsx | 4 + client/admin/components/modal.module.scss | 35 + client/admin/components/modal.tsx | 41 ++ .../admin/components/navigation.module.scss | 122 ++++ client/admin/components/navigation.tsx | 51 ++ client/admin/components/navigation_group.tsx | 72 ++ client/admin/components/navigation_item.tsx | 42 ++ client/admin/components/not_found_page.tsx | 13 + .../components/notifications.module.scss | 15 + client/admin/components/notifications.tsx | 19 + .../admin/components/object_table.module.scss | 15 + client/admin/components/object_table.tsx | 21 + .../admin/components/page_header.module.scss | 10 + client/admin/components/page_header.tsx | 12 + .../admin/components/pagination.module.scss | 63 ++ client/admin/components/pagination.tsx | 103 +++ client/admin/components/process.tsx | 42 ++ client/admin/components/register_form.tsx | 61 ++ client/admin/components/register_page.tsx | 18 + client/admin/components/role_form.tsx | 69 ++ client/admin/components/roles_page.tsx | 87 +++ client/admin/components/roles_table.tsx | 48 ++ client/admin/components/route.tsx | 25 + client/admin/components/row.module.scss | 9 + client/admin/components/row.tsx | 14 + client/admin/components/section.module.scss | 26 + client/admin/components/section.tsx | 36 + client/admin/components/select.tsx | 4 + client/admin/components/start_page.tsx | 44 ++ client/admin/components/table.module.scss | 120 ++++ client/admin/components/table.tsx | 93 +++ client/admin/components/users_page.tsx | 43 ++ client/admin/components/users_table.tsx | 41 ++ client/admin/components/utility.module.scss | 13 + client/admin/contexts/current_user.ts | 9 + client/admin/contexts/notifications.tsx | 92 +++ client/admin/hooks/use_items_reducer.ts | 129 ++++ client/admin/images/icon-delete.svg | 14 + client/admin/images/icon-edit.svg | 14 + client/admin/routes.ts | 75 ++ client/admin/server.ts | 1 + client/admin/styles/_fonts.scss | 1 + client/admin/styles/_layout.scss | 0 client/admin/styles/_styles.scss | 28 + client/admin/styles/_variables.scss | 56 ++ client/admin/styles/main.scss | 6 + client/client.d.ts | 2 + .../components/invoices_by_supplier_page.tsx | 9 +- client/shared/components/button_factory.tsx | 16 +- client/shared/components/checkbox_factory.tsx | 12 +- client/shared/components/input_factory.tsx | 15 +- client/shared/components/portal.tsx | 2 +- client/shared/hooks/use_request_state.ts | 12 +- client/shared/utils/stop_propagation.ts | 3 + docker-compose.base.yml | 6 +- docker/postgres/01-auth_schema.sql | 650 ++++++++++++++++++ ...01-schema.sql => 02-accounting_schema.sql} | 0 docker/postgres/03-auth_data.sql | 34 + .../{02-data.sql => 04-accounting_data.sql} | 0 package.json | 8 +- pnpm-lock.yaml | 122 ++++ server/config.ts | 9 + server/config/auth.ts | 56 ++ server/config/mailgun.ts | 6 + server/config/site.ts | 56 ++ server/env.ts | 15 +- server/lib/emitter.ts | 3 + server/lib/knex_helpers.ts | 53 ++ server/lib/knex_rest_queries.ts | 316 +++++++++ server/lib/send_mail.ts | 42 ++ server/plugins/auth.ts | 92 +++ server/plugins/auth/helpers.ts | 24 + server/plugins/auth/routes/change_password.ts | 83 +++ server/plugins/auth/routes/login.ts | 85 +++ server/plugins/auth/routes/logout.ts | 19 + server/plugins/auth/routes/register.ts | 151 ++++ server/plugins/auth/routes/reset_password.ts | 58 ++ server/plugins/auth/routes/verify_email.ts | 56 ++ server/plugins/vite/development.ts | 2 + server/routes/api.ts | 2 + server/routes/api/admissions.ts | 115 ++++ server/routes/api/invites.ts | 111 +++ server/routes/api/process.ts | 96 +++ server/routes/api/roles.test.ts | 11 + server/routes/api/roles.ts | 104 +++ server/routes/api/users.ts | 61 ++ server/schemas/status_error.ts | 6 + server/server.ts | 54 +- server/services/admissions/queries.ts | 95 +++ server/services/admissions/types.ts | 13 + server/services/errors/queries.ts | 27 + server/services/invites/queries.ts | 92 +++ server/services/invites/types.ts | 20 + server/services/roles/queries.ts | 40 ++ server/services/roles/types.ts | 8 + server/services/users/queries.ts | 127 ++++ server/services/users/types.ts | 20 + server/templates/admin.ts | 25 + server/templates/emails/forgot_password.ts | 19 + server/templates/emails/invite.ts | 17 + server/templates/emails/verify_email.ts | 22 + server/tests/auth.test.ts | 26 + server/tests/process.test.ts | 19 + shared/global.d.ts | 13 +- shared/types.ts | 3 +- 142 files changed, 6491 insertions(+), 53 deletions(-) create mode 100644 .env.testing create mode 100644 client/admin/client.ts create mode 100644 client/admin/components/admission_form.tsx create mode 100644 client/admin/components/admissions_page.tsx create mode 100644 client/admin/components/admissions_table.tsx create mode 100644 client/admin/components/app.module.scss create mode 100644 client/admin/components/app.tsx create mode 100644 client/admin/components/button.module.scss create mode 100644 client/admin/components/button.tsx create mode 100644 client/admin/components/change_password_form.tsx create mode 100644 client/admin/components/change_password_page.tsx create mode 100644 client/admin/components/checkbox.module.scss create mode 100644 client/admin/components/checkbox.tsx create mode 100644 client/admin/components/current_user.module.scss create mode 100644 client/admin/components/current_user.tsx create mode 100644 client/admin/components/error_details.module.scss create mode 100644 client/admin/components/error_details.tsx create mode 100644 client/admin/components/errors_page.module.scss create mode 100644 client/admin/components/errors_page.tsx create mode 100644 client/admin/components/errors_search_form.module.scss create mode 100644 client/admin/components/errors_search_form.tsx create mode 100644 client/admin/components/errors_table.module.scss create mode 100644 client/admin/components/errors_table.tsx create mode 100644 client/admin/components/forgot_password_form.tsx create mode 100644 client/admin/components/forgot_password_page.tsx create mode 100644 client/admin/components/form.module.scss create mode 100644 client/admin/components/git_log.tsx create mode 100644 client/admin/components/input.module.scss create mode 100644 client/admin/components/input.tsx create mode 100644 client/admin/components/invite_form.tsx create mode 100644 client/admin/components/invites_page.module.scss create mode 100644 client/admin/components/invites_page.tsx create mode 100644 client/admin/components/invites_table.tsx create mode 100644 client/admin/components/login_form.module.scss create mode 100644 client/admin/components/login_form.tsx create mode 100644 client/admin/components/login_page.tsx create mode 100644 client/admin/components/message.module.scss create mode 100644 client/admin/components/message.tsx create mode 100644 client/admin/components/modal.module.scss create mode 100644 client/admin/components/modal.tsx create mode 100644 client/admin/components/navigation.module.scss create mode 100644 client/admin/components/navigation.tsx create mode 100644 client/admin/components/navigation_group.tsx create mode 100644 client/admin/components/navigation_item.tsx create mode 100644 client/admin/components/not_found_page.tsx create mode 100644 client/admin/components/notifications.module.scss create mode 100644 client/admin/components/notifications.tsx create mode 100644 client/admin/components/object_table.module.scss create mode 100644 client/admin/components/object_table.tsx create mode 100644 client/admin/components/page_header.module.scss create mode 100644 client/admin/components/page_header.tsx create mode 100644 client/admin/components/pagination.module.scss create mode 100644 client/admin/components/pagination.tsx create mode 100644 client/admin/components/process.tsx create mode 100644 client/admin/components/register_form.tsx create mode 100644 client/admin/components/register_page.tsx create mode 100644 client/admin/components/role_form.tsx create mode 100644 client/admin/components/roles_page.tsx create mode 100644 client/admin/components/roles_table.tsx create mode 100644 client/admin/components/route.tsx create mode 100644 client/admin/components/row.module.scss create mode 100644 client/admin/components/row.tsx create mode 100644 client/admin/components/section.module.scss create mode 100644 client/admin/components/section.tsx create mode 100644 client/admin/components/select.tsx create mode 100644 client/admin/components/start_page.tsx create mode 100644 client/admin/components/table.module.scss create mode 100644 client/admin/components/table.tsx create mode 100644 client/admin/components/users_page.tsx create mode 100644 client/admin/components/users_table.tsx create mode 100644 client/admin/components/utility.module.scss create mode 100644 client/admin/contexts/current_user.ts create mode 100644 client/admin/contexts/notifications.tsx create mode 100644 client/admin/hooks/use_items_reducer.ts create mode 100644 client/admin/images/icon-delete.svg create mode 100644 client/admin/images/icon-edit.svg create mode 100644 client/admin/routes.ts create mode 100644 client/admin/server.ts create mode 100644 client/admin/styles/_fonts.scss create mode 100644 client/admin/styles/_layout.scss create mode 100644 client/admin/styles/_styles.scss create mode 100644 client/admin/styles/_variables.scss create mode 100644 client/admin/styles/main.scss create mode 100644 client/shared/utils/stop_propagation.ts create mode 100644 docker/postgres/01-auth_schema.sql rename docker/postgres/{01-schema.sql => 02-accounting_schema.sql} (100%) create mode 100644 docker/postgres/03-auth_data.sql rename docker/postgres/{02-data.sql => 04-accounting_data.sql} (100%) create mode 100644 server/config.ts create mode 100644 server/config/auth.ts create mode 100644 server/config/mailgun.ts create mode 100644 server/config/site.ts create mode 100644 server/lib/emitter.ts create mode 100644 server/lib/knex_helpers.ts create mode 100644 server/lib/knex_rest_queries.ts create mode 100644 server/lib/send_mail.ts create mode 100644 server/plugins/auth.ts create mode 100644 server/plugins/auth/helpers.ts create mode 100644 server/plugins/auth/routes/change_password.ts create mode 100644 server/plugins/auth/routes/login.ts create mode 100644 server/plugins/auth/routes/logout.ts create mode 100644 server/plugins/auth/routes/register.ts create mode 100644 server/plugins/auth/routes/reset_password.ts create mode 100644 server/plugins/auth/routes/verify_email.ts create mode 100644 server/routes/api/admissions.ts create mode 100644 server/routes/api/invites.ts create mode 100644 server/routes/api/process.ts create mode 100644 server/routes/api/roles.test.ts create mode 100644 server/routes/api/roles.ts create mode 100644 server/routes/api/users.ts create mode 100644 server/schemas/status_error.ts create mode 100644 server/services/admissions/queries.ts create mode 100644 server/services/admissions/types.ts create mode 100644 server/services/errors/queries.ts create mode 100644 server/services/invites/queries.ts create mode 100644 server/services/invites/types.ts create mode 100644 server/services/roles/queries.ts create mode 100644 server/services/roles/types.ts create mode 100644 server/services/users/queries.ts create mode 100644 server/services/users/types.ts create mode 100644 server/templates/admin.ts create mode 100644 server/templates/emails/forgot_password.ts create mode 100644 server/templates/emails/invite.ts create mode 100644 server/templates/emails/verify_email.ts create mode 100644 server/tests/auth.test.ts create mode 100644 server/tests/process.test.ts diff --git a/.env.testing b/.env.testing new file mode 100644 index 0000000..cb032f9 --- /dev/null +++ b/.env.testing @@ -0,0 +1,17 @@ +NODE_ENV=testing +DOMAIN=bitmill.io +PROTOCOL=https +HOSTNAME=brf.local +PORT=null +FASTIFY_HOST=0.0.0.0 +FASTIFY_PORT=1337 +LOG_LEVEL=debug +LOG_STREAM=console +PGHOST=localhost +PGPORT=5432 +PGDATABASE=brf_books +PGUSER=brf_books +PGPASSWORD=brf_books +REDIS_HOST=null +VITE_HMR_PROXY=false +MAILGUN_API_KEY=not_a_valid_key diff --git a/client/admin/client.ts b/client/admin/client.ts new file mode 100644 index 0000000..7ad4b7a --- /dev/null +++ b/client/admin/client.ts @@ -0,0 +1,7 @@ +import './styles/main.scss' +import { h, render } from 'preact' +import App from './components/app.tsx' + +const state = typeof __STATE__ === 'undefined' ? { user: null } : __STATE__ + +render(h(App, { state }), document.body) diff --git a/client/admin/components/admission_form.tsx b/client/admin/components/admission_form.tsx new file mode 100644 index 0000000..eb93d0b --- /dev/null +++ b/client/admin/components/admission_form.tsx @@ -0,0 +1,88 @@ +import { h, type FunctionComponent, type TargetedSubmitEvent } from 'preact' +import { useCallback } from 'preact/hooks' +import rek, { type FetchError } from 'rek' + +import useRequestState from '../../shared/hooks/use_request_state.ts' +import serializeForm from '../../shared/utils/serialize_form.ts' +import Button from './button.tsx' +import Checkbox from './checkbox.tsx' +import Input from './input.tsx' +import Message from './message.tsx' +import sutil from './utility.module.scss' + +const AdmissionForm: FunctionComponent<{ + admission?: ANY + onCancel?: ANY + onCreate?: ANY + onUpdate?: ANY + roles?: ANY[] +}> = ({ admission, onCancel, onCreate, onUpdate, roles }) => { + const [{ error, pending, success }, actions] = useRequestState() + + const create = useCallback(async (e: TargetedSubmitEvent) => { + e.preventDefault() + + actions.pending() + + try { + const result = await rek[admission ? 'patch' : 'post']( + `/api/admissions${admission ? '/' + admission.id : ''}`, + serializeForm(e.currentTarget), + ) + + actions.success() + + if (admission) { + onUpdate?.(result) + } else { + e.currentTarget.reset() + onCreate?.(result) + } + } catch (err) { + actions.error(err as FetchError) + } + }, []) + + return ( +
+ {success && ( + + Admission {admission ? 'updated' : 'created'}! + + )} + {error && ( + + {error.status || 500} {error.body?.message || error.message} + + )} + + + {roles && ( +
+ {roles.map((role) => ( + r.id === role.id)} + /> + ))} +
+ )} + +
+ + {onCancel && ( + + )} +
+
+ ) +} + +export default AdmissionForm diff --git a/client/admin/components/admissions_page.tsx b/client/admin/components/admissions_page.tsx new file mode 100644 index 0000000..526e575 --- /dev/null +++ b/client/admin/components/admissions_page.tsx @@ -0,0 +1,83 @@ +import { h } from 'preact' +import { useCallback, useEffect, useState } from 'preact/hooks' +import { route } from 'preact-router' +import rek, { FetchError } from 'rek' + +import { useNotifications } from '../contexts/notifications.tsx' +import useItemsReducer from '../hooks/use_items_reducer.ts' +import AdmissionForm from './admission_form.tsx' +import Modal from './modal.tsx' +import AdmissionsTable from './admissions_table.tsx' +import PageHeader from './page_header.tsx' +import Section from './section.tsx' + +const AdmissionsPage = () => { + const { notify } = useNotifications() + const [admissions, actions] = useItemsReducer() + const [roles, setRoles] = useState() + const [editing, setEditing] = useState(null) + + useEffect(() => { + Promise.all([rek('/api/admissions' + location.search), rek('/api/roles')]).then(([admissions, roles]) => { + setRoles(roles) + actions.reset(admissions) + }) + }, [location.search]) + + const onDelete = useCallback(async (id: number) => { + if (confirm(`Delete admission ${id}?`)) { + try { + await rek.delete(`/api/admissions/${id}`) + + actions.del(id) + + notify.success(`Admission ${id} deleted`) + } catch (err) { + notify.error((err as FetchError).body?.message || (err as FetchError).toString()) + } + } + }, []) + + const onSortBy = useCallback((column: string) => { + const searchParams = new URLSearchParams(location.search) + + const newSort = (searchParams.get('sort') || 'id') === column ? '-' + column : column + + route(location.pathname + (newSort !== 'id' ? `?sort=${newSort}` : '')) + }, []) + + return ( +
+ Admissions + +
+ Create New Admission + + + + +
+ +
+ List + + + + +
+ + {editing != null && ( + setEditing(null)}> + setEditing(null)} + onUpdate={(admission: ANY) => actions.update(admission.id, admission)} + admission={admissions.find((admission) => admission.id === editing)} + roles={roles} + /> + + )} +
+ ) +} + +export default AdmissionsPage diff --git a/client/admin/components/admissions_table.tsx b/client/admin/components/admissions_table.tsx new file mode 100644 index 0000000..5fd9b40 --- /dev/null +++ b/client/admin/components/admissions_table.tsx @@ -0,0 +1,50 @@ +import { h, type FunctionComponent } from 'preact' +import Button from './button.tsx' +import { Table, Td, Th } from './table.tsx' + +const AdmissionsTable: FunctionComponent<{ admissions: ANY[]; onDelete: ANY; onEdit: ANY; onSortBy: ANY }> = ({ + admissions, + onDelete, + onEdit, + onSortBy, +}) => ( + + + + + + + + + + + + {admissions?.length ? ( + admissions.map((admission) => ( + + + + + + + + + )) + ) : ( + + + + )} + +
IDRegExpRolesCreated AtCreated By +
{admission.id}{admission.regex}{admission.roles.map((role: ANY) => role.name).join(', ')}{admission.createdAt}{admission.createdBy?.email} + {' '} + +
No admissions found
+) + +export default AdmissionsTable diff --git a/client/admin/components/app.module.scss b/client/admin/components/app.module.scss new file mode 100644 index 0000000..eccdee1 --- /dev/null +++ b/client/admin/components/app.module.scss @@ -0,0 +1,53 @@ +@use 'sass:color'; +@use '../styles/variables' as v; + +.base { + display: grid; + min-height: 100vh; + grid-template-columns: v.$aside-width auto; + grid-template-rows: v.$header-height auto v.$footer-height; +} + +.logo { + color: white; + font-weight: bold; + text-decoration: none; + display: flex; + align-items: center; + padding-left: 20px; + background: color.scale(v.$color-1, $alpha: -30%); + z-index: 2; + grid-column: 1 / 2; + grid-row: 1 / 2; +} + +.header { + background: v.$header-bg; + grid-column: 2 / 3; + grid-row: 1 / 2; + display: flex; + align-items: center; + justify-content: flex-end; + padding: v.$gutter; +} + +.aside { + z-index: 1; + background: v.$aside-bg; + grid-column: 1 / 2; + grid-row: 1 / 4; + padding-top: v.$header-height; +} + +.main { + grid-column: 2 / 3; + grid-row: 2 / 3; + background: v.$main-bg; + padding: v.$gutter; +} + +.footer { + background: v.$footer-bg; + grid-column: 2 / 3; + grid-row: 3 / 4; +} diff --git a/client/admin/components/app.tsx b/client/admin/components/app.tsx new file mode 100644 index 0000000..bda92ef --- /dev/null +++ b/client/admin/components/app.tsx @@ -0,0 +1,55 @@ +import { h, type FunctionComponent } from 'preact' +import { useState } from 'preact/hooks' +import { Router } from 'preact-router' + +import CurrentUserContext from '../contexts/current_user.ts' +import { NotificationsProvider } from '../contexts/notifications.tsx' +import routes from '../routes.ts' + +import Route from './route.tsx' + +import CurrentUser from './current_user.tsx' +import Navigation from './navigation.tsx' +import Notifications from './notifications.tsx' +import NotFoundPage from './not_found_page.tsx' +import s from './app.module.scss' + +const App: FunctionComponent<{ state: ANY }> = ({ state }) => { + const [user, setUser] = useState(state.user) + + return ( + + +
+ + Carson Admin + + +
+ +
+ + {user && ( + + )} + +
+ + {routes + ?.flatMap((route) => route.routes || route) + .map(({ auth, component, path }) => ( + + ))} + + +
+ +
+
+
+ ) +} + +export default App diff --git a/client/admin/components/button.module.scss b/client/admin/components/button.module.scss new file mode 100644 index 0000000..94fcee2 --- /dev/null +++ b/client/admin/components/button.module.scss @@ -0,0 +1,120 @@ +@use 'sass:color'; +@use '../styles/variables' as v; + +$horizontal-padding-small: 20px; +$horizontal-padding: 32px; +$icon-size: 22px; +$icon-size-small: 18px; + +.base { + display: inline-block; + cursor: pointer; + border: none; + border-radius: 2px; + color: white; + transition: all 0.5s; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + line-height: 1; + padding: 12px 24px; + + &:has(.icon) { + padding: 12px; + } + + &:disabled { + background: #ccc !important; + cursor: default; + } + + &:focus { + outline: 0; + } + + &::-moz-focus-inner { + border: 0; + padding: 0; + } +} + +.icon { + display: block; + height: $icon-size; + width: $icon-size; + mask: no-repeat center / contain; + background: white; + + .small & { + height: $icon-size-small; + width: $icon-size-small; + } +} + +// default +.blue { + background: v.$color-blue; + + &:hover, + &:focus, + &:active { + background: v.$color-blue-dark; + } +} + +.red { + background: v.$color-red; + + &:hover, + &:focus, + &:active { + background: v.$color-red-dark; + } +} + +.orange { + background: v.$color-orange; + + &:hover, + &:focus, + &:active { + background: color.adjust(v.$color-orange, $lightness: -20%); + } +} + +.small { + &:not(:has(.icon)) { + height: v.$button-height-small; + line-height: v.$button-height-small; + padding: 0 $horizontal-padding-small; + } + + &:has(.icon) { + padding: 10px; + + img { + display: block; + height: 18px; + } + } +} + +.white { + background: white; + color: v.$color-blue; + border: 1px solid v.$color-blue; + line-height: v.$button-height - 2px; + padding: 0 $horizontal-padding - 1px; + + &.red { + color: v.$color-red; + border: 1px solid v.$color-red; + } + + &:hover { + background: #ccc; + } + + &.small { + line-height: v.$button-height-small - 2px; + padding: 0 $horizontal-padding-small - 1px; + } +} diff --git a/client/admin/components/button.tsx b/client/admin/components/button.tsx new file mode 100644 index 0000000..6d3a4a6 --- /dev/null +++ b/client/admin/components/button.tsx @@ -0,0 +1,17 @@ +import buttonFactory from '../../shared/components/button_factory.tsx' + +import deleteIcon from '../images/icon-delete.svg' +import editIcon from '../images/icon-edit.svg' + +import styles from './button.module.scss' + +const defaults = { + color: 'blue', +} + +const icons = { + delete: deleteIcon, + edit: editIcon, +} + +export default buttonFactory({ defaults, icons, styles }) diff --git a/client/admin/components/change_password_form.tsx b/client/admin/components/change_password_form.tsx new file mode 100644 index 0000000..79b8f38 --- /dev/null +++ b/client/admin/components/change_password_form.tsx @@ -0,0 +1,52 @@ +import { h, type FunctionComponent } from 'preact' +import { useCallback } from 'preact/hooks' +import rek, { type FetchError } from 'rek' + +import useRequestState from '../../shared/hooks/use_request_state.ts' +import Button from './button.tsx' +import Input from './input.tsx' +import Message from './message.tsx' + +const ChangePasswordForm: FunctionComponent = () => { + const [{ error, pending, success }, actions] = useRequestState() + + const changePassword = useCallback(async (e: SubmitEvent & { currentTarget: HTMLFormElement }) => { + e.preventDefault() + + actions.pending() + + try { + const params = new URLSearchParams(location.search) + + await rek.post('/auth/change-password', { + password: e.currentTarget.password.value, + token: params.get('token'), + email: params.get('email'), + }) + + actions.success() + } catch (err) { + actions.error(err as FetchError) + } + }, []) + + return success ? ( + + Password changed! Head over to the login page to try it out! + + ) : ( +
+ {error && ( + + {error.status}: {error.body?.message || error.message} + + )} + + + + +
+ ) +} + +export default ChangePasswordForm diff --git a/client/admin/components/change_password_page.tsx b/client/admin/components/change_password_page.tsx new file mode 100644 index 0000000..d780427 --- /dev/null +++ b/client/admin/components/change_password_page.tsx @@ -0,0 +1,19 @@ +import { h } from 'preact' +import ChangePasswordForm from './change_password_form.tsx' + +import PageHeader from './page_header.tsx' +import Section from './section.tsx' + +const ChangePasswordPage = () => ( +
+ Change Password + +
+ + + +
+
+) + +export default ChangePasswordPage diff --git a/client/admin/components/checkbox.module.scss b/client/admin/components/checkbox.module.scss new file mode 100644 index 0000000..940eb40 --- /dev/null +++ b/client/admin/components/checkbox.module.scss @@ -0,0 +1,15 @@ +.base { + display: flex; + align-items: flex-start; + margin-bottom: 8px; +} + +.element { + display: block; + margin: 0; +} + +.label { + display: block; + margin-left: 8px; +} diff --git a/client/admin/components/checkbox.tsx b/client/admin/components/checkbox.tsx new file mode 100644 index 0000000..e639c6e --- /dev/null +++ b/client/admin/components/checkbox.tsx @@ -0,0 +1,4 @@ +import checkboxFactory from '../../shared/components/checkbox_factory.tsx' +import styles from './checkbox.module.scss' + +export default checkboxFactory(styles) diff --git a/client/admin/components/current_user.module.scss b/client/admin/components/current_user.module.scss new file mode 100644 index 0000000..ba4508a --- /dev/null +++ b/client/admin/components/current_user.module.scss @@ -0,0 +1,25 @@ +@use '../styles/variables' as v; + +.base { + color: white; + display: flex; +} + +.links { + display: flex; + margin-left: v.$gutter; + + a { + color: white; + font-weight: bold; + text-decoration: none; + + &:not(:last-child):after { + content: '\00a0|\00a0'; + } + + &:hover { + text-decoration: underline; + } + } +} diff --git a/client/admin/components/current_user.tsx b/client/admin/components/current_user.tsx new file mode 100644 index 0000000..c3b6a36 --- /dev/null +++ b/client/admin/components/current_user.tsx @@ -0,0 +1,32 @@ +import { h, type FunctionComponent } from 'preact' +import cn from 'classnames' + +import { useCurrentUser } from '../contexts/current_user.ts' +import s from './current_user.module.scss' + +const CurrentUser: FunctionComponent<{ className?: string }> = ({ className }) => { + const { user } = useCurrentUser() + + return user ? ( +
+
{user.email}
+ + +
+ ) : ( +
+

You are not logged in

+ +
+ Login + Register +
+
+ ) +} + +export default CurrentUser diff --git a/client/admin/components/error_details.module.scss b/client/admin/components/error_details.module.scss new file mode 100644 index 0000000..df449d8 --- /dev/null +++ b/client/admin/components/error_details.module.scss @@ -0,0 +1,12 @@ +.base { + width: calc(100% - 30px); + max-width: 1024px; + overflow: hidden; +} + +.headers { + pre { + overflow-x: auto; + overflow-y: visible; + } +} diff --git a/client/admin/components/error_details.tsx b/client/admin/components/error_details.tsx new file mode 100644 index 0000000..9d54372 --- /dev/null +++ b/client/admin/components/error_details.tsx @@ -0,0 +1,41 @@ +import { h, type FunctionComponent } from 'preact' +// @ts-ignore +import Format from 'easy-tz/format' +import { omit } from 'lowline' +import Section from './section.tsx' +import s from './error_details.module.scss' + +const format = Format.bind(null, null, 'YYYY.MM.DD\nHH:mm:ss') + +const ErrorDetails: FunctionComponent<{ error: ANY }> = ({ error }) => { + return ( +
+ + {error.id} : {error.statusCode} : {error.type} + + +
{error.message}
+
{format(error.createdAt)}
+
+

Details

+
{JSON.stringify(omit(error.details, ['stack', 'message', 'type']), null, '  ')}
+
+
+

Stack

+
{error.stack}
+
+
+ {error.method} {error.path} +
+
+

Headers

+
{JSON.stringify(error.headers, null, '  ')}
+
+
{error.ip}
+
{error.reqId}
+
+
+ ) +} + +export default ErrorDetails diff --git a/client/admin/components/errors_page.module.scss b/client/admin/components/errors_page.module.scss new file mode 100644 index 0000000..c2854a7 --- /dev/null +++ b/client/admin/components/errors_page.module.scss @@ -0,0 +1,20 @@ +@use 'sass:math'; +@use '../styles/variables' as v; + +.top { + display: flex; + align-items: flex-end; +} + +.search { + flex: 1 0 auto; +} + +.delete { + flex: 0 0 auto; + margin: 0 0 v.$gutter v.$gutter; + + > button:not(:last-child) { + margin-right: math.div(v.$gutter, 4); + } +} diff --git a/client/admin/components/errors_page.tsx b/client/admin/components/errors_page.tsx new file mode 100644 index 0000000..1251a46 --- /dev/null +++ b/client/admin/components/errors_page.tsx @@ -0,0 +1,150 @@ +import { h } from 'preact' +import { useCallback, useEffect, useState } from 'preact/hooks' +import { route } from 'preact-router' +import rek, { type FetchError } from 'rek' +import { pick } from 'lowline' + +import { useNotifications } from '../contexts/notifications.tsx' +import useItemsReducer from '../hooks/use_items_reducer.ts' +import useRequestState from '../../shared/hooks/use_request_state.ts' +import Button from './button.tsx' +import ErrorDetails from './error_details.tsx' +import ErrorsSearchForm from './errors_search_form.tsx' +import ErrorsTable from './errors_table.tsx' +import Modal from './modal.tsx' +import PageHeader from './page_header.tsx' +import Pagination from './pagination.tsx' +import Section from './section.tsx' + +import s from './errors_page.module.scss' + +const defaultSort = '-id' + +export default function ErrorsPage() { + const { notify } = useNotifications() + const [{ pending }, request] = useRequestState() + const [pagination, setPagination] = useState({}) + const [errors, items] = useItemsReducer() + const [selected, setSelected] = useState(null) + + useEffect(() => { + fetch() + }, [location.search]) + + const fetch = useCallback(async () => { + try { + const { items: errors, ...pagination } = await rek(`/api/errors${location.search}`) + + items.reset(errors) + setPagination(pagination) + } catch (err) { + notify.error((err as FetchError).body?.message || (err as FetchError).message) + } + }, []) + + const onSearchSubmit = useCallback( + (query: ANY) => { + const currentParams = new URLSearchParams(location.search) + + if (currentParams.has('sort')) query = { ...query, sort: currentParams.get('sort') } + + route(location.pathname + (Object.keys(query).length ? '?' + new URLSearchParams(query) : '')) + }, + [location.search], + ) + + const onSortBy = useCallback((column: string) => { + const searchParams = new URLSearchParams(location.search) + + searchParams.delete('offset') + + searchParams.set('sort', (searchParams.get('sort') || defaultSort) === column ? '-' + column : column) + + route(location.pathname + '?' + searchParams) + }, []) + + const onDelete = useCallback(async (id: number) => { + if (confirm(`Sure you want to remove error #${id}`)) { + try { + request.pending() + + await rek.delete(`/api/errors/${id}`) + + fetch() + + notify.success(`Successfully deleted error #${id}`) + } catch { + notify.error(`Failed to delete error #${id}`) + } + + request.reset() + } + }, []) + + const deleteErrors = useCallback( + async (page: ANY) => { + if (confirm(page ? 'Delete errors on page?' : 'Delete all filtered errors?')) { + try { + request.pending() + + await rek.delete('/api/errors', { + searchParams: page ? { id: errors.map((error) => error.id).join(',') } : location.search, + }) + + fetch() + + notify.success(page ? 'Errors on page deleted' : 'All filtered errors deleted') + } catch (err) { + notify.error(`Error deleting errors: ${(err as FetchError).body?.message || (err as FetchError).message}`) + } + + request.reset() + } + }, + [errors], + ) + + return ( +
+ Errors + +
+ List + + + + +
+ + +
+
+ + + + + + + + +
+ + {selected != null && ( + setSelected(null)}> + error.id === selected)} /> + + )} +
+ ) +} diff --git a/client/admin/components/errors_search_form.module.scss b/client/admin/components/errors_search_form.module.scss new file mode 100644 index 0000000..09d0eaa --- /dev/null +++ b/client/admin/components/errors_search_form.module.scss @@ -0,0 +1,7 @@ +@use '../styles/variables' as v; + +.button { + align-self: end; + flex: 0 0 auto; + margin-bottom: v.$gutter; +} diff --git a/client/admin/components/errors_search_form.tsx b/client/admin/components/errors_search_form.tsx new file mode 100644 index 0000000..171cc56 --- /dev/null +++ b/client/admin/components/errors_search_form.tsx @@ -0,0 +1,31 @@ +import { h, type FunctionComponent } from 'preact' +import { useCallback } from 'preact/hooks' + +import serializeForm from '../../shared/utils/serialize_form.ts' +import Input from './input.tsx' +import Button from './button.tsx' + +import s from './errors_search_form.module.scss' +import sutil from './utility.module.scss' + +const ErrorsSearchForm: FunctionComponent<{ className?: string; onSubmit: ANY }> = ({ onSubmit, className }) => { + const searchParams = new URLSearchParams(location.search) + + const onSubmitHandler = useCallback((e: SubmitEvent & { currentTarget: HTMLFormElement }) => { + e.preventDefault() + + onSubmit(serializeForm(e.currentTarget)) + }, []) + + return ( +
+
+ + + +
+
+ ) +} + +export default ErrorsSearchForm diff --git a/client/admin/components/errors_table.module.scss b/client/admin/components/errors_table.module.scss new file mode 100644 index 0000000..7e76bea --- /dev/null +++ b/client/admin/components/errors_table.module.scss @@ -0,0 +1,31 @@ +.base { + td { + &.id { + width: 35px; + } + + &.statusCode { + width: 50px; + } + + &.method { + width: 72px; + } + + &.path { + max-width: 120px; + text-overflow: ellipsis; + overflow: hidden; + } + + &.controls { + padding: 0 12px; + width: 87px; + max-width: none; + } + } + + > tbody > tr { + cursor: pointer; + } +} diff --git a/client/admin/components/errors_table.tsx b/client/admin/components/errors_table.tsx new file mode 100644 index 0000000..8af9d24 --- /dev/null +++ b/client/admin/components/errors_table.tsx @@ -0,0 +1,76 @@ +import { h, type FunctionComponent } from 'preact' +// eslint-disable-next-line import/no-unresolved +// @ts-ignore +import Format from 'easy-tz/format' +// @ts-ignore +import suppress from '@domp/suppress' + +import Button from './button.tsx' +import { Table, Th } from './table.tsx' +import s from './errors_table.module.scss' + +const format = Format.bind(null, null, 'YYYY.MM.DD\nHH:mm:ss') + +const ErrorsTable: FunctionComponent<{ + defaultSort: string + errors: ANY[] + onDelete?: ANY + onSelect?: ANY + onSortBy?: ANY + pending?: boolean +}> = ({ defaultSort, errors, onDelete, onSelect, onSortBy, pending }) => { + return ( + + + + + + + + + + + + + + + + {errors?.length ? ( + errors.map((error) => ( + onSelect(error.id)}> + + + + + + + + + + + + )) + ) : ( + + + + )} + +
IDCreatedAtStatusTypeMessageMethodPathIPReqId +
{error.id}{format(error.createdAt)}{error.statusCode}{error.type}{error.message.slice(0, 255)}{error.method}{error.path}{error.ip}{error.reqId} + +
No errors found
+ ) +} + +export default ErrorsTable diff --git a/client/admin/components/forgot_password_form.tsx b/client/admin/components/forgot_password_form.tsx new file mode 100644 index 0000000..9578d9e --- /dev/null +++ b/client/admin/components/forgot_password_form.tsx @@ -0,0 +1,47 @@ +import { h, type FunctionComponent } from 'preact' +import { useCallback } from 'preact/hooks' +import rek, { type FetchError } from 'rek' + +import useRequestState from '../../shared/hooks/use_request_state.ts' +import Button from './button.tsx' +import Input from './input.tsx' +import Message from './message.tsx' + +const ForgotPasswordForm: FunctionComponent = () => { + const [{ error, pending, success }, actions] = useRequestState() + + const forgotPassword = useCallback(async (e: SubmitEvent & { currentTarget: HTMLFormElement }) => { + e.preventDefault() + + actions.pending() + + try { + await rek.post('/auth/reset-password', { + email: e.currentTarget.email.value, + }) + + actions.success() + } catch (err) { + actions.error(err as FetchError) + } + }, []) + + return success ? ( + + Success! Check your inbox to choose a new password. + + ) : ( +
+ {error && ( + + {error.status}: {error.body?.message || error.message} + + )} + + + +
+ ) +} + +export default ForgotPasswordForm diff --git a/client/admin/components/forgot_password_page.tsx b/client/admin/components/forgot_password_page.tsx new file mode 100644 index 0000000..ba717b7 --- /dev/null +++ b/client/admin/components/forgot_password_page.tsx @@ -0,0 +1,19 @@ +import { h } from 'preact' +import ForgotPasswordForm from './forgot_password_form.tsx' + +import PageHeader from './page_header.tsx' +import Section from './section.tsx' + +const ForgotPasswordPage = () => ( +
+ Forgot Password + +
+ + + +
+
+) + +export default ForgotPasswordPage diff --git a/client/admin/components/form.module.scss b/client/admin/components/form.module.scss new file mode 100644 index 0000000..dcd3b40 --- /dev/null +++ b/client/admin/components/form.module.scss @@ -0,0 +1,21 @@ +.row { + display: flex; + width: 100%; + + > * { + flex: 1 0 0px; + + &:not(:last-child) { + margin-right: 8px; + } + } +} + +.buttons { + display: flex; + justify-content: flex-end; + + > :not(:last-child) { + margin-right: 8px; + } +} diff --git a/client/admin/components/git_log.tsx b/client/admin/components/git_log.tsx new file mode 100644 index 0000000..04d844f --- /dev/null +++ b/client/admin/components/git_log.tsx @@ -0,0 +1,37 @@ +import { h, type FunctionComponent } from 'preact' +import { useEffect, useState } from 'preact/hooks' +import rek from 'rek' +import { Table, Td } from './table.tsx' + +const GitLog: FunctionComponent = () => { + const [commits, setCommits] = useState(null) + + useEffect(() => { + rek('/api/git-log').then(setCommits) + }, []) + + return ( + commits && ( + + + {commits.map((commit) => ( + + + + + + + ))} + +
{new Date(commit.date).toLocaleString('sv-SE')}{commit.author}{commit.message} + ( + + {commit.hash.slice(0, 6)} + + ) +
+ ) + ) +} + +export default GitLog diff --git a/client/admin/components/input.module.scss b/client/admin/components/input.module.scss new file mode 100644 index 0000000..9823f82 --- /dev/null +++ b/client/admin/components/input.module.scss @@ -0,0 +1,70 @@ +@use 'sass:color'; +@use '../styles/variables' as v; + +.base { + display: flex; + flex-direction: column; + + &:not(:last-child):not(.noMargin) { + margin-bottom: v.$gutter; + } +} + +.label { + order: 1; + font-weight: bold; + margin-bottom: v.$form-label-margin; + + &:has(input:invalid) { + color: v.$form-invalid-color; + } + + &:has(input:valid) { + color: v.$form-valid-color; + } +} + +.element { + order: 2; + width: 100%; + box-shadow: none; + border: 1px solid v.$form-border-color; + padding: v.$form-element-padding; + font-family: v.$body-font-family; + background: white; + + &:focus { + outline: none; + border-color: v.$color-blue; + box-shadow: 0 0 5px 0px color.scale(v.$color-blue, $alpha: -20%); + } + + .touched &:invalid { + border-color: v.$form-invalid-color; + + ~ .label { + color: v.$form-invalid-color; + } + + &:focus { + box-shadow: 0 0 5px 0px color.scale(v.$form-invalid-color, $alpha: -20%); + } + } + + .touched &:valid { + border-color: v.$form-valid-color; + + ~ .label { + color: v.$form-valid-color; + } + + &:focus { + box-shadow: 0 0 5px 0px color.scale(v.$form-valid-color, $alpha: -20%); + } + } +} + +.errorLabel { + color: v.$form-invalid-color; + margin-top: v.$form-label-margin; +} diff --git a/client/admin/components/input.tsx b/client/admin/components/input.tsx new file mode 100644 index 0000000..c5b631b --- /dev/null +++ b/client/admin/components/input.tsx @@ -0,0 +1,5 @@ +import inputFactory from '../../shared/components/input_factory.tsx' + +import s from './input.module.scss' + +export default inputFactory(s) diff --git a/client/admin/components/invite_form.tsx b/client/admin/components/invite_form.tsx new file mode 100644 index 0000000..a6ea099 --- /dev/null +++ b/client/admin/components/invite_form.tsx @@ -0,0 +1,54 @@ +import { h, type FunctionComponent, type TargetedSubmitEvent } from 'preact' +import { useCallback } from 'preact/hooks' +import rek, { type FetchError } from 'rek' + +import serializeForm from '../../shared/utils/serialize_form.ts' +import useRequestState from '../../shared/hooks/use_request_state.ts' +import Button from './button.tsx' +import Checkbox from './checkbox.tsx' +import Input from './input.tsx' +import Message from './message.tsx' + +const InviteForm: FunctionComponent<{ onCreate?: ANY; roles?: ANY[] }> = ({ onCreate, roles }) => { + const [{ error, pending, success }, actions] = useRequestState() + + const create = useCallback(async (e: TargetedSubmitEvent) => { + e.preventDefault() + + actions.pending() + + try { + const result = await rek.post('/api/invites', serializeForm(e.currentTarget)) + + e.currentTarget.reset() + actions.success() + onCreate?.(result) + } catch (err) { + actions.error(err as FetchError) + } + }, []) + + return ( +
+ {success && Invite sent!} + {error && ( + + {error.status || 500} {error.body?.message || error.message} + + )} + + + {roles && ( +
+ {roles.map((role) => ( + + ))} +
+ )} + + +
+ ) +} + +export default InviteForm diff --git a/client/admin/components/invites_page.module.scss b/client/admin/components/invites_page.module.scss new file mode 100644 index 0000000..e2c1572 --- /dev/null +++ b/client/admin/components/invites_page.module.scss @@ -0,0 +1,11 @@ +@use '../styles/variables' as v; + +.row { + display: flex; + align-items: flex-start; +} + +.left { + flex: 0 0 400px; + margin-right: v.$gutter; +} diff --git a/client/admin/components/invites_page.tsx b/client/admin/components/invites_page.tsx new file mode 100644 index 0000000..6fb3df2 --- /dev/null +++ b/client/admin/components/invites_page.tsx @@ -0,0 +1,74 @@ +import { h } from 'preact' +import { useCallback, useEffect, useState } from 'preact/hooks' +import { route } from 'preact-router' +import rek, { type FetchError } from 'rek' + +import { useNotifications } from '../contexts/notifications.tsx' +import useItemsReducer from '../hooks/use_items_reducer.ts' +import InviteForm from './invite_form.tsx' +import InvitesTable from './invites_table.tsx' +import PageHeader from './page_header.tsx' +import Section from './section.tsx' + +import s from './invites_page.module.scss' + +const InvitesPage = () => { + const { notify } = useNotifications() + const [invites, actions] = useItemsReducer() + const [roles, setRoles] = useState() + + useEffect(() => { + Promise.all([rek('/api/invites' + location.search), rek('/api/roles')]).then(([invites, roles]) => { + setRoles(roles) + actions.reset(invites) + }) + }, [location.search]) + + const onDelete = useCallback(async (id: number) => { + if (confirm(`Delete invite ${id}?`)) { + try { + await rek.delete(`/api/invites/${id}`) + + actions.del(id) + + notify.success(`Invite ${id} deleted`) + } catch (err) { + notify.error((err as FetchError).body?.message || (err as FetchError).toString()) + } + } + }, []) + + const onSortBy = useCallback((column: string) => { + const searchParams = new URLSearchParams(location.search) + + const newSort = (searchParams.get('sort') || 'id') === column ? '-' + column : column + + route(location.pathname + (newSort !== 'id' ? `?sort=${newSort}` : '')) + }, []) + + return ( +
+ Invites + +
+
+ Create New Invite + + + + +
+
+ +
+ List + + + + +
+
+ ) +} + +export default InvitesPage diff --git a/client/admin/components/invites_table.tsx b/client/admin/components/invites_table.tsx new file mode 100644 index 0000000..989bf0f --- /dev/null +++ b/client/admin/components/invites_table.tsx @@ -0,0 +1,52 @@ +import { h, type FunctionComponent } from 'preact' +import Button from './button.tsx' +import { Table, Td, Th } from './table.tsx' + +const InvitesTable: FunctionComponent<{ invites: ANY[]; onDelete?: ANY; onSortBy?: ANY }> = ({ + invites, + onDelete, + onSortBy, +}) => ( + + + + + + + + + + + + + + + {invites?.length ? ( + invites.map((invite) => ( + + + + + + + + + + + + )) + ) : ( + + + + )} + +
IDEmailTokenRolesCreated AtCreated ByConsumed AtConsumed By +
{invite.id}{invite.email}{invite.token}{invite.roles.map((role: ANY) => role.name).join(', ')}{invite.createdAt}{invite.createdBy?.email}{invite.consumedAt}{invite.consumedBy?.email} + +
No invites found
+) + +export default InvitesTable diff --git a/client/admin/components/login_form.module.scss b/client/admin/components/login_form.module.scss new file mode 100644 index 0000000..dae90a2 --- /dev/null +++ b/client/admin/components/login_form.module.scss @@ -0,0 +1,11 @@ +.footer { + display: flex; + justify-content: space-between; + align-items: center; +} + +.links { + li:not(:last-child) { + padding-bottom: 4px; + } +} diff --git a/client/admin/components/login_form.tsx b/client/admin/components/login_form.tsx new file mode 100644 index 0000000..79a0817 --- /dev/null +++ b/client/admin/components/login_form.tsx @@ -0,0 +1,69 @@ +import { h, type FunctionComponent, type TargetedSubmitEvent } from 'preact' +import type { FetchError } from 'rek' +import { useCallback } from 'preact/hooks' +import { useCurrentUser } from '../contexts/current_user.ts' +import rek from 'rek' + +import useRequestState from '../../shared/hooks/use_request_state.ts' +import Button from './button.tsx' +import Input from './input.tsx' +import Message from './message.tsx' +import s from './login_form.module.scss' + +const LoginForm: FunctionComponent = () => { + const [{ error, pending }, actions] = useRequestState() + const { setUser } = useCurrentUser() + + const login = useCallback(async (e: TargetedSubmitEvent) => { + e.preventDefault() + + actions.pending() + + const form = e.currentTarget + + try { + const result = await rek.post('/auth/login', { + email: form.email.value, + password: form.password.value, + }) + + actions.success() + setUser(result) + } catch (err) { + actions.error(err as FetchError) + } + }, []) + + return ( +
+ {error && Error: {error.body?.message || error.message}} + + + + +
+ + + +
+
+ ) +} + +export default LoginForm diff --git a/client/admin/components/login_page.tsx b/client/admin/components/login_page.tsx new file mode 100644 index 0000000..a576baa --- /dev/null +++ b/client/admin/components/login_page.tsx @@ -0,0 +1,18 @@ +import { h } from 'preact' +import LoginForm from './login_form.tsx' +import PageHeader from './page_header.tsx' +import Section from './section.tsx' + +const LoginPage = () => ( +
+ Login + +
+ + + +
+
+) + +export default LoginPage diff --git a/client/admin/components/message.module.scss b/client/admin/components/message.module.scss new file mode 100644 index 0000000..54494ec --- /dev/null +++ b/client/admin/components/message.module.scss @@ -0,0 +1,32 @@ +@use '../styles/variables' as v; + +.base { + padding: 16px; + font-weight: bold; + display: flex; + border-left: 5px solid transparent; + margin-bottom: 20px; + justify-content: space-between; + + &.noMargin { + margin-bottom: 0; + } +} + +.normal { + color: v.$color-blue-dark; + background: v.$color-blue-light; + border-color: v.$color-blue-dark; +} + +.error { + color: v.$color-red-dark; + background: v.$color-red-light; + border-color: v.$color-red-dark; +} + +.success { + color: v.$color-green-dark; + background: v.$color-green-light; + border-color: v.$color-green-dark; +} diff --git a/client/admin/components/message.tsx b/client/admin/components/message.tsx new file mode 100644 index 0000000..e4a2863 --- /dev/null +++ b/client/admin/components/message.tsx @@ -0,0 +1,4 @@ +import s from './message.module.scss' +import messageFactory from '../../shared/components/message_factory.tsx' + +export default messageFactory(s) diff --git a/client/admin/components/modal.module.scss b/client/admin/components/modal.module.scss new file mode 100644 index 0000000..d899d5e --- /dev/null +++ b/client/admin/components/modal.module.scss @@ -0,0 +1,35 @@ +.overlay { + position: fixed; + z-index: 1999; + background: rgba(0, 0, 0, 0.5); + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.base { + position: relative; + background: #fff; +} + +.closeButton { + position: absolute; + right: 20px; + top: 5px; + display: block; + padding: 5px; + border: 1px solid black; + line-height: 1; + cursor: pointer; +} + +.content { + padding: 40px; + max-height: calc(100vh - 80px); + max-width: calc(100vw - 80px); + overflow-y: auto; +} diff --git a/client/admin/components/modal.tsx b/client/admin/components/modal.tsx new file mode 100644 index 0000000..3b2e563 --- /dev/null +++ b/client/admin/components/modal.tsx @@ -0,0 +1,41 @@ +import { h, type FunctionComponent } from 'preact' +import { useEffect } from 'preact/hooks' +import Portal from '../../shared/components/portal.tsx' +import disableScroll from '../../shared/utils/disable_scroll.ts' +import stopPropagation from '../../shared/utils/stop_propagation.ts' +import Button from './button.tsx' +import s from './modal.module.scss' + +const Modal: FunctionComponent<{ onClose?: (e: ANY) => void }> = ({ children, onClose }) => { + useEffect(() => { + function onKeyUp(e: ANY) { + if (e.keyCode === 27) onClose!(e) + } + + disableScroll.disable() + + if (onClose) addEventListener('keyup', onKeyUp) + + return () => { + disableScroll.enable() + removeEventListener('keyup', onKeyUp) + } + }, []) + + return ( + +
+
+ {onClose && ( + + )} +
{children}
+
+
+
+ ) +} + +export default Modal diff --git a/client/admin/components/navigation.module.scss b/client/admin/components/navigation.module.scss new file mode 100644 index 0000000..c3a9b27 --- /dev/null +++ b/client/admin/components/navigation.module.scss @@ -0,0 +1,122 @@ +@use 'sass:color'; +@use '../styles/variables' as v; + +$transition-time: 0.3s; +$item-padding: 16px; + +.base { + margin-top: v.$gutter * 2; + position: relative; +} + +.itemBase { + position: relative; + display: block; + + > a { + position: relative; + text-decoration: none; + display: block; + color: #b8c7ce; + padding: $item-padding $item-padding $item-padding $item-padding + 6px; + } + + &:hover, + &.current { + background: #1e282c; + + > a { + color: white; + } + } + + &.current { + > a { + &:before { + width: 6px; + } + } + } +} + +.groupBase { + display: block; + background: #2c3b41; + + .items { + transition: height $transition-time; + overflow: hidden; + } + + .itemBase { + > a { + color: #8aa4af; + padding: $item-padding $item-padding $item-padding $item-padding + 6px; + } + + &:hover, + &.current { + > a { + background: color.adjust(#2c3b41, $lightness: 5%); + color: white; + } + } + } + + &:not(.visible) > .items { + height: 0 !important; + } +} + +.groupTop { + align-items: center; + background: v.$aside-bg; + display: flex; + justify-content: space-between; + padding: $item-padding $item-padding $item-padding $item-padding + 6px; + position: relative; + color: #b8c7ce; + + &:hover, + .visible &, + .current & { + background: #1e282c; + cursor: pointer; + color: white; + } + + .current &:before { + width: 6px; + } +} + +.groupTop, +.itemBase > a { + &:before { + content: ''; + display: block; + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: 0; + background: v.$color-blue; + transition: width $transition-time; + } +} + +.groupTitle { + display: block; +} + +.groupIcon { + &:before { + display: block; + content: '+'; + + .visible &, + .current & { + content: '-'; + } + } +} diff --git a/client/admin/components/navigation.tsx b/client/admin/components/navigation.tsx new file mode 100644 index 0000000..a5cc23c --- /dev/null +++ b/client/admin/components/navigation.tsx @@ -0,0 +1,51 @@ +import { h, type FunctionComponent } from 'preact' +import { useRouter } from 'preact-router' + +import NavigationItem from './navigation_item.tsx' +import NavigationGroup from './navigation_group.tsx' +import s from './navigation.module.scss' + +import type { Route } from '../../../shared/types.ts' + +type NavigationProps = { + base: string + routes: Route[] +} + +const Navigation: FunctionComponent = ({ base, routes }) => { + const [{ url }] = useRouter() + + const currentPath = base ? url.slice(base.length) : url + + return ( + + ) +} + +export default Navigation diff --git a/client/admin/components/navigation_group.tsx b/client/admin/components/navigation_group.tsx new file mode 100644 index 0000000..2952b5a --- /dev/null +++ b/client/admin/components/navigation_group.tsx @@ -0,0 +1,72 @@ +import { h, type FunctionComponent } from 'preact' +import { useEffect, useReducer, useRef } from 'preact/hooks' +import cn from 'classnames' + +import NavigationItem from './navigation_item.tsx' +import s from './navigation.module.scss' + +import type { Route } from '../../../shared/types.ts' + +type NavigationGroupProps = { + base: string + currentPath: string + path: string + routes: Route[] + title: string +} + +const NavigationGroup: FunctionComponent = ({ base, currentPath, path, routes, title }) => { + const itemsRef = useRef(null) + const [visible, toggle] = useReducer((visible, force) => (typeof force === 'boolean' ? force : !visible), false) + const current = currentPath === path || (path !== '/' && currentPath.startsWith(path)) + + useEffect(() => { + itemsRef.current!.style.height = itemsRef.current!.scrollHeight + 'px' + }, []) + + useEffect(() => { + toggle(false) + }, [currentPath]) + + return ( +
  • +
    + {title} +
    +
    + +
      + {routes + .filter(({ nav }) => nav !== false) + .map(({ path, title, routes }) => + routes ? ( + + ) : ( + + ), + )} +
    +
  • + ) +} + +export default NavigationGroup diff --git a/client/admin/components/navigation_item.tsx b/client/admin/components/navigation_item.tsx new file mode 100644 index 0000000..07cb4d0 --- /dev/null +++ b/client/admin/components/navigation_item.tsx @@ -0,0 +1,42 @@ +import { h, type FunctionComponent } from 'preact' +import cn from 'classnames' + +import s from './navigation.module.scss' + +const NavigationItem: FunctionComponent<{ + base: string + currentPath: string + name: string + path: string + routes?: ANY[] + title: string +}> = ({ base = '', currentPath, name, title, path, routes }) => ( +
  • + + {title} + + + {routes && ( +
      + {routes.map( + (route) => + route.nav !== false && ( + + ), + )} +
    + )} +
  • +) + +export default NavigationItem diff --git a/client/admin/components/not_found_page.tsx b/client/admin/components/not_found_page.tsx new file mode 100644 index 0000000..37cb3f7 --- /dev/null +++ b/client/admin/components/not_found_page.tsx @@ -0,0 +1,13 @@ +import { h, type FunctionComponent } from 'preact' +import type { RoutableProps } from 'preact-router' + +const NotFoundPage: FunctionComponent = () => ( +
    +

    Not Found :(

    +

    + Try the Start Page +

    +
    +) + +export default NotFoundPage diff --git a/client/admin/components/notifications.module.scss b/client/admin/components/notifications.module.scss new file mode 100644 index 0000000..82ddc57 --- /dev/null +++ b/client/admin/components/notifications.module.scss @@ -0,0 +1,15 @@ +.base { + position: fixed; + z-index: 4000; + top: 15px; + left: 50%; + width: calc(100% - 30px); + max-width: 800px; + transform: translateX(-50%); +} + +.notification { + &:not(:last-child) { + margin-bottom: 8px; + } +} diff --git a/client/admin/components/notifications.tsx b/client/admin/components/notifications.tsx new file mode 100644 index 0000000..2bb5b2d --- /dev/null +++ b/client/admin/components/notifications.tsx @@ -0,0 +1,19 @@ +import { h } from 'preact' + +import { useNotifications } from '../contexts/notifications.tsx' +import Message from './message.tsx' +import s from './notifications.module.scss' + +export default function Notifications() { + const { notifications } = useNotifications() + + return ( +
    + {notifications.map(({ id, dismiss, message, type }) => ( + + {message} + + ))} +
    + ) +} diff --git a/client/admin/components/object_table.module.scss b/client/admin/components/object_table.module.scss new file mode 100644 index 0000000..36e3865 --- /dev/null +++ b/client/admin/components/object_table.module.scss @@ -0,0 +1,15 @@ +@use '../styles/variables' as v; + +.row { + &:not(:last-child) { + border-bottom: 1px solid v.$color-light-grey; + } +} + +.cell { + padding: 4px 0; + + &.key { + padding-right: 8px; + } +} diff --git a/client/admin/components/object_table.tsx b/client/admin/components/object_table.tsx new file mode 100644 index 0000000..71465ef --- /dev/null +++ b/client/admin/components/object_table.tsx @@ -0,0 +1,21 @@ +import cn from 'classnames' +import { h, type FunctionComponent } from 'preact' +import styles from './object_table.module.scss' + +const ObjectTable: FunctionComponent<{ styles?: Record; object: Record }> = ({ + styles: s = styles, + object, +}) => ( + + + {Object.entries(object).map(([key, value]) => ( + + + + + ))} + +
    {key}{value}
    +) + +export default ObjectTable diff --git a/client/admin/components/page_header.module.scss b/client/admin/components/page_header.module.scss new file mode 100644 index 0000000..bc0ae2a --- /dev/null +++ b/client/admin/components/page_header.module.scss @@ -0,0 +1,10 @@ +.base { + width: 100%; + margin-bottom: 16px; + display: flex; + justify-content: space-between; +} + +.heading { + font-size: 24px; +} diff --git a/client/admin/components/page_header.tsx b/client/admin/components/page_header.tsx new file mode 100644 index 0000000..38c566f --- /dev/null +++ b/client/admin/components/page_header.tsx @@ -0,0 +1,12 @@ +import { h, type FunctionComponent } from 'preact' +import cn from 'classnames' + +import s from './page_header.module.scss' + +const PageHeader: FunctionComponent<{ className?: string }> = ({ children, className }) => ( +
    +

    {children}

    +
    +) + +export default PageHeader diff --git a/client/admin/components/pagination.module.scss b/client/admin/components/pagination.module.scss new file mode 100644 index 0000000..5d9075f --- /dev/null +++ b/client/admin/components/pagination.module.scss @@ -0,0 +1,63 @@ +@use '../styles/variables' as v; +@use '../../shared/styles/breakpoints'; +@use '../../shared/styles/utils'; + +.pagination { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; +} + +.results { + padding: 3px 9px 0 0; + + > .number { + font-size: 1.1em; + font-weight: bold; + } +} + +.pages { + display: flex; + + > li { + display: block; + } +} + +.page, +.prev, +.next { + > a { + display: block; + padding: 6px 12px; + margin-left: -1px; + line-height: 1.42857143; + color: #666; + text-decoration: none; + background-color: #fafafa; + border: 1px solid #ddd; + + &[href='javascript:;'] { + cursor: not-allowed; + } + } + + &.currentPage { + > a { + color: white; + background: v.$color-blue; + } + } +} + +.prev a { + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; +} + +.next a { + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; +} diff --git a/client/admin/components/pagination.tsx b/client/admin/components/pagination.tsx new file mode 100644 index 0000000..4488204 --- /dev/null +++ b/client/admin/components/pagination.tsx @@ -0,0 +1,103 @@ +import { h, Fragment, type FunctionComponent } from 'preact' +import type { ForwardedRef } from 'preact/compat' +import cn from 'classnames' + +import s from './pagination.module.scss' + +function getPages({ + pathname, + search, + totalCount, + limit, + offset, +}: { + pathname: string + search: string + totalCount: number + limit: number + offset: number +}) { + const pages = [] + const searchParams = new URLSearchParams(search) + const last = Math.floor(totalCount / limit) + const current = offset / limit + + for (let i = 0; i <= last; i++) { + if (i === 0) searchParams.delete('offset') + else searchParams.set('offset', (limit * i) as unknown as string) + + const query = searchParams.toString() + + // TODO should all pages have a rel attribute? + pages.push({ + path: pathname + (query ? '?' + query : ''), + index: i, + current: i === current, + }) + } + + return { + current, + firstItemCount: totalCount ? offset + 1 : 0, + last, + lastItemCount: Math.min(offset + limit, totalCount), + next: pages[current + 1], + pages, + prev: pages[current - 1], + totalCount, + } +} + +type PaginationProps = { + className?: string + forwardRef: ForwardedRef + pathname: string + search: string + totalCount: number + limit: number + offset: number +} + +const Pagination: FunctionComponent = (props) => { + const { current, last, firstItemCount, lastItemCount, totalCount, pages, prev, next } = getPages(props) + + const start = current + 3 > last ? Math.max(last - 6, 0) : Math.max(current - 3, 0) + const end = start + 7 + + return ( +
    +
    + Showing {firstItemCount} to{' '} + {lastItemCount || 0} of{' '} + {totalCount || 0} entries +
    + +
    + ) +} + +export default Pagination diff --git a/client/admin/components/process.tsx b/client/admin/components/process.tsx new file mode 100644 index 0000000..a992dca --- /dev/null +++ b/client/admin/components/process.tsx @@ -0,0 +1,42 @@ +import { h, type FunctionComponent } from 'preact' +import { useEffect, useState } from 'preact/hooks' +import _ from 'lodash' +import rek from 'rek' +import ObjectTable from './object_table.tsx' +import { Table, Td } from './table.tsx' + +const Process: FunctionComponent<{ os?: ANY }> = ({ os }) => { + const [process, setProcess] = useState<{ + os: Record + process: Record | string[]> + } | null>(null) + + useEffect(() => { + rek('/api/process').then(setProcess) + }, []) + + return ( + process && ( + + + {Object.entries(process[os ? 'os' : 'process']).map(([key, value]) => ( + + + + + ))} + +
    {key} + {Array.isArray(value) ? ( + value.join(', ') + ) : _.isPlainObject(value) ? ( + + ) : ( + value + )} +
    + ) + ) +} + +export default Process diff --git a/client/admin/components/register_form.tsx b/client/admin/components/register_form.tsx new file mode 100644 index 0000000..0d5b607 --- /dev/null +++ b/client/admin/components/register_form.tsx @@ -0,0 +1,61 @@ +import { h, type TargetedSubmitEvent } from 'preact' +import { useCallback } from 'preact/hooks' +import rek, { type FetchError } from 'rek' + +import useRequestState from '../../shared/hooks/use_request_state.ts' +import Button from './button.tsx' +import Input from './input.tsx' +import Message from './message.tsx' + +const RegisterForm = () => { + const [{ error, pending, success }, actions] = useRequestState() + + const params = location.search ? new URLSearchParams(location.search) : null + + const register = useCallback(async (e: TargetedSubmitEvent) => { + e.preventDefault() + + actions.pending() + + const form = e.currentTarget + + try { + await rek.post('/auth/register', { + email: form.email.value, + password: form.password.value, + ...(params + ? { + inviteEmail: params.get('email'), + inviteToken: params.get('token'), + } + : {}), + }) + + actions.success() + } catch (err) { + actions.error(err as FetchError) + } + }, []) + + return success ? ( + + Success! Go to login. + + ) : ( +
    + {error && ( + + {error.status}: {error.body?.message || error.message} + + )} + + + + + + +
    + ) +} + +export default RegisterForm diff --git a/client/admin/components/register_page.tsx b/client/admin/components/register_page.tsx new file mode 100644 index 0000000..0f0a6cd --- /dev/null +++ b/client/admin/components/register_page.tsx @@ -0,0 +1,18 @@ +import { h } from 'preact' +import PageHeader from './page_header.tsx' +import RegisterForm from './register_form.tsx' +import Section from './section.tsx' + +const RegisterPage = () => ( +
    + Register + +
    + + + +
    +
    +) + +export default RegisterPage diff --git a/client/admin/components/role_form.tsx b/client/admin/components/role_form.tsx new file mode 100644 index 0000000..649ae79 --- /dev/null +++ b/client/admin/components/role_form.tsx @@ -0,0 +1,69 @@ +import { h, type FunctionComponent, type TargetedSubmitEvent } from 'preact' +import { useCallback } from 'preact/hooks' +import rek, { type FetchError } from 'rek' + +import useRequestState from '../../shared/hooks/use_request_state.ts' +import serializeForm from '../../shared/utils/serialize_form.ts' +import Button from './button.tsx' +import Input from './input.tsx' +import Message from './message.tsx' +import sutil from './utility.module.scss' + +const RoleForm: FunctionComponent<{ role?: ANY; onCancel?: ANY; onCreate?: ANY; onUpdate?: ANY }> = ({ + role, + onCancel, + onCreate, + onUpdate, +}) => { + const [{ error, pending, success }, actions] = useRequestState() + + const create = useCallback(async (e: TargetedSubmitEvent) => { + e.preventDefault() + + actions.pending() + + try { + const result = await rek[role ? 'patch' : 'post']( + `/api/roles${role ? '/' + role.id : ''}`, + serializeForm(e.currentTarget), + ) + + actions.success() + + if (role) { + onUpdate?.(result) + } else { + e.currentTarget.reset() + onCreate?.(result) + } + } catch (err) { + actions.error(err as FetchError) + } + }, []) + + return ( +
    + {success && Role {role ? 'updated' : 'created'}!} + {error && ( + + {error.status || 500} {error.body?.message || error.message} + + )} + + + +
    + + {onCancel && ( + + )} +
    +
    + ) +} + +export default RoleForm diff --git a/client/admin/components/roles_page.tsx b/client/admin/components/roles_page.tsx new file mode 100644 index 0000000..eb6a048 --- /dev/null +++ b/client/admin/components/roles_page.tsx @@ -0,0 +1,87 @@ +import { h } from 'preact' +import { useCallback, useEffect, useState } from 'preact/hooks' +import { route } from 'preact-router' +import rek, { type FetchError } from 'rek' + +import { useNotifications } from '../contexts/notifications.tsx' +import useItemsReducer from '../hooks/use_items_reducer.ts' +import RoleForm from './role_form.tsx' +import Modal from './modal.tsx' +import RolesTable from './roles_table.tsx' +import PageHeader from './page_header.tsx' +import Section from './section.tsx' + +import type { Role } from '../../../server/services/roles/types.ts' + +const RolesPage = () => { + const { notify } = useNotifications() + const [roles, actions] = useItemsReducer() + const [edit, setEdit] = useState(null) + + useEffect(() => { + rek('/api/roles' + location.search).then(actions.reset) + }, [location.search]) + + const onDelete = useCallback(async (id: number) => { + if (confirm(`Delete role ${id}?`)) { + try { + await rek.delete(`/api/roles/${id}`) + + actions.del(id) + + notify.success(`Role ${id} deleted`) + } catch (err) { + notify.error((err as FetchError).body?.message || (err as FetchError).toString()) + } + } + }, []) + + const onEdit = useCallback( + async (id: number) => { + setEdit(id ? roles.find((role) => role.id === id) : null) + }, + [roles], + ) + + const onSortBy = useCallback((column: string | null) => { + const searchParams = new URLSearchParams(location.search) + + const newSort = (searchParams.get('sort') || 'id') === column ? '-' + column : column + + route(location.pathname + (newSort !== 'id' ? `?sort=${newSort}` : '')) + }, []) + + return ( +
    + Roles + +
    + Create New Role + + + + +
    + +
    + List + + + + +
    + + {edit != null && ( + setEdit(null)}> + setEdit(null)} + onUpdate={(role: ANY) => actions.update(role.id, role)} + role={edit} + /> + + )} +
    + ) +} + +export default RolesPage diff --git a/client/admin/components/roles_table.tsx b/client/admin/components/roles_table.tsx new file mode 100644 index 0000000..e53c0f7 --- /dev/null +++ b/client/admin/components/roles_table.tsx @@ -0,0 +1,48 @@ +import { h, type FunctionComponent } from 'preact' +import Button from './button.tsx' +import { Table, Td, Th } from './table.tsx' + +const RolesTable: FunctionComponent<{ roles?: ANY[]; onDelete?: ANY; onEdit?: ANY; onSortBy?: ANY }> = ({ + roles, + onDelete, + onEdit, + onSortBy, +}) => ( + + + + + + + + + + + {roles?.length ? ( + roles.map((role) => ( + + + + + + + + )) + ) : ( + + + + )} + +
    IDNameCreated AtCreated By +
    {role.id}{role.name}{role.createdAt}{role.createdBy?.email} + {' '} + +
    No roles found
    +) + +export default RolesTable diff --git a/client/admin/components/route.tsx b/client/admin/components/route.tsx new file mode 100644 index 0000000..0c0447a --- /dev/null +++ b/client/admin/components/route.tsx @@ -0,0 +1,25 @@ +// @ts-nocheck +import { h } from 'preact' +import { useEffect, useState } from 'preact/hooks' +import { route } from 'preact-router' +import { useCurrentUser } from '../contexts/current_user.ts' + +/** @type {import('preact').FunctionComponent<{ auth: boolean, path: string, component: () => any, loadComponent: boolean}} Page */ +const Route = ({ auth, path, component, loadComponent = true }) => { + const [Component, setComponent] = useState(null) + const { user } = useCurrentUser() + + useEffect(async () => { + if (auth ? !user : user) return route(user ? '/admin' : '/admin/login', true) + + const loadedComponent = loadComponent ? (await component()).default : component + + // Wrapping in arrow function is required since loadedComponent is a function + // and setComponent will call functions passed to it + setComponent(() => loadedComponent) + }, [user]) + + return Component && +} + +export default Route diff --git a/client/admin/components/row.module.scss b/client/admin/components/row.module.scss new file mode 100644 index 0000000..efada6b --- /dev/null +++ b/client/admin/components/row.module.scss @@ -0,0 +1,9 @@ +@use '../../shared/styles/flex'; + +.row { + display: flex; + + > * { + @include flex.simple-cell($gutter: 12px); + } +} diff --git a/client/admin/components/row.tsx b/client/admin/components/row.tsx new file mode 100644 index 0000000..c7cd25c --- /dev/null +++ b/client/admin/components/row.tsx @@ -0,0 +1,14 @@ +import { h, type FunctionComponent, type JSX } from 'preact' +import s from './row.module.scss' + +const Row: FunctionComponent<{ tag?: keyof JSX.IntrinsicElements }> = ({ children, tag: Tag = 'div' }) => ( + {children} +) + +export default Row + +export const Cell: FunctionComponent<{ grow?: string; tag?: keyof JSX.IntrinsicElements }> = ({ + children, + grow, + tag: Tag = 'div', +}) => {children} diff --git a/client/admin/components/section.module.scss b/client/admin/components/section.module.scss new file mode 100644 index 0000000..934e10c --- /dev/null +++ b/client/admin/components/section.module.scss @@ -0,0 +1,26 @@ +@use '../styles/variables' as v; + +.base { + border-top: 3px solid v.$color-blue; + background: white; + margin-bottom: v.$gutter; +} + +.heading { + padding: 12px; + font-size: 18px; + color: #444; +} + +.body { + padding: 12px; + + &.noPadding { + padding: 0; + } +} + +.footer { + border-top: 1px solid v.$color-light-grey; + padding: 12px; +} diff --git a/client/admin/components/section.tsx b/client/admin/components/section.tsx new file mode 100644 index 0000000..156b5f2 --- /dev/null +++ b/client/admin/components/section.tsx @@ -0,0 +1,36 @@ +import { h, type FunctionComponent } from 'preact' +import cn from 'classnames' + +import s from './section.module.scss' + +const Section: FunctionComponent<{ className?: string; maxWidth?: string; minWidth?: string }> & { + Body: typeof SectionBody + Footer: typeof SectionFooter + Heading: typeof SectionHeading +} = ({ children, className, maxWidth, minWidth }) => ( +
    + {children} +
    +) + +const SectionBody: FunctionComponent<{ className?: string; noPadding?: boolean }> = ({ + children, + className, + noPadding, +}) =>
    {children}
    + +Section.Body = SectionBody + +const SectionHeading: FunctionComponent<{ className?: string }> = ({ children, className }) => ( +

    {children}

    +) + +Section.Heading = SectionHeading + +const SectionFooter: FunctionComponent<{ className?: string }> = ({ children, className }) => ( +
    {children}
    +) + +Section.Footer = SectionFooter + +export default Section diff --git a/client/admin/components/select.tsx b/client/admin/components/select.tsx new file mode 100644 index 0000000..d8da8af --- /dev/null +++ b/client/admin/components/select.tsx @@ -0,0 +1,4 @@ +import selectFactory from '../../shared/components/select_factory.tsx' +import styles from './input.module.scss' + +export default selectFactory({ styles }) diff --git a/client/admin/components/start_page.tsx b/client/admin/components/start_page.tsx new file mode 100644 index 0000000..3cd22c3 --- /dev/null +++ b/client/admin/components/start_page.tsx @@ -0,0 +1,44 @@ +import { h } from 'preact' +import PageHeader from './page_header.tsx' +import Row from './row.tsx' +import GitLog from './git_log.tsx' +import Process from './process.tsx' +import Section from './section.tsx' + +/** @type {import('preact').FunctionComponent} StartPage */ +const StartPage = () => ( +
    + Start Page + +
    + +

    Welcome. You are in good company.

    +
    +
    + +
    + Latest Commits + + + +
    + + +
    + Process + + + +
    + +
    + Os + + + +
    +
    +
    +) + +export default StartPage diff --git a/client/admin/components/table.module.scss b/client/admin/components/table.module.scss new file mode 100644 index 0000000..cc2c447 --- /dev/null +++ b/client/admin/components/table.module.scss @@ -0,0 +1,120 @@ +@use '../styles/variables' as v; + +$padding: 12px; + +.table { + width: 100%; + + > tbody, + > thead { + > tr { + > th, + > td { + vertical-align: top; + border-top: 1px solid v.$color-light-grey; + padding: $padding; + } + + > th { + text-align: left; + font-weight: bold; + } + + > td { + background: white; + } + + &:hover { + > td, + > th { + background: #eee; + } + } + } + } +} + +.th { + a { + text-decoration: none; + color: v.$body-font-color; + display: flex; + align-items: center; + + > div.arrows { + order: 0; + display: flex; + flex-direction: column; + margin-left: 6px; + + &:before, + &:after { + content: ''; + display: block; + width: 12px; + height: 5px; + background: #ddd; + } + + &:before { + clip-path: polygon(0 100%, 50% 0, 100% 100%); + } + + &:after { + clip-path: polygon(0 0, 50% 100%, 100% 0); + margin-top: 2px; + } + } + + &:hover { + color: v.$color-blue; + + &:not(.current):not(.desc), + &.current.desc { + > div.arrows:before { + background: v.$color-blue; + } + } + + &:not(.current).desc, + &.current:not(.desc) { + > div.arrows:after { + background: v.$color-blue; + } + } + } + + &.current:not(:hover) { + &:not(.desc) { + > div.arrows:before { + background: v.$body-font-color; + } + } + + &.desc { + > div.arrows:after { + background: v.$body-font-color; + } + } + } + } +} + +.minimize { + width: 0; + white-space: nowrap; +} + +td.buttons { + width: 0; + padding: 0 $padding; + vertical-align: middle; + + > div { + display: flex; + + > *:not(:last-child) { + margin-right: 2px; + } + } +} diff --git a/client/admin/components/table.tsx b/client/admin/components/table.tsx new file mode 100644 index 0000000..4bcdd44 --- /dev/null +++ b/client/admin/components/table.tsx @@ -0,0 +1,93 @@ +import { h, createContext, type FunctionComponent } from 'preact' +import { useCallback, useContext } from 'preact/hooks' +import cn from 'classnames' +import { camelCase } from 'lowline' + +import s from './table.module.scss' + +export type onSortByFunction = (column: string | null) => void | Promise + +const TableContext = createContext<{ sortBy: (e: Event) => void; sortedBy: [string, boolean?] } | null>(null) + +type TableProps = { + className?: string + defaultSort?: string + onSortBy?: onSortByFunction + sortedBy?: string +} + +export const Table: FunctionComponent = ({ + children, + className, + defaultSort = 'id', + onSortBy, + sortedBy, +}) => { + const sortBy = useCallback( + (e: Event) => { + e.preventDefault() + + // @ts-ignore + const { sortBy } = e.currentTarget.dataset + + const newSortBy = sortBy === sortedBy ? '-' + sortBy : sortBy + + onSortBy?.(newSortBy === defaultSort ? null : newSortBy) + }, + [onSortBy, sortedBy], + ) + + sortedBy ??= defaultSort + + const desc = sortedBy.startsWith('-') + + return ( + + {children}
    +
    + ) +} + +export const Td: FunctionComponent<{ + className?: string + buttons?: boolean + minimize?: boolean +}> = ({ children, className, buttons, minimize }) => ( + + {buttons ?
    {children}
    : children} + +) + +export const Th: FunctionComponent<{ + children: string + className?: string + scope?: 'col' | 'row' | 'colgroup' | 'rowgroup' + sort?: string | boolean +}> = ({ children, className, scope, sort }) => { + const column = typeof sort === 'string' ? sort : camelCase(children) + + const { sortBy, sortedBy } = useContext(TableContext)! + + return ( + + {sort ? ( + + {children} +
    + + ) : ( + children + )} + + ) +} diff --git a/client/admin/components/users_page.tsx b/client/admin/components/users_page.tsx new file mode 100644 index 0000000..0620dd4 --- /dev/null +++ b/client/admin/components/users_page.tsx @@ -0,0 +1,43 @@ +import { h } from 'preact' +import { useCallback, useEffect } from 'preact/hooks' +import { route } from 'preact-router' +import rek from 'rek' + +import useItemsReducer from '../hooks/use_items_reducer.ts' +import UsersTable from './users_table.tsx' +import PageHeader from './page_header.tsx' +import Section from './section.tsx' + +import type { User } from '../../../server/services/users/types.ts' + +const UsersPage = () => { + const [users, actions] = useItemsReducer() + + useEffect(() => { + rek('/api/users' + location.search).then(actions.reset) + }, [location.search]) + + const onSortBy = useCallback((column: string | null) => { + const searchParams = new URLSearchParams(location.search) + + const newSort = (searchParams.get('sort') || 'id') === column ? '-' + column : column + + route(location.pathname + (newSort !== 'id' ? `?sort=${newSort}` : '')) + }, []) + + return ( +
    + Users + +
    + List + + + + +
    +
    + ) +} + +export default UsersPage diff --git a/client/admin/components/users_table.tsx b/client/admin/components/users_table.tsx new file mode 100644 index 0000000..03c3217 --- /dev/null +++ b/client/admin/components/users_table.tsx @@ -0,0 +1,41 @@ +import { h, type FunctionComponent } from 'preact' +import { Table, Th, type onSortByFunction } from './table.tsx' + +import type { User } from '../../../server/services/users/types.ts' + +const UsersTable: FunctionComponent<{ users: User[]; onSortBy: onSortByFunction }> = ({ users, onSortBy }) => ( + + + + + + + + + + + + + + {users?.length ? ( + users.map((user) => ( + + + + + + + + + + )) + ) : ( + + + + )} + +
    IDEmailRolesEmail VerifiedLast LoginLogin AttemptsLast Login Attempt
    {user.id}{user.email}{user.roles?.map((role) => role.name).join(', ')}{user.emailVerifiedAt}{user.lastLoginAt}{user.loginAttempts}{user.lastLoginAttemptAt}
    No users found
    +) + +export default UsersTable diff --git a/client/admin/components/utility.module.scss b/client/admin/components/utility.module.scss new file mode 100644 index 0000000..48c2162 --- /dev/null +++ b/client/admin/components/utility.module.scss @@ -0,0 +1,13 @@ +@use '../../shared/styles/flex'; + +.hide { + display: none; +} + +.row { + display: flex; + + > * { + @include flex.simple-cell($gutter: 12px); + } +} diff --git a/client/admin/contexts/current_user.ts b/client/admin/contexts/current_user.ts new file mode 100644 index 0000000..1ff49de --- /dev/null +++ b/client/admin/contexts/current_user.ts @@ -0,0 +1,9 @@ +import { createContext } from 'preact' +import { useContext } from 'preact/hooks' + +type CurrentUserContextType = { user: ANY; setUser: (user: ANY) => void } +const CurrentUserContext = createContext(null) + +export default CurrentUserContext + +export const useCurrentUser = () => useContext(CurrentUserContext) as CurrentUserContextType diff --git a/client/admin/contexts/notifications.tsx b/client/admin/contexts/notifications.tsx new file mode 100644 index 0000000..8e510a7 --- /dev/null +++ b/client/admin/contexts/notifications.tsx @@ -0,0 +1,92 @@ +// @ts-nocheck +import { h, createContext, type FunctionComponent } from 'preact' +import { useContext, useReducer } from 'preact/hooks' + +type MessageType = 'error' | 'info' | 'success' + +type Payload = { + id: number + message: string + type: MessageType + dismiss?: () => void +} + +type Action = { + type: typeof NOTIFY | typeof DISMISS + payload: Payload +} + +const NotificationsContext = createContext<{ + notifications: ANY[] + notify: ((message: string, type?: MessageType, timeout?: number) => void) & { + error: (message: string, timeout?: number) => void + info: (message: string, timeout?: number) => void + success: (message: string, timeout?: number) => void + } +}>() + +const NOTIFY = 'notify' + +export const notifyAction = (message: string, type: MessageType): Action => ({ + type: NOTIFY, + payload: { + id: counter++, + message, + type, + }, +}) + +const DISMISS = 'dismiss' + +export const dismissAction = (id: number) => ({ + type: DISMISS, + id, +}) + +let counter = 0 + +export const reducer = (state: Payload[], action: Action) => { + switch (action.type) { + case NOTIFY: + return state.concat(action.payload) + case DISMISS: { + const index = state.findIndex((notification) => notification.id === action.id) + + if (index > -1) { + return [...state.slice(0, index), ...state.slice(index + 1)] + } + + return state + } + default: + return state + } +} + +export const useNotifications = () => useContext(NotificationsContext) + +export const NotificationsProvider: FunctionComponent<{ defaultTimeout?: number }> = ({ + children, + defaultTimeout = 0, +}) => { + const [notifications, dispatch] = useReducer(reducer, []) + + function notify(message: string, type: MessageType = 'info', timeout = defaultTimeout) { + const action = notifyAction(message, type) + const dismiss = (action.payload.dismiss = () => dispatch(dismissAction(action.payload.id))) + + dispatch(action) + + if (timeout) { + setTimeout(dismiss, timeout) + } + + return dismiss + } + + notify.error = (message: string, timeout?: number) => notify(message, 'error', timeout) + notify.success = (message: string, timeout?: number) => notify(message, 'success', timeout) + notify.info = (message: string, timeout?: number) => notify(message, 'info', timeout) + + return {children} +} diff --git a/client/admin/hooks/use_items_reducer.ts b/client/admin/hooks/use_items_reducer.ts new file mode 100644 index 0000000..bc8348c --- /dev/null +++ b/client/admin/hooks/use_items_reducer.ts @@ -0,0 +1,129 @@ +import { useReducer } from 'preact/hooks' + +type DefaultItem = { + id: number +} + +const ADD = 'add' + +type AddAction = { + type: typeof ADD + payload: I +} + +const add = (item: I): AddAction => ({ + type: ADD, + payload: item, +}) + +const DEL = 'delete' + +type DeleteAction = { + type: typeof DEL + id: number +} + +const del = (id: number): DeleteAction => ({ + type: DEL, + id, +}) + +const LOAD = 'load' + +type LoadAction = { + type: typeof LOAD + payload: I[] +} + +const load = (items: I[]): LoadAction => ({ + type: LOAD, + payload: items, +}) + +const RESET = 'reset' + +type ResetAction = { + type: typeof RESET + payload: I[] +} + +const reset = (items: I[] = []): ResetAction => ({ + type: RESET, + payload: items, +}) + +const UPDATE = 'update' + +type UpdateAction = { + type: typeof UPDATE + id: number + payload: I +} + +const update = (id: number, item: I): UpdateAction => ({ + type: UPDATE, + id, + payload: item, +}) + +export const actions = { + add, + del, + load, + reset, + update, +} + +type Action = AddAction | DeleteAction | LoadAction | ResetAction | UpdateAction + +export const itemsReducer = (state: I[], action: Action) => { + switch (action.type) { + case ADD: + return [...state, action.payload] + case DEL: { + const index = state.findIndex((item) => item.id === action.id) + + if (index > -1) { + return [...state.slice(0, index), ...state.slice(index + 1)] + } + + return state + } + case LOAD: { + return [...state, ...action.payload] + } + case RESET: { + return [...action.payload] + } + case UPDATE: { + const index = state.findIndex((item) => item.id === action.id) + + if (index > -1) { + const newItem = { + ...state[index], + ...action.payload, + } + + return [...state.slice(0, index), newItem, ...state.slice(index + 1)] + } + + return state + } + default: + return state + } +} + +export default (init: I[] = []) => { + const [items, dispatch] = useReducer(itemsReducer, init) + + const actions = { + add: (item: I) => dispatch(add(item)), + del: (id: number) => dispatch(del(id)), + load: (items: I[]) => dispatch(load(items)), + reset: (items: I[]) => dispatch(reset(items)), + update: (id: number, item: I) => dispatch(update(id, item)), + } + + return [items, actions] as [I[], typeof actions] +} diff --git a/client/admin/images/icon-delete.svg b/client/admin/images/icon-delete.svg new file mode 100644 index 0000000..3214467 --- /dev/null +++ b/client/admin/images/icon-delete.svg @@ -0,0 +1,14 @@ + + + + diff --git a/client/admin/images/icon-edit.svg b/client/admin/images/icon-edit.svg new file mode 100644 index 0000000..a1a0f26 --- /dev/null +++ b/client/admin/images/icon-edit.svg @@ -0,0 +1,14 @@ + + + + diff --git a/client/admin/routes.ts b/client/admin/routes.ts new file mode 100644 index 0000000..5ee417d --- /dev/null +++ b/client/admin/routes.ts @@ -0,0 +1,75 @@ +import type { Route } from '../../shared/types.ts' + +export default [ + { + path: '/', + title: 'Start', + component: () => import('./components/start_page.tsx'), + auth: true, + }, + { + path: '/membership', + title: 'Membership', + auth: true, + routes: [ + { + path: '/membership/users', + title: 'Users', + component: () => import('./components/users_page.tsx'), + auth: true, + }, + { + path: '/membership/roles', + title: 'Roles', + component: () => import('./components/roles_page.tsx'), + auth: true, + }, + { + path: '/membership/admissions', + title: 'Admissions', + component: () => import('./components/admissions_page.tsx'), + auth: true, + }, + { + path: '/membership/invites', + title: 'Invites', + component: () => import('./components/invites_page.tsx'), + auth: true, + }, + ], + }, + { + path: '/errors', + title: 'Errors', + component: () => import('./components/errors_page.tsx'), + auth: true, + }, + { + path: '/login', + title: 'Login', + component: () => import('./components/login_page.tsx'), + auth: false, + nav: false, + }, + { + path: '/register', + title: 'Register', + component: () => import('./components/register_page.tsx'), + auth: false, + nav: false, + }, + { + path: '/change-password', + title: 'Change Password', + component: () => import('./components/change_password_page.tsx'), + auth: false, + nav: false, + }, + { + path: '/forgot-password', + title: 'Forgot Password', + component: () => import('./components/forgot_password_page.tsx'), + auth: false, + nav: false, + }, +] as Route[] diff --git a/client/admin/server.ts b/client/admin/server.ts new file mode 100644 index 0000000..c962ec8 --- /dev/null +++ b/client/admin/server.ts @@ -0,0 +1 @@ +export { default as routes } from './routes.ts' diff --git a/client/admin/styles/_fonts.scss b/client/admin/styles/_fonts.scss new file mode 100644 index 0000000..fb835c7 --- /dev/null +++ b/client/admin/styles/_fonts.scss @@ -0,0 +1 @@ +@import url('https://fonts.googleapis.com/css2?family=Source+Sans+Pro:ital,wght@0,200;0,300;0,400;0,600;0,700;0,900;1,200;1,300;1,400;1,600;1,700;1,900&display=swap'); diff --git a/client/admin/styles/_layout.scss b/client/admin/styles/_layout.scss new file mode 100644 index 0000000..e69de29 diff --git a/client/admin/styles/_styles.scss b/client/admin/styles/_styles.scss new file mode 100644 index 0000000..0ae60cf --- /dev/null +++ b/client/admin/styles/_styles.scss @@ -0,0 +1,28 @@ +@use './variables' as v; + +*, +*:before, +*:after { + box-sizing: border-box; +} + +html, +body { + margin: 0; +} + +body { + color: v.$body-font-color; + font-family: v.$body-font-family; + font-size: v.$base-font-size; + overflow-x: hidden; + overflow-y: scroll; + + &.loading { + cursor: wait !important; + + * { + cursor: wait !important; + } + } +} diff --git a/client/admin/styles/_variables.scss b/client/admin/styles/_variables.scss new file mode 100644 index 0000000..99b8b99 --- /dev/null +++ b/client/admin/styles/_variables.scss @@ -0,0 +1,56 @@ +@use 'sass:color'; +@use 'sass:math'; + +$color-1: #3c8dbc; + +$aside-width: 230px; +$aside-bg: #222d32; +$header-height: 50px; +$header-bg: $color-1; +$main-bg: #ecf0f5; +$footer-bg: #fff; +$footer-height: 50px; +$gutter: 16px; + +$base-font-size: 14px; + +$color-blue: #3c8dbc; +$color-blue-dark: color.adjust($color-blue, $lightness: -25%, $space: hsl); +$color-blue-light: color.adjust($color-blue, $lightness: 10%, $space: hsl); +$color-green: #00a65a; +$color-green-dark: color.adjust($color-green, $lightness: -15%, $space: hsl); +$color-green-light: color.adjust($color-green, $lightness: 10%, $space: hsl); +$color-orange: #f39c12; +$color-red: #dd4b39; +$color-red-dark: color.adjust($color-red, $lightness: -20%, $space: hsl); +$color-red-light: color.adjust($color-red, $lightness: 20%, $space: hsl); +$color-black: #333; +$color-light-grey: #f4f4f4; + +$body-font-family: 'Source Sans Pro', 'Helvetica Neue', Helvetica, Arial, sans-serif; +$body-font-color: $color-black; +$heading-font-family: 'Heebo', sans-serif; +$base-font-size: 14px; +$base-line-height: 1.4; +$content-max-width: 1280px; + +$button-height-small: 40px; +$button-height: 50px; + +// FORM +$form-border-color: #d2d6de; +$form-element-padding: 9px; +$form-label-margin: 6px; + +// old +$form-error-label-transition-duration: 0.3s; +$form-field-margin: math.div($gutter, 2); +$form-focus-transition-duration: 0.2s; +$form-focused-color: $color-blue; +$form-font-color: $color-black; +$form-icon-size: 20px; +$form-icon-transition-duration: 0.5s; +$form-input-height: 50px; +$form-input-padding: 16px $form-icon-size + 10px * 2 0 12px; +$form-invalid-color: $color-red; +$form-valid-color: $color-green; diff --git a/client/admin/styles/main.scss b/client/admin/styles/main.scss new file mode 100644 index 0000000..7fac64c --- /dev/null +++ b/client/admin/styles/main.scss @@ -0,0 +1,6 @@ +@use '../../shared/styles/reset'; + +@use './fonts'; + +@use './styles'; +@use './layout'; diff --git a/client/client.d.ts b/client/client.d.ts index 12da21c..3dd28bc 100644 --- a/client/client.d.ts +++ b/client/client.d.ts @@ -4,3 +4,5 @@ declare module '*.module.scss' { } declare module '*.scss' + +declare module '*.svg' diff --git a/client/public/components/invoices_by_supplier_page.tsx b/client/public/components/invoices_by_supplier_page.tsx index e0ee449..35914ef 100644 --- a/client/public/components/invoices_by_supplier_page.tsx +++ b/client/public/components/invoices_by_supplier_page.tsx @@ -6,6 +6,7 @@ import rek from 'rek' import Head from './head.ts' import usePromise from '../../shared/hooks/use_promise.ts' import type { Invoice, Supplier } from '../../../shared/types.ts' +import { formatPrice } from '../utils/format_number.ts' const format = Format.bind(null, null, 'YYYY.MM.DD') @@ -16,7 +17,9 @@ const InvoicesPage: FunctionComponent = () => { Promise.all([ rek(`/api/suppliers/${route.params.supplier}`), rek(`/api/invoices?supplier=${route.params.supplier}`), - rek(`/api/invoices/total-amount?supplier=${route.params.supplier}`), + rek(`/api/invoices/total-amount?supplier=${route.params.supplier}`).then( + (totalAmount: { amount: number }) => totalAmount.amount, + ), ]), ) @@ -29,10 +32,10 @@ const InvoicesPage: FunctionComponent = () => {

    Invoices for {supplier?.name}

    - Total: {totalAmount} + Total: {formatPrice(totalAmount)}

    - +
    diff --git a/client/shared/components/button_factory.tsx b/client/shared/components/button_factory.tsx index e04d502..4e210aa 100644 --- a/client/shared/components/button_factory.tsx +++ b/client/shared/components/button_factory.tsx @@ -1,8 +1,8 @@ import { h, type FunctionComponent, type PointerEventHandler } from 'preact' import cn from 'classnames' -type Styles = { - base: string +type ButtonStyles = { + base?: string autoHeight?: string fullWidth?: string icon?: string @@ -11,7 +11,7 @@ type Styles = { Record & Record -type Props = { +type ButtonProps = { autoHeight?: boolean className?: string color?: C @@ -29,10 +29,10 @@ type Props = { - defaults: Partial> +type ButtonFactoryOptions = { + defaults: Partial> icons: Record - styles: Styles + styles: ButtonStyles } export default function buttonFactory< @@ -40,8 +40,8 @@ export default function buttonFactory< D extends string = never, I extends string = never, S extends string = never, ->({ defaults, icons, styles }: Options) { - const Button: FunctionComponent> = ({ +>({ defaults, icons, styles }: ButtonFactoryOptions) { + const Button: FunctionComponent> = ({ autoHeight = defaults?.autoHeight, children, className, diff --git a/client/shared/components/checkbox_factory.tsx b/client/shared/components/checkbox_factory.tsx index e7c3ab0..4239fb4 100644 --- a/client/shared/components/checkbox_factory.tsx +++ b/client/shared/components/checkbox_factory.tsx @@ -3,14 +3,14 @@ import { type ChangeEvent } from 'preact/compat' import { useCallback, useEffect, useRef, useState } from 'preact/hooks' import cn from 'classnames' -type Styles = { +type CheckboxStyles = { base?: string element?: string label?: string touched?: string } & Record -type Props = { +type CheckboxProps = { autoFocus?: boolean className?: string defaultChecked?: any @@ -19,12 +19,12 @@ type Props = { name?: string onChange?: (e: ChangeEvent) => void required?: boolean - style: S + style?: S value?: string } -export default function checkboxFactory(styles: Styles) { - const Checkbox: FunctionComponent> = ({ +export default function checkboxFactory(styles: CheckboxStyles) { + const Checkbox: FunctionComponent> = ({ autoFocus, className, defaultChecked, @@ -55,7 +55,7 @@ export default function checkboxFactory(styles: Styles }, [autoFocus]) return ( -
    ID
    + + + +
    +

    So you forgot your password huh?

    + +

    Is cool. We got you. Just follow the link below to set a new password.

    + +

    ${link}

    + +

    This link will only be active for 24 hours.

    + +

    This email is autogenerated and cannot be replied to.

    +
    +` diff --git a/server/templates/emails/invite.ts b/server/templates/emails/invite.ts new file mode 100644 index 0000000..7d2b0a3 --- /dev/null +++ b/server/templates/emails/invite.ts @@ -0,0 +1,17 @@ +import html from '../../lib/html.ts' + +export default ({ link }: { link: string }) => html` + + + + +
    +

    Congratulations! You have been invited to Startbit Admin!

    + +

    To redeem your invite, all you need to do now is click on the link below.

    + +

    ${link}

    + +

    This email is autogenerated and cannot be replied to.

    +
    +` diff --git a/server/templates/emails/verify_email.ts b/server/templates/emails/verify_email.ts new file mode 100644 index 0000000..84dac93 --- /dev/null +++ b/server/templates/emails/verify_email.ts @@ -0,0 +1,22 @@ +import html from '../../lib/html.ts' + +export default ({ link }: { link: string }) => html` + + + + +
    +

    Hello

    + +

    + You have almost completed your registration process! All you need to do now is click on the link below to + verify your email address. +

    + +

    ${link}

    + +

    This link will only be active for 24 hours.

    + +

    This email is autogenerated and cannot be replied to.

    +
    +` diff --git a/server/tests/auth.test.ts b/server/tests/auth.test.ts new file mode 100644 index 0000000..1accb87 --- /dev/null +++ b/server/tests/auth.test.ts @@ -0,0 +1,26 @@ +import { test, type TestContext } from 'node:test' + +import Server from '../server.ts' + +test('Auth', async (t: TestContext) => { + const server = await Server() + + await t.test('login', async (t: TestContext) => { + const res = await server.inject({ + method: 'POST', + path: '/auth/login', + payload: { + email: 'linus.miller@bitmill.io', + password: 'rasmus', + }, + }) + + const sessionCookie = res.cookies.find((cookie) => cookie.name === 'sessionId') + + t.assert.ok(sessionCookie) + + t.assert.equal(res.statusCode, 200) + + await server.close() + }) +}) diff --git a/server/tests/process.test.ts b/server/tests/process.test.ts new file mode 100644 index 0000000..04f4ece --- /dev/null +++ b/server/tests/process.test.ts @@ -0,0 +1,19 @@ +import { test, type TestContext } from 'node:test' + +import processPlugin from '../routes/api/process.ts' +import fastify from 'fastify' + +test('/api/process', async (t: TestContext) => { + const server = fastify() + + server.decorate('auth', (_request, _reply, done) => done()) + + server.register(processPlugin, { prefix: '/api/process' }) + + const res = await server.inject({ + method: 'GET', + url: '/api/process', + }) + + t.assert.equal(res.statusCode, 200) +}) diff --git a/shared/global.d.ts b/shared/global.d.ts index 7ff4d1c..fbbf231 100644 --- a/shared/global.d.ts +++ b/shared/global.d.ts @@ -1,4 +1,5 @@ import 'fastify' +import type { onRequestHookHandler } from 'fastify' import { ViteDevServer } from 'vite' @@ -9,14 +10,20 @@ declare global { } declare module 'fastify' { + interface Session { + userId: number + } + interface FastifyInstance { - auth: string + auth: onRequestHookHandler devServer: ViteDevServer } interface FastifyRequest { - login: string - user: Promise + logout: () => void + login: (user: ANY) => Promise + user: Promise + getUser: () => Promise } interface FastifyReply { diff --git a/shared/types.ts b/shared/types.ts index a20cc5c..849d5b0 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -83,9 +83,10 @@ export interface TransactionFull extends Transaction { export interface Route { path: string - name: string + name?: string title: string component: (args: ANY) => ANY + auth?: boolean cache?: boolean nav?: boolean routes?: Route[]