diff --git a/cypress/e2e/adminNavLink.cy.js b/cypress/e2e/adminNavLink.cy.js
new file mode 100644
index 0000000..0b0ae36
--- /dev/null
+++ b/cypress/e2e/adminNavLink.cy.js
@@ -0,0 +1,70 @@
+describe('The admin nav link', () => {
+ beforeEach(() => {
+ cy.exec('npm run db:seed')
+ })
+ afterEach(() => {
+ cy.exec('npm run db:wipe')
+ })
+
+ describe('when logged in as an admin', () => {
+ beforeEach(() => {
+ cy.loginAsAdmin()
+ })
+
+ it('shows the admin link on the home page', () => {
+ cy.visit('/home')
+ cy.get('#admin-link')
+ .should('be.visible')
+ .and('have.attr', 'href', '/admin')
+ })
+
+ it('shows the admin link on the today page', () => {
+ cy.visit('/today')
+ cy.get('#admin-link')
+ .should('be.visible')
+ .and('have.attr', 'href', '/admin')
+ })
+
+ it('shows the admin link on the user texts page', () => {
+ cy.visit('/texts')
+ cy.get('#admin-link')
+ .should('be.visible')
+ .and('have.attr', 'href', '/admin')
+ })
+
+ it('navigates to the admin page when clicked', () => {
+ cy.visit('/home')
+ cy.get('#admin-link').click()
+ cy.url().should('include', '/admin')
+ cy.get('h1').should('contain', 'Admin')
+ })
+ })
+
+ describe('when logged in as a regular user', () => {
+ beforeEach(() => {
+ cy.loginAsUser()
+ })
+
+ it('does not show the admin link on the home page', () => {
+ cy.visit('/home')
+ cy.get('#admin-link').should('not.be.visible')
+ })
+
+ it('does not show the admin link on the today page', () => {
+ cy.visit('/today')
+ cy.get('#admin-link').should('not.be.visible')
+ })
+
+ it('does not show the admin link on the user texts page', () => {
+ cy.visit('/texts')
+ cy.get('#admin-link').should('not.be.visible')
+ })
+
+ it('does not show the admin link on a user text page', () => {
+ cy.intercept('GET', '/api/texts/0').as('getText')
+ cy.visit('/texts/0')
+ cy.wait('@getText')
+ cy.get('#admin-link').should('not.be.visible')
+ })
+ })
+})
diff --git a/cypress/e2e/auth.cy.js b/cypress/e2e/auth.cy.js
index ae22d22..bc44396 100644
--- a/cypress/e2e/auth.cy.js
+++ b/cypress/e2e/auth.cy.js
@@ -69,6 +69,60 @@ describe('Authentication flows', () => {
cy.url().should('include', '/login')
})
+ it('logout button on today page works', () => {
+ cy.loginAsUser()
+ cy.visit('/today')
+ cy.get('#logout').click()
+ cy.url().should('include', '/login')
+ cy.visit('/today')
+ cy.url().should('include', '/login')
+ })
+
+ it('logout button on user texts list page works', () => {
+ cy.loginAsUser()
+ cy.visit('/texts')
+ cy.get('#logout').click()
+ cy.url().should('include', '/login')
+ cy.visit('/texts')
+ cy.url().should('include', '/login')
+ })
+
+ it('logout button on user specific text page works', () => {
+ cy.loginAsUser()
+ cy.visit('/texts/0')
+ cy.get('#logout').click()
+ cy.url().should('include', '/login')
+ cy.visit('/texts/0')
+ cy.url().should('include', '/login')
+ })
+
+ it('logout button on admin page works', () => {
+ cy.loginAsAdmin()
+ cy.visit('/admin')
+ cy.get('#logout').click()
+ cy.url().should('include', '/login')
+ cy.visit('/admin')
+ cy.url().should('include', '/login')
+ })
+
+ it('logout button on admin texts list page works', () => {
+ cy.loginAsAdmin()
+ cy.visit('/admin/texts')
+ cy.get('#logout').click()
+ cy.url().should('include', '/login')
+ cy.visit('/admin/texts')
+ cy.url().should('include', '/login')
+ })
+
+ it('logout button on admin specific text page works', () => {
+ cy.loginAsAdmin()
+ cy.visit('/admin/texts/0')
+ cy.get('#logout').click()
+ cy.url().should('include', '/login')
+ cy.visit('/admin/texts/0')
+ cy.url().should('include', '/login')
+ })
+
it('non-admin user hitting /admin gets 403', () => {
cy.loginAsUser()
cy.request({
diff --git a/data/seedMore.php b/data/seedMore.php
new file mode 100644
index 0000000..abea97f
--- /dev/null
+++ b/data/seedMore.php
@@ -0,0 +1,127 @@
+ 0,
+ 'name' => 'Tanach',
+ 'userId' => 1,
+ ],
+];
+
+$nodes = [
+ [
+ 'id' => 0,
+ 'title' => 'Tanach',
+ 'textId' => 0,
+ 'parentNodeId' => null,
+ ],
+ [
+ 'id' => 1,
+ 'title' => 'Torah',
+ 'textId' => 0,
+ 'parentNodeId' => 0,
+ ],
+ [
+ 'id' => 2,
+ 'title' => 'Neviim',
+ 'textId' => 0,
+ 'parentNodeId' => 0,
+ ],
+ [
+ 'id' => 3,
+ 'title' => 'Kesuvim',
+ 'textId' => 0,
+ 'parentNodeId' => 0,
+ ],
+ [
+ 'id' => 4,
+ 'title' => 'Bereishis',
+ 'textId' => 0,
+ 'parentNodeId' => 1,
+ ],
+ [
+ 'id' => 5,
+ 'title' => 'Shmos',
+ 'textId' => 0,
+ 'parentNodeId' => 1,
+ ],
+ [
+ 'id' => 6,
+ 'title' => 'Vayikra',
+ 'textId' => 0,
+ 'parentNodeId' => 1,
+ ],
+ [
+ 'id' => 7,
+ 'title' => 'Bamidbar',
+ 'textId' => 0,
+ 'parentNodeId' => 1,
+ ],
+ [
+ 'id' => 8,
+ 'title' => 'Devarim',
+ 'textId' => 0,
+ 'parentNodeId' => 1,
+ ],
+ [
+ 'id' => 9,
+ 'title' => 'Bereishis',
+ 'textId' => 0,
+ 'parentNodeId' => 4,
+ ],
+ [
+ 'id' => 10,
+ 'title' => 'Noach',
+ 'textId' => 0,
+ 'parentNodeId' => 4,
+ ],
+ [
+ 'id' => 11,
+ 'title' => 'Lech Lecha',
+ 'textId' => 0,
+ 'parentNodeId' => 4,
+ ],
+];
+
+// Default credentials:
+// admin@example.com / admin1234 (admin)
+// user@example.com / password1 (regular user)
+// user2@example.com / password2 (second regular user, no texts seeded)
+$users = [
+ [
+ 'id' => 0,
+ 'email' => 'admin@example.com',
+ 'passwordHash' => password_hash('admin1234', PASSWORD_DEFAULT),
+ 'isAdmin' => true,
+ ],
+ [
+ 'id' => 1,
+ 'email' => 'user@example.com',
+ 'passwordHash' => password_hash('password1', PASSWORD_DEFAULT),
+ 'isAdmin' => false,
+ ],
+ [
+ 'id' => 2,
+ 'email' => 'user2@example.com',
+ 'passwordHash' => password_hash('password2', PASSWORD_DEFAULT),
+ 'isAdmin' => false,
+ ],
+];
+
+$plans = [];
+$scheduledNodes = [];
+$sessions = [];
+
+$fileDataMap = [
+ 'texts.json' => $texts,
+ 'nodes.json' => $nodes,
+ 'users.json' => $users,
+ 'plans.json' => $plans,
+ 'scheduledNodes.json' => $scheduledNodes,
+ 'sessions.json' => $sessions,
+];
+
+foreach ($fileDataMap as $file => $data) {
+ $path = __DIR__ . "/$file";
+ file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT));
+}
diff --git a/public/js/nav.js b/public/js/nav.js
new file mode 100644
index 0000000..f0ac386
--- /dev/null
+++ b/public/js/nav.js
@@ -0,0 +1,18 @@
+document.addEventListener('DOMContentLoaded', async () => {
+ const adminLink = document.getElementById('admin-link');
+ if (adminLink === null) {
+ return;
+ }
+
+ const response = await fetch('/api/auth/me', {
+ credentials: 'same-origin',
+ });
+ if (!response.ok) {
+ return;
+ }
+
+ const body = await response.json();
+ if (body.user && body.user.isAdmin === true) {
+ adminLink.hidden = false;
+ }
+});
diff --git a/public/js/text.js b/public/js/text.js
index 2b021d0..7566869 100644
--- a/public/js/text.js
+++ b/public/js/text.js
@@ -32,8 +32,15 @@ document.addEventListener('DOMContentLoaded', () => {
function fetchAndRenderNodes(textId) {
return fetch('/api/nodes/' + textId, { credentials: 'same-origin' })
- .then(res => res.json())
+ .then(function (response) {
+ if (!response.ok) {
+ return null;
+ }
+ return response.json();
+ })
.then(nodes => {
+ if (!Array.isArray(nodes)) return;
+
const existing = document.querySelector('#text-detail > ul');
if (existing) existing.remove();
diff --git a/views/templates/home.php b/views/templates/home.php
index 63cfbfd..2a0e13c 100644
--- a/views/templates/home.php
+++ b/views/templates/home.php
@@ -17,6 +17,8 @@
Today's schedule
+ Admin
@@ -43,6 +45,7 @@
+