WIP auth and admin
This commit is contained in:
parent
1ff4684ed3
commit
2c18a61315
17
.env.testing
Normal file
17
.env.testing
Normal 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
7
client/admin/client.ts
Normal 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)
|
||||||
88
client/admin/components/admission_form.tsx
Normal file
88
client/admin/components/admission_form.tsx
Normal 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
|
||||||
83
client/admin/components/admissions_page.tsx
Normal file
83
client/admin/components/admissions_page.tsx
Normal 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
|
||||||
50
client/admin/components/admissions_table.tsx
Normal file
50
client/admin/components/admissions_table.tsx
Normal 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
|
||||||
53
client/admin/components/app.module.scss
Normal file
53
client/admin/components/app.module.scss
Normal 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;
|
||||||
|
}
|
||||||
55
client/admin/components/app.tsx
Normal file
55
client/admin/components/app.tsx
Normal 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
|
||||||
120
client/admin/components/button.module.scss
Normal file
120
client/admin/components/button.module.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
client/admin/components/button.tsx
Normal file
17
client/admin/components/button.tsx
Normal 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 })
|
||||||
52
client/admin/components/change_password_form.tsx
Normal file
52
client/admin/components/change_password_form.tsx
Normal 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
|
||||||
19
client/admin/components/change_password_page.tsx
Normal file
19
client/admin/components/change_password_page.tsx
Normal 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
|
||||||
15
client/admin/components/checkbox.module.scss
Normal file
15
client/admin/components/checkbox.module.scss
Normal 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;
|
||||||
|
}
|
||||||
4
client/admin/components/checkbox.tsx
Normal file
4
client/admin/components/checkbox.tsx
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import checkboxFactory from '../../shared/components/checkbox_factory.tsx'
|
||||||
|
import styles from './checkbox.module.scss'
|
||||||
|
|
||||||
|
export default checkboxFactory(styles)
|
||||||
25
client/admin/components/current_user.module.scss
Normal file
25
client/admin/components/current_user.module.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
client/admin/components/current_user.tsx
Normal file
32
client/admin/components/current_user.tsx
Normal 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
|
||||||
12
client/admin/components/error_details.module.scss
Normal file
12
client/admin/components/error_details.module.scss
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
.base {
|
||||||
|
width: calc(100% - 30px);
|
||||||
|
max-width: 1024px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headers {
|
||||||
|
pre {
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
client/admin/components/error_details.tsx
Normal file
41
client/admin/components/error_details.tsx
Normal 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
|
||||||
20
client/admin/components/errors_page.module.scss
Normal file
20
client/admin/components/errors_page.module.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
150
client/admin/components/errors_page.tsx
Normal file
150
client/admin/components/errors_page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
7
client/admin/components/errors_search_form.module.scss
Normal file
7
client/admin/components/errors_search_form.module.scss
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
@use '../styles/variables' as v;
|
||||||
|
|
||||||
|
.button {
|
||||||
|
align-self: end;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-bottom: v.$gutter;
|
||||||
|
}
|
||||||
31
client/admin/components/errors_search_form.tsx
Normal file
31
client/admin/components/errors_search_form.tsx
Normal 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
|
||||||
31
client/admin/components/errors_table.module.scss
Normal file
31
client/admin/components/errors_table.module.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
76
client/admin/components/errors_table.tsx
Normal file
76
client/admin/components/errors_table.tsx
Normal 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
|
||||||
47
client/admin/components/forgot_password_form.tsx
Normal file
47
client/admin/components/forgot_password_form.tsx
Normal 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
|
||||||
19
client/admin/components/forgot_password_page.tsx
Normal file
19
client/admin/components/forgot_password_page.tsx
Normal 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
|
||||||
21
client/admin/components/form.module.scss
Normal file
21
client/admin/components/form.module.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
client/admin/components/git_log.tsx
Normal file
37
client/admin/components/git_log.tsx
Normal 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
|
||||||
70
client/admin/components/input.module.scss
Normal file
70
client/admin/components/input.module.scss
Normal 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;
|
||||||
|
}
|
||||||
5
client/admin/components/input.tsx
Normal file
5
client/admin/components/input.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import inputFactory from '../../shared/components/input_factory.tsx'
|
||||||
|
|
||||||
|
import s from './input.module.scss'
|
||||||
|
|
||||||
|
export default inputFactory(s)
|
||||||
54
client/admin/components/invite_form.tsx
Normal file
54
client/admin/components/invite_form.tsx
Normal 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
|
||||||
11
client/admin/components/invites_page.module.scss
Normal file
11
client/admin/components/invites_page.module.scss
Normal 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;
|
||||||
|
}
|
||||||
74
client/admin/components/invites_page.tsx
Normal file
74
client/admin/components/invites_page.tsx
Normal 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
|
||||||
52
client/admin/components/invites_table.tsx
Normal file
52
client/admin/components/invites_table.tsx
Normal 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
|
||||||
11
client/admin/components/login_form.module.scss
Normal file
11
client/admin/components/login_form.module.scss
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links {
|
||||||
|
li:not(:last-child) {
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
69
client/admin/components/login_form.tsx
Normal file
69
client/admin/components/login_form.tsx
Normal 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
|
||||||
18
client/admin/components/login_page.tsx
Normal file
18
client/admin/components/login_page.tsx
Normal 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
|
||||||
32
client/admin/components/message.module.scss
Normal file
32
client/admin/components/message.module.scss
Normal 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;
|
||||||
|
}
|
||||||
4
client/admin/components/message.tsx
Normal file
4
client/admin/components/message.tsx
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import s from './message.module.scss'
|
||||||
|
import messageFactory from '../../shared/components/message_factory.tsx'
|
||||||
|
|
||||||
|
export default messageFactory(s)
|
||||||
35
client/admin/components/modal.module.scss
Normal file
35
client/admin/components/modal.module.scss
Normal 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;
|
||||||
|
}
|
||||||
41
client/admin/components/modal.tsx
Normal file
41
client/admin/components/modal.tsx
Normal 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
|
||||||
122
client/admin/components/navigation.module.scss
Normal file
122
client/admin/components/navigation.module.scss
Normal 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: '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
client/admin/components/navigation.tsx
Normal file
51
client/admin/components/navigation.tsx
Normal 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
|
||||||
72
client/admin/components/navigation_group.tsx
Normal file
72
client/admin/components/navigation_group.tsx
Normal 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
|
||||||
42
client/admin/components/navigation_item.tsx
Normal file
42
client/admin/components/navigation_item.tsx
Normal 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
|
||||||
13
client/admin/components/not_found_page.tsx
Normal file
13
client/admin/components/not_found_page.tsx
Normal 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
|
||||||
15
client/admin/components/notifications.module.scss
Normal file
15
client/admin/components/notifications.module.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
client/admin/components/notifications.tsx
Normal file
19
client/admin/components/notifications.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
client/admin/components/object_table.module.scss
Normal file
15
client/admin/components/object_table.module.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
client/admin/components/object_table.tsx
Normal file
21
client/admin/components/object_table.tsx
Normal 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
|
||||||
10
client/admin/components/page_header.module.scss
Normal file
10
client/admin/components/page_header.module.scss
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
.base {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
12
client/admin/components/page_header.tsx
Normal file
12
client/admin/components/page_header.tsx
Normal 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
|
||||||
63
client/admin/components/pagination.module.scss
Normal file
63
client/admin/components/pagination.module.scss
Normal 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;
|
||||||
|
}
|
||||||
103
client/admin/components/pagination.tsx
Normal file
103
client/admin/components/pagination.tsx
Normal 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
|
||||||
42
client/admin/components/process.tsx
Normal file
42
client/admin/components/process.tsx
Normal 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
|
||||||
61
client/admin/components/register_form.tsx
Normal file
61
client/admin/components/register_form.tsx
Normal 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
|
||||||
18
client/admin/components/register_page.tsx
Normal file
18
client/admin/components/register_page.tsx
Normal 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
|
||||||
69
client/admin/components/role_form.tsx
Normal file
69
client/admin/components/role_form.tsx
Normal 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
|
||||||
87
client/admin/components/roles_page.tsx
Normal file
87
client/admin/components/roles_page.tsx
Normal 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
|
||||||
48
client/admin/components/roles_table.tsx
Normal file
48
client/admin/components/roles_table.tsx
Normal 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
|
||||||
25
client/admin/components/route.tsx
Normal file
25
client/admin/components/route.tsx
Normal 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
|
||||||
9
client/admin/components/row.module.scss
Normal file
9
client/admin/components/row.module.scss
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
@use '../../shared/styles/flex';
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
@include flex.simple-cell($gutter: 12px);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
client/admin/components/row.tsx
Normal file
14
client/admin/components/row.tsx
Normal 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>
|
||||||
26
client/admin/components/section.module.scss
Normal file
26
client/admin/components/section.module.scss
Normal 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;
|
||||||
|
}
|
||||||
36
client/admin/components/section.tsx
Normal file
36
client/admin/components/section.tsx
Normal 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
|
||||||
4
client/admin/components/select.tsx
Normal file
4
client/admin/components/select.tsx
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import selectFactory from '../../shared/components/select_factory.tsx'
|
||||||
|
import styles from './input.module.scss'
|
||||||
|
|
||||||
|
export default selectFactory({ styles })
|
||||||
44
client/admin/components/start_page.tsx
Normal file
44
client/admin/components/start_page.tsx
Normal 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
|
||||||
120
client/admin/components/table.module.scss
Normal file
120
client/admin/components/table.module.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
93
client/admin/components/table.tsx
Normal file
93
client/admin/components/table.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
43
client/admin/components/users_page.tsx
Normal file
43
client/admin/components/users_page.tsx
Normal 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
|
||||||
41
client/admin/components/users_table.tsx
Normal file
41
client/admin/components/users_table.tsx
Normal 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
|
||||||
13
client/admin/components/utility.module.scss
Normal file
13
client/admin/components/utility.module.scss
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
@use '../../shared/styles/flex';
|
||||||
|
|
||||||
|
.hide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
@include flex.simple-cell($gutter: 12px);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
client/admin/contexts/current_user.ts
Normal file
9
client/admin/contexts/current_user.ts
Normal 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
|
||||||
92
client/admin/contexts/notifications.tsx
Normal file
92
client/admin/contexts/notifications.tsx
Normal 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>
|
||||||
|
}
|
||||||
129
client/admin/hooks/use_items_reducer.ts
Normal file
129
client/admin/hooks/use_items_reducer.ts
Normal 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]
|
||||||
|
}
|
||||||
14
client/admin/images/icon-delete.svg
Normal file
14
client/admin/images/icon-delete.svg
Normal 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 |
14
client/admin/images/icon-edit.svg
Normal file
14
client/admin/images/icon-edit.svg
Normal 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
75
client/admin/routes.ts
Normal 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
1
client/admin/server.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as routes } from './routes.ts'
|
||||||
1
client/admin/styles/_fonts.scss
Normal file
1
client/admin/styles/_fonts.scss
Normal 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');
|
||||||
0
client/admin/styles/_layout.scss
Normal file
0
client/admin/styles/_layout.scss
Normal file
28
client/admin/styles/_styles.scss
Normal file
28
client/admin/styles/_styles.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
client/admin/styles/_variables.scss
Normal file
56
client/admin/styles/_variables.scss
Normal 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;
|
||||||
6
client/admin/styles/main.scss
Normal file
6
client/admin/styles/main.scss
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
@use '../../shared/styles/reset';
|
||||||
|
|
||||||
|
@use './fonts';
|
||||||
|
|
||||||
|
@use './styles';
|
||||||
|
@use './layout';
|
||||||
2
client/client.d.ts
vendored
2
client/client.d.ts
vendored
@ -4,3 +4,5 @@ declare module '*.module.scss' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
declare module '*.scss'
|
declare module '*.scss'
|
||||||
|
|
||||||
|
declare module '*.svg'
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import rek from 'rek'
|
|||||||
import Head from './head.ts'
|
import Head from './head.ts'
|
||||||
import usePromise from '../../shared/hooks/use_promise.ts'
|
import usePromise from '../../shared/hooks/use_promise.ts'
|
||||||
import type { Invoice, Supplier } from '../../../shared/types.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')
|
const format = Format.bind(null, null, 'YYYY.MM.DD')
|
||||||
|
|
||||||
@ -16,7 +17,9 @@ const InvoicesPage: FunctionComponent = () => {
|
|||||||
Promise.all([
|
Promise.all([
|
||||||
rek(`/api/suppliers/${route.params.supplier}`),
|
rek(`/api/suppliers/${route.params.supplier}`),
|
||||||
rek(`/api/invoices?supplier=${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>
|
<h1>Invoices for {supplier?.name}</h1>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<strong>Total: {totalAmount}</strong>
|
<strong>Total: {formatPrice(totalAmount)}</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<table>
|
<table className='grid'>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { h, type FunctionComponent, type PointerEventHandler } from 'preact'
|
import { h, type FunctionComponent, type PointerEventHandler } from 'preact'
|
||||||
import cn from 'classnames'
|
import cn from 'classnames'
|
||||||
|
|
||||||
type Styles<C extends string, D extends string, S extends string> = {
|
type ButtonStyles<C extends string, D extends string, S extends string> = {
|
||||||
base: string
|
base?: string
|
||||||
autoHeight?: string
|
autoHeight?: string
|
||||||
fullWidth?: string
|
fullWidth?: string
|
||||||
icon?: string
|
icon?: string
|
||||||
@ -11,7 +11,7 @@ type Styles<C extends string, D extends string, S extends string> = {
|
|||||||
Record<D, string> &
|
Record<D, string> &
|
||||||
Record<S, 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
|
autoHeight?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
color?: C
|
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?: 'button' | 'reset' | 'submit'
|
||||||
}
|
}
|
||||||
|
|
||||||
type Options<C extends string, D extends string, I extends string, S extends string> = {
|
type ButtonFactoryOptions<C extends string, D extends string, I extends string, S extends string> = {
|
||||||
defaults: Partial<Props<C, D, I, S>>
|
defaults: Partial<ButtonProps<C, D, I, S>>
|
||||||
icons: Record<I, string>
|
icons: Record<I, string>
|
||||||
styles: Styles<C, D, S>
|
styles: ButtonStyles<C, D, S>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function buttonFactory<
|
export default function buttonFactory<
|
||||||
@ -40,8 +40,8 @@ export default function buttonFactory<
|
|||||||
D extends string = never,
|
D extends string = never,
|
||||||
I extends string = never,
|
I extends string = never,
|
||||||
S extends string = never,
|
S extends string = never,
|
||||||
>({ defaults, icons, styles }: Options<C, D, I, S>) {
|
>({ defaults, icons, styles }: ButtonFactoryOptions<C, D, I, S>) {
|
||||||
const Button: FunctionComponent<Props<C, D, I, S>> = ({
|
const Button: FunctionComponent<ButtonProps<C, D, I, S>> = ({
|
||||||
autoHeight = defaults?.autoHeight,
|
autoHeight = defaults?.autoHeight,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
|||||||
@ -3,14 +3,14 @@ import { type ChangeEvent } from 'preact/compat'
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
||||||
import cn from 'classnames'
|
import cn from 'classnames'
|
||||||
|
|
||||||
type Styles<S extends string> = {
|
type CheckboxStyles<S extends string> = {
|
||||||
base?: string
|
base?: string
|
||||||
element?: string
|
element?: string
|
||||||
label?: string
|
label?: string
|
||||||
touched?: string
|
touched?: string
|
||||||
} & Record<S, string>
|
} & Record<S, string>
|
||||||
|
|
||||||
type Props<S extends string> = {
|
type CheckboxProps<S extends string> = {
|
||||||
autoFocus?: boolean
|
autoFocus?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
defaultChecked?: any
|
defaultChecked?: any
|
||||||
@ -19,12 +19,12 @@ type Props<S extends string> = {
|
|||||||
name?: string
|
name?: string
|
||||||
onChange?: (e: ChangeEvent) => void
|
onChange?: (e: ChangeEvent) => void
|
||||||
required?: boolean
|
required?: boolean
|
||||||
style: S
|
style?: S
|
||||||
value?: string
|
value?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function checkboxFactory<S extends string = never>(styles: Styles<S>) {
|
export default function checkboxFactory<S extends string = never>(styles: CheckboxStyles<S>) {
|
||||||
const Checkbox: FunctionComponent<Props<S>> = ({
|
const Checkbox: FunctionComponent<CheckboxProps<S>> = ({
|
||||||
autoFocus,
|
autoFocus,
|
||||||
className,
|
className,
|
||||||
defaultChecked,
|
defaultChecked,
|
||||||
@ -55,7 +55,7 @@ export default function checkboxFactory<S extends string = never>(styles: Styles
|
|||||||
}, [autoFocus])
|
}, [autoFocus])
|
||||||
|
|
||||||
return (
|
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
|
<input
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
className={styles.element}
|
className={styles.element}
|
||||||
|
|||||||
@ -17,18 +17,19 @@ type Styles<C extends string, D extends string, S extends string> = {
|
|||||||
Record<D, string> &
|
Record<D, string> &
|
||||||
Record<S, 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> = {
|
type Props<C extends string, D extends string, S extends string> = {
|
||||||
autoComplete?: string
|
autoComplete?: string
|
||||||
autoFocus?: boolean
|
autoFocus?: boolean
|
||||||
center?: boolean
|
center?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
classNames?: Partial<Styles<C, D, S>>
|
classNames?: Partial<Styles<C, D, S>>
|
||||||
color: C
|
color?: C
|
||||||
defaultValue?: string
|
defaultValue?: number | string | null
|
||||||
design: D
|
design?: D
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
icon: string
|
icon?: string
|
||||||
label: string
|
label?: string
|
||||||
name: string
|
name: string
|
||||||
noMargin?: boolean
|
noMargin?: boolean
|
||||||
pattern?: string
|
pattern?: string
|
||||||
@ -122,7 +123,9 @@ export default function inputFactory<C extends string = never, D extends string
|
|||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
className={cn(styles.element, classNames?.element)}
|
className={cn(styles.element, classNames?.element)}
|
||||||
defaultValue={
|
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}
|
disabled={disabled}
|
||||||
id={id}
|
id={id}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { type ContainerNode, type FunctionComponent } from 'preact'
|
import { type ContainerNode, type FunctionComponent } from 'preact'
|
||||||
import { createPortal } from 'preact/compat'
|
import { createPortal } from 'preact/compat'
|
||||||
|
|
||||||
const Portal: FunctionComponent<{ container: ContainerNode }> = ({
|
const Portal: FunctionComponent<{ container?: ContainerNode }> = ({
|
||||||
children,
|
children,
|
||||||
container = typeof document !== 'undefined' && document.body,
|
container = typeof document !== 'undefined' && document.body,
|
||||||
}) => container && createPortal(children, container)
|
}) => container && createPortal(children, container)
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
import { useState } from 'preact/hooks'
|
import { useState } from 'preact/hooks'
|
||||||
import isLoading from '../utils/is_loading.ts'
|
import isLoading from '../utils/is_loading.ts'
|
||||||
|
|
||||||
interface State {
|
interface State<E extends Error> {
|
||||||
error: Error | null
|
error: E | null
|
||||||
pending: boolean
|
pending: boolean
|
||||||
success: boolean
|
success: boolean
|
||||||
response: any
|
response: any
|
||||||
}
|
}
|
||||||
|
|
||||||
const useRequestState = () => {
|
const useRequestState = <E extends Error>() => {
|
||||||
const initialState = {
|
const initialState = {
|
||||||
error: null,
|
error: null,
|
||||||
pending: false,
|
pending: false,
|
||||||
@ -16,7 +16,7 @@ const useRequestState = () => {
|
|||||||
response: null,
|
response: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
const [state, setState] = useState<State>(initialState)
|
const [state, setState] = useState<State<E>>(initialState)
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
reset() {
|
reset() {
|
||||||
@ -25,7 +25,7 @@ const useRequestState = () => {
|
|||||||
setState(initialState)
|
setState(initialState)
|
||||||
},
|
},
|
||||||
|
|
||||||
error(error: Error) {
|
error(error: E) {
|
||||||
isLoading(false)
|
isLoading(false)
|
||||||
|
|
||||||
setState({
|
setState({
|
||||||
@ -59,7 +59,7 @@ const useRequestState = () => {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return [state, actions]
|
return [state, actions] as [State<E>, typeof actions]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useRequestState
|
export default useRequestState
|
||||||
|
|||||||
3
client/shared/utils/stop_propagation.ts
Normal file
3
client/shared/utils/stop_propagation.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export default function stopPropagation(e: Event) {
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
@ -25,8 +25,10 @@ services:
|
|||||||
- POSTGRES_USER=brf_books
|
- POSTGRES_USER=brf_books
|
||||||
- POSTGRES_PASSWORD=brf_books
|
- POSTGRES_PASSWORD=brf_books
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/postgres/01-schema.sql:/docker-entrypoint-initdb.d/01-schema.sql
|
- ./docker/postgres/01-auth_schema.sql:/docker-entrypoint-initdb.d/01-auth_schema.sql
|
||||||
- ./docker/postgres/02-data.sql:/docker-entrypoint-initdb.d/02-data.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
|
- postgres:/var/lib/postgresql/data
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
|
|||||||
650
docker/postgres/01-auth_schema.sql
Normal file
650
docker/postgres/01-auth_schema.sql
Normal 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
|
||||||
|
--
|
||||||
|
|
||||||
34
docker/postgres/03-auth_data.sql
Normal file
34
docker/postgres/03-auth_data.sql
Normal 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
|
||||||
|
--
|
||||||
|
|
||||||
@ -19,13 +19,16 @@
|
|||||||
"start:watch": "node --watch-path server --enable-source-maps server/index.ts",
|
"start:watch": "node --watch-path server --enable-source-maps server/index.ts",
|
||||||
"test": "pnpm run test:client && pnpm run test:server",
|
"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: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"
|
"types": "tsgo --skipLibCheck"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bmp/console": "^0.1.0",
|
"@bmp/console": "^0.1.0",
|
||||||
"@bmp/highlight-stack": "^0.1.2",
|
"@bmp/highlight-stack": "^0.1.2",
|
||||||
|
"@domp/suppress": "^0.4.0",
|
||||||
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/middie": "^9.0.3",
|
"@fastify/middie": "^9.0.3",
|
||||||
|
"@fastify/session": "^11.1.1",
|
||||||
"@fastify/static": "^8.3.0",
|
"@fastify/static": "^8.3.0",
|
||||||
"@fastify/type-provider-typebox": "^6.1.0",
|
"@fastify/type-provider-typebox": "^6.1.0",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
@ -33,6 +36,8 @@
|
|||||||
"easy-tz": "^0.2.0",
|
"easy-tz": "^0.2.0",
|
||||||
"fastify": "^5.6.2",
|
"fastify": "^5.6.2",
|
||||||
"fastify-plugin": "^5.1.0",
|
"fastify-plugin": "^5.1.0",
|
||||||
|
"fastify-session-redis-store": "^7.1.2",
|
||||||
|
"ioredis": "^5.8.2",
|
||||||
"knex": "^3.1.0",
|
"knex": "^3.1.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"lowline": "^0.4.2",
|
"lowline": "^0.4.2",
|
||||||
@ -42,6 +47,7 @@
|
|||||||
"pino-abstract-transport": "^3.0.0",
|
"pino-abstract-transport": "^3.0.0",
|
||||||
"preact": "^10.28.0",
|
"preact": "^10.28.0",
|
||||||
"preact-iso": "^2.11.0",
|
"preact-iso": "^2.11.0",
|
||||||
|
"preact-router": "^4.1.2",
|
||||||
"rek": "^0.8.1"
|
"rek": "^0.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
122
pnpm-lock.yaml
generated
122
pnpm-lock.yaml
generated
@ -14,9 +14,18 @@ importers:
|
|||||||
'@bmp/highlight-stack':
|
'@bmp/highlight-stack':
|
||||||
specifier: ^0.1.2
|
specifier: ^0.1.2
|
||||||
version: 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':
|
'@fastify/middie':
|
||||||
specifier: ^9.0.3
|
specifier: ^9.0.3
|
||||||
version: 9.0.3
|
version: 9.0.3
|
||||||
|
'@fastify/session':
|
||||||
|
specifier: ^11.1.1
|
||||||
|
version: 11.1.1
|
||||||
'@fastify/static':
|
'@fastify/static':
|
||||||
specifier: ^8.3.0
|
specifier: ^8.3.0
|
||||||
version: 8.3.0
|
version: 8.3.0
|
||||||
@ -38,6 +47,12 @@ importers:
|
|||||||
fastify-plugin:
|
fastify-plugin:
|
||||||
specifier: ^5.1.0
|
specifier: ^5.1.0
|
||||||
version: 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:
|
knex:
|
||||||
specifier: ^3.1.0
|
specifier: ^3.1.0
|
||||||
version: 3.1.0(pg@8.16.3)
|
version: 3.1.0(pg@8.16.3)
|
||||||
@ -65,6 +80,9 @@ importers:
|
|||||||
preact-iso:
|
preact-iso:
|
||||||
specifier: ^2.11.0
|
specifier: ^2.11.0
|
||||||
version: 2.11.0(preact-render-to-string@6.6.3(preact@10.28.0))(preact@10.28.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:
|
rek:
|
||||||
specifier: ^0.8.1
|
specifier: ^0.8.1
|
||||||
version: 0.8.1
|
version: 0.8.1
|
||||||
@ -273,6 +291,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
|
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
'@domp/suppress@0.4.0':
|
||||||
|
resolution: {integrity: sha512-feoweZY3/R4e7TUw82VcCYSkGt6cv6LyebHmUqHtcY/BnmzTqplH3tpmQn4DBRj8jgy+ss/GGyUNTuio39CBww==}
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.25.12':
|
'@esbuild/aix-ppc64@0.25.12':
|
||||||
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
|
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@ -591,6 +612,9 @@ packages:
|
|||||||
'@fastify/ajv-compiler@4.0.5':
|
'@fastify/ajv-compiler@4.0.5':
|
||||||
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
|
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':
|
'@fastify/error@4.2.0':
|
||||||
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
|
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
|
||||||
|
|
||||||
@ -612,6 +636,9 @@ packages:
|
|||||||
'@fastify/send@4.1.0':
|
'@fastify/send@4.1.0':
|
||||||
resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==}
|
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':
|
'@fastify/static@8.3.0':
|
||||||
resolution: {integrity: sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==}
|
resolution: {integrity: sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==}
|
||||||
|
|
||||||
@ -620,6 +647,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
typebox: ^1.0.13
|
typebox: ^1.0.13
|
||||||
|
|
||||||
|
'@ioredis/commands@1.4.0':
|
||||||
|
resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==}
|
||||||
|
|
||||||
'@isaacs/balanced-match@4.0.1':
|
'@isaacs/balanced-match@4.0.1':
|
||||||
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
|
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
@ -1108,6 +1138,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==}
|
resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==}
|
||||||
engines: {node: '>=20'}
|
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:
|
color-convert@1.9.3:
|
||||||
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
|
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
|
||||||
|
|
||||||
@ -1211,6 +1245,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
|
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
denque@2.1.0:
|
||||||
|
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
|
||||||
|
engines: {node: '>=0.10'}
|
||||||
|
|
||||||
depd@2.0.0:
|
depd@2.0.0:
|
||||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@ -1338,6 +1376,12 @@ packages:
|
|||||||
fastify-plugin@5.1.0:
|
fastify-plugin@5.1.0:
|
||||||
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
|
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:
|
fastify@5.6.2:
|
||||||
resolution: {integrity: sha512-dPugdGnsvYkBlENLhCgX8yhyGCsCPrpA8lFWbTNU428l+YOnLgYHR69hzV8HWPC79n536EqzqQtvhtdaCE0dKg==}
|
resolution: {integrity: sha512-dPugdGnsvYkBlENLhCgX8yhyGCsCPrpA8lFWbTNU428l+YOnLgYHR69hzV8HWPC79n536EqzqQtvhtdaCE0dKg==}
|
||||||
|
|
||||||
@ -1486,6 +1530,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==}
|
resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==}
|
||||||
engines: {node: '>= 0.10'}
|
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:
|
ipaddr.js@2.2.0:
|
||||||
resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==}
|
resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
@ -1658,6 +1706,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==}
|
resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==}
|
||||||
engines: {node: '>=20.0.0'}
|
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:
|
lodash@4.17.21:
|
||||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||||
|
|
||||||
@ -1895,6 +1949,11 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
preact: '>=10 || >= 11.0.0-0'
|
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:
|
preact@10.28.0:
|
||||||
resolution: {integrity: sha512-rytDAoiXr3+t6OIP3WGlDd0ouCUG1iCWzkcY3++Nreuoi17y6T5i/zRhe6uYfoVcxq6YU+sBtJouuRDsq8vvqA==}
|
resolution: {integrity: sha512-rytDAoiXr3+t6OIP3WGlDd0ouCUG1iCWzkcY3++Nreuoi17y6T5i/zRhe6uYfoVcxq6YU+sBtJouuRDsq8vvqA==}
|
||||||
|
|
||||||
@ -1935,6 +1994,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==}
|
resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==}
|
||||||
engines: {node: '>= 10.13.0'}
|
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:
|
regexp.prototype.flags@1.5.4:
|
||||||
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
|
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@ -2084,6 +2151,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==}
|
resolution: {integrity: sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
|
standard-as-callback@2.1.0:
|
||||||
|
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
|
||||||
|
|
||||||
statuses@2.0.2:
|
statuses@2.0.2:
|
||||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@ -2498,6 +2568,8 @@ snapshots:
|
|||||||
|
|
||||||
'@csstools/css-tokenizer@3.0.4': {}
|
'@csstools/css-tokenizer@3.0.4': {}
|
||||||
|
|
||||||
|
'@domp/suppress@0.4.0': {}
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.25.12':
|
'@esbuild/aix-ppc64@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@ -2662,6 +2734,11 @@ snapshots:
|
|||||||
ajv-formats: 3.0.1(ajv@8.17.1)
|
ajv-formats: 3.0.1(ajv@8.17.1)
|
||||||
fast-uri: 3.1.0
|
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/error@4.2.0': {}
|
||||||
|
|
||||||
'@fastify/fast-json-stringify-compiler@5.0.3':
|
'@fastify/fast-json-stringify-compiler@5.0.3':
|
||||||
@ -2694,6 +2771,11 @@ snapshots:
|
|||||||
http-errors: 2.0.1
|
http-errors: 2.0.1
|
||||||
mime: 3.0.0
|
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':
|
'@fastify/static@8.3.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@fastify/accept-negotiator': 2.0.1
|
'@fastify/accept-negotiator': 2.0.1
|
||||||
@ -2707,6 +2789,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
typebox: 1.0.55
|
typebox: 1.0.55
|
||||||
|
|
||||||
|
'@ioredis/commands@1.4.0': {}
|
||||||
|
|
||||||
'@isaacs/balanced-match@4.0.1': {}
|
'@isaacs/balanced-match@4.0.1': {}
|
||||||
|
|
||||||
'@isaacs/brace-expansion@5.0.0':
|
'@isaacs/brace-expansion@5.0.0':
|
||||||
@ -3122,6 +3206,8 @@ snapshots:
|
|||||||
slice-ansi: 7.1.2
|
slice-ansi: 7.1.2
|
||||||
string-width: 8.1.0
|
string-width: 8.1.0
|
||||||
|
|
||||||
|
cluster-key-slot@1.1.2: {}
|
||||||
|
|
||||||
color-convert@1.9.3:
|
color-convert@1.9.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.3
|
color-name: 1.1.3
|
||||||
@ -3233,6 +3319,8 @@ snapshots:
|
|||||||
has-property-descriptors: 1.0.2
|
has-property-descriptors: 1.0.2
|
||||||
object-keys: 1.1.1
|
object-keys: 1.1.1
|
||||||
|
|
||||||
|
denque@2.1.0: {}
|
||||||
|
|
||||||
depd@2.0.0: {}
|
depd@2.0.0: {}
|
||||||
|
|
||||||
dequal@2.0.3: {}
|
dequal@2.0.3: {}
|
||||||
@ -3395,6 +3483,10 @@ snapshots:
|
|||||||
|
|
||||||
fastify-plugin@5.1.0: {}
|
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:
|
fastify@5.6.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@fastify/ajv-compiler': 4.0.5
|
'@fastify/ajv-compiler': 4.0.5
|
||||||
@ -3552,6 +3644,20 @@ snapshots:
|
|||||||
|
|
||||||
interpret@2.2.0: {}
|
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: {}
|
ipaddr.js@2.2.0: {}
|
||||||
|
|
||||||
is-arguments@1.2.0:
|
is-arguments@1.2.0:
|
||||||
@ -3736,6 +3842,10 @@ snapshots:
|
|||||||
rfdc: 1.4.1
|
rfdc: 1.4.1
|
||||||
wrap-ansi: 9.0.2
|
wrap-ansi: 9.0.2
|
||||||
|
|
||||||
|
lodash.defaults@4.2.0: {}
|
||||||
|
|
||||||
|
lodash.isarguments@3.1.0: {}
|
||||||
|
|
||||||
lodash@4.17.21: {}
|
lodash@4.17.21: {}
|
||||||
|
|
||||||
log-update@6.1.0:
|
log-update@6.1.0:
|
||||||
@ -3951,6 +4061,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
preact: 10.28.0
|
preact: 10.28.0
|
||||||
|
|
||||||
|
preact-router@4.1.2(preact@10.28.0):
|
||||||
|
dependencies:
|
||||||
|
preact: 10.28.0
|
||||||
|
|
||||||
preact@10.28.0: {}
|
preact@10.28.0: {}
|
||||||
|
|
||||||
prettier@3.6.2: {}
|
prettier@3.6.2: {}
|
||||||
@ -3979,6 +4093,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
resolve: 1.22.11
|
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:
|
regexp.prototype.flags@1.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.8
|
call-bind: 1.0.8
|
||||||
@ -4152,6 +4272,8 @@ snapshots:
|
|||||||
|
|
||||||
stack-trace@1.0.0-pre2: {}
|
stack-trace@1.0.0-pre2: {}
|
||||||
|
|
||||||
|
standard-as-callback@2.1.0: {}
|
||||||
|
|
||||||
statuses@2.0.2: {}
|
statuses@2.0.2: {}
|
||||||
|
|
||||||
stop-iteration-iterator@1.1.0:
|
stop-iteration-iterator@1.1.0:
|
||||||
|
|||||||
9
server/config.ts
Normal file
9
server/config.ts
Normal 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
56
server/config/auth.ts
Normal 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
Loading…
Reference in New Issue
Block a user