Browse Source

First part of the basic admin going. Can list tables, list contents of tables, search, remove fields, nothing fancy yet.

master
Zed A. Shaw 5 days ago
parent
commit
65c9c449c9
14 changed files with 395 additions and 7 deletions
  1. +1
    -1
      .eslintrc.js
  2. +2
    -0
      __tests__/fixtures/secrets.js
  3. +8
    -2
      lib/models.js
  4. +2
    -0
      lib/serverauth.js
  5. +5
    -0
      package-lock.json
  6. +1
    -0
      package.json
  7. +7
    -0
      src/components/Nav.svelte
  8. +3
    -2
      src/components/Sidebar.svelte
  9. +207
    -0
      src/routes/admin/[table].svelte
  10. +84
    -0
      src/routes/admin/index.svelte
  11. +49
    -0
      src/routes/api/admin/[table].json.js
  12. +24
    -0
      src/routes/api/admin/index.json.js
  13. +1
    -1
      src/routes/live/index.svelte
  14. +1
    -1
      src/routes/modules/[slug]/[exercise].svelte

+ 1
- 1
.eslintrc.js View File

@@ -111,7 +111,7 @@ module.exports = {
"new-parens": "error",
"newline-after-var": "off",
"newline-before-return": "off",
"newline-per-chained-call": "warn",
"newline-per-chained-call": "off",
"no-alert": "error",
"no-array-constructor": "error",
"no-async-promise-executor": "error",


+ 2
- 0
__tests__/fixtures/secrets.js View File

@@ -74,4 +74,6 @@ exports.product = {
id_magic: 81974
}

exports.admin_username = 'zedshaw';

// the secrets for payments are in lib/payments and you have to make those

+ 8
- 2
lib/models.js View File

@@ -11,7 +11,9 @@ const SALT_ROUNDS = 10;
const randomcolor = require('randomcolor');
const dayjs = require('dayjs');

let knex = require('knex')(knexfile[secrets.env]);
const knex = require('knex')(knexfile[secrets.env]);
const { attachPaginate } = require('knex-paginate');
attachPaginate();

class Model {
constructor(attr) {
@@ -111,7 +113,7 @@ class Model {

static async update(where, what) {
assert(where, "You must give a where options.");
return await knex(this.table_name).where(where).update(what);
return knex(this.table_name).where(where).update(what);
}

static async first(where) {
@@ -206,6 +208,10 @@ class User extends Model.from_table('users') {
return this.has_many(Payment, {user_id: this.id});
}

get is_admin() {
return this.username === secrets.admin_username;
}

static async find_and_validate(username, email) {
assert(email, `Valid email required`);
assert(username, `Valid username required`);


+ 2
- 0
lib/serverauth.js View File

@@ -3,6 +3,7 @@ const { Strategy } = require('passport-local');
const { User } = require('../lib/models');
const { log } = require('../lib/logging');
const assert = require('assert');
const { admin_username } = require('../lib/secrets');

const { NODE_ENV } = process.env;
const dev = NODE_ENV === 'development';
@@ -44,6 +45,7 @@ exports.serverauth = (app) => {

if(user && await user.valid_password(password)) {
const cleaned = await User.clean_session(user);
cleaned.is_admin = user.is_admin;
cb(null, cleaned);
} else {
cb(null, false);


+ 5
- 0
package-lock.json View File

@@ -10856,6 +10856,11 @@
}
}
},
"knex-paginate": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/knex-paginate/-/knex-paginate-1.2.3.tgz",
"integrity": "sha512-9vdXuuXzy1poAJvkd3+G2RrOuOqMWqB0Ml5n40j5LyzVN0uhGQs/+DnJD54LkkhOqOSKV1Rq9mDoWJFIrKXtNg=="
},
"last-one-wins": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/last-one-wins/-/last-one-wins-1.0.4.tgz",


+ 1
- 0
package.json View File

@@ -29,6 +29,7 @@
"form-urlencoded": "^4.1.3",
"html-pdf": "^2.2.0",
"knex": "^0.20.12",
"knex-paginate": "^1.2.3",
"morgan": "^1.9.1",
"node-fetch": "^2.6.0",
"nodemailer": "^6.4.5",


+ 7
- 0
src/components/Nav.svelte View File

@@ -58,6 +58,13 @@
</a>

{#if $session.user}

{#if $session.user.is_admin}
<a data-testid='nav-admin-icon' rel="prefetch" class:btn-link="{ segment != 'admin' }" class="btn" href="/admin" alt="Admin" aria-label="Admin">
<Icon name="settings" tooltip="Admin" tooltip_bottom="true"/>
</a>
{/if}

<!-- basic dropdown button -->
<div class="dropdown">
<span aria-label="Dropdown menu" data-testid="user-dropdown-menu" id="user-dropdown-menu" class="btn btn-link dropdown-toggle" tabindex="0">


+ 3
- 2
src/components/Sidebar.svelte View File

@@ -2,6 +2,7 @@
import Icon from './Icon.svelte';

let side_active = false;
export let sidebar_id = 'sidebar';

const toggle_side = () => side_active = !side_active;
</script>
@@ -13,7 +14,7 @@
}
</style>

<div class="off-canvas off-canvas-sidebar-show">
<div class="off-canvas off-canvas-sidebar-show" id={sidebar_id} data-testid={sidebar_id}>
<button on:click="{toggle_side}" class="off-canvas-toggle btn btn-primary btn-action" href="#sidebar-id">
<i class="icon icon-menu"></i>
</button>
@@ -27,7 +28,7 @@
<a on:click|preventDefault={toggle_side} class="off-canvas-overlay" href="#close">=</a>

<div class="off-canvas-content">
<slot name="content">
<slot>
<p>Content missing</p>
</slot>
</div>


+ 207
- 0
src/routes/admin/[table].svelte View File

@@ -0,0 +1,207 @@
<script context="module">
export async function preload(page, session) {
if(session.user.is_admin) {
const { table } = page.params;
const current_page = parseInt(page.query.page || 1);

return { table, current_page };
} else {
this.error(403, "Unauthorized Access");
}
}
</script>

<script>
import { stores} from '@sapper/app';
import { onMount } from 'svelte';
import { GET } from 'sabaton';
import Icon from '../../components/Icon.svelte';
import Sidebar from '../../components/Sidebar.svelte';
const { session, page } = stores();

export let table = '';

let rows = [];
let schema = {};
let pagination = {};
let schema_keys = [];
export let current_page = 0;
let lastPage = 0;

let search="";

$: schema_keys = Object.keys(schema).filter(column => !schema[column].hidden);

const toggle_column = (name) => {
schema[name].hidden = !schema[name].hidden;
schema = schema;
}

const load_page = async (page) => {
let res = await GET($session, `api/admin/${table}.json?current_page=${page}&search=${search}`);

if(res.$status === 200) {
rows = res.rows;
pagination = res.pagination;

// BUG: knex-pagination removes last page randomly
lastPage = pagination.lastPage || lastPage;

// kind of weird but return the schema so it's only set once
return res.schema;
} else {
console.log("Error", res.message);
}
}


onMount(async () => {
schema = await load_page(1);

if(current_page > 1) {
// really stupid but we have to load the first page then load
// the page requested on the URL because of knex pagination not returning lastPage
await load_page(current_page);
}
});
</script>

<svelte:head>
<title>Learn JavaScript The Hard Way - Admin { table }</title>
</svelte:head>

<style lang="scss">
@import "sass/_variables";
#content {
margin-top: 1rem;
}

#admin-nav {
padding: 0.5rem;
}

#admin-table {
width: 1024px;
font-size: 0.7rem;
}

.nav .disabled {
color: $gray-color;
}

#search-input {
width: 600px;
}

.pagination .page-item {
margin-left: 0px;
margin-right: 0px;
}
</style>

<div class="container grid-xl" id="content" data-testid="admin-page">
<div class="columns">
<div class="column col-12">

<Sidebar sidebar_id="exercise-sidebar">
<div slot="nav" id="admin-nav">
<h4><a href="/admin" rel="prefetch"><Icon name="corner-up-left" /> { table }</a></h4>
<ul class="nav">
{#each Object.keys(schema) as column}
<li class:disabled={ schema[column].hidden } class="nav-item clickable" on:click={() => toggle_column(column) }>{ column }</li>
{/each}
</ul>
</div>

<div>
<ul class="pagination">
<li>
<input on:change={() => load_page(1)} type="text" placeholder="Search..." bind:value={ search } id="search-input" data-testid="search-input">
</li>
<li class="page-item" class:disabled={ pagination.currentPage == 1}>
<a href="/admin/{table}?page={ pagination.currentPage - 1}"
tabindex="-1"
on:click={ () => load_page(pagination.currentPage -1 ) }><Icon name="arrow-left-circle" alt="Previous" tooltip="Previous" /></a>
</li>

{#if pagination.currentPage > 1}
<li class="page-item">
<a href="/admin/{table}?page=1"
on:click={ () => load_page(1) }>1</a>
</li>
<li class="page-item">
<span>...</span>
</li>
{/if}

{#if pagination.currentPage === lastPage && pagination.lastPage > 1 }
<li class="page-item">
<a href="/admin/{table}?page={ pagination.currentPage - 1}"
on:click={ () => load_page(pagination.currentPage - 1) }>{ pagination.currentPage - 1}</a>
</li>
{/if}

<li class="page-item active">
<a href="/admin/{table}?page={ pagination.currentPage}"
on:click={ () => load_page(pagination.currentPage) }>{ pagination.currentPage }</a>
</li>

{#if pagination.currentPage + 1 < lastPage }
<li class="page-item">
<a href="/admin/{table}?page={ pagination.currentPage + 1}"
on:click={ () => load_page(pagination.currentPage + 1) }>{ pagination.currentPage + 1}</a>
</li>
{/if}

{#if pagination.currentPage + 2 < lastPage }
<li class="page-item">
<a href="/admin/{table}?page={ pagination.currentPage + 2}"
on:click={ () => load_page(pagination.currentPage + 2) }>{ pagination.currentPage + 2}</a>
</li>
{/if}

{#if pagination.currentPage < lastPage - 2}
<li class="page-item">
<span>...</span>
</li>
{/if}

{#if pagination.currentPage !== lastPage }
<li class="page-item">
<a href="/admin/{table}?page={ lastPage}"
on:click={ () => load_page(lastPage) }>{ lastPage }</a>
</li>
{/if}

<li class="page-item" class:disabled={ pagination.currentPage === lastPage }>
<a href="/admin/{table}?page={ pagination.currentPage + 1}"
on:click={ () => load_page(pagination.currentPage + 1) }>
<Icon name="arrow-right-circle" alt="Next" tooltip="Next" tooltip_right={ true } />
</a>
</li>
</ul>


<table id="admin-table" class="table table-striped table-hover table-scroll">
<thead>
<tr>
{#each schema_keys as header}
<th>{header}</th>
{/each}
</tr>
</thead>
<tbody>
{#each rows as row (row.id)}
<tr>
{#each schema_keys as header}
<td>{ row[header] }</td>
{/each}
</tr>
{/each}
</tbody>
</table>

</Sidebar>
</div>
</div>
</div>

+ 84
- 0
src/routes/admin/index.svelte View File

@@ -0,0 +1,84 @@
<script context="module">
export async function preload(page, session) {
if(session.user.is_admin) {
let res = await this.fetch(`/api/admin.json`, {credentials: 'same-origin'});

if(res.status == 200) {
let { tables } = await res.json();
return { tables };
} else {
this.error(res.status, `${res.status} Can't Load Tables`);
}
} else {
this.error(403, "Unauthorized Access");
}
}
</script>

<script>
import Icon from '../../components/Icon.svelte';
import Sidebar from '../../components/Sidebar.svelte';
export let tables;
let columns = Object.keys(tables[0]);
</script>

<svelte:head>
<title>Learn JavaScript The Hard Way - Admin</title>
</svelte:head>

<style lang="scss">
@import "sass/_variables";
#content {
margin-top: 1rem;
}

.navbar {
padding: 0.5rem;
background-color: lighten($gray-color, 5%);
}

#admin-nav {
padding: 0.5rem;
}
</style>

<div class="container grid-xl" id="content" data-testid="admin-page">
<div class="columns">
<div class="column col-12">

<Sidebar sidebar_id="exercise-sidebar">
<div slot="nav" id="admin-nav">
<h4>Admin</h4>
<ul class="nav">
{#each tables as table}
<li class="nav-item clickable"><a href="/admin/{ table.tablename }" rel="prefetch">{ table.tablename }</a></li>
{/each}
</ul>
</div>

<div>
<table class="table table-striped table-hover table-scroll">
<thead>
{#each columns as column}
<th>{ column }</th>
{/each}
</thead>
<tbody>
{#each tables as table}
<tr>
{#each columns as column}
{#if column === 'tablename'}
<td><a href="/admin/{table.tablename}" rel="prefetch">{table.tablename}</a></td>
{:else}
<td>{ table[column] }</td>
{/if}
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
</Sidebar>
</div>
</div>
</div>

+ 49
- 0
src/routes/api/admin/[table].json.js View File

@@ -0,0 +1,49 @@
import { log } from 'logging';
import { knex } from '../../../../lib/models';
import * as auth from 'auth';
import assert from 'assert';
import { stores } from 'sabaton';

const query = (schema, table, search) => {
if(search) {
const kq = knex(table);
for(let field of Object.keys(schema)) {
const type = schema[field].type;

if(type == 'character varying') {
kq.orWhere(field, 'like', `%${search}%`);
} else if(type === 'integer' && !isNaN(parseInt(search, 10))) {
kq.orWhere(field, parseInt(search, 10));
}
}

return kq;
} else {
return knex(table);
}
}

export const get = auth.restricted(
async (req, res) => {
const { $session, $msg, $app} = stores(req, res);

if(req.user.is_admin) {
const { table } = req.params;
const per_page = parseInt(req.query.per_page || 15, 10);
const current_page = parseInt(req.query.current_page || 1, 10);
const search = req.query.search;

try {
const schema = await knex(table).columnInfo();
const rows = await query(schema, table, search).paginate({perPage: per_page, currentPage: current_page});

$app.done({ schema, rows: rows.data, pagination: rows.pagination });
} catch (error) {
log.error(error);
$app.error(403, error.message);
}
} else {
$app.error(403, "Unauthorized Access");
}
});


+ 24
- 0
src/routes/api/admin/index.json.js View File

@@ -0,0 +1,24 @@
import { log } from 'logging';
import { knex } from '../../../../lib/models';
import * as auth from 'auth';
import assert from 'assert';
import { stores } from 'sabaton';

export const get = auth.restricted(
async (req, res) => {
const { $session, $msg, $app} = stores(req, res);

try {
if(req.user.is_admin) {
const tables = await knex('pg_catalog.pg_tables').where('schemaname', '!=', 'pg_catalog').where('schemaname', '!=', 'information_schema');

$app.done({ tables });
} else {
$app.error(403, "Unauthorized");
}
} catch (error) {
log.error(error);
$app.error(403, 'Unauthorized');
}
});


+ 1
- 1
src/routes/live/index.svelte View File

@@ -105,7 +105,7 @@
</li>
</ul>

<div slot="content" class="content" data-testid="live-listing-page">
<div class="content" data-testid="live-listing-page">

<div class="container grid">
<div class="card pattern-zigzag-md" id="info-card">


+ 1
- 1
src/routes/modules/[slug]/[exercise].svelte View File

@@ -212,7 +212,7 @@
</li>
</ul>

<div slot="content">
<div>
<div class="content" data-testid="modules-exercise-view-page">
{#if module.status === "draft" || exercise.metadata.status === "draft" }
<div in:slide class="toast toast-error" id="draft-warning">


Loading…
Cancel
Save