Added demo access
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user