diff --git a/.env.example b/.env.example index 64d3240..3262269 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,7 @@ POSTGRES_PASSWORD=change_me NODE_ENV=development FRONTEND_URL=http://localhost:3000 VITE_API_BASE_URL=http://localhost:5000/api +ALLOW_REGISTRATION=true # Production-only Traefik settings TRAEFIK_NETWORK=traefik_proxy TRAEFIK_ENTRYPOINT=websecure diff --git a/README.md b/README.md index e1b523a..1444cf5 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,12 @@ VITE_API_BASE_URL=https://api.arsenal.example.com/api FRONTEND_URL=https://arsenal.example.com ``` +To disable self-service account creation and allow only existing users or SSO sign-in, set: + +```env +ALLOW_REGISTRATION=false +``` + ## API routes - `GET /health` diff --git a/backend/database/init.sql b/backend/database/init.sql index 9e004bb..480e6d9 100644 --- a/backend/database/init.sql +++ b/backend/database/init.sql @@ -1,46 +1,155 @@ 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 ( 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_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 ( 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 UNIQUE, + 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 TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + created_at TIMESTAMPTZ 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 ( - 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), 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 - ('9mm', TRUE, TRUE), - ('.22 LR', TRUE, TRUE), - ('5.56 NATO', TRUE, TRUE), - ('.308 Win', TRUE, TRUE), - ('12 Gauge', TRUE, TRUE), - ('.45 ACP', TRUE, TRUE) -ON CONFLICT (name) DO NOTHING; - -INSERT INTO ammo_inventory (caliber_id, rounds_on_hand, cost_per_round) -SELECT id, 0, 0 -FROM calibers -ON CONFLICT (caliber_id) DO NOTHING; + ( + 'google', + 'Google', + 'oidc', + 'https://accounts.google.com/o/oauth2/v2/auth', + 'https://oauth2.googleapis.com/token', + 'https://openidconnect.googleapis.com/v1/userinfo', + 'https://accounts.google.com', + 'openid profile email', + FALSE + ), + ( + '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; diff --git a/backend/package-lock.json b/backend/package-lock.json index 83eae55..e63038d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,6 +8,8 @@ "name": "arsenal-iq-backend", "version": "1.0.0", "dependencies": { + "axios": "1.6.2", + "bcryptjs": "2.4.3", "cors": "2.8.5", "dotenv": "16.4.5", "express": "4.18.2", @@ -17,10 +19,12 @@ "pg": "8.11.3" }, "devDependencies": { + "@types/bcryptjs": "2.4.6", "@types/cors": "2.8.17", "@types/express": "4.17.21", "@types/morgan": "1.9.9", "@types/node": "20.10.6", + "@types/pg": "8.10.9", "tsx": "4.7.0", "typescript": "5.3.3" } @@ -416,6 +420,13 @@ "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": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -500,6 +511,80 @@ "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": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", @@ -554,6 +639,23 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", @@ -572,6 +674,12 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "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": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", @@ -643,6 +751,18 @@ "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": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -701,6 +821,15 @@ "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -791,6 +920,21 @@ "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": { "version": "0.19.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", @@ -920,6 +1064,42 @@ "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": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1036,6 +1216,21 @@ "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": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -1230,6 +1425,13 @@ "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": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -1323,6 +1525,16 @@ "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": { "version": "3.13.0", "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", @@ -1402,6 +1614,13 @@ "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": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1415,6 +1634,12 @@ "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": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", diff --git a/backend/package.json b/backend/package.json index 8c7d1cf..86a4f6d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,6 +10,8 @@ "start": "node dist/app.js" }, "dependencies": { + "axios": "1.6.2", + "bcryptjs": "2.4.3", "cors": "2.8.5", "dotenv": "16.4.5", "express": "4.18.2", @@ -19,10 +21,12 @@ "pg": "8.11.3" }, "devDependencies": { + "@types/bcryptjs": "2.4.6", "@types/cors": "2.8.17", "@types/express": "4.17.21", "@types/morgan": "1.9.9", "@types/node": "20.10.6", + "@types/pg": "8.10.9", "tsx": "4.7.0", "typescript": "5.3.3" } diff --git a/backend/src/app.ts b/backend/src/app.ts index 75fea29..925b041 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,49 +1,49 @@ +import crypto from 'crypto'; +import axios from 'axios'; +import bcrypt from 'bcryptjs'; import cors from 'cors'; import dotenv from 'dotenv'; import express from 'express'; import helmet from 'helmet'; import rateLimit from 'express-rate-limit'; import morgan from 'morgan'; -import { Pool } from 'pg'; +import pg from 'pg'; dotenv.config(); -const app = express(); -const port = Number(process.env.PORT ?? 5000); -const databaseUrl = process.env.DATABASE_URL; +type UserRow = { + id: string; + email: string; + name: string; +}; -if (!databaseUrl) { - throw new Error('DATABASE_URL is required'); -} +type ProfileRow = { + id: string; + name: string; +}; -const pool = new Pool({ connectionString: databaseUrl }); +type SessionRow = { + id: string; + user_id: string; + active_profile_id: string | null; + expires_at: string; +}; -const defaultCalibers = ['9mm', '.22 LR', '5.56 NATO', '.308 Win', '12 Gauge', '.45 ACP']; +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; +}; -const allowedOrigins = (process.env.FRONTEND_URL ?? 'http://localhost:3000') - .split(',') - .map((origin) => origin.trim()) - .filter(Boolean); - -app.use(helmet()); -app.use( - cors({ - origin: allowedOrigins, - credentials: true, - }), -); -app.use( - rateLimit({ - windowMs: 15 * 60 * 1000, - max: 250, - standardHeaders: true, - legacyHeaders: false, - }), -); -app.use(morgan('combined')); -app.use(express.json()); - -type DashboardSummary = { +type DashboardSummaryRow = { totalFirearms: number; totalAmmoRounds: number; firearmsInvestment: string; @@ -78,26 +78,39 @@ type AmmoInventoryRow = { cost_per_round: string; }; -type FirearmInput = { - manufacturer?: unknown; - model?: unknown; - category?: unknown; - caliber?: unknown; - serialNumber?: unknown; - purchasePrice?: unknown; - acquiredOn?: unknown; - imageUrl?: unknown; - notes?: unknown; +type AuthContext = { + user: UserRow; + session: SessionRow; + token: string; }; -type CaliberInput = { - name?: unknown; -}; +declare global { + namespace Express { + interface Request { + auth?: AuthContext; + } + } +} -type AmmoAdjustmentInput = { - rounds?: unknown; - costPerRound?: unknown; -}; +const app = express(); +const port = Number(process.env.PORT ?? 5000); +const databaseUrl = + process.env.DATABASE_URL || + `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@localhost:5432/${process.env.POSTGRES_DB}`; +const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; +const apiBaseUrl = process.env.API_BASE_URL || 'http://localhost:5000/api'; +const allowRegistration = (process.env.ALLOW_REGISTRATION ?? 'true').toLowerCase() !== 'false'; +const { Pool } = pg; +const pool = new Pool({ connectionString: databaseUrl }); + +const defaultCalibers = ['9mm', '.22 LR', '5.56 NATO', '.308 Win', '12 Gauge', '.45 ACP']; +const firearmCategories = ['Handgun', 'Rifle', 'Shotgun', 'PCC', 'Other']; +const sessionHours = 24 * 7; + +const allowedOrigins = (process.env.FRONTEND_URL ?? 'http://localhost:3000,http://localhost:5173') + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean); const formatCurrency = (value: string | number | null): number => Number(value ?? 0); @@ -138,6 +151,11 @@ const getNumber = (value: unknown, fieldName: string): number => { return parsed; }; +const normalizeEmail = (email: string) => email.trim().toLowerCase(); +const hashToken = (token: string) => crypto.createHash('sha256').update(token).digest('hex'); +const createSessionToken = () => crypto.randomBytes(32).toString('hex'); +const isUuid = (value: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); + const normalizeFirearm = (row: FirearmRow) => ({ id: row.id, manufacturer: row.manufacturer, @@ -161,82 +179,441 @@ const normalizeCaliber = (row: CaliberRow) => ({ const normalizeAmmoInventory = (row: AmmoInventoryRow) => ({ caliberId: row.caliber_id, caliber: row.caliber_name, - roundsOnHand: row.rounds_on_hand, + roundsOnHand: Number(row.rounds_on_hand || 0), costPerRound: formatCurrency(row.cost_per_round), - totalValue: formatCurrency(row.cost_per_round) * row.rounds_on_hand, + totalValue: Number(row.rounds_on_hand || 0) * formatCurrency(row.cost_per_round), }); +const serializeProvider = (provider: ProviderConfigRow) => ({ + providerKey: provider.provider_key, + displayName: provider.display_name, + protocol: provider.protocol, + clientId: provider.client_id ?? '', + clientSecret: provider.client_secret ?? '', + authorizationEndpoint: provider.authorization_endpoint ?? '', + tokenEndpoint: provider.token_endpoint ?? '', + userinfoEndpoint: provider.userinfo_endpoint ?? '', + issuer: provider.issuer ?? '', + scopes: provider.scopes, + enabled: provider.enabled, +}); + +const decodeJwtPayload = (token: string) => { + const segments = token.split('.'); + + if (segments.length < 2) { + return null; + } + + const payload = segments[1].replace(/-/g, '+').replace(/_/g, '/'); + const padded = payload.padEnd(Math.ceil(payload.length / 4) * 4, '='); + + try { + return JSON.parse(Buffer.from(padded, 'base64').toString('utf8')) as Record; + } catch { + return null; + } +}; + const ensureSchema = async () => { await 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(), - 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_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 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 UNIQUE, + 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 TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); 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), 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) ); - ALTER TABLE firearms ADD COLUMN IF NOT EXISTS image_url TEXT; - ALTER TABLE firearms ADD COLUMN IF NOT EXISTS notes TEXT; - ALTER TABLE firearms ADD COLUMN IF NOT EXISTS acquired_on DATE; - ALTER TABLE firearms ADD COLUMN IF NOT EXISTS purchase_price NUMERIC(10, 2) NOT NULL DEFAULT 0; - - CREATE INDEX IF NOT EXISTS idx_firearms_caliber ON firearms(caliber); - CREATE INDEX IF NOT EXISTS idx_calibers_active ON calibers(is_active); + 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); `); - for (const caliber of defaultCalibers) { + 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 pool.query( - ` - INSERT INTO calibers (name, is_default, is_active) - VALUES ($1, TRUE, TRUE) - ON CONFLICT (name) DO UPDATE - SET is_default = TRUE - `, - [caliber], + `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, + ], ); } +}; - await pool.query( - ` - DELETE FROM firearms - WHERE serial_number IN ('G19-EXAMPLE-001', 'R1022-EXAMPLE-002', 'M590A1-EXAMPLE-003') - `, +const ensureProfileDefaults = async (profileId: string) => { + for (const caliber of defaultCalibers) { + const caliberResult = await pool.query( + `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 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, caliberResult.rows[0].id], + ); + } +}; + +const getUserProfiles = async (userId: string) => { + const result = await pool.query( + 'SELECT id, name FROM profiles WHERE user_id = $1 ORDER BY created_at ASC', + [userId], ); - await pool.query(` - INSERT INTO ammo_inventory (caliber_id, rounds_on_hand, cost_per_round) - SELECT c.id, 0, 0 - FROM calibers c - WHERE c.is_active = TRUE - ON CONFLICT (caliber_id) DO NOTHING - `); + return result.rows; }; +const ensureDefaultProfile = async (userId: string, userName: string) => { + const profiles = await getUserProfiles(userId); + + if (profiles.length > 0) { + await ensureProfileDefaults(profiles[0].id); + return profiles[0]; + } + + const created = await pool.query( + 'INSERT INTO profiles (user_id, name) VALUES ($1, $2) RETURNING id, name', + [userId, `${userName.split(' ')[0] || 'Primary'} Arsenal`], + ); + + await ensureProfileDefaults(created.rows[0].id); + return created.rows[0]; +}; + +const createSession = async (userId: string, activeProfileId: string) => { + const token = createSessionToken(); + const tokenHash = hashToken(token); + const expiresAt = new Date(Date.now() + sessionHours * 60 * 60 * 1000); + + const result = await pool.query( + `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, expiresAt.toISOString()], + ); + + return { + token, + session: result.rows[0], + }; +}; + +const getSessionFromToken = async (rawToken: string) => { + const tokenHash = hashToken(rawToken); + const result = await pool.query( + `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], + ); + + if ((result.rowCount ?? 0) === 0) { + return null; + } + + return { + user: { + id: result.rows[0].user_id, + email: result.rows[0].email, + name: result.rows[0].name, + }, + session: { + id: result.rows[0].id, + user_id: result.rows[0].user_id, + active_profile_id: result.rows[0].active_profile_id, + expires_at: result.rows[0].expires_at, + }, + }; +}; + +const getAuthProvider = async (providerKey: string) => { + const result = await pool.query( + `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; +}; + +const buildDashboard = async (profileId: string) => { + await ensureProfileDefaults(profileId); + + const [summaryResult, firearmsResult, calibersResult, ammoResult] = await Promise.all([ + pool.query( + `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], + ), + pool.query( + `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], + ), + pool.query( + `SELECT id, name, is_default, is_active + FROM calibers + WHERE profile_id = $1 + ORDER BY is_active DESC, is_default DESC, name ASC`, + [profileId], + ), + pool.query( + `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], + ), + ]); + + const summary = summaryResult.rows[0]; + + return { + summary: { + totalFirearms: Number(summary.totalFirearms || 0), + totalAmmoRounds: Number(summary.totalAmmoRounds || 0), + firearmsInvestment: formatCurrency(summary.firearmsInvestment), + ammoInvestment: formatCurrency(summary.ammoInvestment), + configuredCalibers: Number(summary.configuredCalibers || 0), + }, + firearms: firearmsResult.rows.map(normalizeFirearm), + calibers: calibersResult.rows.map(normalizeCaliber), + ammoInventory: ammoResult.rows.map(normalizeAmmoInventory), + defaultCalibers, + }; +}; + +const resolveProfileId = async (req: express.Request) => { + const requestedProfileId = String(req.header('x-profile-id') || ''); + + if (requestedProfileId && isUuid(requestedProfileId)) { + const result = await pool.query( + 'SELECT id, name FROM profiles WHERE id = $1 AND user_id = $2', + [requestedProfileId, req.auth!.user.id], + ); + + if ((result.rowCount ?? 0) > 0) { + await ensureProfileDefaults(requestedProfileId); + await pool.query('UPDATE auth_sessions SET active_profile_id = $1 WHERE id = $2', [ + requestedProfileId, + req.auth!.session.id, + ]); + req.auth!.session.active_profile_id = requestedProfileId; + return requestedProfileId; + } + } + + if (req.auth!.session.active_profile_id) { + await ensureProfileDefaults(req.auth!.session.active_profile_id); + return req.auth!.session.active_profile_id; + } + + const fallbackProfile = await ensureDefaultProfile(req.auth!.user.id, req.auth!.user.name); + await pool.query('UPDATE auth_sessions SET active_profile_id = $1 WHERE id = $2', [ + fallbackProfile.id, + req.auth!.session.id, + ]); + req.auth!.session.active_profile_id = fallbackProfile.id; + return fallbackProfile.id; +}; + +const requireAuth = async (req: express.Request, res: express.Response, next: express.NextFunction) => { + try { + const header = req.header('authorization'); + const token = header?.startsWith('Bearer ') ? header.slice(7) : ''; + + if (!token) { + res.status(401).json({ error: 'Authentication required' }); + return; + } + + const sessionData = await getSessionFromToken(token); + + if (!sessionData) { + res.status(401).json({ error: 'Session expired or invalid' }); + return; + } + + req.auth = { + user: sessionData.user, + session: sessionData.session, + token, + }; + + next(); + } catch (error) { + next(error); + } +}; + +app.use(helmet()); +app.use( + cors({ + origin(origin, callback) { + if (!origin || allowedOrigins.includes(origin)) { + callback(null, true); + return; + } + + callback(new Error('Origin not allowed by CORS')); + }, + }), +); +app.use( + rateLimit({ + windowMs: 15 * 60 * 1000, + max: 250, + standardHeaders: true, + legacyHeaders: false, + }), +); +app.use(morgan('combined')); +app.use(express.json()); + app.get('/health', async (_req, res, next) => { try { const result = await pool.query('SELECT NOW() AS now'); @@ -249,72 +626,408 @@ app.get('/health', async (_req, res, next) => { app.get('/api', (_req, res) => { res.json({ name: 'Arsenal IQ API', - version: '2.0.0', - resources: ['/api/dashboard', '/api/firearms', '/api/calibers', '/api/ammo'], + version: '3.0.0', + allowRegistration, + resources: [ + '/api/auth/login', + '/api/auth/register', + '/api/dashboard', + '/api/firearms', + '/api/calibers', + '/api/ammo', + ], }); }); -app.get('/api/dashboard', async (_req, res, next) => { +app.get('/api/auth/providers', async (_req, res, next) => { try { - const [summaryResult, firearmsResult, calibersResult, ammoResult] = await Promise.all([ - pool.query(` - SELECT - (SELECT COUNT(*)::int FROM firearms) AS "totalFirearms", - COALESCE((SELECT SUM(rounds_on_hand)::int FROM ammo_inventory), 0) AS "totalAmmoRounds", - COALESCE((SELECT SUM(purchase_price) FROM firearms), 0) AS "firearmsInvestment", - COALESCE((SELECT SUM(rounds_on_hand * cost_per_round) FROM ammo_inventory), 0) AS "ammoInvestment", - (SELECT COUNT(*)::int FROM calibers WHERE is_active = TRUE) AS "configuredCalibers" - `), - pool.query(` - SELECT id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes - FROM firearms - ORDER BY acquired_on DESC NULLS LAST, created_at DESC - `), - pool.query(` - SELECT id, name, is_default, is_active - FROM calibers - ORDER BY is_active DESC, is_default DESC, name ASC - `), - pool.query(` - 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 c.is_active = TRUE - ORDER BY c.name ASC - `), - ]); + const result = await pool.query( + `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`, + ); - const summary = summaryResult.rows[0]; + res.json( + result.rows.map((provider) => ({ + providerKey: provider.provider_key, + displayName: provider.display_name, + })), + ); + } catch (error) { + next(error); + } +}); - res.json({ - summary: { - totalFirearms: summary.totalFirearms, - totalAmmoRounds: summary.totalAmmoRounds, - firearmsInvestment: formatCurrency(summary.firearmsInvestment), - ammoInvestment: formatCurrency(summary.ammoInvestment), - configuredCalibers: summary.configuredCalibers, - }, - firearms: firearmsResult.rows.map(normalizeFirearm), - calibers: calibersResult.rows.map(normalizeCaliber), - ammoInventory: ammoResult.rows.map(normalizeAmmoInventory), - defaultCalibers, +app.post('/api/auth/register', async (req, res, next) => { + try { + if (!allowRegistration) { + res.status(403).json({ error: 'Registration is disabled' }); + return; + } + + const email = normalizeEmail(getString(req.body?.email, 'email')); + const password = getString(req.body?.password, 'password'); + const name = getString(req.body?.name, 'name'); + + const existing = await pool.query('SELECT id FROM users WHERE email = $1', [email]); + + if ((existing.rowCount ?? 0) > 0) { + res.status(409).json({ error: 'An account with that email already exists' }); + return; + } + + const passwordHash = await bcrypt.hash(password, 10); + const userResult = await pool.query( + 'INSERT INTO users (email, password_hash, name) VALUES ($1, $2, $3) RETURNING id, email, name', + [email, passwordHash, name], + ); + + const user = userResult.rows[0]; + const profile = await ensureDefaultProfile(user.id, user.name); + const { token, session } = await createSession(user.id, profile.id); + + res.status(201).json({ + token, + user, + profiles: [profile], + activeProfileId: session.active_profile_id, }); } catch (error) { next(error); } }); -app.get('/api/firearms', async (_req, res, next) => { +app.post('/api/auth/login', async (req, res, next) => { try { - const result = await pool.query(` - SELECT id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes - FROM firearms - ORDER BY acquired_on DESC NULLS LAST, created_at DESC - `); + const email = normalizeEmail(getString(req.body?.email, 'email')); + const password = getString(req.body?.password, 'password'); + const result = await pool.query( + 'SELECT id, email, name, password_hash FROM users WHERE email = $1', + [email], + ); + + if ((result.rowCount ?? 0) === 0) { + res.status(401).json({ error: 'Invalid credentials' }); + return; + } + + const user = result.rows[0]; + + if (!user.password_hash) { + res.status(401).json({ error: 'This account uses SSO. Use your identity provider to sign in.' }); + return; + } + + const valid = await bcrypt.compare(password, user.password_hash); + + if (!valid) { + res.status(401).json({ error: 'Invalid credentials' }); + return; + } + + const profile = await ensureDefaultProfile(user.id, user.name); + const { token, session } = await createSession(user.id, profile.id); + const profiles = await getUserProfiles(user.id); + + res.json({ + token, + user: { id: user.id, email: user.email, name: user.name }, + profiles, + activeProfileId: session.active_profile_id, + }); + } catch (error) { + next(error); + } +}); + +app.post('/api/auth/logout', requireAuth, async (req, res, next) => { + try { + await pool.query('DELETE FROM auth_sessions WHERE id = $1', [req.auth!.session.id]); + res.status(204).send(); + } catch (error) { + next(error); + } +}); + +app.get('/api/auth/me', requireAuth, async (req, res, next) => { + try { + const activeProfileId = await resolveProfileId(req); + const profiles = await getUserProfiles(req.auth!.user.id); + + res.json({ + user: req.auth!.user, + profiles, + activeProfileId, + }); + } catch (error) { + next(error); + } +}); + +app.get('/api/auth/sso/:providerKey/start', async (req, res, next) => { + try { + const provider = await getAuthProvider(req.params.providerKey); + + if (!provider?.enabled) { + res.status(404).json({ error: 'SSO provider is not enabled' }); + return; + } + + if (!provider.client_id || !provider.authorization_endpoint) { + res.status(400).json({ error: 'SSO provider is not fully configured' }); + return; + } + + const state = crypto.randomBytes(24).toString('hex'); + const redirectUri = `${apiBaseUrl}/auth/sso/${provider.provider_key}/callback`; + await pool.query( + 'INSERT INTO oauth_states (provider_key, state_code, redirect_uri) VALUES ($1, $2, $3)', + [provider.provider_key, state, redirectUri], + ); + + const url = new URL(provider.authorization_endpoint); + url.searchParams.set('client_id', provider.client_id); + url.searchParams.set('redirect_uri', redirectUri); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('scope', provider.scopes || 'openid profile email'); + url.searchParams.set('state', state); + + if (provider.provider_key === 'google') { + url.searchParams.set('access_type', 'offline'); + url.searchParams.set('prompt', 'select_account'); + } + + res.json({ authorizationUrl: url.toString() }); + } catch (error) { + next(error); + } +}); + +app.get('/api/auth/sso/:providerKey/callback', async (req, res, next) => { + try { + const provider = await getAuthProvider(req.params.providerKey); + const code = String(req.query.code || ''); + const state = String(req.query.state || ''); + + if (!provider?.enabled || !code || !state) { + res.status(400).send('Invalid SSO callback'); + return; + } + + const stateResult = await pool.query<{ redirect_uri: string }>( + 'SELECT redirect_uri FROM oauth_states WHERE provider_key = $1 AND state_code = $2', + [provider.provider_key, state], + ); + + if ((stateResult.rowCount ?? 0) === 0) { + res.status(400).send('Invalid or expired SSO state'); + return; + } + + await pool.query('DELETE FROM oauth_states WHERE provider_key = $1 AND state_code = $2', [ + provider.provider_key, + state, + ]); + + if (!provider.client_id || !provider.client_secret || !provider.token_endpoint) { + res.status(400).send('Provider is missing token configuration'); + return; + } + + const redirectUri = stateResult.rows[0].redirect_uri; + const tokenResponse = await axios.post( + provider.token_endpoint, + new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: provider.client_id, + client_secret: provider.client_secret, + }), + { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }, + ); + + const accessToken = String(tokenResponse.data.access_token || ''); + const idToken = tokenResponse.data.id_token ? String(tokenResponse.data.id_token) : ''; + let userinfo: Record = {}; + + if (provider.userinfo_endpoint && accessToken) { + const userinfoResponse = await axios.get(provider.userinfo_endpoint, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + userinfo = userinfoResponse.data as Record; + } else if (idToken) { + userinfo = decodeJwtPayload(idToken) ?? {}; + } + + const subject = String(userinfo.sub || ''); + const email = normalizeEmail(String(userinfo.email || '')); + const name = + String(userinfo.name || '').trim() || + `${String(userinfo.given_name || '').trim()} ${String(userinfo.family_name || '').trim()}`.trim() || + email || + `${provider.display_name} User`; + + if (!subject || !email) { + res.status(400).send('Provider did not return a subject and email'); + return; + } + + const identityResult = await pool.query<{ user_id: string }>( + 'SELECT user_id FROM auth_identities WHERE provider_key = $1 AND provider_subject = $2', + [provider.provider_key, subject], + ); + + let userId = identityResult.rows[0]?.user_id; + + if (!userId) { + const existingUser = await pool.query('SELECT id, email, name FROM users WHERE email = $1', [email]); + + if ((existingUser.rowCount ?? 0) > 0) { + userId = existingUser.rows[0].id; + } else { + const createdUser = await pool.query( + 'INSERT INTO users (email, password_hash, name) VALUES ($1, NULL, $2) RETURNING id, email, name', + [email, name], + ); + userId = createdUser.rows[0].id; + } + + await 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, provider.provider_key, subject, email], + ); + } + + const userResult = await pool.query('SELECT id, email, name FROM users WHERE id = $1', [userId]); + const user = userResult.rows[0]; + const profile = await ensureDefaultProfile(user.id, user.name); + const { token } = await createSession(user.id, profile.id); + + res.redirect(`${frontendUrl}/?token=${encodeURIComponent(token)}`); + } catch (error) { + next(error); + } +}); + +app.get('/api/profiles', requireAuth, async (req, res, next) => { + try { + const profiles = await getUserProfiles(req.auth!.user.id); + res.json({ profiles, activeProfileId: await resolveProfileId(req) }); + } catch (error) { + next(error); + } +}); + +app.post('/api/profiles', requireAuth, async (req, res, next) => { + try { + res.status(403).json({ error: 'Multiple profiles are disabled. Each user has a single arsenal.' }); + } catch (error) { + next(error); + } +}); + +app.post('/api/profiles/select', requireAuth, async (req, res, next) => { + try { + const activeProfileId = await resolveProfileId(req); + res.json({ activeProfileId }); + } catch (error) { + next(error); + } +}); + +app.get('/api/settings/auth-providers', requireAuth, async (_req, res, next) => { + try { + const result = await pool.query( + `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`, + ); + + res.json(result.rows.map(serializeProvider)); + } catch (error) { + next(error); + } +}); + +app.put('/api/settings/auth-providers/:providerKey', requireAuth, async (req, res, next) => { + try { + const providerKey = getString(req.params.providerKey, 'providerKey'); + const existing = await getAuthProvider(providerKey); + + if (!existing) { + res.status(404).json({ error: 'Provider not found' }); + return; + } + + await 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, + String(req.body?.displayName || existing.display_name), + String(req.body?.protocol || existing.protocol), + String(req.body?.clientId ?? existing.client_id ?? ''), + typeof req.body?.clientSecret === 'string' ? req.body.clientSecret : (existing.client_secret ?? ''), + String(req.body?.authorizationEndpoint ?? existing.authorization_endpoint ?? ''), + String(req.body?.tokenEndpoint ?? existing.token_endpoint ?? ''), + String(req.body?.userinfoEndpoint ?? existing.userinfo_endpoint ?? ''), + String(req.body?.issuer ?? existing.issuer ?? ''), + String(req.body?.scopes ?? existing.scopes ?? 'openid profile email'), + Boolean(req.body?.enabled), + ], + ); + + const updated = await getAuthProvider(providerKey); + res.json(serializeProvider(updated!)); + } catch (error) { + next(error); + } +}); + +app.get('/api/dashboard', requireAuth, async (req, res, next) => { + try { + const activeProfileId = await resolveProfileId(req); + const profiles = await getUserProfiles(req.auth!.user.id); + const dashboard = await buildDashboard(activeProfileId); + + res.json({ + user: req.auth!.user, + profiles, + activeProfileId, + ...dashboard, + }); + } catch (error) { + next(error); + } +}); + +app.get('/api/firearms', requireAuth, async (req, res, next) => { + try { + const profileId = await resolveProfileId(req); + const result = await pool.query( + `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], + ); res.json(result.rows.map(normalizeFirearm)); } catch (error) { @@ -322,28 +1035,29 @@ app.get('/api/firearms', async (_req, res, next) => { } }); -app.post('/api/firearms', async (req, res, next) => { +app.post('/api/firearms', requireAuth, async (req, res, next) => { try { - const body = req.body as FirearmInput; - const manufacturer = getString(body.manufacturer, 'manufacturer'); - const model = getString(body.model, 'model'); - const category = getString(body.category, 'category'); - const caliber = getString(body.caliber, 'caliber'); - const serialNumber = getString(body.serialNumber, 'serialNumber'); - const purchasePrice = getNumber(body.purchasePrice, 'purchasePrice'); - const acquiredOn = getOptionalString(body.acquiredOn); - const imageUrl = getOptionalString(body.imageUrl); - const notes = getOptionalString(body.notes); + const profileId = await resolveProfileId(req); + const manufacturer = getString(req.body?.manufacturer, 'manufacturer'); + const model = getString(req.body?.model, 'model'); + const category = getString(req.body?.category, 'category'); + const caliber = getString(req.body?.caliber, 'caliber'); + const serialNumber = getString(req.body?.serialNumber, 'serialNumber'); + const purchasePrice = getNumber(req.body?.purchasePrice, 'purchasePrice'); + const acquiredOn = getOptionalString(req.body?.acquiredOn); + const imageUrl = getOptionalString(req.body?.imageUrl); + const notes = getOptionalString(req.body?.notes); + + if (!firearmCategories.includes(category)) { + res.status(400).json({ error: 'Unsupported firearm category' }); + return; + } const result = await pool.query( - ` - INSERT INTO firearms ( - manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes - `, - [manufacturer, model, category, caliber, serialNumber, purchasePrice, acquiredOn, imageUrl, notes], + `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, manufacturer, model, category, caliber, serialNumber, purchasePrice, acquiredOn, imageUrl, notes], ); res.status(201).json(normalizeFirearm(result.rows[0])); @@ -352,40 +1066,37 @@ app.post('/api/firearms', async (req, res, next) => { } }); -app.put('/api/firearms/:id', async (req, res, next) => { +app.put('/api/firearms/:id', requireAuth, async (req, res, next) => { try { - const body = req.body as FirearmInput; - const manufacturer = getString(body.manufacturer, 'manufacturer'); - const model = getString(body.model, 'model'); - const category = getString(body.category, 'category'); - const caliber = getString(body.caliber, 'caliber'); - const serialNumber = getString(body.serialNumber, 'serialNumber'); - const purchasePrice = getNumber(body.purchasePrice, 'purchasePrice'); - const acquiredOn = getOptionalString(body.acquiredOn); - const imageUrl = getOptionalString(body.imageUrl); - const notes = getOptionalString(body.notes); + const profileId = await resolveProfileId(req); + const manufacturer = getString(req.body?.manufacturer, 'manufacturer'); + const model = getString(req.body?.model, 'model'); + const category = getString(req.body?.category, 'category'); + const caliber = getString(req.body?.caliber, 'caliber'); + const serialNumber = getString(req.body?.serialNumber, 'serialNumber'); + const purchasePrice = getNumber(req.body?.purchasePrice, 'purchasePrice'); + const acquiredOn = getOptionalString(req.body?.acquiredOn); + const imageUrl = getOptionalString(req.body?.imageUrl); + const notes = getOptionalString(req.body?.notes); const result = await pool.query( - ` - UPDATE firearms - SET - manufacturer = $2, - model = $3, - category = $4, - caliber = $5, - serial_number = $6, - purchase_price = $7, - acquired_on = $8, - image_url = $9, - notes = $10, - updated_at = CURRENT_TIMESTAMP - WHERE id = $1 - RETURNING id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes - `, - [req.params.id, manufacturer, model, category, caliber, serialNumber, purchasePrice, acquiredOn, imageUrl, notes], + `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`, + [req.params.id, profileId, manufacturer, model, category, caliber, serialNumber, purchasePrice, acquiredOn, imageUrl, notes], ); - if (result.rowCount === 0) { + if ((result.rowCount ?? 0) === 0) { res.status(404).json({ error: 'Firearm not found' }); return; } @@ -396,11 +1107,15 @@ app.put('/api/firearms/:id', async (req, res, next) => { } }); -app.delete('/api/firearms/:id', async (req, res, next) => { +app.delete('/api/firearms/:id', requireAuth, async (req, res, next) => { try { - const result = await pool.query('DELETE FROM firearms WHERE id = $1', [req.params.id]); + const profileId = await resolveProfileId(req); + const result = await pool.query('DELETE FROM firearms WHERE id = $1 AND profile_id = $2', [ + req.params.id, + profileId, + ]); - if (result.rowCount === 0) { + if ((result.rowCount ?? 0) === 0) { res.status(404).json({ error: 'Firearm not found' }); return; } @@ -411,13 +1126,16 @@ app.delete('/api/firearms/:id', async (req, res, next) => { } }); -app.get('/api/calibers', async (_req, res, next) => { +app.get('/api/calibers', requireAuth, async (req, res, next) => { try { - const result = await pool.query(` - SELECT id, name, is_default, is_active - FROM calibers - ORDER BY is_active DESC, is_default DESC, name ASC - `); + const profileId = await resolveProfileId(req); + const result = await pool.query( + `SELECT id, name, is_default, is_active + FROM calibers + WHERE profile_id = $1 + ORDER BY is_active DESC, is_default DESC, name ASC`, + [profileId], + ); res.json({ configured: result.rows.filter((row) => row.is_active).map(normalizeCaliber), @@ -430,29 +1148,25 @@ app.get('/api/calibers', async (_req, res, next) => { } }); -app.post('/api/calibers', async (req, res, next) => { +app.post('/api/calibers', requireAuth, async (req, res, next) => { try { - const body = req.body as CaliberInput; - const name = getString(body.name, 'name'); + const profileId = await resolveProfileId(req); + const name = getString(req.body?.name, 'name'); const result = await pool.query( - ` - INSERT INTO calibers (name, is_default, is_active) - VALUES ($1, $2, TRUE) - ON CONFLICT (name) DO UPDATE - SET is_active = TRUE - RETURNING id, name, is_default, is_active - `, - [name, defaultCalibers.includes(name)], + `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, defaultCalibers.includes(name)], ); await pool.query( - ` - INSERT INTO ammo_inventory (caliber_id, rounds_on_hand, cost_per_round) - VALUES ($1, 0, 0) - ON CONFLICT (caliber_id) DO NOTHING - `, - [result.rows[0].id], + `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, result.rows[0].id], ); res.status(201).json(normalizeCaliber(result.rows[0])); @@ -461,32 +1175,29 @@ app.post('/api/calibers', async (req, res, next) => { } }); -app.patch('/api/calibers/:id', async (req, res, next) => { +app.patch('/api/calibers/:id', requireAuth, async (req, res, next) => { try { + const profileId = await resolveProfileId(req); const isActive = Boolean(req.body?.isActive); const result = await pool.query( - ` - UPDATE calibers - SET is_active = $2 - WHERE id = $1 - RETURNING id, name, is_default, is_active - `, - [req.params.id, isActive], + `UPDATE calibers + SET is_active = $3 + WHERE id = $1 AND profile_id = $2 + RETURNING id, name, is_default, is_active`, + [req.params.id, profileId, isActive], ); - if (result.rowCount === 0) { + if ((result.rowCount ?? 0) === 0) { res.status(404).json({ error: 'Caliber not found' }); return; } if (isActive) { await pool.query( - ` - INSERT INTO ammo_inventory (caliber_id, rounds_on_hand, cost_per_round) - VALUES ($1, 0, 0) - ON CONFLICT (caliber_id) DO NOTHING - `, - [req.params.id], + `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, req.params.id], ); } @@ -496,19 +1207,17 @@ app.patch('/api/calibers/:id', async (req, res, next) => { } }); -app.get('/api/ammo', async (_req, res, next) => { +app.get('/api/ammo', requireAuth, async (req, res, next) => { try { - const result = await pool.query(` - 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 c.is_active = TRUE - ORDER BY c.name ASC - `); + const profileId = await resolveProfileId(req); + const result = await pool.query( + `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], + ); res.json(result.rows.map(normalizeAmmoInventory)); } catch (error) { @@ -516,34 +1225,27 @@ app.get('/api/ammo', async (_req, res, next) => { } }); -app.patch('/api/ammo/:caliberId', async (req, res, next) => { +app.patch('/api/ammo/:caliberId', requireAuth, async (req, res, next) => { try { - const body = req.body as AmmoAdjustmentInput; - const rounds = getNumber(body.rounds, 'rounds'); - const costPerRound = getNumber(body.costPerRound, 'costPerRound'); + const profileId = await resolveProfileId(req); + const rounds = getNumber(req.body?.rounds, 'rounds'); + const costPerRound = req.body?.costPerRound == null ? null : getNumber(req.body?.costPerRound, 'costPerRound'); const result = await pool.query( - ` - UPDATE ammo_inventory - SET - rounds_on_hand = GREATEST(0, rounds_on_hand + $2), - cost_per_round = $3, - updated_at = CURRENT_TIMESTAMP - WHERE caliber_id = $1 - RETURNING - caliber_id, - ( - SELECT name - FROM calibers - WHERE id = ammo_inventory.caliber_id - ) AS caliber_name, - rounds_on_hand, - cost_per_round - `, - [req.params.caliberId, rounds, costPerRound], + `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, req.params.caliberId, rounds, costPerRound], ); - if (result.rowCount === 0) { + if ((result.rowCount ?? 0) === 0) { res.status(404).json({ error: 'Ammo inventory not found' }); return; } diff --git a/docker-compose.yml b/docker-compose.yml index 2d08bf3..e0332bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,7 @@ services: NODE_ENV: ${NODE_ENV:-development} DATABASE_URL: postgresql://${POSTGRES_USER:-arsenal}:${POSTGRES_PASSWORD:-arsenal_dev_password}@postgres:5432/${POSTGRES_DB:-arsenal_iq} FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000} + ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-true} depends_on: postgres: condition: service_healthy @@ -50,6 +51,7 @@ services: container_name: arsenaliq-frontend environment: VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:5000/api} + VITE_ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-true} depends_on: - backend ports: diff --git a/frontend/src/index.css b/frontend/src/index.css index 49a91d4..56d214b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -3,49 +3,29 @@ @tailwind utilities; :root { - color-scheme: dark; - --bg: #0d1216; - --panel: rgba(20, 28, 34, 0.92); - --panel-soft: rgba(28, 38, 45, 0.84); - --line: rgba(255, 255, 255, 0.08); - --text: #edf3ef; - --muted: #97a8a5; - --gold: #d8b36a; - --accent: #78b8a4; - --shadow: 0 24px 70px rgba(0, 0, 0, 0.34); + font-family: "Segoe UI", "Inter", sans-serif; + color: #e8eadf; + background: + radial-gradient(circle at top left, rgba(94, 112, 71, 0.28), transparent 32%), + radial-gradient(circle at bottom right, rgba(67, 80, 51, 0.26), transparent 30%), + linear-gradient(160deg, #10130f 0%, #171b15 45%, #0d100c 100%); } * { box-sizing: border-box; } -html { - background: - radial-gradient(circle at top left, rgba(216, 179, 106, 0.16), transparent 22%), - radial-gradient(circle at bottom right, rgba(120, 184, 164, 0.14), transparent 24%), - linear-gradient(180deg, #11181d 0%, #0a0f12 100%); +html, +body, +#root { + min-height: 100%; + margin: 0; } body { - margin: 0; - min-width: 320px; min-height: 100vh; - color: var(--text); - font-family: "Avenir Next", "Segoe UI", sans-serif; - -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; + color: #e8eadf; + background: transparent; } button, @@ -55,233 +35,456 @@ textarea { font: inherit; } +button { + cursor: pointer; +} + input, select, textarea { width: 100%; - margin-top: 8px; - padding: 12px 14px; - color: var(--text); - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.08); + border: 1px solid rgba(171, 180, 140, 0.18); border-radius: 14px; - appearance: none; - -webkit-appearance: none; - -moz-appearance: none; + background: rgba(12, 16, 11, 0.72); + color: #eef1e5; + padding: 0.85rem 1rem; + transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; } -select { - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.03)), - 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") - no-repeat right 14px center; - padding-right: 42px; -} - -select option { - color: var(--text); - background: #162027; +input:focus, +select:focus, +textarea:focus { + outline: none; + border-color: rgba(140, 158, 101, 0.74); + box-shadow: 0 0 0 3px rgba(109, 127, 73, 0.2); } textarea { 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, .panel-kicker { - color: var(--gold); text-transform: uppercase; 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; - gap: 10px; + gap: 2rem; + align-items: center; + padding: 3rem; } -.nav-button, -.primary-button, -.secondary-button, -.chip-button { +.loading-shell { + place-items: center; +} + +.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; align-items: 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; - 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 { - display: block; - margin-top: 10px; - font-size: 2rem; +.auth-hero h1 { + font-size: clamp(2.4rem, 5vw, 4.2rem); + line-height: 1.04; + margin: 0.35rem 0 0; + max-width: none; } -.main-stage { - display: grid; - gap: 18px; +.auth-hero p, +.muted-copy, +.header-copy, +.card-footer span, +.settings-row p, +.provider-header p { + color: #b8c0af; } -.stage-header { +.hero-tags, +.chip-row, +.button-row { display: flex; - align-items: flex-end; - justify-content: space-between; - gap: 18px; - padding: 8px 4px; + gap: 0.75rem; + flex-wrap: wrap; } -.stage-stats { - display: flex; - gap: 14px; +.hero-tags span, +.profile-chip, +.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 { - min-width: 160px; - padding: 14px 16px; - border-radius: 18px; - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 255, 255, 0.05); +.auth-card { + max-width: 520px; + width: 100%; + justify-self: center; } -.mini-stat strong { - display: block; - margin-top: 8px; - font-size: 1.2rem; -} - -.panel { - padding: 22px; - border-radius: 26px; -} - -.panel-heading, -.card-footer, +.auth-tabs, +.settings-inline, +.header-tools, +.provider-header, +.toggle-row, .ammo-card-top, -.settings-row { +.panel-heading, +.settings-row, +.card-footer, +.stage-header { display: flex; align-items: center; justify-content: space-between; - gap: 16px; + gap: 1rem; } -.panel-heading { - margin-bottom: 18px; +.auth-tabs { + 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 { + display: grid; + gap: 1rem; +} + +.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, .settings-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; } .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, .ammo-grid, -.settings-list, -.chip-grid { - display: grid; - gap: 14px; +.provider-config-grid { + margin-top: 1rem; } -.ammo-toolbar { - display: grid; - gap: 14px; - margin-bottom: 18px; +.firearm-grid { + grid-template-columns: repeat(auto-fit, minmax(290px, 1fr)); +} + +.ammo-grid, +.provider-config-grid { + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); } .firearm-card, -.ammo-card { - padding: 18px; +.ammo-card, +.provider-card { border-radius: 22px; - border: 1px solid rgba(255, 255, 255, 0.06); - background: var(--panel-soft); -} - -.firearm-card { - display: grid; - gap: 16px; + padding: 1rem; + background: rgba(12, 16, 11, 0.74); + border: 1px solid rgba(171, 180, 140, 0.1); } .firearm-visual { - overflow: hidden; - border-radius: 18px; - aspect-ratio: 16 / 7; - background: rgba(255, 255, 255, 0.04); + min-height: 180px; + border-radius: 20px; + padding: 1rem; + display: flex; + align-items: center; + justify-content: center; + background: rgba(171, 180, 140, 0.04); } -.firearm-visual img { +.firearm-photo, +.firearm-silhouette { width: 100%; - height: 100%; + max-height: 180px; + border-radius: 18px; } .firearm-photo { @@ -297,134 +500,101 @@ textarea { .form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 14px; + gap: 0.9rem; + margin-top: 1rem; } .form-grid.compact { - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: minmax(0, 1fr); } .full-width { grid-column: 1 / -1; } -label span { - display: block; - color: var(--muted); - font-size: 0.84rem; -} - .card-footer { - margin-top: 4px; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid rgba(171, 180, 140, 0.08); } -.primary-button, -.secondary-button, -.chip-button { - padding: 12px 16px; -} - -.button-row { - display: flex; - gap: 10px; -} - -.primary-button { - background: var(--gold); - 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; +.placeholder-copy { + margin: 0; + color: #abb4a1; + padding: 1rem 0; } .settings-row { - padding: 14px 0; - border-bottom: 1px solid rgba(255, 255, 255, 0.05); + padding: 0.95rem 0; + border-top: 1px solid rgba(171, 180, 140, 0.08); } -.settings-row:last-child { - border-bottom: 0; +.settings-row:first-child { + border-top: 0; + padding-top: 0; } -.badge { - display: inline-flex; - 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; +.settings-row.static { + padding-bottom: 0.4rem; } -.error-banner { - padding: 14px 16px; - border-radius: 18px; - background: rgba(201, 83, 83, 0.16); - border-color: rgba(201, 83, 83, 0.3); +.status-pill { + color: #eef3e4; + background: linear-gradient(135deg, #829455, #59693d); + border: 0; } -@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, - .view-grid { + .auth-shell, + .view-grid, + .settings-grid { grid-template-columns: 1fr; } + .desktop-sidebar { + display: none; + } + + .mobile-sidebar { + display: block; + position: static; + height: auto; + } + .sidebar { position: static; + height: auto; } } @media (max-width: 720px) { - .app-shell { - width: min(100% - 16px, 1440px); - padding-top: 12px; + .app-shell, + .auth-shell { + padding: 1rem; } .stage-header, - .panel-heading, + .header-tools, .card-footer, - .button-row, - .settings-inline, - .settings-row, - .ammo-card-top { + .settings-inline { flex-direction: column; - align-items: flex-start; + align-items: stretch; } .stage-stats, - .form-grid, - .form-grid.compact { + .form-grid { grid-template-columns: 1fr; } - - .mini-stat { - width: 100%; - } } diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index a5c05e9..ac70abf 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -1,5 +1,27 @@ -import { useEffect, useState } from 'react'; -import { Boxes, Settings, ShieldCheck } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { + Boxes, + Crosshair, + Lock, + LogOut, + Plus, + Settings, + ShieldCheck, + UserCog, +} from 'lucide-react'; + +type View = 'firearms' | 'ammo' | 'settings'; + +type UserAccount = { + id: string; + email: string; + name: string; +}; + +type Profile = { + id: string; + name: string; +}; type Firearm = { id: string; @@ -30,6 +52,9 @@ type AmmoInventory = { }; type DashboardData = { + user: UserAccount; + profiles: Profile[]; + activeProfileId: string; summary: { totalFirearms: number; totalAmmoRounds: number; @@ -57,8 +82,40 @@ type FirearmForm = { type AmmoAdjustments = Record; -const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '/api'; -const storageKey = 'arsenal-iq-dashboard'; +type AuthPayload = { + token: string; + user: UserAccount; + profiles: Profile[]; + activeProfileId: string; +}; + +type AuthProviderConfig = { + providerKey: string; + displayName: string; + protocol: string; + clientId: string; + clientSecret: string; + authorizationEndpoint: string; + tokenEndpoint: string; + userinfoEndpoint: string; + issuer: string; + scopes: string; + enabled: boolean; +}; + +type PublicProvider = { + providerKey: string; + displayName: string; +}; + +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 tokenStorageKey = 'arsenal-iq-token'; +const profileStorageKey = 'arsenal-iq-profile'; +const ammoPageSelectionKey = 'arsenal-iq-ammo-page-calibers'; +const ammoPageSelectionMigrationKey = 'arsenal-iq-ammo-page-calibers-v2'; +const firearmCategories = ['Handgun', 'Rifle', 'Shotgun', 'PCC', 'Other']; +const defaultCaliberNames = ['9mm', '.22 LR', '5.56 NATO', '.308 Win', '12 Gauge', '.45 ACP']; const currency = new Intl.NumberFormat('en-US', { style: 'currency', @@ -66,66 +123,6 @@ const currency = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0, }); -const firearmCategories = ['Handgun', 'Rifle', 'Shotgun', 'PCC', 'Other']; -const defaultCalibers = ['9mm', '.22 LR', '5.56 NATO', '.308 Win', '12 Gauge', '.45 ACP']; - -const fallbackAmmo: AmmoInventory[] = defaultCalibers.map((name) => ({ - caliberId: `cal-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`, - caliber: name, - roundsOnHand: 0, - costPerRound: 0, - totalValue: 0, -})); - -const fallbackCalibers: Caliber[] = defaultCalibers.map((name) => ({ - id: `cal-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`, - name, - isDefault: true, - isActive: fallbackAmmo.some((item) => item.caliber === name) || name === '.308 Win' || name === '.45 ACP', -})); - -const computeSummary = (firearms: Firearm[], calibers: Caliber[], ammoInventory: AmmoInventory[]) => { - const activeCaliberIds = new Set( - calibers.filter((item) => item.isActive).map((item) => item.id), - ); - - const activeAmmoInventory = ammoInventory.filter((item) => activeCaliberIds.has(item.caliberId)); - - return { - totalFirearms: firearms.length, - totalAmmoRounds: activeAmmoInventory.reduce((sum, item) => sum + item.roundsOnHand, 0), - firearmsInvestment: firearms.reduce((sum, item) => sum + item.purchasePrice, 0), - ammoInvestment: activeAmmoInventory.reduce((sum, item) => sum + item.totalValue, 0), - configuredCalibers: calibers.filter((item) => item.isActive).length, - }; -}; - -const buildDashboardData = ( - firearms: Firearm[] = [], - calibers: Caliber[] = fallbackCalibers, - ammoInventory: AmmoInventory[] = fallbackAmmo, -): DashboardData => ({ - summary: computeSummary(firearms, calibers, ammoInventory), - firearms, - calibers, - ammoInventory, - defaultCalibers, -}); - -const loadStoredDashboard = (): DashboardData | null => { - try { - const raw = window.localStorage.getItem(storageKey); - - if (!raw) { - return null; - } - - return normalizePayload(JSON.parse(raw) as DashboardData); - } catch { - return null; - } -}; - const emptyFirearmForm: FirearmForm = { manufacturer: '', model: '', @@ -161,10 +158,20 @@ const buildAmmoAdjustments = (inventory: AmmoInventory[]): AmmoAdjustments => ]), ); -const normalizePayload = (payload: DashboardData): DashboardData => - buildDashboardData(payload.firearms, payload.calibers, payload.ammoInventory); +const loadStoredAmmoPageSelection = (): string[] => { + try { + const raw = window.localStorage.getItem(ammoPageSelectionKey); -const isLocalId = (id: string) => id.startsWith('local-'); + if (!raw) { + return []; + } + + const parsed = JSON.parse(raw) as unknown; + return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : []; + } catch { + return []; + } +}; const getCategorySilhouette = (category: string) => { const normalized = category.toLowerCase(); @@ -181,56 +188,284 @@ const getCategorySilhouette = (category: string) => { }; export default function Home() { - const [activeView, setActiveView] = useState<'firearms' | 'ammo' | 'settings'>('firearms'); - const [data, setData] = useState(() => loadStoredDashboard() ?? buildDashboardData()); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [token, setToken] = useState(() => window.localStorage.getItem(tokenStorageKey) ?? ''); + const [activeView, setActiveView] = useState('firearms'); + const [authMode, setAuthMode] = useState<'login' | 'register'>('login'); + const [error, setError] = useState(''); + const [message, setMessage] = useState(''); + + const [user, setUser] = useState(null); + const [profiles, setProfiles] = useState([]); + const [activeProfileId, setActiveProfileId] = useState(() => window.localStorage.getItem(profileStorageKey) ?? ''); + const [data, setData] = useState(null); const [firearmDrafts, setFirearmDrafts] = useState>({}); const [newFirearm, setNewFirearm] = useState(emptyFirearmForm); - const [ammoAdjustments, setAmmoAdjustments] = useState(() => - buildAmmoAdjustments((loadStoredDashboard() ?? buildDashboardData()).ammoInventory), - ); + const [showNewFirearmForm, setShowNewFirearmForm] = useState(false); + const [ammoAdjustments, setAmmoAdjustments] = useState({}); + const [ammoPageCaliberIds, setAmmoPageCaliberIds] = useState(() => loadStoredAmmoPageSelection()); + const [selectedAmmoCaliberId, setSelectedAmmoCaliberId] = useState(''); const [newCaliber, setNewCaliber] = useState(''); - const [loading, setLoading] = useState(true); - - const activeAmmoInventory = data.ammoInventory.filter((inventory) => - data.calibers.some((caliber) => caliber.id === inventory.caliberId && caliber.isActive), - ); - - const ammoTypesWithRounds = activeAmmoInventory.filter( - (inventory) => inventory.roundsOnHand > 0, - ).length; - - const applyDashboard = (payload: DashboardData) => { - const normalized = normalizePayload(payload); - setData(normalized); - setFirearmDrafts( - Object.fromEntries(normalized.firearms.map((firearm) => [firearm.id, buildFirearmForm(firearm)])), - ); - setAmmoAdjustments(buildAmmoAdjustments(normalized.ammoInventory)); - }; + const [providerConfigs, setProviderConfigs] = useState([]); + const [publicProviders, setPublicProviders] = useState([]); + const [loginForm, setLoginForm] = useState({ email: '', password: '' }); + const [registerForm, setRegisterForm] = useState({ name: '', email: '', password: '' }); useEffect(() => { - window.localStorage.setItem(storageKey, JSON.stringify(data)); - }, [data]); + if (!allowRegistration && authMode === 'register') { + setAuthMode('login'); + } + }, [authMode]); - const refreshDashboard = async () => { - const response = await fetch(`${apiBaseUrl}/dashboard`); + const enabledCalibers = useMemo( + () => (data?.calibers ?? []).filter((caliber) => caliber.isActive), + [data], + ); + const firstName = useMemo(() => { + const trimmed = user?.name.trim() ?? ''; + return trimmed.split(/\s+/)[0] || 'Owner'; + }, [user]); + + const enabledAmmoInventory = useMemo( + () => (data?.ammoInventory ?? []).filter((inventory) => enabledCalibers.some((caliber) => caliber.id === inventory.caliberId)), + [data, enabledCalibers], + ); + + const ammoTypesWithRounds = useMemo( + () => enabledAmmoInventory.filter((inventory) => inventory.roundsOnHand > 0).length, + [enabledAmmoInventory], + ); + const ammoPageInventory = useMemo( + () => + ammoPageCaliberIds + .map((caliberId) => { + const existing = enabledAmmoInventory.find((inventory) => inventory.caliberId === caliberId); + + if (existing) { + return existing; + } + + const caliber = enabledCalibers.find((item) => item.id === caliberId); + + if (!caliber) { + return null; + } + + return { + caliberId: caliber.id, + caliber: caliber.name, + roundsOnHand: 0, + costPerRound: 0, + totalValue: 0, + }; + }) + .filter((inventory): inventory is AmmoInventory => inventory !== null), + [ammoPageCaliberIds, enabledAmmoInventory, enabledCalibers], + ); + const availableAmmoPageCalibers = useMemo( + () => enabledCalibers.filter((caliber) => !ammoPageCaliberIds.includes(caliber.id)), + [ammoPageCaliberIds, enabledCalibers], + ); + + const apiFetch = async (path: string, init: RequestInit = {}, useProfile = true): Promise => { + const headers = new Headers(init.headers || {}); + + if (init.body && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + + if (useProfile && activeProfileId) { + headers.set('x-profile-id', activeProfileId); + } + + const response = await fetch(`${apiBaseUrl}${path}`, { + ...init, + headers, + }); if (!response.ok) { - throw new Error(`Dashboard request failed with ${response.status}`); + const payload = (await response.json().catch(() => ({ error: 'Request failed' }))) as { error?: string }; + throw new Error(payload.error || `Request failed with ${response.status}`); + } + + if (response.status === 204) { + return undefined as T; + } + + return (await response.json()) as T; + }; + + const applyDashboard = (payload: DashboardData) => { + setData(payload); + setUser(payload.user); + setProfiles(payload.profiles); + setActiveProfileId(payload.activeProfileId); + window.localStorage.setItem(profileStorageKey, payload.activeProfileId); + setFirearmDrafts( + Object.fromEntries(payload.firearms.map((firearm) => [firearm.id, buildFirearmForm(firearm)])), + ); + setAmmoAdjustments(buildAmmoAdjustments(payload.ammoInventory)); + }; + + const loadDashboard = async (profileId = activeProfileId) => { + const headers = new Headers(); + + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + + if (profileId) { + headers.set('x-profile-id', profileId); + } + + const response = await fetch(`${apiBaseUrl}/dashboard`, { headers }); + + if (!response.ok) { + const payload = (await response.json().catch(() => ({ error: 'Unable to load dashboard' }))) as { error?: string }; + throw new Error(payload.error || 'Unable to load dashboard'); } const payload = (await response.json()) as DashboardData; applyDashboard(payload); }; + const loadProviderConfigs = async () => { + const payload = await apiFetch('/settings/auth-providers'); + setProviderConfigs(payload); + }; + + const loadPublicProviders = async () => { + try { + const response = await fetch(`${apiBaseUrl}/auth/providers`); + + if (!response.ok) { + setPublicProviders([]); + return; + } + + const payload = (await response.json()) as PublicProvider[]; + setPublicProviders(payload); + } catch { + setPublicProviders([]); + } + }; + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const callbackToken = params.get('token'); + + if (callbackToken) { + window.localStorage.setItem(tokenStorageKey, callbackToken); + setToken(callbackToken); + window.history.replaceState({}, '', '/'); + } + }, []); + + useEffect(() => { + void loadPublicProviders(); + }, []); + + useEffect(() => { + if (!message) { + return; + } + + const timeoutId = window.setTimeout(() => { + setMessage(''); + }, 3500); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [message]); + + useEffect(() => { + window.localStorage.setItem(ammoPageSelectionKey, JSON.stringify(ammoPageCaliberIds)); + }, [ammoPageCaliberIds]); + + useEffect(() => { + const hasMigrated = window.localStorage.getItem(ammoPageSelectionMigrationKey) === 'true'; + + if (hasMigrated || enabledCalibers.length === 0) { + return; + } + + setAmmoPageCaliberIds((current) => { + const matchingEnabledCount = current.filter((id) => enabledCalibers.some((caliber) => caliber.id === id)).length; + const hasStoredRounds = (data?.ammoInventory.length ?? 0) > 0; + + if (!hasStoredRounds && matchingEnabledCount === enabledCalibers.length && enabledCalibers.length > 1) { + return []; + } + + return current; + }); + + window.localStorage.setItem(ammoPageSelectionMigrationKey, 'true'); + }, [data, enabledCalibers]); + + useEffect(() => { + const validEnabledIds = new Set(enabledCalibers.map((caliber) => caliber.id)); + + setAmmoPageCaliberIds((current) => { + const filtered = current.filter((id) => validEnabledIds.has(id)); + + if (filtered.length > 0) { + return filtered; + } + + const withRounds = enabledAmmoInventory + .filter((inventory) => inventory.roundsOnHand > 0) + .map((inventory) => inventory.caliberId); + + return Array.from(new Set(withRounds)); + }); + }, [enabledAmmoInventory, enabledCalibers]); + + useEffect(() => { + if (!selectedAmmoCaliberId && availableAmmoPageCalibers.length > 0) { + setSelectedAmmoCaliberId(availableAmmoPageCalibers[0].id); + return; + } + + if ( + selectedAmmoCaliberId && + !availableAmmoPageCalibers.some((caliber) => caliber.id === selectedAmmoCaliberId) + ) { + setSelectedAmmoCaliberId(availableAmmoPageCalibers[0]?.id ?? ''); + } + }, [availableAmmoPageCalibers, selectedAmmoCaliberId]); + useEffect(() => { let active = true; - const load = async () => { + const bootstrap = async () => { + if (!token) { + if (active) { + setLoading(false); + } + return; + } + try { - await refreshDashboard(); - } catch { - // Keep local fallback ammo/caliber data while API is restarting. + await loadDashboard(); + await loadProviderConfigs(); + } catch (loadError) { + window.localStorage.removeItem(tokenStorageKey); + window.localStorage.removeItem(profileStorageKey); + if (active) { + setToken(''); + setUser(null); + setProfiles([]); + setData(null); + setProviderConfigs([]); + setError(loadError instanceof Error ? loadError.message : 'Unable to load account'); + } } finally { if (active) { setLoading(false); @@ -238,12 +473,100 @@ export default function Home() { } }; - void load(); + void bootstrap(); return () => { active = false; }; - }, []); + }, [token]); + + const handleRegister = async () => { + setSaving(true); + setError(''); + setMessage(''); + + try { + const payload = await apiFetch( + '/auth/register', + { + method: 'POST', + body: JSON.stringify(registerForm), + }, + false, + ); + + window.localStorage.setItem(tokenStorageKey, payload.token); + window.localStorage.setItem(profileStorageKey, payload.activeProfileId); + setToken(payload.token); + setRegisterForm({ name: '', email: '', password: '' }); + setMessage('Account created.'); + } catch (registerError) { + setError(registerError instanceof Error ? registerError.message : 'Unable to create account'); + } finally { + setSaving(false); + } + }; + + const handleLogin = async () => { + setSaving(true); + setError(''); + setMessage(''); + + try { + const payload = await apiFetch( + '/auth/login', + { + method: 'POST', + body: JSON.stringify(loginForm), + }, + 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'); + } finally { + setSaving(false); + } + }; + + const handleLogout = async () => { + try { + await apiFetch('/auth/logout', { method: 'POST' }); + } catch { + // local cleanup is enough if session is already invalid + } + + window.localStorage.removeItem(tokenStorageKey); + window.localStorage.removeItem(profileStorageKey); + setToken(''); + setUser(null); + setProfiles([]); + setData(null); + setProviderConfigs([]); + setError(''); + setMessage(''); + }; + + const handleProviderSignIn = async (providerKey: string) => { + setError(''); + + try { + const response = await fetch(`${apiBaseUrl}/auth/sso/${providerKey}/start`); + const payload = (await response.json()) as { authorizationUrl?: string; error?: string }; + + if (!response.ok || !payload.authorizationUrl) { + throw new Error(payload.error || 'Unable to start SSO'); + } + + window.location.href = payload.authorizationUrl; + } catch (providerError) { + setError(providerError instanceof Error ? providerError.message : 'Unable to start SSO'); + } + }; const handleFirearmChange = (id: string, field: keyof FirearmForm, value: string) => { setFirearmDrafts((current) => ({ @@ -257,346 +580,357 @@ export default function Home() { const saveFirearm = async (id: string) => { const draft = firearmDrafts[id]; - const updatedFirearm: Firearm = { - id, - manufacturer: draft.manufacturer, - model: draft.model, - category: draft.category, - caliber: draft.caliber, - serialNumber: draft.serialNumber, - purchasePrice: Number(draft.purchasePrice || 0), - acquiredOn: draft.acquiredOn || null, - imageUrl: draft.imageUrl || null, - notes: draft.notes || null, - }; - setData((current) => - buildDashboardData( - current.firearms.map((firearm) => (firearm.id === id ? updatedFirearm : firearm)), - current.calibers, - current.ammoInventory, - ), - ); + if (!draft) { + return; + } + + setSaving(true); + setError(''); try { - const endpoint = isLocalId(id) ? `${apiBaseUrl}/firearms` : `${apiBaseUrl}/firearms/${id}`; - const method = isLocalId(id) ? 'POST' : 'PUT'; - const response = await fetch(endpoint, { - method, - headers: { 'Content-Type': 'application/json' }, + await apiFetch(`/firearms/${id}`, { + method: 'PUT', body: JSON.stringify({ ...draft, - purchasePrice: Number(draft.purchasePrice), + purchasePrice: Number(draft.purchasePrice || 0), }), }); - - if (response.ok) { - if (isLocalId(id)) { - const savedFirearm = (await response.json()) as Firearm; - setData((current) => - buildDashboardData( - current.firearms.map((firearm) => (firearm.id === id ? savedFirearm : firearm)), - current.calibers, - current.ammoInventory, - ), - ); - setFirearmDrafts((current) => { - const next = { ...current }; - delete next[id]; - next[savedFirearm.id] = buildFirearmForm(savedFirearm); - return next; - }); - } else { - await refreshDashboard(); - } - } - } catch { - // Keep optimistic update. + await loadDashboard(); + setMessage('Firearm saved.'); + } catch (saveError) { + setError(saveError instanceof Error ? saveError.message : 'Unable to save firearm'); + } finally { + setSaving(false); } }; const createFirearm = async () => { - const draft = { ...newFirearm }; - const createdFirearm: Firearm = { - id: `local-${Date.now()}`, - manufacturer: draft.manufacturer, - model: draft.model, - category: draft.category, - caliber: draft.caliber, - serialNumber: draft.serialNumber, - purchasePrice: Number(draft.purchasePrice || 0), - acquiredOn: draft.acquiredOn || null, - imageUrl: draft.imageUrl || null, - notes: draft.notes || null, - }; - - setData((current) => - buildDashboardData([...current.firearms, createdFirearm], current.calibers, current.ammoInventory), - ); - setFirearmDrafts((current) => ({ - ...current, - [createdFirearm.id]: buildFirearmForm(createdFirearm), - })); - setNewFirearm(emptyFirearmForm); - setActiveView('firearms'); + setSaving(true); + setError(''); try { - const response = await fetch(`${apiBaseUrl}/firearms`, { + await apiFetch('/firearms', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - ...draft, - purchasePrice: Number(draft.purchasePrice), + ...newFirearm, + purchasePrice: Number(newFirearm.purchasePrice || 0), }), }); - - if (response.ok) { - const savedFirearm = (await response.json()) as Firearm; - setData((current) => - buildDashboardData( - current.firearms.map((firearm) => (firearm.id === createdFirearm.id ? savedFirearm : firearm)), - current.calibers, - current.ammoInventory, - ), - ); - setFirearmDrafts((current) => { - const next = { ...current }; - delete next[createdFirearm.id]; - next[savedFirearm.id] = buildFirearmForm(savedFirearm); - return next; - }); - } - } catch { - // Keep optimistic row visible. + setNewFirearm(emptyFirearmForm); + setShowNewFirearmForm(false); + await loadDashboard(); + setMessage('Firearm added.'); + } catch (createError) { + setError(createError instanceof Error ? createError.message : 'Unable to add firearm'); + } finally { + setSaving(false); } }; const deleteFirearm = async (id: string) => { - setData((current) => - buildDashboardData( - current.firearms.filter((firearm) => firearm.id !== id), - current.calibers, - current.ammoInventory, - ), - ); - setFirearmDrafts((current) => { - const next = { ...current }; - delete next[id]; - return next; - }); - - if (isLocalId(id)) { - return; - } + setSaving(true); + setError(''); try { - const response = await fetch(`${apiBaseUrl}/firearms/${id}`, { - method: 'DELETE', - }); - - if (response.ok || response.status === 204) { - await refreshDashboard(); - } - } catch { - // Keep optimistic delete. + await apiFetch(`/firearms/${id}`, { method: 'DELETE' }); + await loadDashboard(); + setMessage('Firearm removed.'); + } catch (deleteError) { + setError(deleteError instanceof Error ? deleteError.message : 'Unable to remove firearm'); + } finally { + setSaving(false); } }; - const adjustAmmo = async (caliberId: string) => { + const adjustAmmo = async (caliberId: string, direction: 'update' | 'remove') => { const adjustment = ammoAdjustments[caliberId]; - const roundsDelta = Number(adjustment?.rounds || 0); - const nextCost = Number(adjustment?.costPerRound || 0); + const rawRounds = Number(adjustment?.rounds || 0); - setData((current) => { - const nextInventory = current.ammoInventory.map((item) => { - if (item.caliberId !== caliberId) { - return item; - } + if (!rawRounds) { + return; + } - const roundsOnHand = Math.max(0, item.roundsOnHand + roundsDelta); - const costPerRound = nextCost; + const rounds = direction === 'remove' ? -Math.abs(rawRounds) : rawRounds; + const costPerRound = Number(adjustment?.costPerRound || 0); - return { - ...item, - roundsOnHand, - costPerRound, - totalValue: roundsOnHand * costPerRound, - }; - }); - - return buildDashboardData(current.firearms, current.calibers, nextInventory); - }); - - setAmmoAdjustments((current) => ({ - ...current, - [caliberId]: { - ...current[caliberId], - rounds: '', - }, - })); + setSaving(true); + setError(''); try { - const response = await fetch(`${apiBaseUrl}/ammo/${caliberId}`, { + await apiFetch(`/ammo/${caliberId}`, { method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - rounds: roundsDelta, - costPerRound: nextCost, - }), + body: JSON.stringify({ rounds, costPerRound }), }); - - if (response.ok) { - await refreshDashboard(); - } - } catch { - // Keep optimistic update. + await loadDashboard(); + setMessage(direction === 'remove' ? 'Ammo removed.' : 'Ammo updated.'); + } catch (ammoError) { + setError(ammoError instanceof Error ? ammoError.message : 'Unable to update ammo'); + } finally { + setSaving(false); } }; const addCaliber = async (name: string) => { - const trimmedName = name.trim(); + const trimmed = name.trim(); - if (!trimmedName) { + if (!trimmed) { return; } - const existing = data.calibers.find((item) => item.name.toLowerCase() === trimmedName.toLowerCase()); - - if (!existing) { - const caliberId = `local-${trimmedName.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`; - const nextCaliber: Caliber = { - id: caliberId, - name: trimmedName, - isDefault: defaultCalibers.includes(trimmedName), - isActive: true, - }; - const nextInventory: AmmoInventory = { - caliberId, - caliber: trimmedName, - roundsOnHand: 0, - costPerRound: 0, - totalValue: 0, - }; - - setData((current) => - buildDashboardData( - current.firearms, - [...current.calibers, nextCaliber], - [...current.ammoInventory, nextInventory], - ), - ); - setAmmoAdjustments((current) => ({ - ...current, - [caliberId]: { rounds: '', costPerRound: '0.00' }, - })); - } else { - setData((current) => - buildDashboardData( - current.firearms, - current.calibers.map((item) => - item.id === existing.id ? { ...item, isActive: true } : item, - ), - current.ammoInventory, - ), - ); - } - - setNewCaliber(''); + setSaving(true); + setError(''); try { - const response = await fetch(`${apiBaseUrl}/calibers`, { + await apiFetch('/calibers', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: trimmedName }), + body: JSON.stringify({ name: trimmed }), }); - - if (response.ok) { - await refreshDashboard(); - } - } catch { - // Keep optimistic update. + setNewCaliber(''); + await loadDashboard(); + setMessage(`${trimmed} enabled.`); + } catch (caliberError) { + setError(caliberError instanceof Error ? caliberError.message : 'Unable to add caliber'); + } finally { + setSaving(false); } }; - const toggleCaliber = async (caliberId: string, isActive: boolean) => { - setData((current) => - buildDashboardData( - current.firearms, - current.calibers.map((item) => (item.id === caliberId ? { ...item, isActive } : item)), - current.ammoInventory, - ), + const addAmmoPageCaliber = () => { + if (!selectedAmmoCaliberId) { + return; + } + + setAmmoPageCaliberIds((current) => + current.includes(selectedAmmoCaliberId) ? current : [...current, selectedAmmoCaliberId], ); + setSelectedAmmoCaliberId(''); + }; + + const removeAmmoPageCaliber = (caliberId: string) => { + setAmmoPageCaliberIds((current) => current.filter((id) => id !== caliberId)); + }; + + const toggleCaliber = async (caliberId: string, isActive: boolean) => { + setSaving(true); + setError(''); try { - const response = await fetch(`${apiBaseUrl}/calibers/${caliberId}`, { + await apiFetch(`/calibers/${caliberId}`, { method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ isActive }), }); + await loadDashboard(); + } catch (toggleError) { + setError(toggleError instanceof Error ? toggleError.message : 'Unable to update caliber'); + } finally { + setSaving(false); + } + }; - if (response.ok) { - await refreshDashboard(); - } - } catch { - // Keep optimistic update. + const updateProviderDraft = (providerKey: string, field: keyof AuthProviderConfig, value: string | boolean) => { + setProviderConfigs((current) => + current.map((provider) => + provider.providerKey === providerKey + ? { + ...provider, + [field]: value, + } + : provider, + ), + ); + }; + + const saveProviderConfig = async (providerKey: string) => { + const config = providerConfigs.find((provider) => provider.providerKey === providerKey); + + if (!config) { + return; + } + + setSaving(true); + setError(''); + + try { + await apiFetch(`/settings/auth-providers/${providerKey}`, { + method: 'PUT', + body: JSON.stringify(config), + }, false); + await loadProviderConfigs(); + await loadPublicProviders(); + setMessage(`${config.displayName} settings saved.`); + } catch (providerError) { + setError(providerError instanceof Error ? providerError.message : 'Unable to save provider'); + } finally { + setSaving(false); } }; const views = [ - { id: 'firearms', label: 'Firearms', icon: ShieldCheck }, - { id: 'ammo', label: 'Ammo', icon: Boxes }, - { id: 'settings', label: 'Settings', icon: Settings }, - ] as const; + { id: 'firearms' as const, label: 'Firearms', icon: ShieldCheck }, + { id: 'ammo' as const, label: 'Ammo', icon: Boxes }, + { id: 'settings' as const, label: 'Settings', icon: Settings }, + ]; + const sidebarContent = ( + <> +
+ Arsenal IQ +

