WIP auth and admin

This commit is contained in:
Linus Miller 2025-12-15 16:02:51 +01:00
parent 1ff4684ed3
commit 2c18a61315
142 changed files with 6491 additions and 53 deletions

17
.env.testing Normal file
View File

@ -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

7
client/admin/client.ts Normal file
View File

@ -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)

View File

@ -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<FetchError>()
const create = useCallback(async (e: TargetedSubmitEvent<HTMLFormElement>) => {
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 (
<form onSubmit={create}>
{success && (
<Message type='success' dismiss={actions.reset}>
Admission {admission ? 'updated' : 'created'}!
</Message>
)}
{error && (
<Message type='error' dismiss={actions.reset}>
{error.status || 500} {error.body?.message || error.message}
</Message>
)}
<Input name='regex' label='Regex' type='text' defaultValue={admission?.regex} required />
{roles && (
<div>
{roles.map((role) => (
<Checkbox
key={role.id}
name='roles'
label={role.name}
value={role.id}
defaultChecked={!!admission?.roles.find((r: ANY) => r.id === role.id)}
/>
))}
</div>
)}
<div className={sutil.row}>
<Button disabled={pending} type='submit'>
{admission ? 'Update' : 'Create'}
</Button>
{onCancel && (
<Button disabled={pending} onClick={onCancel} type='button'>
Cancel
</Button>
)}
</div>
</form>
)
}
export default AdmissionForm

View File

@ -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 (
<section>
<PageHeader>Admissions</PageHeader>
<Section maxWidth='50%'>
<Section.Heading>Create New Admission</Section.Heading>
<Section.Body>
<AdmissionForm onCreate={actions.add} roles={roles} />
</Section.Body>
</Section>
<Section>
<Section.Heading>List</Section.Heading>
<Section.Body noPadding>
<AdmissionsTable admissions={admissions} onDelete={onDelete} onEdit={setEditing} onSortBy={onSortBy} />
</Section.Body>
</Section>
{editing != null && (
<Modal onClose={() => setEditing(null)}>
<AdmissionForm
onCancel={() => setEditing(null)}
onUpdate={(admission: ANY) => actions.update(admission.id, admission)}
admission={admissions.find((admission) => admission.id === editing)}
roles={roles}
/>
</Modal>
)}
</section>
)
}
export default AdmissionsPage

View File

@ -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,
}) => (
<Table onSortBy={onSortBy}>
<thead>
<tr>
<Th sort='id'>ID</Th>
<Th sort='regex'>RegExp</Th>
<th>Roles</th>
<Th sort>Created At</Th>
<th>Created By</th>
<th />
</tr>
</thead>
<tbody>
{admissions?.length ? (
admissions.map((admission) => (
<tr key={admission.id}>
<td>{admission.id}</td>
<td>{admission.regex}</td>
<td>{admission.roles.map((role: ANY) => role.name).join(', ')}</td>
<td>{admission.createdAt}</td>
<td>{admission.createdBy?.email}</td>
<Td buttons>
<Button size='small' icon='edit' onClick={() => onEdit(admission.id)}>
Edit
</Button>{' '}
<Button size='small' icon='delete' color='red' onClick={() => onDelete(admission.id)}>
Delete
</Button>
</Td>
</tr>
))
) : (
<tr>
<td colSpan={6}>No admissions found</td>
</tr>
)}
</tbody>
</Table>
)
export default AdmissionsTable

View File

@ -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;
}

View File

@ -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 (
<NotificationsProvider defaultTimeout={15e3}>
<CurrentUserContext.Provider value={{ user, setUser }}>
<div className={s.base}>
<a href='/admin' className={s.logo}>
Carson Admin
</a>
<header className={s.header}>
<CurrentUser className={s.currentUser} />
</header>
{user && (
<aside className={s.aside}>
<Navigation base='/admin' routes={routes} />
</aside>
)}
<main className={s.main}>
<Router>
{routes
?.flatMap((route) => route.routes || route)
.map(({ auth, component, path }) => (
<Route key={path} path={'/admin' + path} component={component} auth={auth} />
))}
<NotFoundPage default />
</Router>
</main>
<Notifications />
</div>
</CurrentUserContext.Provider>
</NotificationsProvider>
)
}
export default App

View File

@ -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;
}
}

View File

@ -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 })

View File

@ -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<FetchError>()
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 ? (
<Message type='success' noMargin>
Password changed! Head over to <a href='/admin/login'>the login page</a> to try it out!
</Message>
) : (
<form onSubmit={changePassword}>
{error && (
<Message type='error'>
{error.status}: {error.body?.message || error.message}
</Message>
)}
<Input name='password' label='Password' type='password' required />
<Input name='confirmPassword' label='Confirm Password' type='password' sameAs='password' required />
<Button disabled={pending}>Change Password</Button>
</form>
)
}
export default ChangePasswordForm

View File

@ -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 = () => (
<section>
<PageHeader>Change Password</PageHeader>
<Section maxWidth='400px'>
<Section.Body>
<ChangePasswordForm />
</Section.Body>
</Section>
</section>
)
export default ChangePasswordPage

View File

@ -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;
}

View File

@ -0,0 +1,4 @@
import checkboxFactory from '../../shared/components/checkbox_factory.tsx'
import styles from './checkbox.module.scss'
export default checkboxFactory(styles)

View File

@ -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;
}
}
}

View File

@ -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 ? (
<div className={cn(s.base, className)}>
<div className={s.email}>{user.email}</div>
<div className={s.links}>
<a href='/auth/logout' data-native>
Logout
</a>
</div>
</div>
) : (
<div className={cn(s.base, className)}>
<p>You are not logged in</p>
<div className={s.links}>
<a href='/admin/login'>Login</a>
<a href='/admin/register'>Register</a>
</div>
</div>
)
}
export default CurrentUser

View File

@ -0,0 +1,12 @@
.base {
width: calc(100% - 30px);
max-width: 1024px;
overflow: hidden;
}
.headers {
pre {
overflow-x: auto;
overflow-y: visible;
}
}

View File

@ -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 (
<Section className={s.base}>
<Section.Heading>
{error.id} : {error.statusCode} : {error.type}
</Section.Heading>
<Section.Body>
<div>{error.message}</div>
<div>{format(error.createdAt)}</div>
<div className={s.details}>
<h2>Details</h2>
<pre>{JSON.stringify(omit(error.details, ['stack', 'message', 'type']), null, ' ')}</pre>
</div>
<div className={s.stack}>
<h2>Stack</h2>
<pre>{error.stack}</pre>
</div>
<div className={s.method}>
{error.method} {error.path}
</div>
<div className={s.headers}>
<h2>Headers</h2>
<pre>{JSON.stringify(error.headers, null, ' ')}</pre>
</div>
<div>{error.ip}</div>
<div className={s.reqId}>{error.reqId}</div>
</Section.Body>
</Section>
)
}
export default ErrorDetails

View File

@ -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);
}
}

View File

@ -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 (
<section>
<PageHeader>Errors</PageHeader>
<Section>
<Section.Heading>List</Section.Heading>
<Section.Body className={s.top}>
<ErrorsSearchForm className={s.search} onSubmit={onSearchSubmit} />
<div className={s.delete}>
<Button color='red' className={s.button} onClick={() => deleteErrors(true)}>
Delete Page
</Button>
<Button color='red' className={s.button} onClick={() => deleteErrors(false)}>
Delete All
</Button>
</div>
</Section.Body>
<Section.Body noPadding>
<Pagination {...pagination} {...pick(location, 'search', 'pathname')} />
<ErrorsTable
defaultSort={defaultSort}
errors={errors}
onDelete={onDelete}
onSelect={setSelected}
pending={pending}
onSortBy={onSortBy}
/>
<Pagination {...pagination} {...pick(location, 'search', 'pathname')} />
</Section.Body>
</Section>
{selected != null && (
<Modal onClose={() => setSelected(null)}>
<ErrorDetails error={errors.find((error) => error.id === selected)} />
</Modal>
)}
</section>
)
}

View File

@ -0,0 +1,7 @@
@use '../styles/variables' as v;
.button {
align-self: end;
flex: 0 0 auto;
margin-bottom: v.$gutter;
}

View File

