From 8b7763cf13c060cd483bef41528f2ce01df233b4 Mon Sep 17 00:00:00 2001 From: blaisadmin Date: Thu, 26 Mar 2026 09:59:40 -0400 Subject: [PATCH] Added demo access --- .env.example | 4 +++ README.md | 7 ++++ backend/src/app.ts | 66 ++++++++++++++++++++++++++++++++++++- docker-compose.yml | 5 +++ frontend/src/pages/Home.tsx | 35 ++++++++++++++++++++ 5 files changed, 116 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 3262269..2df97c5 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,10 @@ NODE_ENV=development FRONTEND_URL=http://localhost:3000 VITE_API_BASE_URL=http://localhost:5000/api ALLOW_REGISTRATION=true +ALLOW_DEMO_ACCOUNT=true +DEMO_ACCOUNT_EMAIL=demo@arsenaliq.local +DEMO_ACCOUNT_PASSWORD=demo1234 +DEMO_ACCOUNT_NAME=Demo User # Production-only Traefik settings TRAEFIK_NETWORK=traefik_proxy TRAEFIK_ENTRYPOINT=websecure diff --git a/README.md b/README.md index 9cd527b..7a01334 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,11 @@ The app includes an Express API in [backend/src/app.ts](/home/corey/github/Arsen - `ALLOW_REGISTRATION=true|false` - Controls whether `POST /api/auth/register` is available - When `false`, the login UI hides self-service account creation +- `ALLOW_DEMO_ACCOUNT=true|false` + - Enables a backend-managed demo account and a demo sign-in button on the login page +- `DEMO_ACCOUNT_EMAIL` +- `DEMO_ACCOUNT_PASSWORD` +- `DEMO_ACCOUNT_NAME` ### Response shape notes @@ -167,6 +172,8 @@ Example: - Creates a local account when registration is enabled - `POST /api/auth/login` - Signs in with local email/password +- `POST /api/auth/demo` + - Signs in to the configured demo account when demo mode is enabled - `POST /api/auth/logout` - Invalidates the current session token - `GET /api/auth/me` diff --git a/backend/src/app.ts b/backend/src/app.ts index 925b041..de62eee 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -100,6 +100,9 @@ const databaseUrl = const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; const apiBaseUrl = process.env.API_BASE_URL || 'http://localhost:5000/api'; const allowRegistration = (process.env.ALLOW_REGISTRATION ?? 'true').toLowerCase() !== 'false'; +const allowDemoAccount = (process.env.ALLOW_DEMO_ACCOUNT ?? 'false').toLowerCase() === 'true'; +const demoAccountPassword = process.env.DEMO_ACCOUNT_PASSWORD ?? 'demo1234'; +const demoAccountName = process.env.DEMO_ACCOUNT_NAME ?? 'Demo User'; const { Pool } = pg; const pool = new Pool({ connectionString: databaseUrl }); @@ -152,6 +155,7 @@ const getNumber = (value: unknown, fieldName: string): number => { }; const normalizeEmail = (email: string) => email.trim().toLowerCase(); +const demoAccountEmail = normalizeEmail(process.env.DEMO_ACCOUNT_EMAIL ?? 'demo@arsenaliq.local'); const hashToken = (token: string) => crypto.createHash('sha256').update(token).digest('hex'); const createSessionToken = () => crypto.randomBytes(32).toString('hex'); const isUuid = (value: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); @@ -414,6 +418,35 @@ const ensureDefaultProfile = async (userId: string, userName: string) => { return created.rows[0]; }; +const ensureDemoAccount = async () => { + if (!allowDemoAccount) { + return null; + } + + const passwordHash = await bcrypt.hash(demoAccountPassword, 10); + const existing = await pool.query('SELECT id, email, name FROM users WHERE email = $1', [demoAccountEmail]); + + let user: UserRow; + + if ((existing.rowCount ?? 0) > 0) { + user = existing.rows[0]; + await pool.query('UPDATE users SET name = $2, password_hash = $3 WHERE id = $1', [ + user.id, + demoAccountName, + passwordHash, + ]); + } else { + const created = await pool.query( + 'INSERT INTO users (email, password_hash, name) VALUES ($1, $2, $3) RETURNING id, email, name', + [demoAccountEmail, passwordHash, demoAccountName], + ); + user = created.rows[0]; + } + + const profile = await ensureDefaultProfile(user.id, demoAccountName); + return { user, profile }; +}; + const createSession = async (userId: string, activeProfileId: string) => { const token = createSessionToken(); const tokenHash = hashToken(token); @@ -628,6 +661,7 @@ app.get('/api', (_req, res) => { name: 'Arsenal IQ API', version: '3.0.0', allowRegistration, + allowDemoAccount, resources: [ '/api/auth/login', '/api/auth/register', @@ -639,6 +673,35 @@ app.get('/api', (_req, res) => { }); }); +app.post('/api/auth/demo', async (_req, res, next) => { + try { + if (!allowDemoAccount) { + res.status(403).json({ error: 'Demo account is disabled' }); + return; + } + + const demoAccount = await ensureDemoAccount(); + + if (!demoAccount) { + res.status(500).json({ error: 'Demo account is unavailable' }); + return; + } + + const { user, profile } = demoAccount; + const { token, session } = await createSession(user.id, profile.id); + const profiles = await getUserProfiles(user.id); + + res.json({ + token, + user, + profiles, + activeProfileId: session.active_profile_id, + }); + } catch (error) { + next(error); + } +}); + app.get('/api/auth/providers', async (_req, res, next) => { try { const result = await pool.query( @@ -1268,7 +1331,8 @@ app.use((error: Error, _req: express.Request, res: express.Response, _next: expr }); void ensureSchema() - .then(() => { + .then(async () => { + await ensureDemoAccount(); app.listen(port, () => { console.log(`Arsenal IQ API listening on http://localhost:${port}`); }); diff --git a/docker-compose.yml b/docker-compose.yml index e0332bc..fb5c23a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,10 @@ services: DATABASE_URL: postgresql://${POSTGRES_USER:-arsenal}:${POSTGRES_PASSWORD:-arsenal_dev_password}@postgres:5432/${POSTGRES_DB:-arsenal_iq} FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000} ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-true} + ALLOW_DEMO_ACCOUNT: ${ALLOW_DEMO_ACCOUNT:-false} + DEMO_ACCOUNT_EMAIL: ${DEMO_ACCOUNT_EMAIL:-demo@arsenaliq.local} + DEMO_ACCOUNT_PASSWORD: ${DEMO_ACCOUNT_PASSWORD:-demo1234} + DEMO_ACCOUNT_NAME: ${DEMO_ACCOUNT_NAME:-Demo User} depends_on: postgres: condition: service_healthy @@ -52,6 +56,7 @@ services: environment: VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:5000/api} VITE_ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-true} + VITE_ALLOW_DEMO_ACCOUNT: ${ALLOW_DEMO_ACCOUNT:-false} depends_on: - backend ports: diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index ac70abf..0809891 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -110,6 +110,7 @@ type PublicProvider = { const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api'; const allowRegistration = (import.meta.env.VITE_ALLOW_REGISTRATION ?? 'true').toLowerCase() !== 'false'; +const allowDemoAccount = (import.meta.env.VITE_ALLOW_DEMO_ACCOUNT ?? 'false').toLowerCase() === 'true'; const tokenStorageKey = 'arsenal-iq-token'; const profileStorageKey = 'arsenal-iq-profile'; const ammoPageSelectionKey = 'arsenal-iq-ammo-page-calibers'; @@ -533,6 +534,31 @@ export default function Home() { } }; + const handleDemoLogin = async () => { + setSaving(true); + setError(''); + setMessage(''); + + try { + const payload = await apiFetch( + '/auth/demo', + { + method: 'POST', + }, + false, + ); + + window.localStorage.setItem(tokenStorageKey, payload.token); + window.localStorage.setItem(profileStorageKey, payload.activeProfileId); + setToken(payload.token); + setLoginForm({ email: '', password: '' }); + } catch (loginError) { + setError(loginError instanceof Error ? loginError.message : 'Unable to sign in to demo account'); + } finally { + setSaving(false); + } + }; + const handleLogout = async () => { try { await apiFetch('/auth/logout', { method: 'POST' }); @@ -858,6 +884,11 @@ export default function Home() { + {allowDemoAccount ? ( + + ) : null} ) : allowRegistration ? (
@@ -883,6 +914,10 @@ export default function Home() {

Account registration is disabled.

) : null} + {allowDemoAccount ? ( +

Demo mode is enabled for quick access.

+ ) : null} +
Single sign-on