Inventory Control

+
+ + + + ); + + if (loading) { + return ( +
+
+ Arsenal IQ +

Loading workspace

+
+
+ ); + } + + if (!token || !user || !data) { + return ( +
+ {message ?

{message}

: null} +
+
+
+ +
+
+ Secure Access +

Arsenal_IQ

+
+
+
+ +
+
+ + {allowRegistration ? ( + + ) : null} +
+ + {authMode === 'login' ? ( +
+ + + +
+ ) : allowRegistration ? ( +
+ + + + +
+ ) : null} + + {!allowRegistration ? ( +

Account registration is disabled.

+ ) : null} + +
+ Single sign-on +
+ +
+ {publicProviders.length === 0 ? ( +

No SSO providers available.

+ ) : ( + publicProviders.map((provider) => ( + + )) + )} +
+ + {error ?

{error}

: null} +
+
+ ); + } return (
- + {message ?

{message}

: null} +
- Overview -

- {activeView === 'firearms' - ? 'Firearms' - : activeView === 'ammo' - ? 'Ammo' - : 'Settings'} -

+ Arsenal Overview +

{`${firstName}'s Arsenal`}

+

{user.name} ยท {user.email}

-
+
+ +
+
+ + {activeView !== 'settings' ? ( +
{activeView === 'ammo' ? 'Ammo types' : 'Firearms'} - - {activeView === 'ammo' - ? ammoTypesWithRounds - : data.summary.totalFirearms} - + {activeView === 'ammo' ? ammoTypesWithRounds : data.summary.totalFirearms}
{activeView === 'ammo' ? 'Total rounds' : 'Total firearm value'} @@ -606,9 +940,12 @@ export default function Home() { : currency.format(data.summary.firearmsInvestment)}
- - +
+ ) : null} + + + {error ?

{error}

: null} {activeView === 'firearms' ? (
@@ -695,7 +1032,7 @@ export default function Home() { Remove firearm @@ -712,67 +1049,82 @@ export default function Home() { Add Record

New firearm

- - -
- - - - - - - - -