@ -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 (
<form onSubmit={onSubmitHandler} className={className}>
<div className={sutil.row}>
<Input label='Status Code' name='statusCode' defaultValue={searchParams.get('statusCode')} />
<Input label='Type' name='type' defaultValue={searchParams.get('type')} />
<Button className={s.button}>Search</Button>
</div>
</form>
)
}
export default ErrorsSearchForm

View File

@ -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;
}
}

View File

@ -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 (
<Table className={s.base} defaultSort={defaultSort} onSortBy={onSortBy}>
<thead>
<tr>
<Th sort='id'>ID</Th>
<Th sort>CreatedAt</Th>
<Th sort='statusCode'>Status</Th>
<Th sort>Type</Th>
<Th sort>Message</Th>
<Th sort>Method</Th>
<Th sort>Path</Th>
<Th sort='ip'>IP</Th>
<Th sort>ReqId</Th>
<th />
</tr>
</thead>
<tbody>
{errors?.length ? (
errors.map((error) => (
<tr key={error.id} onClick={() => onSelect(error.id)}>
<td className={s.id}>{error.id}</td>
<td>{format(error.createdAt)}</td>
<td className={s.statusCode}>{error.statusCode}</td>
<td>{error.type}</td>
<td>{error.message.slice(0, 255)}</td>
<td className={s.method}>{error.method}</td>
<td className={s.path}>{error.path}</td>
<td>{error.ip}</td>
<td className={s.reqId}>{error.reqId}</td>
<td className={s.controls}>
<Button
size='small'
disabled={pending}
color='red'
onClick={(e) => {
suppress(e)
onDelete(error.id)
}}
>
Delete
</Button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={6}>No errors found</td>
</tr>
)}
</tbody>
</Table>
)
}
export default ErrorsTable

View File

@ -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<FetchError>()
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 ? (
<Message type='success' noMargin>
Success! Check your inbox to choose a new password.
</Message>
) : (
<form onSubmit={forgotPassword}>
{error && (
<Message type='error'>
{error.status}: {error.body?.message || error.message}
</Message>
)}
<Input name='email' label='Email' type='email' required />
<Button disabled={pending}>Forgot Password</Button>
</form>
)
}
export default ForgotPasswordForm

View File

@ -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 = () => (
<section>
<PageHeader>Forgot Password</PageHeader>
<Section maxWidth='400px'>
<Section.Body>
<ForgotPasswordForm />
</Section.Body>
</Section>
</section>
)
export default ForgotPasswordPage

View File

@ -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;
}
}

View File

@ -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<ANY[] | null>(null)
useEffect(() => {
rek('/api/git-log').then(setCommits)
}, [])
return (
commits && (
<Table>
<tbody>
{commits.map((commit) => (
<tr key={commit.hash}>
<Td minimize>{new Date(commit.date).toLocaleString('sv-SE')}</Td>
<Td minimize>{commit.author}</Td>
<Td>{commit.message}</Td>
<Td>
(
<a target='_blank' href={`https://github.com/tlth/new_fmval/commit/${commit.hash}`} rel='noreferrer'>
{commit.hash.slice(0, 6)}
</a>
)
</Td>
</tr>
))}
</tbody>
</Table>
)
)
}
export default GitLog

View File

@ -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;
}

View File

@ -0,0 +1,5 @@
import inputFactory from '../../shared/components/input_factory.tsx'
import s from './input.module.scss'
export default inputFactory(s)

View File

@ -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<FetchError>()
const create = useCallback(async (e: TargetedSubmitEvent<HTMLFormElement>) => {
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 (
<form onSubmit={create}>
{success && <Message type='success'>Invite sent!</Message>}
{error && (
<Message type='error'>
{error.status || 500} {error.body?.message || error.message}
</Message>
)}
<Input name='email' label='Email' type='email' required />
{roles && (
<div>
{roles.map((role) => (
<Checkbox key={role.id} name='roles' label={role.name} value={role.id} />
))}
</div>
)}
<Button disabled={pending}>Create</Button>
</form>
)
}
export default InviteForm

View File

@ -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;
}

View File

@ -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 (
<section>
<PageHeader>Invites</PageHeader>
<div className={s.row}>
<Section className={s.left}>
<Section.Heading>Create New Invite</Section.Heading>
<Section.Body>
<InviteForm onCreate={actions.add} roles={roles} />
</Section.Body>
</Section>
</div>
<Section>
<Section.Heading>List</Section.Heading>
<Section.Body noPadding>
<InvitesTable invites={invites} onDelete={onDelete} onSortBy={onSortBy} />
</Section.Body>
</Section>
</section>
)
}
export default InvitesPage

View File

@ -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,
}) => (
<Table onSortBy={onSortBy}>
<thead>
<tr>
<Th sort='id'>ID</Th>
<Th sort>Email</Th>
<Th sort>Token</Th>
<Th>Roles</Th>
<Th sort>Created At</Th>
<Th>Created By</Th>
<Th sort>Consumed At</Th>
<Th>Consumed By</Th>
<th />
</tr>
</thead>
<tbody>
{invites?.length ? (
invites.map((invite) => (
<tr key={invite.id}>
<td>{invite.id}</td>
<td>{invite.email}</td>
<td>{invite.token}</td>
<td>{invite.roles.map((role: ANY) => role.name).join(', ')}</td>
<td>{invite.createdAt}</td>
<td>{invite.createdBy?.email}</td>
<td>{invite.consumedAt}</td>
<td>{invite.consumedBy?.email}</td>
<Td buttons>
<Button size='small' icon='delete' color='red' onClick={() => onDelete(invite.id)}>
Delete
</Button>
</Td>
</tr>
))
) : (
<tr>
<td colSpan={8}>No invites found</td>
</tr>
)}
</tbody>
</Table>
)
export default InvitesTable

View File

@ -0,0 +1,11 @@
.footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.links {
li:not(:last-child) {
padding-bottom: 4px;
}
}

View File

@ -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<FetchError>()
const { setUser } = useCurrentUser()
const login = useCallback(async (e: TargetedSubmitEvent<HTMLFormElement>) => {
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 (
<form onSubmit={login}>
{error && <Message type='error'>Error: {error.body?.message || error.message}</Message>}
<Input name='email' label='Email' placeholder='Email' type='email' autoFocus required />
<Input
type='password'
label='Password'
name='password'
placeholder='Password'
autoComplete='current-password'
required
/>
<div className={s.footer}>
<ul className={s.links}>
<li>
<a href='/admin/forgot-password'>Forgot your password?</a>
</li>
<li>
<a href='/admin/register'>No account? Register a new</a>
</li>
</ul>
<Button type='submit' disabled={pending}>
Login
</Button>
</div>
</form>
)
}
export default LoginForm

View File

@ -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 = () => (
<section>
<PageHeader>Login</PageHeader>
<Section maxWidth='400px'>
<Section.Body>
<LoginForm />
</Section.Body>
</Section>
</section>
)
export default LoginPage

View File

@ -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;
}

View File

@ -0,0 +1,4 @@
import s from './message.module.scss'
import messageFactory from '../../shared/components/message_factory.tsx'
export default messageFactory(s)

View File

@ -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;
}

View File

@ -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 (
<Portal>
<div className={s.overlay} onClick={onClose}>
<div className={s.base} onClick={stopPropagation}>
{onClose && (
<Button onClick={onClose} className={s.closeButton}>
X
</Button>
)}
<div className={s.content}>{children}</div>
</div>
</div>
</Portal>
)
}
export default Modal

View File

@ -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: '-';
}
}
}

View File

@ -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<NavigationProps> = ({ base, routes }) => {
const [{ url }] = useRouter()
const currentPath = base ? url.slice(base.length) : url
return (
<nav className={s.base}>
<ul>
{routes
.filter(({ nav }) => nav !== false)
.map(({ path, title, routes }) =>
routes ? (
<NavigationGroup
key={path}
base={base}
currentPath={currentPath}
path={path}
title={title}
routes={routes}
/>
) : (
<NavigationItem
key={path}
base={base}
currentPath={currentPath}
path={path}
title={title}
name={title.toLowerCase()}
/>
),
)}
</ul>
</nav>
)
}
export default Navigation

View File

