Added demo access
This commit is contained in:
@@ -5,6 +5,10 @@ NODE_ENV=development
|
|||||||
FRONTEND_URL=http://localhost:3000
|
FRONTEND_URL=http://localhost:3000
|
||||||
VITE_API_BASE_URL=http://localhost:5000/api
|
VITE_API_BASE_URL=http://localhost:5000/api
|
||||||
ALLOW_REGISTRATION=true
|
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
|
# Production-only Traefik settings
|
||||||
TRAEFIK_NETWORK=traefik_proxy
|
TRAEFIK_NETWORK=traefik_proxy
|
||||||
TRAEFIK_ENTRYPOINT=websecure
|
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`
|
- `ALLOW_REGISTRATION=true|false`
|
||||||
- Controls whether `POST /api/auth/register` is available
|
- Controls whether `POST /api/auth/register` is available
|
||||||
- When `false`, the login UI hides self-service account creation
|
- 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
|
### Response shape notes
|
||||||
|
|
||||||
@@ -167,6 +172,8 @@ Example:
|
|||||||
- Creates a local account when registration is enabled
|
- Creates a local account when registration is enabled
|
||||||
- `POST /api/auth/login`
|
- `POST /api/auth/login`
|
||||||
- Signs in with local email/password
|
- 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`
|
- `POST /api/auth/logout`
|
||||||
- Invalidates the current session token
|
- Invalidates the current session token
|
||||||
- `GET /api/auth/me`
|
- `GET /api/auth/me`
|
||||||
|
|||||||
+65
-1
@@ -100,6 +100,9 @@ const databaseUrl =
|
|||||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
|
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
|
||||||
const apiBaseUrl = process.env.API_BASE_URL || 'http://localhost:5000/api';
|
const apiBaseUrl = process.env.API_BASE_URL || 'http://localhost:5000/api';
|
||||||
const allowRegistration = (process.env.ALLOW_REGISTRATION ?? 'true').toLowerCase() !== 'false';
|
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 } = pg;
|
||||||
const pool = new Pool({ connectionString: databaseUrl });
|
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 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 hashToken = (token: string) => crypto.createHash('sha256').update(token).digest('hex');
|
||||||
const createSessionToken = () => crypto.randomBytes(32).toString('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);
|
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];
|
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 createSession = async (userId: string, activeProfileId: string) => {
|
||||||
const token = createSessionToken();
|
const token = createSessionToken();
|
||||||
const tokenHash = hashToken(token);
|
const tokenHash = hashToken(token);
|
||||||
@@ -628,6 +661,7 @@ app.get('/api', (_req, res) => {
|
|||||||
name: 'Arsenal IQ API',
|
name: 'Arsenal IQ API',
|
||||||
version: '3.0.0',
|
version: '3.0.0',
|
||||||
allowRegistration,
|
allowRegistration,
|
||||||
|
allowDemoAccount,
|
||||||
resources: [
|
resources: [
|
||||||
'/api/auth/login',
|
'/api/auth/login',
|
||||||
'/api/auth/register',
|
'/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) => {
|
app.get('/api/auth/providers', async (_req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query<ProviderConfigRow>(
|
const result = await pool.query<ProviderConfigRow>(
|
||||||
@@ -1268,7 +1331,8 @@ app.use((error: Error, _req: express.Request, res: express.Response, _next: expr
|
|||||||
});
|
});
|
||||||
|
|
||||||
void ensureSchema()
|
void ensureSchema()
|
||||||
.then(() => {
|
.then(async () => {
|
||||||
|
await ensureDemoAccount();
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`Arsenal IQ API listening on http://localhost:${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}
|
DATABASE_URL: postgresql://${POSTGRES_USER:-arsenal}:${POSTGRES_PASSWORD:-arsenal_dev_password}@postgres:5432/${POSTGRES_DB:-arsenal_iq}
|
||||||
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
|
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
|
||||||
ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-true}
|
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:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -52,6 +56,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:5000/api}
|
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:5000/api}
|
||||||
VITE_ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-true}
|
VITE_ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-true}
|
||||||
|
VITE_ALLOW_DEMO_ACCOUNT: ${ALLOW_DEMO_ACCOUNT:-false}
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ type PublicProvider = {
|
|||||||
|
|
||||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api';
|
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 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 tokenStorageKey = 'arsenal-iq-token';
|
||||||
const profileStorageKey = 'arsenal-iq-profile';
|
const profileStorageKey = 'arsenal-iq-profile';
|
||||||
const ammoPageSelectionKey = 'arsenal-iq-ammo-page-calibers';
|
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 () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
await apiFetch('/auth/logout', { method: 'POST' });
|
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">
|
<button className="primary-button" disabled={saving} onClick={() => void handleLogin()} type="button">
|
||||||
Sign in
|
Sign in
|
||||||
</button>
|
</button>
|
||||||
|
{allowDemoAccount ? (
|
||||||
|
<button className="secondary-button" disabled={saving} onClick={() => void handleDemoLogin()} type="button">
|
||||||
|
Use demo account
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : allowRegistration ? (
|
) : allowRegistration ? (
|
||||||
<div className="form-stack">
|
<div className="form-stack">
|
||||||
@@ -883,6 +914,10 @@ export default function Home() {
|
|||||||
<p className="muted-copy">Account registration is disabled.</p>
|
<p className="muted-copy">Account registration is disabled.</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{allowDemoAccount ? (
|
||||||
|
<p className="muted-copy">Demo mode is enabled for quick access.</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="auth-divider">
|
<div className="auth-divider">
|
||||||
<span>Single sign-on</span>
|
<span>Single sign-on</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user