Compare commits

...

17 Commits

Author SHA1 Message Date
Corey Blais be4e7e9a63 chanmged app.ts based on Onyxoasis's recommendations 2026-04-14 14:49:57 -04:00
Corey Blais 40e43c01b1 chanmged app.ts based on Onyxoasis's recommendations 2026-04-14 14:44:13 -04:00
blaisadmin b8ed8b94f2 fixing 12 gauge format 2026-03-29 22:56:21 -04:00
blaisadmin 289dbb324b added additonal 12 gauge types 2026-03-29 22:45:05 -04:00
blaisadmin a0c0d2a9eb remove 0 calibers from graph 2026-03-29 22:36:45 -04:00
blaisadmin 8aaf6f7902 single graph 2026-03-29 22:34:12 -04:00
blaisadmin bf18dcdc7b adding ammo graph 2026-03-29 22:29:10 -04:00
Corey Blais 4bc809ff8b site hardening 2026-03-27 10:04:54 -04:00
Corey Blais 900bf4eb06 Fixing image url 2026-03-26 17:19:01 -04:00
Corey Blais e078e3312b merging category and total counts 2026-03-26 17:16:45 -04:00
Corey Blais de2b63cd87 adding firearm filter and category summary 2026-03-26 17:12:47 -04:00
Corey Blais 8d42df6c27 added favicon 2026-03-26 12:34:58 -04:00
blaisadmin 96d9e3ebd5 updating compose 2026-03-26 11:14:53 -04:00
blaisadmin 266e45f16f Fixing login 2026-03-26 10:49:15 -04:00
blaisadmin 8b7763cf13 Added demo access 2026-03-26 09:59:40 -04:00
blaisadmin 45c6d03f8d Working and documented 2026-03-26 00:28:34 -04:00
blaisadmin 4cdfaa822e Working Prototype 2026-03-26 00:24:33 -04:00
19 changed files with 3910 additions and 1178 deletions
View File
+5
View File
@@ -4,6 +4,11 @@ POSTGRES_PASSWORD=change_me
NODE_ENV=development 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_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
+307 -1
View File
@@ -78,10 +78,316 @@ VITE_API_BASE_URL=https://api.arsenal.example.com/api
FRONTEND_URL=https://arsenal.example.com FRONTEND_URL=https://arsenal.example.com
``` ```
## API routes To disable self-service account creation and allow only existing users or SSO sign-in, set:
```env
ALLOW_REGISTRATION=false
```
## API
The app includes an Express API in [backend/src/app.ts](/home/corey/github/Arsenal_IQ/backend/src/app.ts). In local development, the frontend calls:
- Base URL: `http://localhost:5000/api`
- Health check: `http://localhost:5000/health`
### Authentication model
- Authenticated requests use `Authorization: Bearer <token>`
- The token is returned by local login/register or after a successful SSO callback
- The app is currently single-profile per user, even though profile endpoints still exist for compatibility
- Most inventory routes also accept `x-profile-id`, but in the current app this resolves to the users single arsenal/profile
### Environment flags
- `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
- Validation and business-rule errors generally return:
```json
{ "error": "Human readable message" }
```
- Successful login/register responses return:
```json
{
"token": "session-token",
"user": {
"id": "uuid",
"email": "owner@example.com",
"name": "Owner Name"
},
"profiles": [
{
"id": "uuid",
"name": "Owner"
}
],
"activeProfileId": "uuid"
}
```
### Core routes
#### Service and discovery
- `GET /health` - `GET /health`
- Returns service/database health
- `GET /api` - `GET /api`
- Returns API metadata and `allowRegistration`
Example:
```json
{
"name": "Arsenal IQ API",
"version": "3.0.0",
"allowRegistration": true,
"resources": [
"/api/auth/login",
"/api/auth/register",
"/api/dashboard",
"/api/firearms",
"/api/calibers",
"/api/ammo"
]
}
```
#### Authentication
- `GET /api/auth/providers`
- Public list of enabled SSO providers for the login page
- `POST /api/auth/register`
- 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`
- Returns the current authenticated user and active profile
- `GET /api/auth/sso/:providerKey/start`
- Starts the OIDC login flow and returns an authorization URL
- `GET /api/auth/sso/:providerKey/callback`
- Handles the provider callback, creates or links a user, then redirects back to the frontend with a token
Register request:
```json
{
"name": "Owner Name",
"email": "owner@example.com",
"password": "change-me"
}
```
Login request:
```json
{
"email": "owner@example.com",
"password": "change-me"
}
```
SSO behavior:
- If the SSO provider returns an email that matches an existing user, the SSO identity is linked to that user
- If the email does not exist yet, a new user is created automatically
- If the account was created via SSO only, local password login is rejected for that user
#### Profiles
- `GET /api/profiles`
- Returns the users profile list and active profile ID
- `POST /api/profiles`
- Currently disabled and returns `403`
- `POST /api/profiles/select`
- Returns the active profile ID
Note:
- The backend still exposes these endpoints, but the product now behaves as one user per arsenal/profile
#### Dashboard
- `GET /api/dashboard` - `GET /api/dashboard`
- Returns the current user, active profile, firearms, calibers, ammo inventory, defaults, and summary metrics used by the React UI
Summary fields:
- `totalFirearms`
- `totalAmmoRounds`
- `firearmsInvestment`
- `ammoInvestment`
- `configuredCalibers`
#### Firearms
- `GET /api/firearms` - `GET /api/firearms`
- Lists firearms for the current profile
- `POST /api/firearms`
- Creates a firearm
- `PUT /api/firearms/:id`
- Updates a firearm
- `DELETE /api/firearms/:id`
- Deletes a firearm
Firearm body:
```json
{
"manufacturer": "Glock",
"model": "19",
"category": "Handgun",
"caliber": "9mm",
"serialNumber": "ABC123",
"purchasePrice": 550,
"acquiredOn": "2025-06-01",
"imageUrl": "",
"notes": "Carry pistol"
}
```
Valid firearm categories:
- `Handgun`
- `Rifle`
- `Shotgun`
- `PCC`
- `Other`
Firearm response:
```json
{
"id": "uuid",
"manufacturer": "Glock",
"model": "19",
"category": "Handgun",
"caliber": "9mm",
"serialNumber": "ABC123",
"purchasePrice": 550,
"acquiredOn": "2025-06-01",
"imageUrl": null,
"notes": "Carry pistol"
}
```
#### Calibers
- `GET /api/calibers`
- Returns configured active calibers plus unused default caliber names
- `POST /api/calibers`
- Adds or re-enables a caliber for the current profile
- `PATCH /api/calibers/:id`
- Enables or disables a caliber
Create caliber request:
```json
{
"name": "300 BLK"
}
```
Toggle caliber request:
```json
{
"isActive": false
}
```
Caliber response:
```json
{
"id": "uuid",
"name": "9mm",
"isDefault": true,
"isActive": true
}
```
#### Ammo
- `GET /api/ammo` - `GET /api/ammo`
- Returns active caliber inventory rows for the current profile
- `PATCH /api/ammo/:caliberId`
- Adjusts rounds on hand and optionally updates cost per round
Ammo patch request:
```json
{
"rounds": 250,
"costPerRound": 0.24
}
```
Notes:
- Positive `rounds` adds to the current count
- Negative `rounds` removes from the current count
- The backend clamps the final total to `0`
- If `costPerRound` is omitted or `null`, the existing value is preserved
Ammo response:
```json
{
"caliberId": "uuid",
"caliber": "9mm",
"roundsOnHand": 750,
"costPerRound": 0.24,
"totalValue": 180
}
```
#### Auth provider settings
- `GET /api/settings/auth-providers`
- Returns the full editable provider configuration for the authenticated settings page
- `PUT /api/settings/auth-providers/:providerKey`
- Updates an auth provider config such as Google, Entra, or another OIDC-compatible provider
Provider update request:
```json
{
"displayName": "Google",
"protocol": "oidc",
"clientId": "your-client-id",
"clientSecret": "your-client-secret",
"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",
"scopes": "openid profile email",
"enabled": true
}
```
### Current limitations
- There is no password reset or account recovery flow yet
- There is no API versioning beyond the current route structure
- Profile endpoints remain present, but multiple profiles are intentionally disabled in the product
+4 -2
View File
@@ -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"]
+129 -20
View File
@@ -1,46 +1,155 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto; 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 INDEX IF NOT EXISTS idx_users_email ON users(email);
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 INDEX IF NOT EXISTS idx_profiles_user_id ON profiles(user_id);
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 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 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 ( CREATE TABLE IF NOT EXISTS calibers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(40) NOT NULL UNIQUE, profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
name VARCHAR(40) NOT NULL,
is_default BOOLEAN NOT NULL DEFAULT FALSE, is_default BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE, is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE (profile_id, name)
); );
CREATE INDEX IF NOT EXISTS idx_calibers_profile_id ON calibers(profile_id);
CREATE TABLE IF NOT EXISTS firearms ( CREATE TABLE IF NOT EXISTS firearms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
manufacturer VARCHAR(120) NOT NULL, manufacturer VARCHAR(120) NOT NULL,
model VARCHAR(120) NOT NULL, model VARCHAR(120) NOT NULL,
category VARCHAR(80) NOT NULL, category VARCHAR(80) NOT NULL,
caliber VARCHAR(40) NOT NULL, caliber VARCHAR(40) NOT NULL,
serial_number VARCHAR(120) NOT NULL UNIQUE, serial_number VARCHAR(120) NOT NULL,
purchase_price NUMERIC(10, 2) NOT NULL DEFAULT 0, purchase_price NUMERIC(10, 2) NOT NULL DEFAULT 0,
acquired_on DATE, acquired_on DATE,
image_url TEXT, image_url TEXT,
notes TEXT, notes TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
CREATE INDEX IF NOT EXISTS idx_firearms_profile_id ON firearms(profile_id);
CREATE TABLE IF NOT EXISTS ammo_inventory ( CREATE TABLE IF NOT EXISTS ammo_inventory (
caliber_id UUID PRIMARY KEY REFERENCES calibers(id) ON DELETE CASCADE, 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), rounds_on_hand INT NOT NULL DEFAULT 0 CHECK (rounds_on_hand >= 0),
cost_per_round NUMERIC(10, 2) NOT NULL DEFAULT 0, cost_per_round NUMERIC(10, 2) NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (profile_id, caliber_id)
); );
INSERT INTO calibers (name, is_default, is_active) INSERT INTO auth_provider_configs (
provider_key,
display_name,
protocol,
authorization_endpoint,
token_endpoint,
userinfo_endpoint,
issuer,
scopes,
enabled
)
VALUES VALUES
('9mm', TRUE, TRUE), (
('.22 LR', TRUE, TRUE), 'google',
('5.56 NATO', TRUE, TRUE), 'Google',
('.308 Win', TRUE, TRUE), 'oidc',
('12 Gauge', TRUE, TRUE), 'https://accounts.google.com/o/oauth2/v2/auth',
('.45 ACP', TRUE, TRUE) 'https://oauth2.googleapis.com/token',
ON CONFLICT (name) DO NOTHING; 'https://openidconnect.googleapis.com/v1/userinfo',
'https://accounts.google.com',
INSERT INTO ammo_inventory (caliber_id, rounds_on_hand, cost_per_round) 'openid profile email',
SELECT id, 0, 0 FALSE
FROM calibers ),
ON CONFLICT (caliber_id) DO NOTHING; (
'entra',
'Microsoft Entra ID',
'oidc',
'',
'',
'https://graph.microsoft.com/oidc/userinfo',
'',
'openid profile email',
FALSE
),
(
'oidc',
'Custom OIDC',
'oidc',
'',
'',
'',
'',
'openid profile email',
FALSE
)
ON CONFLICT (provider_key) DO NOTHING;
+225
View File
@@ -8,6 +8,8 @@
"name": "arsenal-iq-backend", "name": "arsenal-iq-backend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"axios": "1.6.2",
"bcryptjs": "2.4.3",
"cors": "2.8.5", "cors": "2.8.5",
"dotenv": "16.4.5", "dotenv": "16.4.5",
"express": "4.18.2", "express": "4.18.2",
@@ -17,10 +19,12 @@
"pg": "8.11.3" "pg": "8.11.3"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "2.4.6",
"@types/cors": "2.8.17", "@types/cors": "2.8.17",
"@types/express": "4.17.21", "@types/express": "4.17.21",
"@types/morgan": "1.9.9", "@types/morgan": "1.9.9",
"@types/node": "20.10.6", "@types/node": "20.10.6",
"@types/pg": "8.10.9",
"tsx": "4.7.0", "tsx": "4.7.0",
"typescript": "5.3.3" "typescript": "5.3.3"
} }
@@ -416,6 +420,13 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/body-parser": { "node_modules/@types/body-parser": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -500,6 +511,80 @@
"undici-types": "~5.26.4" "undici-types": "~5.26.4"
} }
}, },
"node_modules/@types/pg": {
"version": "8.10.9",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.10.9.tgz",
"integrity": "sha512-UksbANNE/f8w0wOMxVKKIrLCbEMV+oM1uKejmwXr39olg4xqcfBDbXxObJAt6XxHbDa4XTKOlUEcEltXDX+XLQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^4.0.1"
}
},
"node_modules/@types/pg/node_modules/pg-types": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.1.0.tgz",
"integrity": "sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg==",
"dev": true,
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"pg-numeric": "1.0.2",
"postgres-array": "~3.0.1",
"postgres-bytea": "~3.0.0",
"postgres-date": "~2.1.0",
"postgres-interval": "^3.0.0",
"postgres-range": "^1.1.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@types/pg/node_modules/postgres-array": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz",
"integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/@types/pg/node_modules/postgres-bytea": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz",
"integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==",
"dev": true,
"license": "MIT",
"dependencies": {
"obuf": "~1.1.2"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/@types/pg/node_modules/postgres-date": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz",
"integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/@types/pg/node_modules/postgres-interval": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz",
"integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/@types/qs": { "node_modules/@types/qs": {
"version": "6.15.0", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
@@ -554,6 +639,23 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz",
"integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/basic-auth": { "node_modules/basic-auth": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
@@ -572,6 +674,12 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
"license": "MIT"
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "1.20.1", "version": "1.20.1",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz",
@@ -643,6 +751,18 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -701,6 +821,15 @@
"ms": "2.0.0" "ms": "2.0.0"
} }
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -791,6 +920,21 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.19.12", "version": "0.19.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz",
@@ -920,6 +1064,42 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/forwarded": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1036,6 +1216,21 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -1230,6 +1425,13 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/obuf": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz",
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
"dev": true,
"license": "MIT"
},
"node_modules/on-finished": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -1323,6 +1525,16 @@
"node": ">=4.0.0" "node": ">=4.0.0"
} }
}, },
"node_modules/pg-numeric": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz",
"integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=4"
}
},
"node_modules/pg-pool": { "node_modules/pg-pool": {
"version": "3.13.0", "version": "3.13.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
@@ -1402,6 +1614,13 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/postgres-range": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz",
"integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==",
"dev": true,
"license": "MIT"
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1415,6 +1634,12 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/qs": { "node_modules/qs": {
"version": "6.11.0", "version": "6.11.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz",
+4
View File
@@ -10,6 +10,8 @@
"start": "node dist/app.js" "start": "node dist/app.js"
}, },
"dependencies": { "dependencies": {
"axios": "1.6.2",
"bcryptjs": "2.4.3",
"cors": "2.8.5", "cors": "2.8.5",
"dotenv": "16.4.5", "dotenv": "16.4.5",
"express": "4.18.2", "express": "4.18.2",
@@ -19,10 +21,12 @@
"pg": "8.11.3" "pg": "8.11.3"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "2.4.6",
"@types/cors": "2.8.17", "@types/cors": "2.8.17",
"@types/express": "4.17.21", "@types/express": "4.17.21",
"@types/morgan": "1.9.9", "@types/morgan": "1.9.9",
"@types/node": "20.10.6", "@types/node": "20.10.6",
"@types/pg": "8.10.9",
"tsx": "4.7.0", "tsx": "4.7.0",
"typescript": "5.3.3" "typescript": "5.3.3"
} }
+672 -356
View File
File diff suppressed because it is too large Load Diff
+716
View File
@@ -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
View File
@@ -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}
+28 -7
View File
@@ -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,8 +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_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
@@ -41,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:
@@ -50,6 +57,8 @@ services:
container_name: arsenaliq-frontend container_name: arsenaliq-frontend
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_DEMO_ACCOUNT: ${ALLOW_DEMO_ACCOUNT:-false}
depends_on: depends_on:
- backend - backend
ports: ports:
@@ -60,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}
+26
View File
@@ -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
+1
View File
@@ -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>
+35
View File
@@ -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;
}
}
+20
View File
@@ -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