@ -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<NavigationGroupProps> = ({ base, currentPath, path, routes, title }) => {
const itemsRef = useRef<HTMLUListElement | null>(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 (
<li
className={cn(s.groupBase, {
[s.visible]: current || visible,
[s.current]: current,
})}
>
<div className={s.groupTop} onClick={toggle}>
<span className={s.groupTitle}>{title}</span>
<div className={s.groupIcon} />
</div>
<ul ref={itemsRef} className={s.items}>
{routes
.filter(({ nav }) => nav !== false)
.map(({ path, title, routes }) =>
routes ? (
<NavigationGroup
key={path}
base={base}
currentPath={currentPath}
path={path}
title={title}
routes={routes}
/>
) : (
<NavigationItem
key={path}
base={base}
currentPath={currentPath}
path={path}
title={title}
name={title.toLowerCase()}
/>
),
)}
</ul>
</li>
)
}
export default NavigationGroup

View File

@ -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 }) => (
<li
className={cn(s.itemBase, name, {
[s.current]: currentPath && (currentPath === path || (path !== '/' && currentPath.startsWith(path))),
})}
>
<a href={base + path}>
<span>{title}</span>
</a>
{routes && (
<ul className={s.sub}>
{routes.map(
(route) =>
route.nav !== false && (
<NavigationItem
key={route.name}
base={base}
currentPath={currentPath}
path={path + route.path}
{...route}
/>
),
)}
</ul>
)}
</li>
)
export default NavigationItem

View File

@ -0,0 +1,13 @@
import { h, type FunctionComponent } from 'preact'
import type { RoutableProps } from 'preact-router'
const NotFoundPage: FunctionComponent<RoutableProps> = () => (
<section>
<h1>Not Found :(</h1>
<p>
Try the <a href='/admin'>Start Page</a>
</p>
</section>
)
export default NotFoundPage

View File

@ -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;
}
}

View File

@ -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 (
<div className={s.base}>
{notifications.map(({ id, dismiss, message, type }) => (
<Message key={id} className={s.notification} type={type} dismiss={dismiss} noMargin>
{message}
</Message>
))}
</div>
)
}

View File

@ -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;
}
}

View File

@ -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<string, string>; object: Record<string, any> }> = ({
styles: s = styles,
object,
}) => (
<table className={s.base}>
<tbody>
{Object.entries(object).map(([key, value]) => (
<tr key={key} className={s.row}>
<td className={cn(s.cell, s.key)}>{key}</td>
<td className={cn(s.cell, s.value)}>{value}</td>
</tr>
))}
</tbody>
</table>
)
export default ObjectTable

View File

@ -0,0 +1,10 @@
.base {
width: 100%;
margin-bottom: 16px;
display: flex;
justify-content: space-between;
}
.heading {
font-size: 24px;
}

View File

@ -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 }) => (
<div className={cn(s.base, className)}>
<h1 className={s.heading}>{children}</h1>
</div>
)
export default PageHeader

View File

@ -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;
}

View File

@ -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<HTMLDivElement>
pathname: string
search: string
totalCount: number
limit: number
offset: number
}
const Pagination: FunctionComponent<PaginationProps> = (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 (
<div className={cn(s.pagination, props.className)} ref={props.forwardRef}>
<div className={s.results}>
Showing <span className={cn(s.first, s.number)}>{firstItemCount}</span> to{' '}
<span className={cn(s.last, s.number)}>{lastItemCount || 0}</span> of{' '}
<span className={cn(s.total, s.number)}>{totalCount || 0}</span> entries
</div>
<ul className={s.pages}>
{totalCount > 0 && (
<Fragment>
<li className={s.prev}>
<a href={prev?.path || 'javascript:;'} rel={prev && 'prev'} className={s.nav} title='Previous Page'>
Previous
</a>
</li>
{pages.slice(start, end).map((page) => (
<li key={page.index} className={cn(s.page, page.current && s.currentPage)}>
<a href={page.path} className={s.nav} title={`Sida ${page.index + 1}`}>
{page.index + 1}
</a>
</li>
))}
<li className={s.next}>
<a href={next?.path || 'javascript:;'} rel={next && 'next'} className={s.nav} title='Next Page'>
Next
</a>
</li>
</Fragment>
)}
</ul>
</div>
)
}
export default Pagination

View File

@ -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<string, string | string[]>
process: Record<string, Record<string, string> | string[]>
} | null>(null)
useEffect(() => {
rek('/api/process').then(setProcess)
}, [])
return (
process && (
<Table>
<tbody>
{Object.entries(process[os ? 'os' : 'process']).map(([key, value]) => (
<tr key={key}>
<Td>{key}</Td>
<Td>
{Array.isArray(value) ? (
value.join(', ')
) : _.isPlainObject(value) ? (
<ObjectTable object={value} />
) : (
value
)}
</Td>
</tr>
))}
</tbody>
</Table>
)
)
}
export default Process

View File

@ -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<FetchError>()
const params = location.search ? new URLSearchParams(location.search) : null
const register = useCallback(async (e: TargetedSubmitEvent<HTMLFormElement>) => {
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 ? (
<Message type='success' noMargin>
Success! Go to <a href='/admin/login'>login</a>.
</Message>
) : (
<form onSubmit={register}>
{error && (
<Message type='error'>
{error.status}: {error.body?.message || error.message}
</Message>
)}
<Input name='email' label='Email' type='email' defaultValue={params?.get('email')} required />
<Input name='password' label='Password' type='password' required />
<Input name='confirmPassword' label='Confirm Password' type='password' sameAs='password' required />
<Button disabled={pending}>Register</Button>
</form>
)
}
export default RegisterForm

View File

@ -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 = () => (
<section>
<PageHeader>Register</PageHeader>
<Section maxWidth='400px'>
<Section.Body>
<RegisterForm />
</Section.Body>
</Section>
</section>
)
export default RegisterPage

View File

@ -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<FetchError>()
const create = useCallback(async (e: TargetedSubmitEvent<HTMLFormElement>) => {
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 (
<form onSubmit={create}>
{success && <Message type='success'>Role {role ? 'updated' : 'created'}!</Message>}
{error && (
<Message type='error'>
{error.status || 500} {error.body?.message || error.message}
</Message>
)}
<Input name='name' label='Name' type='text' defaultValue={role?.name} required />
<div className={sutil.row}>
<Button disabled={pending} type='submit'>
{role ? 'Update' : 'Create'}
</Button>
{onCancel && (
<Button disabled={pending} onClick={onCancel} type='button'>
Cancel
</Button>
)}
</div>
</form>
)
}
export default RoleForm

View File

@ -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<Role>()
const [edit, setEdit] = useState<Role | null | undefined>(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 (
<section>
<PageHeader>Roles</PageHeader>
<Section maxWidth='50%'>
<Section.Heading>Create New Role</Section.Heading>
<Section.Body>
<RoleForm onCreate={actions.add} />
</Section.Body>
</Section>
<Section>
<Section.Heading>List</Section.Heading>
<Section.Body noPadding>
<RolesTable roles={roles} onDelete={onDelete} onEdit={onEdit} onSortBy={onSortBy} />
</Section.Body>
</Section>
{edit != null && (
<Modal onClose={() => setEdit(null)}>
<RoleForm
onCancel={() => setEdit(null)}
onUpdate={(role: ANY) => actions.update(role.id, role)}
role={edit}
/>
</Modal>
)}
</section>
)
}
export default RolesPage

View File

@ -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,
}) => (
<Table onSortBy={onSortBy}>
<thead>
<tr>
<Th sort='id'>ID</Th>
<Th sort>Name</Th>
<Th sort>Created At</Th>
<th>Created By</th>
<th />
</tr>
</thead>
<tbody>
{roles?.length ? (
roles.map((role) => (
<tr key={role.id}>
<td>{role.id}</td>
<td>{role.name}</td>
<td>{role.createdAt}</td>
<td>{role.createdBy?.email}</td>
<Td buttons>
<Button size='small' icon='edit' onClick={() => onEdit(role.id)}>
Edit
</Button>{' '}
<Button size='small' icon='delete' color='red' onClick={() => onDelete(role.id)}>
Delete
</Button>
</Td>
</tr>
))
) : (
<tr>
<td colSpan={5}>No roles found</td>
</tr>
)}
</tbody>
</Table>
)
export default RolesTable

View File

