Compare commits
15 Commits
45c6d03f8d
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| be4e7e9a63 | |||
| 40e43c01b1 | |||
| b8ed8b94f2 | |||
| 289dbb324b | |||
| a0c0d2a9eb | |||
| 8aaf6f7902 | |||
| bf18dcdc7b | |||
| 4bc809ff8b | |||
| 900bf4eb06 | |||
| e078e3312b | |||
| de2b63cd87 | |||
| 8d42df6c27 | |||
| 96d9e3ebd5 | |||
| 266e45f16f | |||
| 8b7763cf13 |
@@ -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,16 @@ 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`
|
||||
|
||||
Database note:
|
||||
|
||||
- In Docker Compose, the backend uses `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB`, `POSTGRES_USER`, and `POSTGRES_PASSWORD`
|
||||
- This avoids malformed `DATABASE_URL` issues when the database password contains URL-sensitive characters
|
||||
|
||||
### Response shape notes
|
||||
|
||||
@@ -167,6 +177,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`
|
||||
|
||||
+4
-2
@@ -4,10 +4,12 @@ WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install --legacy-peer-deps
|
||||
RUN npm ci --legacy-peer-deps
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build && npm prune --omit=dev
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
CMD ["npm", "run", "start"]
|
||||
|
||||
+191
-577
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,716 @@
|
||||
import pg from 'pg';
|
||||
|
||||
export type UserRow = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type ProfileRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type SessionRow = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
active_profile_id: string | null;
|
||||
expires_at: string;
|
||||
};
|
||||
|
||||
export type ProviderConfigRow = {
|
||||
provider_key: string;
|
||||
display_name: string;
|
||||
protocol: string;
|
||||
client_id: string | null;
|
||||
client_secret: string | null;
|
||||
authorization_endpoint: string | null;
|
||||
token_endpoint: string | null;
|
||||
userinfo_endpoint: string | null;
|
||||
issuer: string | null;
|
||||
scopes: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type DashboardSummaryRow = {
|
||||
totalFirearms: number;
|
||||
totalAmmoRounds: number;
|
||||
firearmsInvestment: string;
|
||||
ammoInvestment: string;
|
||||
configuredCalibers: number;
|
||||
};
|
||||
|
||||
export type FirearmRow = {
|
||||
id: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
category: string;
|
||||
caliber: string;
|
||||
serial_number: string;
|
||||
purchase_price: string;
|
||||
acquired_on: string | null;
|
||||
image_url: string | null;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
export type CaliberRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
is_default: boolean;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
export type AmmoInventoryRow = {
|
||||
caliber_id: string;
|
||||
caliber_name: string;
|
||||
rounds_on_hand: number;
|
||||
cost_per_round: string;
|
||||
};
|
||||
|
||||
type SessionWithUserRow = SessionRow & {
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type UserWithPasswordRow = UserRow & {
|
||||
password_hash: string | null;
|
||||
};
|
||||
|
||||
export type AuthProviderUpdate = {
|
||||
displayName: string;
|
||||
protocol: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
authorizationEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
userinfoEndpoint: string;
|
||||
issuer: string;
|
||||
scopes: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type FirearmMutation = {
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
category: string;
|
||||
caliber: string;
|
||||
serialNumber: string;
|
||||
purchasePrice: number;
|
||||
acquiredOn: string | null;
|
||||
imageUrl: string | null;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
export type ClientConfig = {
|
||||
databaseUrl: string;
|
||||
host: string;
|
||||
port: number;
|
||||
database: string;
|
||||
user: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export class ArsenalIqClient {
|
||||
private pool: pg.Pool;
|
||||
|
||||
constructor(config: ClientConfig) {
|
||||
const { Pool } = pg;
|
||||
this.pool = config.databaseUrl
|
||||
? new Pool({ connectionString: config.databaseUrl })
|
||||
: new Pool({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
database: config.database,
|
||||
user: config.user,
|
||||
password: config.password,
|
||||
});
|
||||
}
|
||||
|
||||
async getNow() {
|
||||
const result = await this.pool.query<{ now: string }>('SELECT NOW() AS now');
|
||||
return result.rows[0].now;
|
||||
}
|
||||
|
||||
async ensureSchema() {
|
||||
await this.pool.query(`
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profiles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (user_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
active_profile_id UUID REFERENCES profiles(id) ON DELETE SET NULL,
|
||||
token_hash VARCHAR(255) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_provider_configs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
provider_key VARCHAR(100) NOT NULL UNIQUE,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
protocol VARCHAR(50) NOT NULL DEFAULT 'oidc',
|
||||
client_id TEXT,
|
||||
client_secret TEXT,
|
||||
authorization_endpoint TEXT,
|
||||
token_endpoint TEXT,
|
||||
userinfo_endpoint TEXT,
|
||||
issuer TEXT,
|
||||
scopes TEXT NOT NULL DEFAULT 'openid profile email',
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_identities (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider_key VARCHAR(100) NOT NULL REFERENCES auth_provider_configs(provider_key) ON DELETE CASCADE,
|
||||
provider_subject TEXT NOT NULL,
|
||||
email VARCHAR(255),
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (provider_key, provider_subject)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth_states (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
provider_key VARCHAR(100) NOT NULL REFERENCES auth_provider_configs(provider_key) ON DELETE CASCADE,
|
||||
state_code VARCHAR(255) NOT NULL UNIQUE,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS calibers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
name VARCHAR(40) NOT NULL,
|
||||
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (profile_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS firearms (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
manufacturer VARCHAR(120) NOT NULL,
|
||||
model VARCHAR(120) NOT NULL,
|
||||
category VARCHAR(80) NOT NULL,
|
||||
caliber VARCHAR(40) NOT NULL,
|
||||
serial_number VARCHAR(120) NOT NULL,
|
||||
purchase_price NUMERIC(10, 2) NOT NULL DEFAULT 0,
|
||||
acquired_on DATE,
|
||||
image_url TEXT,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ammo_inventory (
|
||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
caliber_id UUID NOT NULL REFERENCES calibers(id) ON DELETE CASCADE,
|
||||
rounds_on_hand INT NOT NULL DEFAULT 0 CHECK (rounds_on_hand >= 0),
|
||||
cost_per_round NUMERIC(10, 2) NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (profile_id, caliber_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_user_id ON profiles(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_sessions_user_id ON auth_sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires_at ON auth_sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_calibers_profile_id ON calibers(profile_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_firearms_profile_id ON firearms(profile_id);
|
||||
`);
|
||||
|
||||
await this.seedAuthProviders();
|
||||
}
|
||||
|
||||
async ensureProfileDefaults(profileId: string, defaultCalibers: string[]) {
|
||||
await this.pool.query(
|
||||
`UPDATE calibers
|
||||
SET name = '12 Gauge - Sporting',
|
||||
is_default = TRUE
|
||||
WHERE profile_id = $1
|
||||
AND name = '12 Gauge'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM calibers existing
|
||||
WHERE existing.profile_id = $1
|
||||
AND existing.name = '12 Gauge - Sporting'
|
||||
)`,
|
||||
[profileId],
|
||||
);
|
||||
|
||||
await this.pool.query(
|
||||
`UPDATE calibers
|
||||
SET name = '12 Gauge - Sporting',
|
||||
is_default = TRUE
|
||||
WHERE profile_id = $1
|
||||
AND name = '12 Gauge Sporting'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM calibers existing
|
||||
WHERE existing.profile_id = $1
|
||||
AND existing.name = '12 Gauge - Sporting'
|
||||
)`,
|
||||
[profileId],
|
||||
);
|
||||
|
||||
for (const caliber of defaultCalibers) {
|
||||
const caliberResult = await this.pool.query<CaliberRow>(
|
||||
`INSERT INTO calibers (profile_id, name, is_default, is_active)
|
||||
VALUES ($1, $2, TRUE, TRUE)
|
||||
ON CONFLICT (profile_id, name) DO UPDATE
|
||||
SET is_default = TRUE
|
||||
RETURNING id, name, is_default, is_active`,
|
||||
[profileId, caliber],
|
||||
);
|
||||
|
||||
await this.ensureAmmoInventory(profileId, caliberResult.rows[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
async getUserProfiles(userId: string) {
|
||||
const result = await this.pool.query<ProfileRow>(
|
||||
'SELECT id, name FROM profiles WHERE user_id = $1 ORDER BY created_at ASC',
|
||||
[userId],
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async ensureDefaultProfile(userId: string, userName: string, defaultCalibers: string[]) {
|
||||
const profiles = await this.getUserProfiles(userId);
|
||||
|
||||
if (profiles.length > 0) {
|
||||
await this.ensureProfileDefaults(profiles[0].id, defaultCalibers);
|
||||
return profiles[0];
|
||||
}
|
||||
|
||||
const created = await this.pool.query<ProfileRow>(
|
||||
'INSERT INTO profiles (user_id, name) VALUES ($1, $2) RETURNING id, name',
|
||||
[userId, `${userName.split(' ')[0] || 'Primary'} Arsenal`],
|
||||
);
|
||||
|
||||
await this.ensureProfileDefaults(created.rows[0].id, defaultCalibers);
|
||||
return created.rows[0];
|
||||
}
|
||||
|
||||
async findUserByEmail(email: string) {
|
||||
const result = await this.pool.query<UserRow>('SELECT id, email, name FROM users WHERE email = $1', [email]);
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findUserWithPasswordByEmail(email: string) {
|
||||
const result = await this.pool.query<UserWithPasswordRow>(
|
||||
'SELECT id, email, name, password_hash FROM users WHERE email = $1',
|
||||
[email],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
async userExistsByEmail(email: string) {
|
||||
const result = await this.pool.query<{ id: string }>('SELECT id FROM users WHERE email = $1', [email]);
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
async updateUserPasswordAndName(userId: string, name: string, passwordHash: string) {
|
||||
await this.pool.query('UPDATE users SET name = $2, password_hash = $3 WHERE id = $1', [
|
||||
userId,
|
||||
name,
|
||||
passwordHash,
|
||||
]);
|
||||
}
|
||||
|
||||
async createUser(email: string, passwordHash: string | null, name: string) {
|
||||
const result = await this.pool.query<UserRow>(
|
||||
'INSERT INTO users (email, password_hash, name) VALUES ($1, $2, $3) RETURNING id, email, name',
|
||||
[email, passwordHash, name],
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async getUserById(userId: string) {
|
||||
const result = await this.pool.query<UserRow>('SELECT id, email, name FROM users WHERE id = $1', [userId]);
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
async createSession(userId: string, activeProfileId: string, tokenHash: string, expiresAtIso: string) {
|
||||
const result = await this.pool.query<SessionRow>(
|
||||
`INSERT INTO auth_sessions (user_id, active_profile_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, user_id, active_profile_id, expires_at`,
|
||||
[userId, activeProfileId, tokenHash, expiresAtIso],
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async getSessionByTokenHash(tokenHash: string) {
|
||||
const result = await this.pool.query<SessionWithUserRow>(
|
||||
`SELECT s.id, s.user_id, s.active_profile_id, s.expires_at, u.email, u.name
|
||||
FROM auth_sessions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = $1 AND s.expires_at > NOW()`,
|
||||
[tokenHash],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
async deleteSession(sessionId: string) {
|
||||
await this.pool.query('DELETE FROM auth_sessions WHERE id = $1', [sessionId]);
|
||||
}
|
||||
|
||||
async setSessionActiveProfile(sessionId: string, profileId: string) {
|
||||
await this.pool.query('UPDATE auth_sessions SET active_profile_id = $1 WHERE id = $2', [profileId, sessionId]);
|
||||
}
|
||||
|
||||
async getProfileForUser(profileId: string, userId: string) {
|
||||
const result = await this.pool.query<ProfileRow>(
|
||||
'SELECT id, name FROM profiles WHERE id = $1 AND user_id = $2',
|
||||
[profileId, userId],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
async getAuthProvider(providerKey: string) {
|
||||
const result = await this.pool.query<ProviderConfigRow>(
|
||||
`SELECT provider_key, display_name, protocol, client_id, client_secret,
|
||||
authorization_endpoint, token_endpoint, userinfo_endpoint, issuer, scopes, enabled
|
||||
FROM auth_provider_configs
|
||||
WHERE provider_key = $1`,
|
||||
[providerKey],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
async listEnabledAuthProviders() {
|
||||
const result = await this.pool.query<ProviderConfigRow>(
|
||||
`SELECT provider_key, display_name, protocol, client_id, client_secret,
|
||||
authorization_endpoint, token_endpoint, userinfo_endpoint, issuer, scopes, enabled
|
||||
FROM auth_provider_configs
|
||||
WHERE enabled = TRUE
|
||||
ORDER BY display_name ASC`,
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async listAuthProviders() {
|
||||
const result = await this.pool.query<ProviderConfigRow>(
|
||||
`SELECT provider_key, display_name, protocol, client_id, client_secret,
|
||||
authorization_endpoint, token_endpoint, userinfo_endpoint, issuer, scopes, enabled
|
||||
FROM auth_provider_configs
|
||||
ORDER BY display_name ASC`,
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async updateAuthProvider(providerKey: string, update: AuthProviderUpdate) {
|
||||
await this.pool.query(
|
||||
`UPDATE auth_provider_configs
|
||||
SET display_name = $2,
|
||||
protocol = $3,
|
||||
client_id = $4,
|
||||
client_secret = $5,
|
||||
authorization_endpoint = $6,
|
||||
token_endpoint = $7,
|
||||
userinfo_endpoint = $8,
|
||||
issuer = $9,
|
||||
scopes = $10,
|
||||
enabled = $11,
|
||||
updated_at = NOW()
|
||||
WHERE provider_key = $1`,
|
||||
[
|
||||
providerKey,
|
||||
update.displayName,
|
||||
update.protocol,
|
||||
update.clientId,
|
||||
update.clientSecret,
|
||||
update.authorizationEndpoint,
|
||||
update.tokenEndpoint,
|
||||
update.userinfoEndpoint,
|
||||
update.issuer,
|
||||
update.scopes,
|
||||
update.enabled,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async createOauthState(providerKey: string, stateCode: string, redirectUri: string) {
|
||||
await this.pool.query(
|
||||
'INSERT INTO oauth_states (provider_key, state_code, redirect_uri) VALUES ($1, $2, $3)',
|
||||
[providerKey, stateCode, redirectUri],
|
||||
);
|
||||
}
|
||||
|
||||
async getOauthState(providerKey: string, stateCode: string) {
|
||||
const result = await this.pool.query<{ redirect_uri: string }>(
|
||||
'SELECT redirect_uri FROM oauth_states WHERE provider_key = $1 AND state_code = $2',
|
||||
[providerKey, stateCode],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
async deleteOauthState(providerKey: string, stateCode: string) {
|
||||
await this.pool.query('DELETE FROM oauth_states WHERE provider_key = $1 AND state_code = $2', [
|
||||
providerKey,
|
||||
stateCode,
|
||||
]);
|
||||
}
|
||||
|
||||
async findIdentityUserId(providerKey: string, subject: string) {
|
||||
const result = await this.pool.query<{ user_id: string }>(
|
||||
'SELECT user_id FROM auth_identities WHERE provider_key = $1 AND provider_subject = $2',
|
||||
[providerKey, subject],
|
||||
);
|
||||
|
||||
return result.rows[0]?.user_id ?? null;
|
||||
}
|
||||
|
||||
async createIdentity(userId: string, providerKey: string, subject: string, email: string) {
|
||||
await this.pool.query(
|
||||
`INSERT INTO auth_identities (user_id, provider_key, provider_subject, email)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (provider_key, provider_subject) DO NOTHING`,
|
||||
[userId, providerKey, subject, email],
|
||||
);
|
||||
}
|
||||
|
||||
async getDashboardSummary(profileId: string) {
|
||||
const result = await this.pool.query<DashboardSummaryRow>(
|
||||
`SELECT
|
||||
(SELECT COUNT(*)::int FROM firearms WHERE profile_id = $1) AS "totalFirearms",
|
||||
COALESCE((SELECT SUM(rounds_on_hand)::int FROM ammo_inventory WHERE profile_id = $1), 0) AS "totalAmmoRounds",
|
||||
COALESCE((SELECT SUM(purchase_price) FROM firearms WHERE profile_id = $1), 0) AS "firearmsInvestment",
|
||||
COALESCE((SELECT SUM(rounds_on_hand * cost_per_round) FROM ammo_inventory WHERE profile_id = $1), 0) AS "ammoInvestment",
|
||||
(SELECT COUNT(*)::int FROM calibers WHERE profile_id = $1 AND is_active = TRUE) AS "configuredCalibers"`,
|
||||
[profileId],
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async listFirearms(profileId: string) {
|
||||
const result = await this.pool.query<FirearmRow>(
|
||||
`SELECT id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes
|
||||
FROM firearms
|
||||
WHERE profile_id = $1
|
||||
ORDER BY acquired_on DESC NULLS LAST, created_at DESC`,
|
||||
[profileId],
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async createFirearm(profileId: string, firearm: FirearmMutation) {
|
||||
const result = await this.pool.query<FirearmRow>(
|
||||
`INSERT INTO firearms (profile_id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes`,
|
||||
[
|
||||
profileId,
|
||||
firearm.manufacturer,
|
||||
firearm.model,
|
||||
firearm.category,
|
||||
firearm.caliber,
|
||||
firearm.serialNumber,
|
||||
firearm.purchasePrice,
|
||||
firearm.acquiredOn,
|
||||
firearm.imageUrl,
|
||||
firearm.notes,
|
||||
],
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async updateFirearm(id: string, profileId: string, firearm: FirearmMutation) {
|
||||
const result = await this.pool.query<FirearmRow>(
|
||||
`UPDATE firearms
|
||||
SET manufacturer = $3,
|
||||
model = $4,
|
||||
category = $5,
|
||||
caliber = $6,
|
||||
serial_number = $7,
|
||||
purchase_price = $8,
|
||||
acquired_on = $9,
|
||||
image_url = $10,
|
||||
notes = $11,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1 AND profile_id = $2
|
||||
RETURNING id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes`,
|
||||
[
|
||||
id,
|
||||
profileId,
|
||||
firearm.manufacturer,
|
||||
firearm.model,
|
||||
firearm.category,
|
||||
firearm.caliber,
|
||||
firearm.serialNumber,
|
||||
firearm.purchasePrice,
|
||||
firearm.acquiredOn,
|
||||
firearm.imageUrl,
|
||||
firearm.notes,
|
||||
],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
async deleteFirearm(id: string, profileId: string) {
|
||||
const result = await this.pool.query('DELETE FROM firearms WHERE id = $1 AND profile_id = $2', [id, profileId]);
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
async listCalibers(profileId: string) {
|
||||
const result = await this.pool.query<CaliberRow>(
|
||||
`SELECT id, name, is_default, is_active
|
||||
FROM calibers
|
||||
WHERE profile_id = $1
|
||||
ORDER BY is_active DESC, is_default DESC, name ASC`,
|
||||
[profileId],
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async upsertCaliber(profileId: string, name: string, isDefault: boolean) {
|
||||
const result = await this.pool.query<CaliberRow>(
|
||||
`INSERT INTO calibers (profile_id, name, is_default, is_active)
|
||||
VALUES ($1, $2, $3, TRUE)
|
||||
ON CONFLICT (profile_id, name) DO UPDATE
|
||||
SET is_active = TRUE
|
||||
RETURNING id, name, is_default, is_active`,
|
||||
[profileId, name, isDefault],
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async updateCaliberActive(id: string, profileId: string, isActive: boolean) {
|
||||
const result = await this.pool.query<CaliberRow>(
|
||||
`UPDATE calibers
|
||||
SET is_active = $3
|
||||
WHERE id = $1 AND profile_id = $2
|
||||
RETURNING id, name, is_default, is_active`,
|
||||
[id, profileId, isActive],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
async ensureAmmoInventory(profileId: string, caliberId: string) {
|
||||
await this.pool.query(
|
||||
`INSERT INTO ammo_inventory (profile_id, caliber_id, rounds_on_hand, cost_per_round)
|
||||
VALUES ($1, $2, 0, 0)
|
||||
ON CONFLICT (profile_id, caliber_id) DO NOTHING`,
|
||||
[profileId, caliberId],
|
||||
);
|
||||
}
|
||||
|
||||
async listAmmoInventory(profileId: string) {
|
||||
const result = await this.pool.query<AmmoInventoryRow>(
|
||||
`SELECT ai.caliber_id, c.name AS caliber_name, ai.rounds_on_hand, ai.cost_per_round
|
||||
FROM ammo_inventory ai
|
||||
INNER JOIN calibers c ON c.id = ai.caliber_id
|
||||
WHERE ai.profile_id = $1 AND c.profile_id = $1 AND c.is_active = TRUE
|
||||
ORDER BY c.name ASC`,
|
||||
[profileId],
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async updateAmmoInventory(profileId: string, caliberId: string, rounds: number, costPerRound: number | null) {
|
||||
const result = await this.pool.query<AmmoInventoryRow>(
|
||||
`UPDATE ammo_inventory ai
|
||||
SET rounds_on_hand = GREATEST(0, ai.rounds_on_hand + $3),
|
||||
cost_per_round = CASE WHEN $4::numeric IS NULL THEN ai.cost_per_round ELSE $4 END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
FROM calibers c
|
||||
WHERE ai.caliber_id = $2
|
||||
AND ai.profile_id = $1
|
||||
AND c.id = ai.caliber_id
|
||||
AND c.profile_id = $1
|
||||
RETURNING ai.caliber_id, c.name AS caliber_name, ai.rounds_on_hand, ai.cost_per_round`,
|
||||
[profileId, caliberId, rounds, costPerRound],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.pool.end();
|
||||
}
|
||||
|
||||
private async seedAuthProviders() {
|
||||
const providers = [
|
||||
{
|
||||
providerKey: 'google',
|
||||
displayName: 'Google',
|
||||
authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
|
||||
tokenEndpoint: 'https://oauth2.googleapis.com/token',
|
||||
userinfoEndpoint: 'https://openidconnect.googleapis.com/v1/userinfo',
|
||||
issuer: 'https://accounts.google.com',
|
||||
},
|
||||
{
|
||||
providerKey: 'entra',
|
||||
displayName: 'Microsoft Entra ID',
|
||||
authorizationEndpoint: '',
|
||||
tokenEndpoint: '',
|
||||
userinfoEndpoint: 'https://graph.microsoft.com/oidc/userinfo',
|
||||
issuer: '',
|
||||
},
|
||||
{
|
||||
providerKey: 'oidc',
|
||||
displayName: 'Custom OIDC',
|
||||
authorizationEndpoint: '',
|
||||
tokenEndpoint: '',
|
||||
userinfoEndpoint: '',
|
||||
issuer: '',
|
||||
},
|
||||
];
|
||||
|
||||
for (const provider of providers) {
|
||||
await this.pool.query(
|
||||
`INSERT INTO auth_provider_configs
|
||||
(provider_key, display_name, protocol, authorization_endpoint, token_endpoint, userinfo_endpoint, issuer, scopes, enabled)
|
||||
VALUES ($1, $2, 'oidc', $3, $4, $5, $6, 'openid profile email', FALSE)
|
||||
ON CONFLICT (provider_key) DO NOTHING`,
|
||||
[
|
||||
provider.providerKey,
|
||||
provider.displayName,
|
||||
provider.authorizationEndpoint,
|
||||
provider.tokenEndpoint,
|
||||
provider.userinfoEndpoint,
|
||||
provider.issuer,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
-26
@@ -15,7 +15,7 @@ services:
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
networks:
|
||||
- app
|
||||
- arsenal_iq
|
||||
|
||||
backend:
|
||||
build:
|
||||
@@ -25,57 +25,58 @@ services:
|
||||
environment:
|
||||
PORT: 5000
|
||||
NODE_ENV: ${NODE_ENV:-production}
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-arsenal}:${POSTGRES_PASSWORD:-arsenal_dev_password}@postgres:5432/${POSTGRES_DB:-arsenal_iq}
|
||||
POSTGRES_HOST: postgres
|
||||
POSTGRES_PORT: 5432
|
||||
POSTGRES_DB: ${POSTGRES_DB:-arsenal_iq}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-arsenal}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-arsenal_dev_password}
|
||||
FRONTEND_URL: ${FRONTEND_URL:-https://arsenal.example.com}
|
||||
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
|
||||
command: >
|
||||
sh -c "npm install --legacy-peer-deps &&
|
||||
npm run dev"
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ./backend/node_modules:/app/node_modules
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=${TRAEFIK_NETWORK:-traefik_proxy}
|
||||
- traefik.docker.network=${TRAEFIK_NETWORK:-traefik}
|
||||
- traefik.http.routers.arsenaliq-api.rule=Host(`${TRAEFIK_API_HOST:-api.arsenal.local}`)
|
||||
- traefik.http.routers.arsenaliq-api.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}
|
||||
- traefik.http.routers.arsenaliq-api.tls=true
|
||||
- traefik.http.services.arsenaliq-api.loadbalancer.server.port=5000
|
||||
networks:
|
||||
- app
|
||||
- traefik_proxy
|
||||
- arsenal_iq
|
||||
- traefik
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile.dev
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-https://api.arsenal.example.com/api}
|
||||
VITE_ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-true}
|
||||
VITE_ALLOW_DEMO_ACCOUNT: ${ALLOW_DEMO_ACCOUNT:-false}
|
||||
container_name: arsenaliq-frontend
|
||||
environment:
|
||||
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-https://api.arsenal.example.com/api}
|
||||
CSP_CONNECT_SRC: ${FRONTEND_CSP_CONNECT_SRC:-https://api.arsenal.example.com}
|
||||
depends_on:
|
||||
- backend
|
||||
command: >
|
||||
sh -c "npm install --legacy-peer-deps &&
|
||||
npm run dev -- --host"
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- ./frontend/node_modules:/app/node_modules
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=${TRAEFIK_NETWORK:-traefik_proxy}
|
||||
- traefik.docker.network=${TRAEFIK_NETWORK:-traefik}
|
||||
- traefik.http.routers.arsenaliq-web.rule=Host(`${TRAEFIK_WEB_HOST:-arsenal.local}`)
|
||||
- traefik.http.routers.arsenaliq-web.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}
|
||||
- traefik.http.routers.arsenaliq-web.tls=true
|
||||
- traefik.http.services.arsenaliq-web.loadbalancer.server.port=3000
|
||||
- traefik.http.services.arsenaliq-web.loadbalancer.server.port=80
|
||||
networks:
|
||||
- app
|
||||
- traefik_proxy
|
||||
- arsenal_iq
|
||||
- traefik
|
||||
|
||||
networks:
|
||||
app:
|
||||
arsenal_iq:
|
||||
driver: bridge
|
||||
traefik_proxy:
|
||||
traefik:
|
||||
external: true
|
||||
name: ${TRAEFIK_NETWORK:-traefik_proxy}
|
||||
name: ${TRAEFIK_NETWORK:-traefik}
|
||||
|
||||
+26
-7
@@ -9,15 +9,13 @@ services:
|
||||
volumes:
|
||||
- ./data/postgres:/var/lib/postgresql/data
|
||||
- ./backend/database/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-arsenal} -d ${POSTGRES_DB:-arsenal_iq}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
networks:
|
||||
- app
|
||||
- arsenal_iq
|
||||
|
||||
backend:
|
||||
build:
|
||||
@@ -27,9 +25,17 @@ services:
|
||||
environment:
|
||||
PORT: 5000
|
||||
NODE_ENV: ${NODE_ENV:-development}
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-arsenal}:${POSTGRES_PASSWORD:-arsenal_dev_password}@postgres:5432/${POSTGRES_DB:-arsenal_iq}
|
||||
POSTGRES_HOST: postgres
|
||||
POSTGRES_PORT: 5432
|
||||
POSTGRES_DB: ${POSTGRES_DB:-arsenal_iq}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-arsenal}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-arsenal_dev_password}
|
||||
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
|
||||
@@ -42,7 +48,7 @@ services:
|
||||
- ./backend:/app
|
||||
- ./backend/node_modules:/app/node_modules
|
||||
networks:
|
||||
- app
|
||||
- arsenal_iq
|
||||
|
||||
frontend:
|
||||
build:
|
||||
@@ -52,6 +58,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:
|
||||
@@ -62,9 +69,21 @@ services:
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- ./frontend/node_modules:/app/node_modules
|
||||
labels:
|
||||
- ${WATCH:-traefik.enable=true}
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=${TRAEFIK_NETWORK:-traefik}"
|
||||
- "traefik.http.routers.${NAME:-arsenaliq}.rule=Host(`${URL:-arsenal.local}`)"
|
||||
- "traefik.http.routers.${NAME:-arsenaliq}.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}"
|
||||
- "traefik.http.routers.${NAME:-arsenaliq}.tls.certresolver=${TRAEFIK_CERTRESOLVER:-myresolver}"
|
||||
- "traefik.http.services.${NAME:-arsenaliq}.loadbalancer.server.port=3000"
|
||||
networks:
|
||||
- app
|
||||
- arsenal_iq
|
||||
- traefik
|
||||
|
||||
networks:
|
||||
app:
|
||||
arsenal_iq:
|
||||
driver: bridge
|
||||
traefik:
|
||||
external: true
|
||||
name: ${TRAEFIK_NETWORK:-traefik}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG VITE_API_BASE_URL=https://api.arsenal.example.com/api
|
||||
ARG VITE_ALLOW_REGISTRATION=true
|
||||
ARG VITE_ALLOW_DEMO_ACCOUNT=false
|
||||
|
||||
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||
ENV VITE_ALLOW_REGISTRATION=${VITE_ALLOW_REGISTRATION}
|
||||
ENV VITE_ALLOW_DEMO_ACCOUNT=${VITE_ALLOW_DEMO_ACCOUNT}
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm ci --legacy-peer-deps
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY nginx/default.conf.template /etc/nginx/templates/default.conf.template
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
@@ -2,6 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Arsenal IQ</title>
|
||||
</head>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
server_tokens off;
|
||||
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
add_header Expires "0" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()" always;
|
||||
add_header Cross-Origin-Embedder-Policy "unsafe-none" always;
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; connect-src 'self' ${CSP_CONNECT_SRC}; font-src 'self' data:; frame-ancestors 'none'; img-src 'self' data: https:; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; form-action 'self'; manifest-src 'self'; upgrade-insecure-requests" always;
|
||||
|
||||
location = /robots.txt {
|
||||
default_type text/plain;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /sitemap.xml {
|
||||
default_type application/xml;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
|
||||
<title>Arsenal IQ Crosshairs Icon</title>
|
||||
<rect width="64" height="64" rx="14" fill="#11150f" />
|
||||
<circle cx="32" cy="32" r="18" fill="none" stroke="#dce7c2" stroke-width="4" />
|
||||
<circle cx="32" cy="32" r="4.5" fill="#dce7c2" />
|
||||
<path
|
||||
d="M32 8v10M32 46v10M8 32h10M46 32h10"
|
||||
fill="none"
|
||||
stroke="#89a05e"
|
||||
stroke-linecap="round"
|
||||
stroke-width="4"
|
||||
/>
|
||||
<path
|
||||
d="M32 18v9M32 37v9M18 32h9M37 32h9"
|
||||
fill="none"
|
||||
stroke="#dce7c2"
|
||||
stroke-linecap="round"
|
||||
stroke-width="4"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 618 B |
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://arsenal.blaishome.online/sitemap.xml
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://arsenal.blaishome.online/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
+81
-1
@@ -232,11 +232,58 @@ textarea {
|
||||
.view-grid,
|
||||
.firearm-grid,
|
||||
.ammo-grid,
|
||||
.settings-grid {
|
||||
.settings-grid,
|
||||
.ammo-chart {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.ammo-chart-panel {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1.1rem 1.2rem 1.25rem;
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(171, 180, 140, 0.12);
|
||||
background: linear-gradient(180deg, rgba(38, 46, 30, 0.44), rgba(15, 19, 13, 0.74));
|
||||
}
|
||||
|
||||
.ammo-chart-header h4 {
|
||||
margin: 0.3rem 0 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.ammo-chart-row {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.ammo-chart-meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.ammo-chart-meta span {
|
||||
color: #b8c0af;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.ammo-chart-track {
|
||||
width: 100%;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: rgba(171, 180, 140, 0.08);
|
||||
border: 1px solid rgba(171, 180, 140, 0.08);
|
||||
}
|
||||
|
||||
.ammo-chart-bar {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #6f8751, #afbf74);
|
||||
box-shadow: 0 0 22px rgba(126, 151, 82, 0.28);
|
||||
}
|
||||
|
||||
.auth-divider {
|
||||
margin: 1.5rem 0 1rem;
|
||||
text-align: center;
|
||||
@@ -429,6 +476,31 @@ label span {
|
||||
font-size: 1.65rem;
|
||||
}
|
||||
|
||||
.category-count-list {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
margin-top: 0.9rem;
|
||||
padding-top: 0.9rem;
|
||||
border-top: 1px solid rgba(171, 180, 140, 0.08);
|
||||
}
|
||||
|
||||
.category-count-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
color: #dfe5d3;
|
||||
}
|
||||
|
||||
.category-count-item span {
|
||||
margin-bottom: 0;
|
||||
color: #b7bead;
|
||||
}
|
||||
|
||||
.category-count-item strong {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.view-grid {
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(360px, 0.8fr);
|
||||
}
|
||||
@@ -524,6 +596,14 @@ label span {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.filter-control {
|
||||
min-width: 210px;
|
||||
}
|
||||
|
||||
.filter-control span {
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.settings-row {
|
||||
padding: 0.95rem 0;
|
||||
border-top: 1px solid rgba(171, 180, 140, 0.08);
|
||||
|
||||
+202
-9
@@ -110,12 +110,15 @@ 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';
|
||||
const ammoPageSelectionMigrationKey = 'arsenal-iq-ammo-page-calibers-v2';
|
||||
const firearmCategories = ['Handgun', 'Rifle', 'Shotgun', 'PCC', 'Other'];
|
||||
const defaultCaliberNames = ['9mm', '.22 LR', '5.56 NATO', '.308 Win', '12 Gauge', '.45 ACP'];
|
||||
const allFirearmCategoriesLabel = 'All categories';
|
||||
const defaultCaliberNames = ['9mm', '.22 LR', '5.56 NATO', '.308 Win', '12 Gauge - Birdshot', '12 Gauge - Buckshot', '12 Gauge - Slug', '12 Gauge - Sporting', '.45 ACP'];
|
||||
const boxTrackedCalibers = ['12 Gauge - Sporting'];
|
||||
|
||||
const currency = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
@@ -187,6 +190,45 @@ const getCategorySilhouette = (category: string) => {
|
||||
return silhouettes[normalized] ?? silhouettes.other;
|
||||
};
|
||||
|
||||
const isBoxTrackedCaliber = (caliber: string) => boxTrackedCalibers.includes(caliber);
|
||||
|
||||
const getAmmoUnitLabel = (caliber: string, quantity: number) => {
|
||||
if (isBoxTrackedCaliber(caliber)) {
|
||||
return quantity === 1 ? 'box' : 'boxes';
|
||||
}
|
||||
|
||||
return quantity === 1 ? 'round' : 'rounds';
|
||||
};
|
||||
|
||||
const getAmmoUnitNoun = (caliber: string) => (isBoxTrackedCaliber(caliber) ? 'boxes' : 'rounds');
|
||||
|
||||
const resolveFirearmImageUrl = (imageUrl: string | null | undefined) => {
|
||||
const trimmed = imageUrl?.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = trimmed.startsWith('//') ? `https:${trimmed}` : trimmed;
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalized);
|
||||
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parsed.hostname === 'commons.wikimedia.org' && parsed.pathname.startsWith('/wiki/File:')) {
|
||||
const fileName = parsed.pathname.replace('/wiki/File:', '');
|
||||
return `https://commons.wikimedia.org/wiki/Special:FilePath/${fileName}`;
|
||||
}
|
||||
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -203,6 +245,8 @@ export default function Home() {
|
||||
const [firearmDrafts, setFirearmDrafts] = useState<Record<string, FirearmForm>>({});
|
||||
const [newFirearm, setNewFirearm] = useState<FirearmForm>(emptyFirearmForm);
|
||||
const [showNewFirearmForm, setShowNewFirearmForm] = useState(false);
|
||||
const [selectedFirearmCategory, setSelectedFirearmCategory] = useState(allFirearmCategoriesLabel);
|
||||
const [failedFirearmImages, setFailedFirearmImages] = useState<Record<string, boolean>>({});
|
||||
const [ammoAdjustments, setAmmoAdjustments] = useState<AmmoAdjustments>({});
|
||||
const [ammoPageCaliberIds, setAmmoPageCaliberIds] = useState<string[]>(() => loadStoredAmmoPageSelection());
|
||||
const [selectedAmmoCaliberId, setSelectedAmmoCaliberId] = useState('');
|
||||
@@ -267,6 +311,42 @@ export default function Home() {
|
||||
() => enabledCalibers.filter((caliber) => !ammoPageCaliberIds.includes(caliber.id)),
|
||||
[ammoPageCaliberIds, enabledCalibers],
|
||||
);
|
||||
const ammoChartData = useMemo(() => {
|
||||
const withRounds = enabledAmmoInventory
|
||||
.filter((inventory) => inventory.roundsOnHand > 0)
|
||||
.sort((left, right) => {
|
||||
if (right.roundsOnHand !== left.roundsOnHand) {
|
||||
return right.roundsOnHand - left.roundsOnHand;
|
||||
}
|
||||
|
||||
return left.caliber.localeCompare(right.caliber);
|
||||
});
|
||||
const maxRounds = withRounds[0]?.roundsOnHand ?? 0;
|
||||
|
||||
return withRounds.map((inventory) => ({
|
||||
...inventory,
|
||||
widthPercent: maxRounds > 0 ? Math.max((inventory.roundsOnHand / maxRounds) * 100, 8) : 0,
|
||||
}));
|
||||
}, [enabledAmmoInventory]);
|
||||
const firearmCategoryCounts = useMemo(
|
||||
() =>
|
||||
firearmCategories.map((category) => ({
|
||||
category,
|
||||
count: data?.firearms.filter((firearm) => firearm.category === category).length ?? 0,
|
||||
})),
|
||||
[data],
|
||||
);
|
||||
const filteredFirearms = useMemo(() => {
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (selectedFirearmCategory === allFirearmCategoriesLabel) {
|
||||
return data.firearms;
|
||||
}
|
||||
|
||||
return data.firearms.filter((firearm) => firearm.category === selectedFirearmCategory);
|
||||
}, [data, selectedFirearmCategory]);
|
||||
|
||||
const apiFetch = async <T,>(path: string, init: RequestInit = {}, useProfile = true): Promise<T> => {
|
||||
const headers = new Headers(init.headers || {});
|
||||
@@ -309,6 +389,7 @@ export default function Home() {
|
||||
setFirearmDrafts(
|
||||
Object.fromEntries(payload.firearms.map((firearm) => [firearm.id, buildFirearmForm(firearm)])),
|
||||
);
|
||||
setFailedFirearmImages({});
|
||||
setAmmoAdjustments(buildAmmoAdjustments(payload.ammoInventory));
|
||||
};
|
||||
|
||||
@@ -533,6 +614,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' });
|
||||
@@ -569,6 +675,18 @@ export default function Home() {
|
||||
};
|
||||
|
||||
const handleFirearmChange = (id: string, field: keyof FirearmForm, value: string) => {
|
||||
if (field === 'imageUrl') {
|
||||
setFailedFirearmImages((current) => {
|
||||
if (!current[id]) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const next = { ...current };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
setFirearmDrafts((current) => ({
|
||||
...current,
|
||||
[id]: {
|
||||
@@ -858,6 +976,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 +1006,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>
|
||||
@@ -931,9 +1058,19 @@ export default function Home() {
|
||||
<div className="mini-stat">
|
||||
<span>{activeView === 'ammo' ? 'Ammo types' : 'Firearms'}</span>
|
||||
<strong>{activeView === 'ammo' ? ammoTypesWithRounds : data.summary.totalFirearms}</strong>
|
||||
{activeView === 'firearms' ? (
|
||||
<div className="category-count-list" aria-label="Firearm counts by category">
|
||||
{firearmCategoryCounts.map((item) => (
|
||||
<div className="category-count-item" key={item.category}>
|
||||
<span>{item.category}</span>
|
||||
<strong>{item.count}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mini-stat">
|
||||
<span>{activeView === 'ammo' ? 'Total rounds' : 'Total firearm value'}</span>
|
||||
<span>{activeView === 'ammo' ? 'Total ammo units' : 'Total firearm value'}</span>
|
||||
<strong>
|
||||
{activeView === 'ammo'
|
||||
? data.summary.totalAmmoRounds
|
||||
@@ -954,22 +1091,48 @@ export default function Home() {
|
||||
<span className="panel-kicker">Registry</span>
|
||||
<h3>Existing firearms</h3>
|
||||
</div>
|
||||
<label className="filter-control">
|
||||
<span>Category filter</span>
|
||||
<select value={selectedFirearmCategory} onChange={(event) => setSelectedFirearmCategory(event.target.value)}>
|
||||
<option value={allFirearmCategoriesLabel}>
|
||||
{allFirearmCategoriesLabel} ({data.summary.totalFirearms})
|
||||
</option>
|
||||
{firearmCategoryCounts.map((item) => (
|
||||
<option key={item.category} value={item.category}>
|
||||
{item.category} ({item.count})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="firearm-grid">
|
||||
{data.firearms.length === 0 ? (
|
||||
<p className="placeholder-copy">No firearms yet. Add your first record from the panel on the right.</p>
|
||||
) : filteredFirearms.length === 0 ? (
|
||||
<p className="placeholder-copy">No firearms match the selected category.</p>
|
||||
) : (
|
||||
data.firearms.map((firearm) => {
|
||||
filteredFirearms.map((firearm) => {
|
||||
const draft = firearmDrafts[firearm.id] ?? buildFirearmForm(firearm);
|
||||
const resolvedImageUrl = resolveFirearmImageUrl(draft.imageUrl);
|
||||
const firearmImageSrc = !failedFirearmImages[firearm.id] && resolvedImageUrl
|
||||
? resolvedImageUrl
|
||||
: getCategorySilhouette(draft.category);
|
||||
|
||||
return (
|
||||
<article className="firearm-card" key={firearm.id}>
|
||||
<div className="firearm-visual">
|
||||
<img
|
||||
className={draft.imageUrl ? 'firearm-photo' : 'firearm-silhouette'}
|
||||
className={!failedFirearmImages[firearm.id] && resolvedImageUrl ? 'firearm-photo' : 'firearm-silhouette'}
|
||||
alt={`${firearm.manufacturer} ${firearm.model}`}
|
||||
src={draft.imageUrl || getCategorySilhouette(draft.category)}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() =>
|
||||
setFailedFirearmImages((current) =>
|
||||
current[firearm.id] ? current : { ...current, [firearm.id]: true },
|
||||
)
|
||||
}
|
||||
src={firearmImageSrc}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
@@ -1138,22 +1301,52 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ammo-chart-panel">
|
||||
<div className="ammo-chart-header">
|
||||
<div>
|
||||
<span className="panel-kicker">Caliber Breakdown</span>
|
||||
<h4>Rounds by caliber</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ammoChartData.length === 0 ? (
|
||||
<p className="placeholder-copy">Add ammo to a caliber to see how your inventory stacks up.</p>
|
||||
) : (
|
||||
<div className="ammo-chart">
|
||||
{ammoChartData.map((inventory) => (
|
||||
<div className="ammo-chart-row" key={inventory.caliberId}>
|
||||
<div className="ammo-chart-meta">
|
||||
<strong>{inventory.caliber}</strong>
|
||||
<span>{inventory.roundsOnHand.toLocaleString()} {getAmmoUnitLabel(inventory.caliber, inventory.roundsOnHand)}</span>
|
||||
</div>
|
||||
<div className="ammo-chart-track" aria-hidden="true">
|
||||
<div
|
||||
className="ammo-chart-bar"
|
||||
style={{ width: `${inventory.widthPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ammo-grid">
|
||||
{ammoPageInventory.length === 0 ? (
|
||||
<p className="placeholder-copy">Add an enabled caliber to start tracking rounds on this page.</p>
|
||||
<p className="placeholder-copy">Add an enabled caliber to start tracking ammo on this page.</p>
|
||||
) : (
|
||||
ammoPageInventory.map((inventory) => (
|
||||
<article className="ammo-card" key={inventory.caliberId}>
|
||||
<div className="ammo-card-top">
|
||||
<div>
|
||||
<strong>{inventory.caliber}</strong>
|
||||
<p>{inventory.roundsOnHand} rounds on hand</p>
|
||||
<p>{inventory.roundsOnHand} {getAmmoUnitLabel(inventory.caliber, inventory.roundsOnHand)} on hand</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-grid compact">
|
||||
<label>
|
||||
<span>Round adjustment</span>
|
||||
<span>{isBoxTrackedCaliber(inventory.caliber) ? 'Box adjustment' : 'Round adjustment'}</span>
|
||||
<input
|
||||
type="number"
|
||||
value={ammoAdjustments[inventory.caliberId]?.rounds ?? ''}
|
||||
@@ -1172,7 +1365,7 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
<div className="card-footer">
|
||||
<span>Enter a quantity, then update or remove rounds for this caliber.</span>
|
||||
<span>Enter a quantity, then update or remove {getAmmoUnitNoun(inventory.caliber)} for this caliber.</span>
|
||||
<div className="button-row">
|
||||
<button className="secondary-button" onClick={() => void adjustAmmo(inventory.caliberId, 'remove')} type="button">
|
||||
Remove ammo
|
||||
|
||||
Reference in New Issue
Block a user