Compare commits
No commits in common. "8a028d271933a19f01d4169fc4ebf231fb9a238b" and "1ff4684ed3c397dac3d6ae111241431cc5376f58" have entirely different histories.
8a028d2719
...
1ff4684ed3
@ -1,15 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: /api/admissions
|
|
||||||
type: http
|
|
||||||
seq: 1
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{base_url}}/api/admissions
|
|
||||||
body: none
|
|
||||||
auth: none
|
|
||||||
}
|
|
||||||
|
|
||||||
headers {
|
|
||||||
Accept: application/json
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: /api/errors
|
|
||||||
type: http
|
|
||||||
seq: 22
|
|
||||||
}
|
|
||||||
|
|
||||||
delete {
|
|
||||||
url: {{base_url}}/api/errors?id=4
|
|
||||||
body: none
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
|
|
||||||
params:query {
|
|
||||||
id: 4
|
|
||||||
}
|
|
||||||
|
|
||||||
settings {
|
|
||||||
encodeUrl: true
|
|
||||||
timeout: 0
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: /api/invites
|
|
||||||
type: http
|
|
||||||
seq: 10
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{base_url}}/api/invites
|
|
||||||
body: none
|
|
||||||
auth: none
|
|
||||||
}
|
|
||||||
|
|
||||||
headers {
|
|
||||||
Accept: application/json
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: /api/invoices
|
|
||||||
type: http
|
|
||||||
seq: 7
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{base_url}}/api/invoices?limit=2&supplierId=10
|
|
||||||
body: none
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
|
|
||||||
params:query {
|
|
||||||
limit: 2
|
|
||||||
supplierId: 10
|
|
||||||
~year: 2015
|
|
||||||
}
|
|
||||||
|
|
||||||
settings {
|
|
||||||
encodeUrl: true
|
|
||||||
timeout: 0
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: /api/objects/:id/transactions
|
|
||||||
type: http
|
|
||||||
seq: 12
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{base_url}}/api/objects/:id/transactions
|
|
||||||
body: none
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
|
|
||||||
params:path {
|
|
||||||
id: 10
|
|
||||||
}
|
|
||||||
|
|
||||||
settings {
|
|
||||||
encodeUrl: true
|
|
||||||
timeout: 0
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: /api/roles
|
|
||||||
type: http
|
|
||||||
seq: 15
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{base_url}}/api/roles
|
|
||||||
body: none
|
|
||||||
auth: none
|
|
||||||
}
|
|
||||||
|
|
||||||
headers {
|
|
||||||
Accept: application/json
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: /api/transactions
|
|
||||||
type: http
|
|
||||||
seq: 19
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{base_url}}/api/transactions?year=2020
|
|
||||||
body: none
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
|
|
||||||
params:query {
|
|
||||||
year: 2020
|
|
||||||
~accountNumber: 4800
|
|
||||||
}
|
|
||||||
|
|
||||||
settings {
|
|
||||||
encodeUrl: true
|
|
||||||
timeout: 0
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: /api/users
|
|
||||||
type: http
|
|
||||||
seq: 20
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{base_url}}/api/users
|
|
||||||
body: none
|
|
||||||
auth: none
|
|
||||||
}
|
|
||||||
|
|
||||||
headers {
|
|
||||||
Accept: application/json
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: API
|
|
||||||
seq: 2
|
|
||||||
}
|
|
||||||
|
|
||||||
auth {
|
|
||||||
mode: inherit
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Login
|
|
||||||
type: http
|
|
||||||
seq: 1
|
|
||||||
}
|
|
||||||
|
|
||||||
post {
|
|
||||||
url: {{base_url}}/auth/login
|
|
||||||
body: json
|
|
||||||
auth: none
|
|
||||||
}
|
|
||||||
|
|
||||||
body:json {
|
|
||||||
{
|
|
||||||
"email": "linus.miller@bitmill.io",
|
|
||||||
"password": "rasmus"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Logout
|
|
||||||
type: http
|
|
||||||
seq: 2
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{base_url}}/auth/logout
|
|
||||||
body: none
|
|
||||||
auth: none
|
|
||||||
}
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: /auth/register
|
|
||||||
type: http
|
|
||||||
seq: 3
|
|
||||||
}
|
|
||||||
|
|
||||||
post {
|
|
||||||
url: {{base_url}}/auth/register
|
|
||||||
body: json
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
|
|
||||||
body:json {
|
|
||||||
{
|
|
||||||
"email": "linus.k.miller@gmail.com",
|
|
||||||
"password": "rasmus",
|
|
||||||
"inviteEmail": "linus.k.miller@gmail.com",
|
|
||||||
"inviteToken": "1502f035584e09870aab05611161a636f88fb08ccba745850a0430f2bb5b3d8c"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body:form-urlencoded {
|
|
||||||
email: linus.k.miller@gmail.com
|
|
||||||
password: rasmus
|
|
||||||
}
|
|
||||||
|
|
||||||
body:multipart-form {
|
|
||||||
linus.k.miller@gmail.com:
|
|
||||||
}
|
|
||||||
|
|
||||||
settings {
|
|
||||||
encodeUrl: true
|
|
||||||
timeout: 0
|
|
||||||
}
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
meta {
|
meta {
|
||||||
name: /api/balances
|
name: /api/balances
|
||||||
type: http
|
type: http
|
||||||
seq: 3
|
seq: 15
|
||||||
}
|
}
|
||||||
|
|
||||||
get {
|
get {
|
||||||
@ -12,5 +12,4 @@ get {
|
|||||||
|
|
||||||
settings {
|
settings {
|
||||||
encodeUrl: true
|
encodeUrl: true
|
||||||
timeout: 0
|
|
||||||
}
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
meta {
|
meta {
|
||||||
name: /api/entries/:id
|
name: /api/entries/:id
|
||||||
type: http
|
type: http
|
||||||
seq: 5
|
seq: 4
|
||||||
}
|
}
|
||||||
|
|
||||||
get {
|
get {
|
||||||
@ -1,7 +1,7 @@
|
|||||||
meta {
|
meta {
|
||||||
name: /api/entries
|
name: /api/entries
|
||||||
type: http
|
type: http
|
||||||
seq: 4
|
seq: 3
|
||||||
}
|
}
|
||||||
|
|
||||||
get {
|
get {
|
||||||
@ -1,7 +1,7 @@
|
|||||||
meta {
|
meta {
|
||||||
name: /api/financial-years
|
name: /api/financial-years
|
||||||
type: http
|
type: http
|
||||||
seq: 6
|
seq: 5
|
||||||
}
|
}
|
||||||
|
|
||||||
get {
|
get {
|
||||||
@ -11,7 +11,7 @@ get {
|
|||||||
}
|
}
|
||||||
|
|
||||||
params:path {
|
params:path {
|
||||||
id: 10
|
id: 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
settings {
|
settings {
|
||||||
@ -1,20 +1,15 @@
|
|||||||
meta {
|
meta {
|
||||||
name: /api/invoices/total-amount
|
name: /api/invoices/total-amount
|
||||||
type: http
|
type: http
|
||||||
seq: 9
|
seq: 12
|
||||||
}
|
}
|
||||||
|
|
||||||
get {
|
get {
|
||||||
url: {{base_url}}/api/invoices/total-amount?supplier=150
|
url: {{base_url}}/api/invoices/total-amount
|
||||||
body: none
|
body: none
|
||||||
auth: inherit
|
auth: inherit
|
||||||
}
|
}
|
||||||
|
|
||||||
params:query {
|
|
||||||
supplier: 150
|
|
||||||
}
|
|
||||||
|
|
||||||
settings {
|
settings {
|
||||||
encodeUrl: true
|
encodeUrl: true
|
||||||
timeout: 0
|
|
||||||
}
|
}
|
||||||
@ -1,11 +1,11 @@
|
|||||||
meta {
|
meta {
|
||||||
name: /api/journals
|
name: /api/invoices
|
||||||
type: http
|
type: http
|
||||||
seq: 21
|
seq: 8
|
||||||
}
|
}
|
||||||
|
|
||||||
get {
|
get {
|
||||||
url: {{base_url}}/api/journals
|
url: {{base_url}}/api/invoices
|
||||||
body: none
|
body: none
|
||||||
auth: inherit
|
auth: inherit
|
||||||
}
|
}
|
||||||
@ -1,19 +1,15 @@
|
|||||||
meta {
|
meta {
|
||||||
name: /api/suppliers/:id
|
name: /api/objects/:id
|
||||||
type: http
|
type: http
|
||||||
seq: 17
|
seq: 6
|
||||||
}
|
}
|
||||||
|
|
||||||
get {
|
get {
|
||||||
url: {{base_url}}/api/suppliers/:id
|
url: {{base_url}}/api/objects/10
|
||||||
body: none
|
body: none
|
||||||
auth: inherit
|
auth: inherit
|
||||||
}
|
}
|
||||||
|
|
||||||
params:path {
|
|
||||||
id: 105
|
|
||||||
}
|
|
||||||
|
|
||||||
settings {
|
settings {
|
||||||
encodeUrl: true
|
encodeUrl: true
|
||||||
timeout: 0
|
timeout: 0
|
||||||
@ -1,7 +1,7 @@
|
|||||||
meta {
|
meta {
|
||||||
name: /api/objects
|
name: /api/objects
|
||||||
type: http
|
type: http
|
||||||
seq: 11
|
seq: 7
|
||||||
}
|
}
|
||||||
|
|
||||||
get {
|
get {
|
||||||
@ -1,19 +1,15 @@
|
|||||||
meta {
|
meta {
|
||||||
name: /api/results/:year
|
name: /api/results/:year
|
||||||
type: http
|
type: http
|
||||||
seq: 14
|
seq: 12
|
||||||
}
|
}
|
||||||
|
|
||||||
get {
|
get {
|
||||||
url: {{base_url}}/api/results/:year
|
url: {{base_url}}/api/results/2018
|
||||||
body: none
|
body: none
|
||||||
auth: inherit
|
auth: inherit
|
||||||
}
|
}
|
||||||
|
|
||||||
params:path {
|
|
||||||
year: 2017
|
|
||||||
}
|
|
||||||
|
|
||||||
settings {
|
settings {
|
||||||
encodeUrl: true
|
encodeUrl: true
|
||||||
timeout: 0
|
timeout: 0
|
||||||
@ -1,7 +1,7 @@
|
|||||||
meta {
|
meta {
|
||||||
name: /api/results
|
name: /api/results
|
||||||
type: http
|
type: http
|
||||||
seq: 13
|
seq: 11
|
||||||
}
|
}
|
||||||
|
|
||||||
get {
|
get {
|
||||||
@ -1,26 +1,15 @@
|
|||||||
meta {
|
meta {
|
||||||
name: /api/suppliers/merge
|
name: /api/suppliers/merge
|
||||||
type: http
|
type: http
|
||||||
seq: 18
|
seq: 13
|
||||||
}
|
}
|
||||||
|
|
||||||
post {
|
post {
|
||||||
url: {{base_url}}/api/suppliers/merge
|
url: {{base_url}}/api/suppliers/merge
|
||||||
body: json
|
body: none
|
||||||
auth: inherit
|
auth: inherit
|
||||||
}
|
}
|
||||||
|
|
||||||
body:json {
|
|
||||||
{
|
|
||||||
"ids": [ 105, 203 ]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body:multipart-form {
|
|
||||||
ids: [
|
|
||||||
}
|
|
||||||
|
|
||||||
settings {
|
settings {
|
||||||
encodeUrl: true
|
encodeUrl: true
|
||||||
timeout: 0
|
|
||||||
}
|
}
|
||||||
@ -1,11 +1,11 @@
|
|||||||
meta {
|
meta {
|
||||||
name: /api/suppliers
|
name: /api/suppliers
|
||||||
type: http
|
type: http
|
||||||
seq: 16
|
seq: 10
|
||||||
}
|
}
|
||||||
|
|
||||||
get {
|
get {
|
||||||
url: {{base_url}}/api/suppliers
|
url: {{base_url}}/
|
||||||
body: none
|
body: none
|
||||||
auth: inherit
|
auth: inherit
|
||||||
}
|
}
|
||||||
15
.bruno/BRF/api-transactions.bru
Normal file
15
.bruno/BRF/api-transactions.bru
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
meta {
|
||||||
|
name: /api/transactions
|
||||||
|
type: http
|
||||||
|
seq: 14
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{base_url}}/api/transactions
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
settings {
|
||||||
|
encodeUrl: true
|
||||||
|
}
|
||||||
@ -1,16 +1,16 @@
|
|||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
DOMAIN=brf.lkm.nu
|
DOMAIN=bitmill.io
|
||||||
PROTOCOL=https
|
PROTOCOL=https
|
||||||
HOSTNAME=brf.local
|
HOSTNAME=brf.local
|
||||||
PORT=null
|
PORT=null
|
||||||
FASTIFY_HOST=0.0.0.0
|
FASTIFY_HOST=0.0.0.0
|
||||||
FASTIFY_PORT=3080
|
FASTIFY_PORT=1337
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
LOG_STREAM=console
|
LOG_STREAM=console
|
||||||
PGHOST=postgres
|
PGHOST=postgres
|
||||||
PGPORT=5432
|
PGPORT=5432
|
||||||
PGDATABASE=brf
|
PGDATABASE=brf_books
|
||||||
PGUSER=brf
|
PGUSER=brf_books
|
||||||
PGPASSWORD=brf
|
PGPASSWORD=brf_books
|
||||||
REDIS_HOST=redis
|
REDIS_HOST=redis
|
||||||
VITE_HMR_PROXY=true
|
VITE_HMR_PROXY=true
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
NODE_ENV=production
|
|
||||||
DOMAIN=brf.lkm.nu
|
|
||||||
PROTOCOL=https
|
|
||||||
HOSTNAME=brf.lkm.nu
|
|
||||||
PORT=null
|
|
||||||
FASTIFY_HOST=localhost
|
|
||||||
FASTIFY_PORT=3080
|
|
||||||
LOG_LEVEL=debug
|
|
||||||
LOG_STREAM=console
|
|
||||||
PGHOST=/run/postgresql
|
|
||||||
PGDATABASE=brf
|
|
||||||
REDIS_HOST=/run/redis/redis.sock
|
|
||||||
VITE_HMR_PROXY=false
|
|
||||||
17
.env.testing
17
.env.testing
@ -1,17 +0,0 @@
|
|||||||
NODE_ENV=testing
|
|
||||||
DOMAIN=brf.lkm.nu
|
|
||||||
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
|
|
||||||
PGUSER=brf
|
|
||||||
PGPASSWORD=brf
|
|
||||||
REDIS_HOST=null
|
|
||||||
VITE_HMR_PROXY=false
|
|
||||||
MAILGUN_API_KEY=not_a_valid_key
|
|
||||||
@ -13,8 +13,8 @@ RUN groupmod -g $GID node
|
|||||||
RUN usermod -u $UID -g node node
|
RUN usermod -u $UID -g node node
|
||||||
|
|
||||||
USER node
|
USER node
|
||||||
RUN mkdir /home/node/brf
|
RUN mkdir /home/node/brf_books
|
||||||
WORKDIR /home/node/brf
|
WORKDIR /home/node/brf_books
|
||||||
|
|
||||||
COPY --chown=node pnpm-lock.yaml package.json .
|
COPY --chown=node pnpm-lock.yaml package.json .
|
||||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import fs from 'node:fs/promises'
|
import fs from 'node:fs/promises'
|
||||||
import { existsSync } from 'node:fs'
|
import { existsSync } from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import db from '../server/lib/kysely.ts'
|
import knex from '../server/lib/knex.ts'
|
||||||
|
|
||||||
const dirs = process.argv.slice(2)
|
const dirs = process.argv.slice(2)
|
||||||
|
|
||||||
@ -12,7 +12,7 @@ for await (const dir of dirs) {
|
|||||||
await readdir(dir)
|
await readdir(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
db.destroy()
|
knex.destroy()
|
||||||
|
|
||||||
async function readdir(dir: string) {
|
async function readdir(dir: string) {
|
||||||
const files = (await fs.readdir(dir)).toSorted((a: string, b: string) => {
|
const files = (await fs.readdir(dir)).toSorted((a: string, b: string) => {
|
||||||
@ -26,7 +26,7 @@ async function readdir(dir: string) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const trx = await db.startTransaction().execute()
|
const trx = await knex.transaction()
|
||||||
|
|
||||||
for await (const originalFilename of files) {
|
for await (const originalFilename of files) {
|
||||||
const result = originalFilename.match(rFileName)
|
const result = originalFilename.match(rFileName)
|
||||||
@ -37,21 +37,15 @@ async function readdir(dir: string) {
|
|||||||
|
|
||||||
const [, fiskenNumber, invoiceDate, supplierName] = result
|
const [, fiskenNumber, invoiceDate, supplierName] = result
|
||||||
|
|
||||||
let supplier = await trx.selectFrom('supplier').selectAll().where('name', '=', supplierName).executeTakeFirst()
|
let supplier = await trx('supplier').first('*').where('name', supplierName)
|
||||||
|
|
||||||
if (!supplier) {
|
if (!supplier) {
|
||||||
supplier = await trx
|
supplier = (await trx('supplier').insert({ name: supplierName, supplierTypeId: 1 }).returning('*'))[0]
|
||||||
.insertInto('supplier')
|
|
||||||
.values({ name: supplierName, supplierTypeId: 1 })
|
|
||||||
.returningAll()
|
|
||||||
.executeTakeFirstOrThrow()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const invoice = await trx
|
const invoice = (
|
||||||
.insertInto('invoice')
|
await trx('invoice').insert({ fiskenNumber, invoiceDate, supplierId: supplier.id }).returning('*')
|
||||||
.values({ fiskenNumber: parseInt(fiskenNumber), invoiceDate, supplierId: supplier.id })
|
)[0]
|
||||||
.returningAll()
|
|
||||||
.executeTakeFirstOrThrow()
|
|
||||||
|
|
||||||
const ext = path.extname(originalFilename)
|
const ext = path.extname(originalFilename)
|
||||||
const filename = `${invoiceDate}_fisken_${fiskenNumber}_${supplierName.split(/[\s/]/).join('_').split(/[']/).join('')}${ext}`
|
const filename = `${invoiceDate}_fisken_${fiskenNumber}_${supplierName.split(/[\s/]/).join('_').split(/[']/).join('')}${ext}`
|
||||||
@ -64,9 +58,9 @@ async function readdir(dir: string) {
|
|||||||
console.info('ALREADY EXISTS: ' + filename)
|
console.info('ALREADY EXISTS: ' + filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = await trx.insertInto('file').values({ filename }).returning('id').executeTakeFirstOrThrow()
|
const file = (await trx('file').insert({ filename }).returning('id'))[0]
|
||||||
await trx.insertInto('filesToInvoice').values({ fileId: file.id, invoiceId: invoice.id }).execute()
|
await trx('filesToInvoice').insert({ fileId: file.id, invoiceId: invoice.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
await trx.commit().execute()
|
await trx.commit()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,56 +1,52 @@
|
|||||||
import fs from 'node:fs/promises'
|
import fs from 'node:fs/promises'
|
||||||
import { existsSync } from 'node:fs'
|
import { existsSync } from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import db from '../server/lib/kysely.ts'
|
import knex from '../server/lib/knex.ts'
|
||||||
import { csvParseRows } from 'd3-dsv'
|
import { csvParseRows } from 'd3-dsv'
|
||||||
|
|
||||||
const csvFilename = process.argv[2]
|
const csvFilename = process.argv[2]
|
||||||
const csvString = await fs.readFile(csvFilename, { encoding: 'utf8' })
|
const csvString = await fs.readFile(csvFilename, { encoding: 'utf8' })
|
||||||
const rows = csvParseRows(csvString)
|
const rows = csvParseRows(csvString)
|
||||||
|
|
||||||
const trx = await db.startTransaction().execute()
|
const trx = await knex.transaction()
|
||||||
|
|
||||||
for (const row of rows.toReversed()) {
|
for (const row of rows.toReversed()) {
|
||||||
const [
|
const [
|
||||||
phmNumber,
|
phmNumber,
|
||||||
_type,
|
// type,
|
||||||
_supplierId,
|
// supplierId,
|
||||||
supplierName,
|
supplierName,
|
||||||
invoiceDate,
|
invoiceDate,
|
||||||
dueDate,
|
dueDate,
|
||||||
invoiceNumber,
|
invoiceNumber,
|
||||||
ocr,
|
ocr,
|
||||||
amount,
|
amount,
|
||||||
_vat,
|
// vat,
|
||||||
_balance,
|
// balance,
|
||||||
_currency,
|
// currency,
|
||||||
_status,
|
// status,
|
||||||
filesString,
|
filesString,
|
||||||
] = row
|
] = row
|
||||||
|
|
||||||
let supplier = await trx.selectFrom('supplier').selectAll().where('name', '=', supplierName).executeTakeFirst()
|
let supplier = await trx('supplier').first('*').where('name', supplierName)
|
||||||
|
|
||||||
if (!supplier) {
|
if (!supplier) {
|
||||||
supplier = await trx
|
supplier = (await trx('supplier').insert({ name: supplierName, supplierTypeId: 1 }).returning('*'))[0]
|
||||||
.insertInto('supplier')
|
|
||||||
.values({ name: supplierName, supplierTypeId: 1 })
|
|
||||||
.returningAll()
|
|
||||||
.executeTakeFirstOrThrow()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const invoice = await trx
|
const invoice = (
|
||||||
.insertInto('invoice')
|
await trx('invoice')
|
||||||
.values({
|
.insert({
|
||||||
invoiceDate,
|
invoiceDate,
|
||||||
supplierId: supplier.id,
|
supplierId: supplier.id,
|
||||||
dueDate,
|
dueDate,
|
||||||
ocr,
|
ocr,
|
||||||
invoiceNumber,
|
invoiceNumber,
|
||||||
phmNumber: parseInt(phmNumber),
|
phmNumber,
|
||||||
amount,
|
amount,
|
||||||
})
|
})
|
||||||
.returning('id')
|
.returning('id')
|
||||||
.executeTakeFirstOrThrow()
|
)[0]
|
||||||
|
|
||||||
const filenames = filesString.split(',').map((filename) => filename.trim())
|
const filenames = filesString.split(',').map((filename) => filename.trim())
|
||||||
|
|
||||||
@ -67,11 +63,11 @@ for (const row of rows.toReversed()) {
|
|||||||
console.info('ALREADY EXISTS: ' + filename)
|
console.info('ALREADY EXISTS: ' + filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = await trx.insertInto('file').values({ filename }).returning('id').executeTakeFirstOrThrow()
|
const file = (await trx('file').insert({ filename }).returning('id'))[0]
|
||||||
await trx.insertInto('filesToInvoice').values({ fileId: file.id, invoiceId: invoice.id }).execute()
|
await trx('filesToInvoice').insert({ fileId: file.id, invoiceId: invoice.id })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await trx.commit().execute()
|
trx.commit()
|
||||||
|
|
||||||
db.destroy()
|
knex.destroy()
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import fs from 'fs/promises'
|
import fs from 'fs/promises'
|
||||||
import parseStream from '../server/lib/parse_stream.ts'
|
import parseStream from '../server/lib/parse_stream.ts'
|
||||||
import db from '../server/lib/kysely.ts'
|
import knex from '../server/lib/knex.ts'
|
||||||
|
|
||||||
for await (const file of process.argv.slice(2)) {
|
for await (const file of process.argv.slice(2)) {
|
||||||
const fh = await fs.open(file)
|
const fh = await fs.open(file)
|
||||||
@ -12,4 +12,4 @@ for await (const file of process.argv.slice(2)) {
|
|||||||
await fh.close()
|
await fh.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
db.destroy()
|
knex.destroy()
|
||||||
|
|||||||
@ -1,7 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
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 form = e.currentTarget
|
|
||||||
|
|
||||||
const result = await rek[admission ? 'patch' : 'post'](
|
|
||||||
`/api/admissions${admission ? '/' + admission.id : ''}`,
|
|
||||||
serializeForm(form),
|
|
||||||
)
|
|
||||||
|
|
||||||
actions.success()
|
|
||||||
|
|
||||||
if (admission) {
|
|
||||||
onUpdate?.(result)
|
|
||||||
} else {
|
|
||||||
form.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
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,120 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
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 })
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
.base {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.element {
|
|
||||||
display: block;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
display: block;
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import checkboxFactory from '../../shared/components/checkbox_factory.tsx'
|
|
||||||
import styles from './checkbox.module.scss'
|
|
||||||
|
|
||||||
export default checkboxFactory(styles)
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
.base {
|
|
||||||
width: calc(100% - 30px);
|
|
||||||
max-width: 1024px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.headers {
|
|
||||||
pre {
|
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: visible;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
@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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,150 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
@use '../styles/variables' as v;
|
|
||||||
|
|
||||||
.button {
|
|
||||||
align-self: end;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
margin-bottom: v.$gutter;
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import inputFactory from '../../shared/components/input_factory.tsx'
|
|
||||||
|
|
||||||
import s from './input.module.scss'
|
|
||||||
|
|
||||||
export default inputFactory(s)
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
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 form = e.currentTarget
|
|
||||||
const result = await rek.post('/api/invites', serializeForm(form))
|
|
||||||
|
|
||||||
form.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
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
@use '../styles/variables' as v;
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left {
|
|
||||||
flex: 0 0 400px;
|
|
||||||
margin-right: v.$gutter;
|
|
||||||
}
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
.footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.links {
|
|
||||||
li:not(:last-child) {
|
|
||||||
padding-bottom: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import s from './message.module.scss'
|
|
||||||
import messageFactory from '../../shared/components/message_factory.tsx'
|
|
||||||
|
|
||||||
export default messageFactory(s)
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
@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: '-';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
.base {
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heading {
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
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 form = e.currentTarget
|
|
||||||
|
|
||||||
const result = await rek[role ? 'patch' : 'post'](`/api/roles${role ? '/' + role.id : ''}`, serializeForm(form))
|
|
||||||
|
|
||||||
actions.success()
|
|
||||||
|
|
||||||
if (role) {
|
|
||||||
onUpdate?.(result)
|
|
||||||
} else {
|
|
||||||
form.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
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
import { h } from 'preact'
|
|
||||||
import { useCallback, useEffect, useState } from 'preact/hooks'
|
|
||||||
import { route } from 'preact-router'
|
|
||||||
import rek, { type FetchError } from 'rek'
|
|
||||||
import type { Selectable } from 'kysely'
|
|
||||||
|
|
||||||
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 '../../../shared/types.db.ts'
|
|
||||||
|
|
||||||
const RolesPage = () => {
|
|
||||||
const { notify } = useNotifications()
|
|
||||||
const [roles, actions] = useItemsReducer<Selectable<Role>>()
|
|
||||||
const [edit, setEdit] = useState<Selectable<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
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
// @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
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
@use '../../shared/styles/flex';
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
> * {
|
|
||||||
@include flex.simple-cell($gutter: 12px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
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>
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import selectFactory from '../../shared/components/select_factory.tsx'
|
|
||||||
import styles from './input.module.scss'
|
|
||||||
|
|
||||||
export default selectFactory({ styles })
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
import { h } from 'preact'
|
|
||||||
import PageHeader from './page_header.tsx'
|
|
||||||
import Row from './row.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>
|
|
||||||
|
|
||||||
<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
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user