@ -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 && <Component path={path} />
}
export default Route

View File

@ -0,0 +1,9 @@
@use '../../shared/styles/flex';
.row {
display: flex;
> * {
@include flex.simple-cell($gutter: 12px);
}
}

View File

@ -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' }) => (
<Tag className={s.row}>{children}</Tag>
)
export default Row
export const Cell: FunctionComponent<{ grow?: string; tag?: keyof JSX.IntrinsicElements }> = ({
children,
grow,
tag: Tag = 'div',
}) => <Tag style={{ flexGrow: grow }}>{children}</Tag>

View File

@ -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;
}

View File

@ -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 }) => (
<section className={cn(s.base, className)} style={{ maxWidth, minWidth }}>
{children}
</section>
)
const SectionBody: FunctionComponent<{ className?: string; noPadding?: boolean }> = ({
children,
className,
noPadding,
}) => <div className={cn(s.body, noPadding && s.noPadding, className)}>{children}</div>
Section.Body = SectionBody
const SectionHeading: FunctionComponent<{ className?: string }> = ({ children, className }) => (
<h1 className={cn(s.heading, className)}>{children}</h1>
)
Section.Heading = SectionHeading
const SectionFooter: FunctionComponent<{ className?: string }> = ({ children, className }) => (
<div className={cn(s.footer, className)}>{children}</div>
)
Section.Footer = SectionFooter
export default Section

View File

@ -0,0 +1,4 @@
import selectFactory from '../../shared/components/select_factory.tsx'
import styles from './input.module.scss'
export default selectFactory({ styles })

View File

@ -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 = () => (
<section>
<PageHeader>Start Page</PageHeader>
<Section>
<Section.Body>
<p>Welcome. You are in good company.</p>
</Section.Body>
</Section>
<Section>
<Section.Heading>Latest Commits</Section.Heading>
<Section.Body>
<GitLog />
</Section.Body>
</Section>
<Row>
<Section>
<Section.Heading>Process</Section.Heading>
<Section.Body>
<Process />
</Section.Body>
</Section>
<Section>
<Section.Heading>Os</Section.Heading>
<Section.Body>
<Process os />
</Section.Body>
</Section>
</Row>
</section>
)
export default StartPage

View File

@ -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;
}
}
}

View File

@ -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<void>
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<TableProps> = ({
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 (
<TableContext.Provider
value={{
sortBy,
sortedBy: [desc ? sortedBy.slice(1) : sortedBy, desc],
}}
>
<table className={cn(s.table, className)}>{children}</table>
</TableContext.Provider>
)
}
export const Td: FunctionComponent<{
className?: string
buttons?: boolean
minimize?: boolean
}> = ({ children, className, buttons, minimize }) => (
<td className={cn(className, buttons && s.buttons, minimize && s.minimize)}>
{buttons ? <div>{children}</div> : children}
</td>
)
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 (
<th className={cn(s.th, className)} scope={scope}>
{sort ? (
<a
href='javascript:;'
onClick={sortBy}
data-sort-by={column}
className={cn({ [s.current]: column === sortedBy[0], [s.desc]: column === sortedBy[0] && sortedBy[1] })}
>
{children}
<div className={s.arrows} />
</a>
) : (
children
)}
</th>
)
}

View File

@ -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<User>()
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 (
<section>
<PageHeader>Users</PageHeader>
<Section>
<Section.Heading>List</Section.Heading>
<Section.Body noPadding>
<UsersTable users={users} onSortBy={onSortBy} />
</Section.Body>
</Section>
</section>
)
}
export default UsersPage

View File

@ -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 }) => (
<Table onSortBy={onSortBy}>
<thead>
<tr>
<Th sort='id'>ID</Th>
<Th sort>Email</Th>
<Th>Roles</Th>
<Th sort='emailVerifiedAt'>Email Verified</Th>
<Th sort='lastLoginAt'>Last Login</Th>
<Th sort>Login Attempts</Th>
<Th sort='lastLoginAttemptAt'>Last Login Attempt</Th>
</tr>
</thead>
<tbody>
{users?.length ? (
users.map((user) => (
<tr key={user.id}>
<td>{user.id}</td>
<td>{user.email}</td>
<td>{user.roles?.map((role) => role.name).join(', ')}</td>
<td>{user.emailVerifiedAt}</td>
<td>{user.lastLoginAt}</td>
<td>{user.loginAttempts}</td>
<td>{user.lastLoginAttemptAt}</td>
</tr>
))
) : (
<tr>
<td colSpan={7}>No users found</td>
</tr>
)}
</tbody>
</Table>
)
export default UsersTable

View File

@ -0,0 +1,13 @@
@use '../../shared/styles/flex';
.hide {
display: none;
}
.row {
display: flex;
> * {
@include flex.simple-cell($gutter: 12px);
}
}

View File

@ -0,0 +1,9 @@
import { createContext } from 'preact'
import { useContext } from 'preact/hooks'
type CurrentUserContextType = { user: ANY; setUser: (user: ANY) => void }
const CurrentUserContext = createContext<CurrentUserContextType | null>(null)
export default CurrentUserContext
export const useCurrentUser = () => useContext(CurrentUserContext) as CurrentUserContextType

View File

@ -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 <NotificationsContext.Provider value={{ notifications, notify }}>{children}</NotificationsContext.Provider>
}

View File

@ -0,0 +1,129 @@
import { useReducer } from 'preact/hooks'
type DefaultItem = {
id: number
}
const ADD = 'add'
type AddAction<I> = {
type: typeof ADD
payload: I
}
const add = <I>(item: I): AddAction<I> => ({
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<I> = {
type: typeof LOAD
payload: I[]
}
const load = <I>(items: I[]): LoadAction<I> => ({
type: LOAD,
payload: items,
})
const RESET = 'reset'
type ResetAction<I> = {
type: typeof RESET
payload: I[]
}
const reset = <I>(items: I[] = []): ResetAction<I> => ({
type: RESET,
payload: items,
})
const UPDATE = 'update'
type UpdateAction<I> = {
type: typeof UPDATE
id: number
payload: I
}
const update = <I>(id: number, item: I): UpdateAction<I> => ({
type: UPDATE,
id,
payload: item,
})
export const actions = {
add,
del,
load,
reset,
update,
}
type Action<I> = AddAction<I> | DeleteAction | LoadAction<I> | ResetAction<I> | UpdateAction<I>
export const itemsReducer = <I extends DefaultItem>(state: I[], action: Action<I>) => {
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 <I extends DefaultItem>(init: I[] = []) => {
const [items, dispatch] = useReducer(itemsReducer<I>, init)
const actions = {
add: (item: I) => dispatch(add<I>(item)),
del: (id: number) => dispatch(del(id)),
load: (items: I[]) => dispatch(load<I>(items)),
reset: (items: I[]) => dispatch(reset<I>(items)),
update: (id: number, item: I) => dispatch(update<I>(id, item)),
}
return [items, actions] as [I[], typeof actions]
}

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 18 20"
version="1.1"
width="18"
height="20"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<path
d="M 17,4.506 H 1 V 18.929 C 1,19.52 1.448,20 2,20 h 14 c 0.552,0 1,-0.48 1,-1.071 0,-3.905 0,-14.423 0,-14.423 z M 11.25,7 C 11.664,7 12,7.336 12,7.75 v 8.5 C 12,16.664 11.664,17 11.25,17 10.836,17 10.5,16.664 10.5,16.25 V 7.75 C 10.5,7.336 10.836,7 11.25,7 Z M 6.75,7 C 7.164,7 7.5,7.336 7.5,7.75 v 8.5 C 7.5,16.664 7.164,17 6.75,17 6.336,17 6,16.664 6,16.25 V 7.75 C 6,7.336 6.336,7 6.75,7 Z M 6,2 V 1 C 6,0.465 6.474,0 7,0 h 4 c 0.526,0 1,0.465 1,1 v 1 h 5.254 C 17.666,2 18,2.335 18,2.747 18,3.159 17.666,3.494 17.254,3.494 H 0.747 C 0.334,3.494 0,3.159 0,2.747 0,2.335 0.334,2 0.747,2 Z m 4.5,0 V 1.5 h -3 V 2 Z"
fill-rule="nonzero"
id="path1"
style="fill:#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 919 B

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 18 18"
version="1.1"
width="18"
height="18"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<path
d="M 1.48,12.664 C 0.146,16.58 0,16.896 0,17.251 0,17.779 0.46,18 0.749,18 1.101,18 1.417,17.863 5.323,16.508 Z M 2.54,11.603 6.386,15.449 17.707,4.138 C 17.902,3.943 18,3.688 18,3.431 18,3.176 17.902,2.921 17.707,2.725 17.015,2.034 15.965,0.985 15.272,0.293 15.077,0.098 14.821,0 14.565,0 14.311,0 14.055,0.098 13.859,0.293 Z"
fill-rule="nonzero"
id="path1"
style="fill:#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 628 B

75
client/admin/routes.ts Normal file
View File

@ -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[]

1
client/admin/server.ts Normal file
View File

@ -0,0 +1 @@
export { default as routes } from './routes.ts'

View File

@ -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');

View File

View File

@ -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;
}
}
}

