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
|
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,16 @@ 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`
|
||||||
|
|
||||||
|
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
|
### Response shape notes
|
||||||
|
|
||||||
@@ -167,6 +177,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`
|
||||||
|
|||||||
+4
-2
@@ -4,10 +4,12 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
RUN npm install --legacy-peer-deps
|
RUN npm ci --legacy-peer-deps
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
RUN npm run build && npm prune --omit=dev
|
||||||
|
|
||||||
EXPOSE 5000
|
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
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
networks:
|
networks:
|
||||||
- app
|
- arsenal_iq
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
@@ -25,57 +25,58 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PORT: 5000
|
PORT: 5000
|
||||||
NODE_ENV: ${NODE_ENV:-production}
|
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}
|
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:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
command: >
|
|
||||||
sh -c "npm install --legacy-peer-deps &&
|
|
||||||
npm run dev"
|
|
||||||
volumes:
|
|
||||||
- ./backend:/app
|
|
||||||
- ./backend/node_modules:/app/node_modules
|
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- 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.rule=Host(`${TRAEFIK_API_HOST:-api.arsenal.local}`)
|
||||||
- traefik.http.routers.arsenaliq-api.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}
|
- traefik.http.routers.arsenaliq-api.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}
|
||||||
- traefik.http.routers.arsenaliq-api.tls=true
|
- traefik.http.routers.arsenaliq-api.tls=true
|
||||||
- traefik.http.services.arsenaliq-api.loadbalancer.server.port=5000
|
- traefik.http.services.arsenaliq-api.loadbalancer.server.port=5000
|
||||||
networks:
|
networks:
|
||||||
- app
|
- arsenal_iq
|
||||||
- traefik_proxy
|
- traefik
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
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
|
container_name: arsenaliq-frontend
|
||||||
environment:
|
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:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
command: >
|
|
||||||
sh -c "npm install --legacy-peer-deps &&
|
|
||||||
npm run dev -- --host"
|
|
||||||
volumes:
|
|
||||||
- ./frontend:/app
|
|
||||||
- ./frontend/node_modules:/app/node_modules
|
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- 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.rule=Host(`${TRAEFIK_WEB_HOST:-arsenal.local}`)
|
||||||
- traefik.http.routers.arsenaliq-web.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}
|
- traefik.http.routers.arsenaliq-web.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}
|
||||||
- traefik.http.routers.arsenaliq-web.tls=true
|
- 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:
|
networks:
|
||||||
- app
|
- arsenal_iq
|
||||||
- traefik_proxy
|
- traefik
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
app:
|
arsenal_iq:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
traefik_proxy:
|
traefik:
|
||||||
external: true
|
external: true
|
||||||
name: ${TRAEFIK_NETWORK:-traefik_proxy}
|
name: ${TRAEFIK_NETWORK:-traefik}
|
||||||
|
|||||||
+26
-7
@@ -9,15 +9,13 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./data/postgres:/var/lib/postgresql/data
|
- ./data/postgres:/var/lib/postgresql/data
|
||||||
- ./backend/database/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
|
- ./backend/database/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-arsenal} -d ${POSTGRES_DB:-arsenal_iq}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-arsenal} -d ${POSTGRES_DB:-arsenal_iq}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
networks:
|
networks:
|
||||||
- app
|
- arsenal_iq
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
@@ -27,9 +25,17 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PORT: 5000
|
PORT: 5000
|
||||||
NODE_ENV: ${NODE_ENV:-development}
|
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}
|
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
|
||||||
@@ -42,7 +48,7 @@ services:
|
|||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- ./backend/node_modules:/app/node_modules
|
- ./backend/node_modules:/app/node_modules
|
||||||
networks:
|
networks:
|
||||||
- app
|
- arsenal_iq
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
@@ -52,6 +58,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:
|
||||||
@@ -62,9 +69,21 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./frontend:/app
|
- ./frontend:/app
|
||||||
- ./frontend/node_modules:/app/node_modules
|
- ./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:
|
networks:
|
||||||
- app
|
- arsenal_iq
|
||||||
|
- traefik
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
app:
|
arsenal_iq:
|
||||||
driver: bridge
|
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">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Arsenal IQ</title>
|
<title>Arsenal IQ</title>
|
||||||
</head>
|
</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,
|
.view-grid,
|
||||||
.firearm-grid,
|
.firearm-grid,
|
||||||
.ammo-grid,
|
.ammo-grid,
|
||||||
.settings-grid {
|
.settings-grid,
|
||||||
|
.ammo-chart {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
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 {
|
.auth-divider {
|
||||||
margin: 1.5rem 0 1rem;
|
margin: 1.5rem 0 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -429,6 +476,31 @@ label span {
|
|||||||
font-size: 1.65rem;
|
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 {
|
.view-grid {
|
||||||
grid-template-columns: minmax(0, 1.35fr) minmax(360px, 0.8fr);
|
grid-template-columns: minmax(0, 1.35fr) minmax(360px, 0.8fr);
|
||||||
}
|
}
|
||||||
@@ -524,6 +596,14 @@ label span {
|
|||||||
padding: 1rem 0;
|
padding: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-control {
|
||||||
|
min-width: 210px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-control span {
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-row {
|
.settings-row {
|
||||||
padding: 0.95rem 0;
|
padding: 0.95rem 0;
|
||||||
border-top: 1px solid rgba(171, 180, 140, 0.08);
|
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 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';
|
||||||
const ammoPageSelectionMigrationKey = 'arsenal-iq-ammo-page-calibers-v2';
|
const ammoPageSelectionMigrationKey = 'arsenal-iq-ammo-page-calibers-v2';
|
||||||
const firearmCategories = ['Handgun', 'Rifle', 'Shotgun', 'PCC', 'Other'];
|
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', {
|
const currency = new Intl.NumberFormat('en-US', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
@@ -187,6 +190,45 @@ const getCategorySilhouette = (category: string) => {
|
|||||||
return silhouettes[normalized] ?? silhouettes.other;
|
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() {
|
export default function Home() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -203,6 +245,8 @@ export default function Home() {
|
|||||||
const [firearmDrafts, setFirearmDrafts] = useState<Record<string, FirearmForm>>({});
|
const [firearmDrafts, setFirearmDrafts] = useState<Record<string, FirearmForm>>({});
|
||||||
const [newFirearm, setNewFirearm] = useState<FirearmForm>(emptyFirearmForm);
|
const [newFirearm, setNewFirearm] = useState<FirearmForm>(emptyFirearmForm);
|
||||||
const [showNewFirearmForm, setShowNewFirearmForm] = useState(false);
|
const [showNewFirearmForm, setShowNewFirearmForm] = useState(false);
|
||||||
|
const [selectedFirearmCategory, setSelectedFirearmCategory] = useState(allFirearmCategoriesLabel);
|
||||||
|
const [failedFirearmImages, setFailedFirearmImages] = useState<Record<string, boolean>>({});
|
||||||
const [ammoAdjustments, setAmmoAdjustments] = useState<AmmoAdjustments>({});
|
const [ammoAdjustments, setAmmoAdjustments] = useState<AmmoAdjustments>({});
|
||||||
const [ammoPageCaliberIds, setAmmoPageCaliberIds] = useState<string[]>(() => loadStoredAmmoPageSelection());
|
const [ammoPageCaliberIds, setAmmoPageCaliberIds] = useState<string[]>(() => loadStoredAmmoPageSelection());
|
||||||
const [selectedAmmoCaliberId, setSelectedAmmoCaliberId] = useState('');
|
const [selectedAmmoCaliberId, setSelectedAmmoCaliberId] = useState('');
|
||||||
@@ -267,6 +311,42 @@ export default function Home() {
|
|||||||
() => enabledCalibers.filter((caliber) => !ammoPageCaliberIds.includes(caliber.id)),
|
() => enabledCalibers.filter((caliber) => !ammoPageCaliberIds.includes(caliber.id)),
|
||||||
[ammoPageCaliberIds, enabledCalibers],
|
[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 apiFetch = async <T,>(path: string, init: RequestInit = {}, useProfile = true): Promise<T> => {
|
||||||
const headers = new Headers(init.headers || {});
|
const headers = new Headers(init.headers || {});
|
||||||
@@ -309,6 +389,7 @@ export default function Home() {
|
|||||||
setFirearmDrafts(
|
setFirearmDrafts(
|
||||||
Object.fromEntries(payload.firearms.map((firearm) => [firearm.id, buildFirearmForm(firearm)])),
|
Object.fromEntries(payload.firearms.map((firearm) => [firearm.id, buildFirearmForm(firearm)])),
|
||||||
);
|
);
|
||||||
|
setFailedFirearmImages({});
|
||||||
setAmmoAdjustments(buildAmmoAdjustments(payload.ammoInventory));
|
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 () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
await apiFetch('/auth/logout', { method: 'POST' });
|
await apiFetch('/auth/logout', { method: 'POST' });
|
||||||
@@ -569,6 +675,18 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleFirearmChange = (id: string, field: keyof FirearmForm, value: string) => {
|
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) => ({
|
setFirearmDrafts((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[id]: {
|
[id]: {
|
||||||
@@ -858,6 +976,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 +1006,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>
|
||||||
@@ -931,9 +1058,19 @@ export default function Home() {
|
|||||||
<div className="mini-stat">
|
<div className="mini-stat">
|
||||||
<span>{activeView === 'ammo' ? 'Ammo types' : 'Firearms'}</span>
|
<span>{activeView === 'ammo' ? 'Ammo types' : 'Firearms'}</span>
|
||||||
<strong>{activeView === 'ammo' ? ammoTypesWithRounds : data.summary.totalFirearms}</strong>
|
<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>
|
||||||
<div className="mini-stat">
|
<div className="mini-stat">
|
||||||
<span>{activeView === 'ammo' ? 'Total rounds' : 'Total firearm value'}</span>
|
<span>{activeView === 'ammo' ? 'Total ammo units' : 'Total firearm value'}</span>
|
||||||
<strong>
|
<strong>
|
||||||
{activeView === 'ammo'
|
{activeView === 'ammo'
|
||||||
? data.summary.totalAmmoRounds
|
? data.summary.totalAmmoRounds
|
||||||
@@ -954,22 +1091,48 @@ export default function Home() {
|
|||||||
<span className="panel-kicker">Registry</span>
|
<span className="panel-kicker">Registry</span>
|
||||||
<h3>Existing firearms</h3>
|
<h3>Existing firearms</h3>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="firearm-grid">
|
<div className="firearm-grid">
|
||||||
{data.firearms.length === 0 ? (
|
{data.firearms.length === 0 ? (
|
||||||
<p className="placeholder-copy">No firearms yet. Add your first record from the panel on the right.</p>
|
<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 draft = firearmDrafts[firearm.id] ?? buildFirearmForm(firearm);
|
||||||
|
const resolvedImageUrl = resolveFirearmImageUrl(draft.imageUrl);
|
||||||
|
const firearmImageSrc = !failedFirearmImages[firearm.id] && resolvedImageUrl
|
||||||
|
? resolvedImageUrl
|
||||||
|
: getCategorySilhouette(draft.category);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="firearm-card" key={firearm.id}>
|
<article className="firearm-card" key={firearm.id}>
|
||||||
<div className="firearm-visual">
|
<div className="firearm-visual">
|
||||||
<img
|
<img
|
||||||
className={draft.imageUrl ? 'firearm-photo' : 'firearm-silhouette'}
|
className={!failedFirearmImages[firearm.id] && resolvedImageUrl ? 'firearm-photo' : 'firearm-silhouette'}
|
||||||
alt={`${firearm.manufacturer} ${firearm.model}`}
|
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>
|
||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
@@ -1138,22 +1301,52 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="ammo-grid">
|
||||||
{ammoPageInventory.length === 0 ? (
|
{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) => (
|
ammoPageInventory.map((inventory) => (
|
||||||
<article className="ammo-card" key={inventory.caliberId}>
|
<article className="ammo-card" key={inventory.caliberId}>
|
||||||
<div className="ammo-card-top">
|
<div className="ammo-card-top">
|
||||||
<div>
|
<div>
|
||||||
<strong>{inventory.caliber}</strong>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="form-grid compact">
|
<div className="form-grid compact">
|
||||||
<label>
|
<label>
|
||||||
<span>Round adjustment</span>
|
<span>{isBoxTrackedCaliber(inventory.caliber) ? 'Box adjustment' : 'Round adjustment'}</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={ammoAdjustments[inventory.caliberId]?.rounds ?? ''}
|
value={ammoAdjustments[inventory.caliberId]?.rounds ?? ''}
|
||||||
@@ -1172,7 +1365,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card-footer">
|
<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">
|
<div className="button-row">
|
||||||
<button className="secondary-button" onClick={() => void adjustAmmo(inventory.caliberId, 'remove')} type="button">
|
<button className="secondary-button" onClick={() => void adjustAmmo(inventory.caliberId, 'remove')} type="button">
|
||||||
Remove ammo
|
Remove ammo
|
||||||
|
|||||||
Reference in New Issue
Block a user