Added demo access

This commit is contained in:
blaisadmin
2026-03-26 09:59:40 -04:00
parent 45c6d03f8d
commit 8b7763cf13
5 changed files with 116 additions and 1 deletions
+4
View File
@@ -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
+7
View File
@@ -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`
+65 -1
View File
@@ -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<UserRow>('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<UserRow>(
'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<ProviderConfigRow>(
@@ -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}`);
});
+5
View File
@@ -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:
+35
View File
@@ -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<AuthPayload>(
'/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() {
<button className="primary-button" disabled={saving} onClick={() => void handleLogin()} type="button">
Sign in
</button>
{allowDemoAccount ? (
<button className="secondary-button" disabled={saving} onClick={() => void handleDemoLogin()} type="button">
Use demo account
</button>
) : null}
</div>
) : allowRegistration ? (
<div className="form-stack">
@@ -883,6 +914,10 @@ export default function Home() {
<p className="muted-copy">Account registration is disabled.</p>
) : null}
{allowDemoAccount ? (
<p className="muted-copy">Demo mode is enabled for quick access.</p>
) : null}
<div className="auth-divider">
<span>Single sign-on</span>
</div>