+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://arsenal.blaishome.online/sitemap.xml
+8
View File
@@ -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>
+524 -274
View File
@@ -3,49 +3,29 @@
@tailwind utilities; @tailwind utilities;
:root { :root {
color-scheme: dark; font-family: "Segoe UI", "Inter", sans-serif;
--bg: #0d1216; color: #e8eadf;
--panel: rgba(20, 28, 34, 0.92); background:
--panel-soft: rgba(28, 38, 45, 0.84); radial-gradient(circle at top left, rgba(94, 112, 71, 0.28), transparent 32%),
--line: rgba(255, 255, 255, 0.08); radial-gradient(circle at bottom right, rgba(67, 80, 51, 0.26), transparent 30%),
--text: #edf3ef; linear-gradient(160deg, #10130f 0%, #171b15 45%, #0d100c 100%);
--muted: #97a8a5;
--gold: #d8b36a;
--accent: #78b8a4;
--shadow: 0 24px 70px rgba(0, 0, 0, 0.34);
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
html { html,
background: body,
radial-gradient(circle at top left, rgba(216, 179, 106, 0.16), transparent 22%), #root {
radial-gradient(circle at bottom right, rgba(120, 184, 164, 0.14), transparent 24%), min-height: 100%;
linear-gradient(180deg, #11181d 0%, #0a0f12 100%); margin: 0;
} }
body { body {
margin: 0;
min-width: 320px;
min-height: 100vh; min-height: 100vh;
color: var(--text); color: #e8eadf;
font-family: "Avenir Next", "Segoe UI", sans-serif; background: transparent;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
min-height: 100vh;
}
h1,
h2,
h3,
.eyebrow,
.panel-kicker {
font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
} }
button, button,
@@ -55,233 +35,528 @@ textarea {
font: inherit; font: inherit;
} }
button {
cursor: pointer;
}
input, input,
select, select,
textarea { textarea {
width: 100%; width: 100%;
margin-top: 8px; border: 1px solid rgba(171, 180, 140, 0.18);
padding: 12px 14px;
color: var(--text);
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px; border-radius: 14px;
appearance: none; background: rgba(12, 16, 11, 0.72);
-webkit-appearance: none; color: #eef1e5;
-moz-appearance: none; padding: 0.85rem 1rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
} }
select { input:focus,
background: select:focus,
linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.03)), textarea:focus {
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 20 20' fill='none'%3E%3Cpath d='M5 7.5L10 12.5L15 7.5' stroke='%23d8b36a' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") outline: none;
no-repeat right 14px center; border-color: rgba(140, 158, 101, 0.74);
padding-right: 42px; box-shadow: 0 0 0 3px rgba(109, 127, 73, 0.2);
}
select option {
color: var(--text);
background: #162027;
} }
textarea { textarea {
resize: vertical; resize: vertical;
} }
.app-shell {
display: grid;
grid-template-columns: 290px minmax(0, 1fr);
gap: 22px;
width: min(1440px, calc(100% - 28px));
margin: 0 auto;
padding: 20px 0 36px;
}
.sidebar,
.panel,
.summary-card,
.error-banner {
border: 1px solid var(--line);
background: var(--panel);
backdrop-filter: blur(16px);
box-shadow: var(--shadow);
}
.sidebar {
display: flex;
flex-direction: column;
gap: 18px;
padding: 22px;
border-radius: 28px;
position: sticky;
top: 20px;
height: fit-content;
}
.brand-block h1,
.stage-header h2 {
margin: 8px 0 12px;
}
.brand-block p,
.summary-card p,
.placeholder-copy,
.settings-row p,
.ammo-card p,
.firearm-card p,
.mini-stat span,
.card-footer span {
color: var(--muted);
}
.eyebrow, .eyebrow,
.panel-kicker { .panel-kicker {
color: var(--gold);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.16em; letter-spacing: 0.16em;
font-size: 0.76rem; font-size: 0.72rem;
color: #aab37d;
} }
.nav-stack { .loading-shell,
.auth-shell {
min-height: 100vh;
display: grid; display: grid;
gap: 10px; gap: 2rem;
align-items: center;
padding: 3rem;
} }
.nav-button, .loading-shell {
.primary-button, place-items: center;
.secondary-button, }
.chip-button {
.loading-card,
.auth-card,
.auth-hero,
.panel,
.sidebar {
border: 1px solid rgba(171, 180, 140, 0.12);
background: rgba(17, 22, 16, 0.76);
backdrop-filter: blur(16px);
box-shadow: 0 28px 60px rgba(0, 0, 0, 0.28);
}
.loading-card,
.auth-card,
.auth-hero {
border-radius: 28px;
padding: 2rem;
}
.auth-shell {
grid-template-columns: 1.1fr 0.9fr;
}
.auth-hero {
min-height: 520px;
display: flex;
flex-direction: column;
justify-content: center;
background:
linear-gradient(135deg, rgba(103, 120, 68, 0.2), transparent 55%),
rgba(17, 22, 16, 0.82);
}
.auth-brand {
display: flex;
align-items: center;
gap: 1rem;
}
.brand-mark {
width: 72px;
height: 72px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 10px;
border-radius: 14px;
border: 1px solid transparent;
cursor: pointer;
}
.nav-button {
width: 100%;
justify-content: flex-start;
padding: 14px 16px;
background: rgba(255, 255, 255, 0.03);
color: var(--text);
}
.nav-button.active {
background: linear-gradient(135deg, rgba(216, 179, 106, 0.18), rgba(120, 184, 164, 0.12));
border-color: rgba(216, 179, 106, 0.18);
}
.summary-card {
padding: 18px;
border-radius: 22px; border-radius: 22px;
background: linear-gradient(180deg, rgba(216, 179, 106, 0.12), rgba(255, 255, 255, 0.02)); color: #dfe6c8;
background: linear-gradient(145deg, rgba(104, 121, 68, 0.34), rgba(47, 56, 36, 0.76));
border: 1px solid rgba(171, 180, 140, 0.18);
} }
.summary-card strong { .auth-hero h1 {
display: block; font-size: clamp(2.4rem, 5vw, 4.2rem);
margin-top: 10px; line-height: 1.04;
font-size: 2rem; margin: 0.35rem 0 0;
max-width: none;
} }
.main-stage { .auth-hero p,
display: grid; .muted-copy,
gap: 18px; .header-copy,
.card-footer span,
.settings-row p,
.provider-header p {
color: #b8c0af;
} }
.stage-header { .hero-tags,
.chip-row,
.button-row {
display: flex; display: flex;
align-items: flex-end; gap: 0.75rem;
justify-content: space-between; flex-wrap: wrap;
gap: 18px;
padding: 8px 4px;
} }
.stage-stats { .hero-tags span,
display: flex; .profile-chip,
gap: 14px; .status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
border-radius: 999px;
padding: 0.55rem 0.95rem;
background: rgba(171, 180, 140, 0.08);
border: 1px solid rgba(171, 180, 140, 0.12);
color: inherit;
} }
.mini-stat { .auth-card {
min-width: 160px; max-width: 520px;
padding: 14px 16px; width: 100%;
border-radius: 18px; justify-self: center;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.05);
} }
.mini-stat strong { .auth-tabs,
display: block; .settings-inline,
margin-top: 8px; .header-tools,
font-size: 1.2rem; .provider-header,
} .toggle-row,
.panel {
padding: 22px;
border-radius: 26px;
}
.panel-heading,
.card-footer,
.ammo-card-top, .ammo-card-top,
.settings-row { .panel-heading,
.settings-row,
.card-footer,
.stage-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 1rem;
} }
.panel-heading { .auth-tabs {
margin-bottom: 18px; margin-bottom: 1.25rem;
} }
.tab-button,
.nav-button,
.profile-chip,
.secondary-button,
.primary-button {
border: 0;
transition: transform 0.2s ease, background 0.2s ease, border-color 0.2s ease;
}
.tab-button {
flex: 1;
padding: 0.95rem 1rem;
border-radius: 16px;
background: rgba(171, 180, 140, 0.06);
color: #dce2d2;
}
.tab-button.active,
.nav-button.active,
.primary-button,
.profile-chip.active {
background: linear-gradient(135deg, #89985f, #4c5736);
color: #eef3e4;
}
.form-stack,
.settings-block,
.settings-list,
.provider-config-grid,
.nav-stack,
.main-stage,
.view-grid,
.firearm-grid,
.ammo-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;
color: #8d9586;
position: relative;
}
.auth-divider span {
position: relative;
padding: 0 0.75rem;
background: rgba(16, 20, 27, 0.92);
}
.auth-divider::before {
content: "";
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background: rgba(171, 180, 140, 0.12);
}
.provider-list {
display: grid;
gap: 0.75rem;
}
.sso-button {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 14px;
padding: 0.9rem 1rem;
border: 1px solid rgba(171, 180, 140, 0.12);
background: rgba(171, 180, 140, 0.06);
color: #edf0e3;
transition: transform 0.2s ease, background 0.2s ease, border-color 0.2s ease;
}
.sso-button:hover {
transform: translateY(-1px);
background: rgba(171, 180, 140, 0.12);
}
.full-width {
width: 100%;
}
.error-banner,
.success-banner {
margin: 0;
padding: 0.95rem 1rem;
border-radius: 16px;
}
.error-banner {
background: rgba(146, 49, 49, 0.24);
border: 1px solid rgba(222, 96, 96, 0.28);
color: #ffd0d0;
}
.success-banner {
background: rgba(43, 97, 76, 0.24);
border: 1px solid rgba(88, 180, 143, 0.28);
color: #d3ffe8;
}
.toast-banner {
position: fixed;
top: 1rem;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
min-width: min(520px, calc(100vw - 2rem));
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.28);
}
.primary-button,
.secondary-button,
.nav-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.55rem;
border-radius: 14px;
padding: 0.85rem 1.1rem;
}
.primary-button {
font-weight: 700;
}
.secondary-button,
.nav-button,
.profile-chip {
background: rgba(171, 180, 140, 0.06);
color: #edf0e3;
border: 1px solid rgba(171, 180, 140, 0.12);
}
.nav-button {
justify-content: flex-start;
}
.primary-button:hover,
.secondary-button:hover,
.tab-button:hover,
.nav-button:hover,
.profile-chip:hover {
transform: translateY(-1px);
}
.app-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 300px 1fr;
gap: 1.5rem;
padding: 1.5rem;
}
.sidebar,
.panel {
border-radius: 28px;
}
.sidebar {
padding: 1.5rem;
position: sticky;
top: 1.5rem;
height: calc(100vh - 3rem);
}
.mobile-sidebar {
display: none;
}
.brand-block h1,
.stage-header h2,
.panel h3 {
margin: 0.55rem 0 0.35rem;
}
.main-stage {
align-content: start;
}
.stage-header {
padding: 0.5rem 0;
}
.profile-picker {
min-width: 220px;
}
.profile-picker span,
label span {
display: block;
margin-bottom: 0.45rem;
color: #d9e0e6;
font-size: 0.92rem;
}
.stage-stats,
.view-grid, .view-grid,
.settings-grid { .settings-grid {
display: grid; display: grid;
gap: 18px; gap: 1rem;
}
.stage-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.mini-stat {
border-radius: 22px;
padding: 1.2rem 1.35rem;
background: rgba(17, 22, 16, 0.72);
border: 1px solid rgba(171, 180, 140, 0.12);
}
.mini-stat span {
color: #b7bead;
display: block;
margin-bottom: 0.45rem;
}
.mini-stat strong {
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.4fr) minmax(340px, 0.8fr); grid-template-columns: minmax(0, 1.35fr) minmax(360px, 0.8fr);
}
.settings-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.auth-settings-panel,
.settings-menu-panel {
grid-column: 1 / -1;
}
.panel {
padding: 1.4rem;
} }
.firearm-grid, .firearm-grid,
.ammo-grid, .ammo-grid,
.settings-list, .provider-config-grid {
.chip-grid { margin-top: 1rem;
display: grid;
gap: 14px;
} }
.ammo-toolbar { .firearm-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
gap: 14px; }
margin-bottom: 18px;
.ammo-grid,
.provider-config-grid {
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
} }
.firearm-card, .firearm-card,
.ammo-card { .ammo-card,
padding: 18px; .provider-card {
border-radius: 22px; border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.06); padding: 1rem;
background: var(--panel-soft); background: rgba(12, 16, 11, 0.74);
} border: 1px solid rgba(171, 180, 140, 0.1);
.firearm-card {
display: grid;
gap: 16px;
} }
.firearm-visual { .firearm-visual {
overflow: hidden; min-height: 180px;
border-radius: 18px; border-radius: 20px;
aspect-ratio: 16 / 7; padding: 1rem;
background: rgba(255, 255, 255, 0.04); display: flex;
align-items: center;
justify-content: center;
background: rgba(171, 180, 140, 0.04);
} }
.firearm-visual img { .firearm-photo,
.firearm-silhouette {
width: 100%; width: 100%;
height: 100%; max-height: 180px;
border-radius: 18px;
} }
.firearm-photo { .firearm-photo {
@@ -297,134 +572,109 @@ textarea {
.form-grid { .form-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px; gap: 0.9rem;
margin-top: 1rem;
} }
.form-grid.compact { .form-grid.compact {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: minmax(0, 1fr);
} }
.full-width { .full-width {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
label span {
display: block;
color: var(--muted);
font-size: 0.84rem;
}
.card-footer { .card-footer {
margin-top: 4px; margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(171, 180, 140, 0.08);
} }
.primary-button, .placeholder-copy {
.secondary-button, margin: 0;
.chip-button { color: #abb4a1;
padding: 12px 16px; padding: 1rem 0;
} }
.button-row { .filter-control {
display: flex; min-width: 210px;
gap: 10px;
} }
.primary-button { .filter-control span {
background: var(--gold); margin-bottom: 0.45rem;
color: #16120d;
}
.secondary-button {
background: rgba(255, 255, 255, 0.05);
color: var(--text);
border-color: rgba(255, 255, 255, 0.08);
}
.chip-grid {
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
}
.chip-button {
background: rgba(255, 255, 255, 0.04);
color: var(--text);
border-color: rgba(255, 255, 255, 0.08);
}
.chip-button.disabled {
opacity: 0.45;
cursor: not-allowed;
}
.settings-inline {
display: flex;
gap: 12px;
}
.settings-inline input {
margin-top: 0;
} }
.settings-row { .settings-row {
padding: 14px 0; padding: 0.95rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05); border-top: 1px solid rgba(171, 180, 140, 0.08);
} }
.settings-row:last-child { .settings-row:first-child {
border-bottom: 0; border-top: 0;
padding-top: 0;
} }
.badge { .settings-row.static {
display: inline-flex; padding-bottom: 0.4rem;
align-items: center;
border-radius: 999px;
padding: 8px 12px;
background: rgba(120, 184, 164, 0.14);
border: 1px solid rgba(120, 184, 164, 0.18);
color: #c9efe4;
} }
.error-banner { .status-pill {
padding: 14px 16px; color: #eef3e4;
border-radius: 18px; background: linear-gradient(135deg, #829455, #59693d);
background: rgba(201, 83, 83, 0.16); border: 0;
border-color: rgba(201, 83, 83, 0.3);
} }
@media (max-width: 1120px) { .toggle-row {
gap: 0.65rem;
color: #d7ddc8;
}
.toggle-row input {
width: 18px;
height: 18px;
padding: 0;
}
@media (max-width: 1100px) {
.app-shell, .app-shell,
.view-grid { .auth-shell,
.view-grid,
.settings-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.desktop-sidebar {
display: none;
}
.mobile-sidebar {
display: block;
position: static;
height: auto;
}
.sidebar { .sidebar {
position: static; position: static;
height: auto;
} }
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.app-shell { .app-shell,
width: min(100% - 16px, 1440px); .auth-shell {
padding-top: 12px; padding: 1rem;
} }
.stage-header, .stage-header,
.panel-heading, .header-tools,
.card-footer, .card-footer,
.button-row, .settings-inline {
.settings-inline,
.settings-row,
.ammo-card-top {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: stretch;
} }
.stage-stats, .stage-stats,
.form-grid, .form-grid {
.form-grid.compact {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.mini-stat {
width: 100%;
}
} }
+1078 -395
View File
File diff suppressed because it is too large Load Diff