View File

@ -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;

View File

@ -0,0 +1,6 @@
@use '../../shared/styles/reset';
@use './fonts';
@use './styles';
@use './layout';

2
client/client.d.ts vendored
View File

@ -4,3 +4,5 @@ declare module '*.module.scss' {
}
declare module '*.scss'
declare module '*.svg'

View File

@ -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 = () => {
<h1>Invoices for {supplier?.name}</h1>
<p>
<strong>Total: {totalAmount}</strong>
<strong>Total: {formatPrice(totalAmount)}</strong>
</p>
<table>
<table className='grid'>
<thead>
<tr>
<th>ID</th>

View File

@ -1,8 +1,8 @@
import { h, type FunctionComponent, type PointerEventHandler } from 'preact'
import cn from 'classnames'
type Styles<C extends string, D extends string, S extends string> = {
base: string
type ButtonStyles<C extends string, D extends string, S extends string> = {
base?: string
autoHeight?: string
fullWidth?: string
icon?: string
@ -11,7 +11,7 @@ type Styles<C extends string, D extends string, S extends string> = {
Record<D, string> &
Record<S, string>
type Props<C extends string, D extends string, I extends string, S extends string> = {
type ButtonProps<C extends string, D extends string, I extends string, S extends string> = {
autoHeight?: boolean
className?: string
color?: C
@ -29,10 +29,10 @@ type Props<C extends string, D extends string, I extends string, S extends strin
type?: 'button' | 'reset' | 'submit'
}
type Options<C extends string, D extends string, I extends string, S extends string> = {
defaults: Partial<Props<C, D, I, S>>
type ButtonFactoryOptions<C extends string, D extends string, I extends string, S extends string> = {
defaults: Partial<ButtonProps<C, D, I, S>>
icons: Record<I, string>
styles: Styles<C, D, S>
styles: ButtonStyles<C, D, S>
}
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<C, D, I, S>) {
const Button: FunctionComponent<Props<C, D, I, S>> = ({
>({ defaults, icons, styles }: ButtonFactoryOptions<C, D, I, S>) {
const Button: FunctionComponent<ButtonProps<C, D, I, S>> = ({
autoHeight = defaults?.autoHeight,
children,
className,

View File

@ -3,14 +3,14 @@ import { type ChangeEvent } from 'preact/compat'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import cn from 'classnames'
type Styles<S extends string> = {
type CheckboxStyles<S extends string> = {
base?: string
element?: string
label?: string
touched?: string
} & Record<S, string>
type Props<S extends string> = {
type CheckboxProps<S extends string> = {
autoFocus?: boolean
className?: string
defaultChecked?: any
@ -19,12 +19,12 @@ type Props<S extends string> = {
name?: string
onChange?: (e: ChangeEvent) => void
required?: boolean
style: S
style?: S
value?: string
}
export default function checkboxFactory<S extends string = never>(styles: Styles<S>) {
const Checkbox: FunctionComponent<Props<S>> = ({
export default function checkboxFactory<S extends string = never>(styles: CheckboxStyles<S>) {
const Checkbox: FunctionComponent<CheckboxProps<S>> = ({
autoFocus,
className,
defaultChecked,
@ -55,7 +55,7 @@ export default function checkboxFactory<S extends string = never>(styles: Styles
}, [autoFocus])
return (
<label className={cn(styles.base, styles[style], touched && styles.touched, className)}>
<label className={cn(styles.base, style && styles[style], touched && styles.touched, className)}>
<input
autoFocus={autoFocus}
className={styles.element}

View File

@ -17,18 +17,19 @@ type Styles<C extends string, D extends string, S extends string> = {
Record<D, string> &
Record<S, string>
// TODO only add color, design and size props if generic props are set
type Props<C extends string, D extends string, S extends string> = {
autoComplete?: string
autoFocus?: boolean
center?: boolean
className?: string
classNames?: Partial<Styles<C, D, S>>
color: C
defaultValue?: string
design: D
color?: C
defaultValue?: number | string | null
design?: D
disabled?: boolean
icon: string
label: string
icon?: string
label?: string
name: string
noMargin?: boolean
pattern?: string
@ -122,7 +123,9 @@ export default function inputFactory<C extends string = never, D extends string
autoFocus={autoFocus}
className={cn(styles.element, classNames?.element)}
defaultValue={
defaultValue && type === 'datetime-local' ? new Date(defaultValue).toLocaleString('sv-SE') : defaultValue
defaultValue && type === 'datetime-local'
? new Date(defaultValue).toLocaleString('sv-SE')
: (defaultValue ?? undefined)
}
disabled={disabled}
id={id}

View File

@ -1,7 +1,7 @@
import { type ContainerNode, type FunctionComponent } from 'preact'
import { createPortal } from 'preact/compat'
const Portal: FunctionComponent<{ container: ContainerNode }> = ({
const Portal: FunctionComponent<{ container?: ContainerNode }> = ({
children,
container = typeof document !== 'undefined' && document.body,
}) => container && createPortal(children, container)

View File

@ -1,14 +1,14 @@
import { useState } from 'preact/hooks'
import isLoading from '../utils/is_loading.ts'
interface State {
error: Error | null
interface State<E extends Error> {
error: E | null
pending: boolean
success: boolean
response: any
}
const useRequestState = () => {
const useRequestState = <E extends Error>() => {
const initialState = {
error: null,
pending: false,
@ -16,7 +16,7 @@ const useRequestState = () => {
response: null,
}
const [state, setState] = useState<State>(initialState)
const [state, setState] = useState<State<E>>(initialState)
const actions = {
reset() {
@ -25,7 +25,7 @@ const useRequestState = () => {
setState(initialState)
},
error(error: Error) {
error(error: E) {
isLoading(false)
setState({
@ -59,7 +59,7 @@ const useRequestState = () => {
},
}
return [state, actions]
return [state, actions] as [State<E>, typeof actions]
}
export default useRequestState

View File

@ -0,0 +1,3 @@
export default function stopPropagation(e: Event) {
e.stopPropagation()
}

View File

@ -25,8 +25,10 @@ services:
- POSTGRES_USER=brf_books
- POSTGRES_PASSWORD=brf_books
volumes:
- ./docker/postgres/01-schema.sql:/docker-entrypoint-initdb.d/01-schema.sql
- ./docker/postgres/02-data.sql:/docker-entrypoint-initdb.d/02-data.sql
- ./docker/postgres/01-auth_schema.sql:/docker-entrypoint-initdb.d/01-auth_schema.sql
- ./docker/postgres/02-accounting_schema.sql:/docker-entrypoint-initdb.d/02-accounting_schema.sql
- ./docker/postgres/03-auth_data.sql:/docker-entrypoint-initdb.d/03-auth_data.sql
- ./docker/postgres/04-accounting_data.sql:/docker-entrypoint-initdb.d/04-accounting_data.sql
- postgres:/var/lib/postgresql/data
redis:

View File

@ -0,0 +1,650 @@
--
-- PostgreSQL database dump
--
-- Dumped from database version 16.0
-- Dumped by pg_dump version 16.0
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
SET default_tablespace = '';
SET default_table_access_method = heap;
--
-- Name: admission; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.admission (
id integer NOT NULL,
regex text NOT NULL,
"createdAt" timestamp with time zone DEFAULT now(),
"createdById" integer NOT NULL,
"modifiedAt" timestamp with time zone,
"modifiedById" integer
);
--
-- Name: admissions_roles; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.admissions_roles (
"admissionId" integer NOT NULL,
"roleId" integer NOT NULL
);
--
-- Name: "emailToken"; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public."emailToken" (
id integer NOT NULL,
"userId" integer NOT NULL,
email text NOT NULL,
token text NOT NULL,
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
"cancelledAt" timestamp with time zone,
"consumedAt" timestamp with time zone
);
--
-- Name: "emailToken_id_seq"; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public."emailToken_id_seq"
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: "emailToken_id_seq"; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public."emailToken_id_seq" OWNED BY public."emailToken".id;
--
-- Name: error; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.error (
id integer NOT NULL,
"statusCode" integer,
type text,
message text,
details json,
stack text,
method text,
path text,
headers json,
ip text,
"reqId" text,
"createdAt" timestamp with time zone
);
--
-- Name: error_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.error_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: error_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public.error_id_seq OWNED BY public.error.id;
--
-- Name: invite; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.invite (
id integer NOT NULL,
email text NOT NULL,
token text NOT NULL,
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
"createdById" integer,
"modifiedAt" timestamp with time zone,
"modifiedById" integer,
"consumedAt" timestamp with time zone,
"consumedById" integer
);
--
-- Name: invite_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.invite_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: invite_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public.invite_id_seq OWNED BY public.invite.id;
--
-- Name: invites_roles; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.invites_roles (
"invitedId" integer NOT NULL,
"roleId" integer NOT NULL
);
--
-- Name: "passwordToken"; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public."passwordToken" (
id integer NOT NULL,
"userId" integer NOT NULL,
token text NOT NULL,
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
"cancelledAt" timestamp with time zone,
"consumedAt" timestamp with time zone
);
--
-- Name: "passwordToken_id_seq"; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public."passwordToken_id_seq"
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: "passwordToken_id_seq"; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public."passwordToken_id_seq" OWNED BY public."passwordToken".id;
--
-- Name: admissions_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.admissions_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: admissions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public.admissions_id_seq OWNED BY public.admission.id;
--
-- Name: role; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.role (
id integer NOT NULL,
name character varying(64) NOT NULL,
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
"createdById" integer,
"modifiedAt" timestamp with time zone,
"modifiedById" integer
);
--
-- Name: role_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.role_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: role_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public.role_id_seq OWNED BY public.role.id;
--
-- Name: user; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public."user" (
id integer NOT NULL,
email character varying(254) NOT NULL,
password character varying(256) NOT NULL,
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
"lastLoginAt" timestamp with time zone,
"loginAttempts" integer DEFAULT 0,
"lastLoginAttemptAt" timestamp with time zone,
"lastActivityAt" timestamp with time zone,
"bannedAt" timestamp with time zone,
"bannedById" integer,
"blockedAt" timestamp with time zone,
"blockedById" integer,
"emailVerifiedAt" timestamp with time zone
);
--
-- Name: user_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.user_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: user_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public.user_id_seq OWNED BY public."user".id;
--
-- Name: users_roles; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.users_roles (
"userId" integer NOT NULL,
"roleId" integer NOT NULL
);
--
-- Name: admission id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.admission ALTER COLUMN id SET DEFAULT nextval('public.admissions_id_seq'::regclass);
--
-- Name: "emailToken" id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public."emailToken" ALTER COLUMN id SET DEFAULT nextval('public."emailToken_id_seq"'::regclass);
--
-- Name: error id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.error ALTER COLUMN id SET DEFAULT nextval('public.error_id_seq'::regclass);
--
-- Name: invite id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.invite ALTER COLUMN id SET DEFAULT nextval('public.invite_id_seq'::regclass);
--
-- Name: "passwordToken" id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public."passwordToken" ALTER COLUMN id SET DEFAULT nextval('public."passwordToken_id_seq"'::regclass);
--
-- Name: role id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.role ALTER COLUMN id SET DEFAULT nextval('public.role_id_seq'::regclass);
--
-- Name: user id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public."user" ALTER COLUMN id SET DEFAULT nextval('public.user_id_seq'::regclass);
--
-- Name: admission admission_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.admission
ADD CONSTRAINT admission_pkey PRIMARY KEY (id);
--
-- Name: admissions_roles admissions_roles_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.admissions_roles
ADD CONSTRAINT admissions_roles_pkey PRIMARY KEY ("admissionId", "roleId");
--
-- Name: "emailToken" email_token_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public."emailToken"
ADD CONSTRAINT email_token_pkey PRIMARY KEY (id);
--
-- Name: "emailToken" email_token_unique; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public."emailToken"
ADD CONSTRAINT email_token_unique UNIQUE ("userId", email);
--
-- Name: error error_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.error
ADD CONSTRAINT error_pkey PRIMARY KEY (id);
--
-- Name: invite invite_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.invite
ADD CONSTRAINT invite_pkey PRIMARY KEY (id);
--
-- Name: invites_roles invites_roles_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.invites_roles
ADD CONSTRAINT invites_roles_pkey PRIMARY KEY ("invitedId", "roleId");
--
-- Name: "passwordToken" "passwordToken_pkey"; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public."passwordToken"
ADD CONSTRAINT "passwordToken_pkey" PRIMARY KEY (id);
--
-- Name: role role_name_key; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.role
ADD CONSTRAINT role_name_key UNIQUE (name);
--
-- Name: role role_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.role
ADD CONSTRAINT role_pkey PRIMARY KEY (id);
--
-- Name: user user_email_key; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public."user"
ADD CONSTRAINT user_email_key UNIQUE (email);
--
-- Name: user user_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public."user"
ADD CONSTRAINT user_pkey PRIMARY KEY (id);
--
-- Name: users_roles users_roles_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.users_roles
ADD CONSTRAINT users_roles_pkey PRIMARY KEY ("userId", "roleId");
--
-- Name: "fki_admission_createdById_fkey"; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX "fki_admission_createdById_fkey" ON public.admission USING btree ("createdById");
--
-- Name: "fki_admission_modifiedById_fkey"; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX "fki_admission_modifiedById_fkey" ON public.admission USING btree ("modifiedById");
--
-- Name: "fki_invite_modifiedById_fkey"; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX "fki_invite_modifiedById_fkey" ON public.invite USING btree ("modifiedById");
--
-- Name: "fki_role_createdById_fkey"; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX "fki_role_createdById_fkey" ON public.role USING btree ("createdById");
--
-- Name: "fki_role_modifiedById_fkey"; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX "fki_role_modifiedById_fkey" ON public.role USING btree ("modifiedById");
--
-- Name: "fki_user_bannedById_fkey"; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX "fki_user_bannedById_fkey" ON public."user" USING btree ("bannedById");
--
-- Name: "fki_user_blockedById_fkey"; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX "fki_user_blockedById_fkey" ON public."user" USING btree ("blockedById");
--
-- Name: "fki_users_roles_roleId_fkey"; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX "fki_users_roles_roleId_fkey" ON public.users_roles USING btree ("roleId");
--
-- Name: admission "admission_createdById_fkey"; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.admission
ADD CONSTRAINT "admission_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES public."user"(id) ON UPDATE CASCADE ON DELETE SET NULL;
--
-- Name: admission "admission_modifiedById_fkey"; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.admission
ADD CONSTRAINT "admission_modifiedById_fkey" FOREIGN KEY ("modifiedById") REFERENCES public."user"(id) ON UPDATE CASCADE ON DELETE SET NULL;
--
-- Name: admissions_roles "admissions_roles_admissionId_fkey"; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.admissions_roles
ADD CONSTRAINT "admissions_roles_admissionId_fkey" FOREIGN KEY ("admissionId") REFERENCES public.admission(id) ON UPDATE CASCADE ON DELETE CASCADE;
--
-- Name: admissions_roles "admissions_roles_roleId_fkey"; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.admissions_roles
ADD CONSTRAINT "admissions_roles_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES public.role(id) ON UPDATE CASCADE ON DELETE CASCADE;
--
-- Name: "emailToken" "emailToken_userId_fkey"; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public."emailToken"
ADD CONSTRAINT "emailToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES public."user"(id) ON UPDATE CASCADE ON DELETE CASCADE;
--
-- Name: invite "invite_consumedById_fkey"; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.invite
ADD CONSTRAINT "invite_consumedById_fkey" FOREIGN KEY ("consumedById") REFERENCES public."user"(id) ON UPDATE CASCADE ON DELETE SET NULL;
--
-- Name: invite "invite_createdById_fkey"; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.invite
ADD CONSTRAINT "invite_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES public."user"(id) ON UPDATE CASCADE ON DELETE SET NULL;
--
-- Name: invite "invite_modifiedById_fkey"; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.invite
ADD CONSTRAINT "invite_modifiedById_fkey" FOREIGN KEY ("modifiedById") REFERENCES public."user"(id) ON UPDATE CASCADE ON DELETE SET NULL;
--
-- Name: invites_roles "invites_roles_inviteId_fkey"; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.invites_roles
ADD CONSTRAINT "invites_roles_inviteId_fkey" FOREIGN KEY ("invitedId") REFERENCES public.invite(id) ON UPDATE CASCADE ON DELETE CASCADE;
--
-- Name: invites_roles invites_roles_roleId_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.invites_roles
ADD CONSTRAINT "invites_roles_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES public.role(id) ON UPDATE CASCADE ON DELETE CASCADE;
--
-- Name: passwordToken passwordToken_userId_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public."passwordToken"
ADD CONSTRAINT "passwordToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES public."user"(id);
--
-- Name: role role_createdById_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.role
ADD CONSTRAINT "role_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES public."user"(id) ON UPDATE CASCADE ON DELETE SET NULL;
--
-- Name: role "role_modifiedById_fkey"; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.role
ADD CONSTRAINT "role_modifiedById_fkey" FOREIGN KEY ("modifiedById") REFERENCES public."user"(id) ON UPDATE CASCADE ON DELETE SET NULL;
--
-- Name: user "user_bannedById_fkey"; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public."user"
ADD CONSTRAINT "user_bannedById_fkey" FOREIGN KEY ("bannedById") REFERENCES public."user"(id) ON UPDATE CASCADE ON DELETE SET NULL;
--
-- Name: user "user_blockedById_fkey"; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public."user"
ADD CONSTRAINT "user_blockedById_fkey" FOREIGN KEY ("blockedById") REFERENCES public."user"(id) ON UPDATE CASCADE ON DELETE SET NULL;
--
-- Name: users_roles "users_roles_roleId_fkey"; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.users_roles
ADD CONSTRAINT "users_roles_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES public.role(id) ON UPDATE CASCADE ON DELETE CASCADE;
--
-- Name: users_roles "users_roles_userId_fkey"; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.users_roles
ADD CONSTRAINT "users_roles_userId_fkey" FOREIGN KEY ("userId") REFERENCES public."user"(id) ON UPDATE CASCADE ON DELETE CASCADE;
--
-- PostgreSQL database dump complete
--

View File

@ -0,0 +1,34 @@
--
-- PostgreSQL database dump
--
-- Dumped from database version 16.0
-- Dumped by pg_dump version 16.0
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
--
-- Data for Name: user; Type: TABLE DATA; Schema: public; Owner: -
--
COPY public."user" (id, email, password, "lastLoginAt", "loginAttempts", "lastLoginAttemptAt", "lastActivityAt", "bannedAt", "bannedById", "blockedAt", "blockedById", "emailVerifiedAt", "createdAt") FROM stdin;
1 linus.miller@bitmill.io 0lET71UJlcXP7YFWvMOPxR8XyGcrYVRDadhbtt7vSNN6kU3CPvYFym8/Qg13zXk5Va2rWFuS//KMHSMalUzd9SyfqbeV95Nuc8YopQgmsPKuLIubmtJeQ4YRFgPRpUVk 2024-05-11 19:08:18.117124+00 0 \N \N \N \N \N \N 2023-04-23 13:05:46.014017+00 2023-04-23 13:05:46.014017+00
\.
SELECT pg_catalog.setval('public.user_id_seq', 1, true);
--
-- PostgreSQL database dump complete
--

View File

@ -19,13 +19,16 @@
"start:watch": "node --watch-path server --enable-source-maps server/index.ts",
"test": "pnpm run test:client && pnpm run test:server",
"test:client": "node --no-warnings --import=./client/test/jsdom_polyfills.ts --import=./client/test/register_tsx_hook.ts --test ./client/**/*.test.ts{,x}",
"test:server": "node --no-warnings --test ./server/**/*.test.ts",
"test:server": "node --env-file .env.testing --no-warnings --test ./server/**/*.test.ts",
"types": "tsgo --skipLibCheck"
},
"dependencies": {
"@bmp/console": "^0.1.0",
"@bmp/highlight-stack": "^0.1.2",
"@domp/suppress": "^0.4.0",
"@fastify/cookie": "^11.0.2",
"@fastify/middie": "^9.0.3",
"@fastify/session": "^11.1.1",
"@fastify/static": "^8.3.0",
"@fastify/type-provider-typebox": "^6.1.0",
"chalk": "^5.6.2",
@ -33,6 +36,8 @@
"easy-tz": "^0.2.0",
"fastify": "^5.6.2",
"fastify-plugin": "^5.1.0",
"fastify-session-redis-store": "^7.1.2",
"ioredis": "^5.8.2",
"knex": "^3.1.0",
"lodash": "^4.17.21",
"lowline": "^0.4.2",
@ -42,6 +47,7 @@
"pino-abstract-transport": "^3.0.0",
"preact": "^10.28.0",
"preact-iso": "^2.11.0",
"preact-router": "^4.1.2",
"rek": "^0.8.1"
},
"devDependencies": {

122
pnpm-lock.yaml generated
View File

@ -14,9 +14,18 @@ importers:
'@bmp/highlight-stack':
specifier: ^0.1.2
version: 0.1.2
'@domp/suppress':
specifier: ^0.4.0
version: 0.4.0
'@fastify/cookie':
specifier: ^11.0.2
version: 11.0.2
'@fastify/middie':
specifier: ^9.0.3
version: 9.0.3
'@fastify/session':
specifier: ^11.1.1
version: 11.1.1
'@fastify/static':
specifier: ^8.3.0
version: 8.3.0
@ -38,6 +47,12 @@ importers:
fastify-plugin:
specifier: ^5.1.0
version: 5.1.0
fastify-session-redis-store:
specifier: ^7.1.2
version: 7.1.2(@fastify/session@11.1.1)
ioredis:
specifier: ^5.8.2
version: 5.8.2
knex:
specifier: ^3.1.0
version: 3.1.0(pg@8.16.3)
@ -65,6 +80,9 @@ importers:
preact-iso:
specifier: ^2.11.0
version: 2.11.0(preact-render-to-string@6.6.3(preact@10.28.0))(preact@10.28.0)
preact-router:
specifier: ^4.1.2
version: 4.1.2(preact@10.28.0)
rek:
specifier: ^0.8.1
version: 0.8.1
@ -273,6 +291,9 @@ packages:
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
engines: {node: '>=18'}
'@domp/suppress@0.4.0':
resolution: {integrity: sha512-feoweZY3/R4e7TUw82VcCYSkGt6cv6LyebHmUqHtcY/BnmzTqplH3tpmQn4DBRj8jgy+ss/GGyUNTuio39CBww==}
'@esbuild/aix-ppc64@0.25.12':
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
engines: {node: '>=18'}
@ -591,6 +612,9 @@ packages:
'@fastify/ajv-compiler@4.0.5':
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
'@fastify/cookie@11.0.2':
resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==}
'@fastify/error@4.2.0':
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
@ -612,6 +636,9 @@ packages:
'@fastify/send@4.1.0':
resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==}
'@fastify/session@11.1.1':
resolution: {integrity: sha512-nuKwTHxh3eJsI4NJeXoYVGzXUsg+kH1WfHgS7IofVyVhmjc+A6qGr+29WQy8hYZiNtmCjfG415COpf5xTBkW4Q==}
'@fastify/static@8.3.0':
resolution: {integrity: sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==}
@ -620,6 +647,9 @@ packages:
peerDependencies:
typebox: ^1.0.13
'@ioredis/commands@1.4.0':
resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==}
'@isaacs/balanced-match@4.0.1':
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
engines: {node: 20 || >=22}
@ -1108,6 +1138,10 @@ packages:
resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==}
engines: {node: '>=20'}
cluster-key-slot@1.1.2:
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
engines: {node: '>=0.10.0'}
color-convert@1.9.3:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
@ -1211,6 +1245,10 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
denque@2.1.0:
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
engines: {node: '>=0.10'}
depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
@ -1338,6 +1376,12 @@ packages:
fastify-plugin@5.1.0:
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
fastify-session-redis-store@7.1.2:
resolution: {integrity: sha512-e8WrZhwS0zvEAVWYTXAgvdqjcgS0lkW42RrMnUZ0wxxwykARn1Eb+nUEEdKGBwItdgZm0A2Nmmg/8usqP1l0cQ==}
engines: {node: '>=16'}
peerDependencies:
'@fastify/session': '>=10'
fastify@5.6.2:
resolution: {integrity: sha512-dPugdGnsvYkBlENLhCgX8yhyGCsCPrpA8lFWbTNU428l+YOnLgYHR69hzV8HWPC79n536EqzqQtvhtdaCE0dKg==}
@ -1486,6 +1530,10 @@ packages:
resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==}
engines: {node: '>= 0.10'}
ioredis@5.8.2:
resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==}
engines: {node: '>=12.22.0'}
ipaddr.js@2.2.0:
resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==}
engines: {node: '>= 10'}
@ -1658,6 +1706,12 @@ packages:
resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==}
engines: {node: '>=20.0.0'}
lodash.defaults@4.2.0:
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
lodash.isarguments@3.1.0:
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
@ -1895,6 +1949,11 @@ packages:
peerDependencies:
preact: '>=10 || >= 11.0.0-0'
preact-router@4.1.2:
resolution: {integrity: sha512-uICUaUFYh+XQ+6vZtQn1q+X6rSqwq+zorWOCLWPF5FAsQh3EJ+RsDQ9Ee+fjk545YWQHfUxhrBAaemfxEnMOUg==}
peerDependencies:
preact: '>=10'
preact@10.28.0:
resolution: {integrity: sha512-rytDAoiXr3+t6OIP3WGlDd0ouCUG1iCWzkcY3++Nreuoi17y6T5i/zRhe6uYfoVcxq6YU+sBtJouuRDsq8vvqA==}
@ -1935,6 +1994,14 @@ packages:
resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==}
engines: {node: '>= 10.13.0'}
redis-errors@1.2.0:
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
engines: {node: '>=4'}
redis-parser@3.0.0:
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
engines: {node: '>=4'}
regexp.prototype.flags@1.5.4:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'}
@ -2084,6 +2151,9 @@ packages:
resolution: {integrity: sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==}
engines: {node: '>=16'}
standard-as-callback@2.1.0:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
@ -2498,6 +2568,8 @@ snapshots:
'@csstools/css-tokenizer@3.0.4': {}
'@domp/suppress@0.4.0': {}
'@esbuild/aix-ppc64@0.25.12':
optional: true
@ -2662,6 +2734,11 @@ snapshots:
ajv-formats: 3.0.1(ajv@8.17.1)
fast-uri: 3.1.0
'@fastify/cookie@11.0.2':
dependencies:
cookie: 1.0.2
fastify-plugin: 5.1.0
'@fastify/error@4.2.0': {}
'@fastify/fast-json-stringify-compiler@5.0.3':
@ -2694,6 +2771,11 @@ snapshots:
http-errors: 2.0.1
mime: 3.0.0
'@fastify/session@11.1.1':
dependencies:
fastify-plugin: 5.1.0
safe-stable-stringify: 2.5.0
'@fastify/static@8.3.0':
dependencies:
'@fastify/accept-negotiator': 2.0.1
@ -2707,6 +2789,8 @@ snapshots:
dependencies:
typebox: 1.0.55
'@ioredis/commands@1.4.0': {}
'@isaacs/balanced-match@4.0.1': {}
'@isaacs/brace-expansion@5.0.0':
@ -3122,6 +3206,8 @@ snapshots:
slice-ansi: 7.1.2
string-width: 8.1.0
cluster-key-slot@1.1.2: {}
color-convert@1.9.3:
dependencies:
color-name: 1.1.3
@ -3233,6 +3319,8 @@ snapshots:
has-property-descriptors: 1.0.2
object-keys: 1.1.1
denque@2.1.0: {}
depd@2.0.0: {}
dequal@2.0.3: {}
@ -3395,6 +3483,10 @@ snapshots:
fastify-plugin@5.1.0: {}
fastify-session-redis-store@7.1.2(@fastify/session@11.1.1):
dependencies:
'@fastify/session': 11.1.1
fastify@5.6.2:
dependencies:
'@fastify/ajv-compiler': 4.0.5
@ -3552,6 +3644,20 @@ snapshots:
interpret@2.2.0: {}
ioredis@5.8.2:
dependencies:
'@ioredis/commands': 1.4.0
cluster-key-slot: 1.1.2
debug: 4.4.3
denque: 2.1.0
lodash.defaults: 4.2.0
lodash.isarguments: 3.1.0
redis-errors: 1.2.0
redis-parser: 3.0.0
standard-as-callback: 2.1.0
transitivePeerDependencies:
- supports-color
ipaddr.js@2.2.0: {}
is-arguments@1.2.0:
@ -3736,6 +3842,10 @@ snapshots:
rfdc: 1.4.1
wrap-ansi: 9.0.2
lodash.defaults@4.2.0: {}
lodash.isarguments@3.1.0: {}
lodash@4.17.21: {}
log-update@6.1.0:
@ -3951,6 +4061,10 @@ snapshots:
dependencies:
preact: 10.28.0
preact-router@4.1.2(preact@10.28.0):
dependencies:
preact: 10.28.0
preact@10.28.0: {}
prettier@3.6.2: {}
@ -3979,6 +4093,12 @@ snapshots:
dependencies:
resolve: 1.22.11
redis-errors@1.2.0: {}
redis-parser@3.0.0:
dependencies:
redis-errors: 1.2.0
regexp.prototype.flags@1.5.4:
dependencies:
call-bind: 1.0.8
@ -4152,6 +4272,8 @@ snapshots:
stack-trace@1.0.0-pre2: {}
standard-as-callback@2.1.0: {}
statuses@2.0.2: {}
stop-iteration-iterator@1.1.0:

9
server/config.ts Normal file
View File

@ -0,0 +1,9 @@
import auth from './config/auth.ts'
import mailgun from './config/mailgun.ts'
import site from './config/site.ts'
export default {
auth,
mailgun,
site,
}

56
server/config/auth.ts Normal file
View File

@ -0,0 +1,56 @@
const errors: Record<string, [number, string]> = {
banned: [401, 'User is banned.'],
blocked: [401, 'User is blocked due to too many login attempts.'],
duplicateEmail: [409, 'The email has already been registered'],
emailNotVerified: [401, "This account's email has not been verified"],
missingParameters: [422, 'Oh no missing stuff'],
noUserFound: [400, 'No user registered with that email.'],
notAuthorized: [401, 'The email is not authorized to create an account'],
tokenConsumed: [410, 'Token already consumed'],
tokenExpired: [410, 'Token expired'],
tokenNotFound: [404, 'Token not found'],
tokenNotLatest: [410, 'Newer token has been issued'],
tooManyLoginAttempts: [401, 'Too many failed login attempts'],
wrongPassword: [401, 'Wrong password'],
}
const paths = {
changePassword: '/admin/change-password',
forgotPassword: '/admin/forgot-password',
login: '/admin/login',
register: '/admin/register',
}
const maxLoginAttempts = 5
const requireVerification = true
const redirects = {
login: '/admin/',
logout: '/admin/login',
register: '/admin/login',
}
const remember = {
// if expires is defined, it will be used. otherwise maxage
expires: new Date('2038-01-19T03:14:07.000Z'),
maxAge: 10 * 365 * 24 * 60 * 60 * 1000,
}
const timeouts = {
invite: 30 * 24 * 60 * 60 * 1000,
// 1 day
changePassword: 24 * 60 * 60 * 1000,
// verify email
verifyEmail: 7 * 24 * 60 * 60 * 1000,
}
export default {
errors,
paths,
redirects,
maxLoginAttempts,
requireVerification,
remember,
timeouts,
}

Some files were not shown because too many files have changed in this diff Show More