diff --git a/.env.example b/.env.example index d89c939..354b104 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ # App ----------------------------------- -NEXT_PUBLIC_APP_URL=http://localhost:3000 -BETTER_AUTH_URL=http://localhost:3000 +NEXT_PUBLIC_APP_URL=http://localhost:3005 +BETTER_AUTH_URL=http://localhost:3005 # Space-separated list of extra allowed origins (leave empty for same-origin only) BETTER_AUTH_TRUSTED_ORIGINS= diff --git a/docs/architecture.mdx b/docs/architecture.mdx index 68ebdc9..1d5bb57 100644 --- a/docs/architecture.mdx +++ b/docs/architecture.mdx @@ -11,29 +11,35 @@ It's a single Next.js app (no separate backend service) that renders pages on th ## Top-level `src/` layout -| Folder | Purpose | -| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `src/app` | Every route: pages, layouts, and API route handlers (Next.js App Router - see [Next.js & TypeScript](/tech-stack/nextjs-typescript/)). | -| `src/components` | Client-facing UI, one subfolder per feature area (`admin`, `auth`, `dashboard`, `finance`, `github`, `minecraft`, `network`, `onshape`, `orders`, `settings`, `vault`, `layout`) plus `ui` for the shadcn/ui primitives. | -| `src/lib` | Everything that isn't UI: `auth/` (better-auth setup + session helpers), `db/` (Drizzle schema + SQLite client), `finance/`, `integrations/` (GitHub, OnShape, Minecraft/Azalea, email), `security/` (vault crypto, API-key hashing, rate limiting), plus small root-level modules (`features.ts`, `use-poll.ts`, `utils.ts`, `validation.ts`). | +| Folder | Purpose | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/app` | Every route: pages, layouts, and API route handlers (Next.js App Router - see [Next.js & TypeScript](/tech-stack/nextjs-typescript/)). | +| `src/components` | Client-facing UI, one subfolder per feature area (`admin`, `auth`, `dashboard`, `finance`, `github`, `minecraft`, `network`, `onshape`, `orders`, `settings`, `vault`, `layout`) plus `ui` for the shadcn/ui primitives. | +| `src/lib` | Everything that isn't UI: `auth/` (better-auth setup + session helpers), `db/` (Drizzle schema + SQLite client), `finance/`, `integrations/` (GitHub, OnShape, Minecraft/Azalea, email), `security/` (vault crypto, API-key hashing, rate limiting), plus small root-level modules (`use-poll.ts`, `utils.ts`, `validation.ts`). | There's no `src/hooks` or `src/types` folder - the one meaningful custom hook (`usePoll`) lives in `src/lib`, and types are defined next to the schema/integration code they belong to rather than centralized. + + The `admin` subfolder under `src/components` (and `admin/*` under `src/app/api`) is a naming + holdover, not a live access tier - there's no admin role anymore (see below). It groups + components/routes that happen to manage shared/global data (users, whitelist, server control, + vault), as opposed to a member's own data. + + ## `src/app` structure ``` src/app/ ├── (auth)/ # /login, /pending - unauthenticated flow, own layout -├── (dashboard)/ # everything behind auth - own layout +├── (dashboard)/ # everything behind auth - own layout, flat routes │ ├── dashboard/ -│ ├── orders/[id]/edit, orders/new -│ ├── api-keys/ -│ ├── minecraft/ +│ ├── orders/[id]/edit +│ ├── api-keys/ # the vault UI +│ ├── minecraft/ # status, whitelist, server control, playtime, map │ ├── network/ -│ ├── features/ -│ ├── settings/ -│ └── admin/ # nested layout re-checks role === "admin" -│ ├── finance/ github/ minecraft/ network/ onshape/ orders/ server/ users/ +│ ├── members/ # users, GitHub org, OnShape company - tabbed +│ ├── finance/ +│ └── settings/ ├── pl3xmap/[[...path]] # Minecraft map proxy - relaxed CSP, no auth group ├── sentry-example-page/ └── api/ # route handlers - see the tech-stack pages for details @@ -41,13 +47,14 @@ src/app/ The two parenthesized folders - `(auth)` and `(dashboard)` - are [Next.js route groups](/tech-stack/nextjs-typescript/#route-groups-and-layouts): they organize routes and give each area its own `layout.tsx` without adding a `/auth` or `/dashboard` segment to the URL. -## How a page request is authorized (three layers) +Every `(dashboard)` route is a flat top-level page - there used to be a nested `admin/` section with its own layout, but that's gone along with the admin role (see below). What were once separate admin pages (users, GitHub, OnShape, server control) are now tabs or sections on the ordinary top-level pages (`/members`, `/minecraft`) instead of a separate route tree. + +## How a page request is authorized (two layers) -Access control isn't handled in one place - it's layered, and each layer exists for a different reason: +Access control isn't layered by role anymore - every approved, active user reaches the same pages and the same data. What's still layered is _where_ the session/status check happens, because `/api/*` isn't covered by the middleware: -1. **`src/middleware.ts`** runs first, before any page renders, for everything matched by its `matcher` (`/dashboard`, `/orders`, `/minecraft`, `/network`, `/features`, `/api-keys`, `/admin` - notably _not_ `/api/*`). It checks the session cookie via `auth.api.getSession()`, redirects to `/login` if missing or the account is deactivated, to `/pending` if not yet approved, and for non-admins, checks the requested path against `FEATURE_ROUTES` (in `src/lib/features.ts`) against a `userFeature` grant in the database. -2. **`(dashboard)/admin/layout.tsx`** re-checks `role === "admin"` as a server component, independent of the middleware. This is defense in depth - if the middleware matcher were ever misconfigured, the admin section still guards itself. -3. **Individual API route handlers** each call `getSessionUser()` and re-check role/`isActive` themselves, because `/api/*` isn't covered by the middleware at all. This is why you'll see the same-looking auth check copy-pasted at the top of many `route.ts` files rather than factored into one shared function - see [Authentication & Authorization](/tech-stack/auth/) for why, and where the vault endpoints add an extra check on top. +1. **`src/middleware.ts`** runs first, before any page renders, for everything matched by its `matcher` (`/dashboard`, `/orders`, `/minecraft`, `/network`, `/members`, `/finance`, `/api-keys` - notably _not_ `/api/*`). It checks the session cookie via `auth.api.getSession()`, redirects to `/login` if missing or the account is deactivated, and to `/pending` if not yet approved. That's the entire check - there's no further per-route or per-role gate after it. +2. **Individual API route handlers** each call `getSessionUser()` and re-check the session/`isActive` themselves, because `/api/*` isn't covered by the middleware at all. This is why you'll see the same-looking auth check copy-pasted at the top of many `route.ts` files rather than factored into one shared function - see [Authentication & Authorization](/tech-stack/auth/) for why, and where the vault endpoints add an extra per-entry check on top. ## Data fetching pattern @@ -58,20 +65,22 @@ Only three files in the entire `src/app` tree are `"use client"` at the page/lay ## The ordering workflow -Parts ordering is the largest feature, and it splits deliberately between what a -member does and what an officer does. +Parts ordering is the largest feature, and it splits deliberately between +submitting an item and triaging it - not between two different roles. Both +halves live on the single `/orders` page; anyone can do either. -**Members submit items only.** The submission form asks for vendor, link, item -name, part number, quantity, cost and notes - no fund type and no STF bucket. -One form can carry many items (see the repeatable-rows section in +**Submission carries items only.** The submission form asks for vendor, link, +item name, part number, quantity, cost and notes - no fund type and no STF +bucket. One form can carry many items (see the repeatable-rows section in [Forms & Validation](/tech-stack/forms-validation/)), and `POST /api/orders` writes them all in one request, stamping a shared `batchId` when there is more than one. Orders therefore land with `fundType`, `stfBucketId` and `quarterId` all null. -**Officers triage in batches.** `/admin/orders` groups pending orders into -"Needs triage" (no fund yet) and "Ready to review" (assigned). Officers -multi-select rows and act on the whole selection: +**The order queue triages in batches.** The "Order queue" section of `/orders` +groups pending orders into "Needs triage" (no fund yet) and "Ready to review" +(assigned). Whoever's triaging multi-selects rows and acts on the whole +selection: | Endpoint | What it does | | ------------------------------ | ---------------------------------------------------------------- | @@ -92,15 +101,15 @@ the time an officer reviews it. Two consequences worth knowing: ## Where each concern is documented -| Concern | Page | -| ---------------------------------------------------------------- | ----------------------------------------------------------- | -| Next.js routing, server/client components, `next.config.ts` | [Next.js & TypeScript](/tech-stack/nextjs-typescript/) | -| Tailwind v4, shadcn/ui, icons, toasts | [Styling & UI Components](/tech-stack/styling-ui/) | -| Forms and input validation | [Forms & Validation](/tech-stack/forms-validation/) | -| Schema, migrations, seeding | [Database](/tech-stack/database/) | -| Sessions, roles, feature grants | [Authentication & Authorization](/tech-stack/auth/) | -| Vault encryption, API keys, rate limiting | [Security](/tech-stack/security/) | -| Unit tests | [Testing](/tech-stack/testing/) | -| Linting, formatting, git hooks, CI | [Code Quality Tooling](/tech-stack/tooling/) | -| Error tracking, uptime monitoring | [Observability](/tech-stack/observability/) | -| Minecraft/RCON, GitHub, Resend, OnShape, Tailscale, system stats | [External Integrations](/tech-stack/external-integrations/) | +| Concern | Page | +| ----------------------------------------------------------- | ----------------------------------------------------------- | +| Next.js routing, server/client components, `next.config.ts` | [Next.js & TypeScript](/tech-stack/nextjs-typescript/) | +| Tailwind v4, shadcn/ui, icons, toasts | [Styling & UI Components](/tech-stack/styling-ui/) | +| Forms and input validation | [Forms & Validation](/tech-stack/forms-validation/) | +| Schema, migrations, seeding | [Database](/tech-stack/database/) | +| Sessions, account status | [Authentication & Authorization](/tech-stack/auth/) | +| Vault encryption, API keys, rate limiting | [Security](/tech-stack/security/) | +| Unit tests | [Testing](/tech-stack/testing/) | +| Linting, formatting, git hooks, CI | [Code Quality Tooling](/tech-stack/tooling/) | +| Error tracking, uptime monitoring | [Observability](/tech-stack/observability/) | +| Minecraft/RCON, GitHub, Resend, OnShape, Tailscale | [External Integrations](/tech-stack/external-integrations/) | diff --git a/docs/getting-started.mdx b/docs/getting-started.mdx index 465682a..dce9943 100644 --- a/docs/getting-started.mdx +++ b/docs/getting-started.mdx @@ -51,4 +51,4 @@ Open `http://localhost:3000` and log in with whatever you set as `SEED_ADMIN_EMA ### What "seeding" gives you -`pnpm db:seed` populates the local database with the 6 TrickFire subteams, an admin user (from `SEED_ADMIN_*`), and a handful of sample orders across every status, so the UI isn't empty on first run. It's idempotent - safe to re-run any time. +`pnpm db:seed` populates the local database with the 6 TrickFire subteams, a seed account (from `SEED_ADMIN_*`), and a handful of sample orders across every status, so the UI isn't empty on first run. It's idempotent - safe to re-run any time. diff --git a/docs/guides/deploy.mdx b/docs/guides/deploy.mdx index ebb876b..9b5191c 100644 --- a/docs/guides/deploy.mdx +++ b/docs/guides/deploy.mdx @@ -244,7 +244,7 @@ To update the Discord user IDs that get pinged on failure, edit `PING_IDS` in `h ## Staging -`pnpm staging` rsyncs your local working tree (including uncommitted changes) to `/home/trickfire/dashboard-staging` on the server, builds it there, and serves it over HTTPS via [Tailscale Serve](https://tailscale.com/kb/1242/tailscale-serve) on port 3001 - useful for trying out changes against the real production `.env.production` and a separate `db/staging.db` before merging to `main`. +`pnpm staging` rsyncs your local working tree (including uncommitted changes) to `/home/trickfire/dashboard-staging` on the server, builds it there, and serves it over HTTPS via [Tailscale Serve](https://tailscale.com/kb/1242/tailscale-serve) on port 3001 - useful for trying out changes against real production data before merging to `main`. ```bash title="Terminal (local machine)" pnpm staging @@ -252,10 +252,31 @@ pnpm staging Browse to the URL printed in the terminal (your machine's Tailscale HTTPS hostname) once the build finishes. Press Ctrl+C to stop - this also tears down the Tailscale Serve config. +Every run copies `/home/trickfire/db/dashboard.db` into a separate `db/staging.db`, migrates it, and wipes the `session` table - so staging always reflects current prod data, but no live prod session cookie works against it and nothing you do in staging writes back to prod. + - The staging database is seeded on first run only, with `admin@admin.local` / `trickfire` as the - login. It's a separate SQLite file from both the dev and production databases, so nothing you do - in staging touches real data. + Login with any real account from prod - since sessions are wiped on every run, everyone has to + log in fresh. + + +### `.env.staging` + +Staging uses its own `/home/trickfire/dashboard/.env.staging`, not `.env.production` - this keeps +it from touching live external services. Create it once, manually, based on `.env.production`: + +- `BETTER_AUTH_SECRET` - generate a **new** value (`openssl rand -hex 32`), distinct from prod's +- `VAULT_ENCRYPTION_KEY` - copy prod's value **verbatim**. The copied database includes the + vault table, which is encrypted with this key - a different key makes every entry fail to + decrypt +- `RESEND_API_KEY`, `TAILSCALE_API_KEY`, `GITHUB_TOKEN`, `ONSHAPE_ACCESS_KEY`, + `ONSHAPE_SECRET_KEY`, `SENTRY_AUTH_TOKEN`, `NEXT_PUBLIC_SENTRY_DSN` - leave **blank**. Every + integration checks `Boolean(process.env.X)` before doing anything, so an unset key makes it + report "not configured" instead of erroring - and it means staging never sends real email, + calls the real Tailscale/GitHub/OnShape APIs, or reports errors into prod's Sentry project +- Everything else - copy as-is + + + Keep `.env.staging` off git, same as `.env.production`. ## Updating an Existing Deployment diff --git a/docs/guides/development.mdx b/docs/guides/development.mdx index 0f74f5e..8774875 100644 --- a/docs/guides/development.mdx +++ b/docs/guides/development.mdx @@ -17,7 +17,7 @@ Collection of info about the codebase environment, setup and design choices. For | `pnpm format:check` | Check formatting without writing (used in CI) | | `pnpm db:generate` | Generate migrations from schema changes | | `pnpm db:migrate` | Apply all pending migrations | -| `pnpm db:seed` | Seed the 6 teams + admin user (idempotent) | +| `pnpm db:seed` | Seed the 6 teams + a seed account (idempotent) | | `pnpm db:reset` | Drop and recreate the local database (**blocked in production**) | | `pnpm db:studio` | Open Drizzle Studio - visual database browser (dev only) | | `pnpm test` | Run the unit test suite once | @@ -58,10 +58,10 @@ Every push and PR runs ESLint, Prettier, a TypeScript check, and the Vitest suit | `SIM_CACHE_DIR` | No | Directory the simulation export proxy caches generated archives in (default: `/var/cache/trickfire-sim`) | | `SIM_CACHE_MAX_ENTRIES` | No | Max cached export archives before the oldest are evicted (default: `50`) | | `SIM_CACHE_TTL_DAYS` | No | Days before a cached export archive expires (default: `7`) | -| `SEED_ADMIN_EMAIL` | No | Email for the seeded admin account | -| `SEED_ADMIN_PASSWORD` | No | Password for the seeded admin account | -| `SEED_ADMIN_NAME` | No | Display name for the seeded admin | -| `GITHUB_ORG` | No | GitHub organization name (e.g. `trickfirerobotics`) — enables the GitHub admin page | +| `SEED_ADMIN_EMAIL` | No | Email for the seeded account | +| `SEED_ADMIN_PASSWORD` | No | Password for the seeded account | +| `SEED_ADMIN_NAME` | No | Display name for the seeded account | +| `GITHUB_ORG` | No | GitHub organization name (e.g. `trickfirerobotics`) — enables the GitHub tab on the Members page | | `GITHUB_TOKEN` | No | Fine-grained PAT with **Organization → Members: Read and write** permission | | `NEXT_PUBLIC_SENTRY_DSN` | No | Sentry DSN — errors are silently dropped when unset | | `SENTRY_AUTH_TOKEN` | Build | Sentry auth token for source map uploads — only needed during `pnpm build` | @@ -77,16 +77,13 @@ Every push and PR runs ESLint, Prettier, a TypeScript check, and the Vitest suit ## API Key Vault -The **API Keys** page is a shared credential vault where admins store third-party API keys and service logins. Two layers of access apply: - -- **Page visibility** - the **Vault access** toggle on the Users admin page controls who can open the vault at all (admins always can). -- **Per-secret access** - reading any individual secret requires a per-person grant set from the entry's **Manage access** action. The global Vault-access toggle does not grant secret access on its own. +The **API Keys** page is a shared credential vault to store third-party API keys and service logins. Any logged-in member can open the page and see what entries exist - reading a specific secret is the gated part, via a per-person grant set from that entry's **Manage access** action. There's no global "vault access" toggle and no bypass for any particular user. Each entry is either a `login` (username + password, revealed in the browser on demand) or an `api_key` (never shown in the UI - retrieved only via the API endpoint below). ### Fetching an API key - `GET /api/vault/{id}/key` -Authenticated by the caller's dashboard session cookie. Returns the key only if the user is an admin or has been granted access. +Authenticated by the caller's dashboard session cookie. Returns the key only if the user has been granted access to that entry. | Status | Meaning | | ------ | ---------------------------------------------------------------------------------- | diff --git a/docs/tech-stack/auth.mdx b/docs/tech-stack/auth.mdx index e4b37fe..a49ca95 100644 --- a/docs/tech-stack/auth.mdx +++ b/docs/tech-stack/auth.mdx @@ -1,6 +1,6 @@ --- title: Authentication & Authorization -description: better-auth setup, sessions, roles, feature grants, and where each access check actually lives. +description: better-auth setup, sessions, and where each access check actually lives. --- ## What better-auth is @@ -10,7 +10,14 @@ description: better-auth setup, sessions, roles, feature grants, and where each It's worth distinguishing two related but different ideas this page covers: - **Authentication** - "who is this user?" (better-auth's job: sessions, login, verification) -- **Authorization** - "is this user allowed to do this?" (this app's job: roles, feature grants, vault access - built on top of the session better-auth provides) +- **Authorization** - "is this user allowed to do this?" (this app's job: account status and vault access - built on top of the session better-auth provides) + + + There is no admin/member role split anymore - every approved, active user has the same access to + every page and API route. The one exception is the API Key Vault, where reading a _specific_ + secret still requires an explicit per-entry grant (see [below](#helpers-in-srclibauthsessionts) + and [Security](/tech-stack/security/)). + ## Server configuration (`src/lib/auth/auth.ts`) @@ -21,9 +28,7 @@ export const auth = betterAuth({ plugins: [emailOTP({ otpLength: 6, expiresIn: 600 })], user: { additionalFields: { - role: { type: "string", defaultValue: "member", input: false }, isActive: { type: "boolean", defaultValue: true, input: false }, - canAccessVault: { type: "boolean", defaultValue: false, input: false }, approved: { type: "boolean", defaultValue: false, input: false }, }, changeEmail: { enabled: true }, @@ -37,16 +42,14 @@ Key points: - **`emailAndPassword`** is the only sign-in method - no OAuth/social login is configured. - **`requireEmailVerification: true`** means a new account can't log in until it clicks a verification link/code. - **The `emailOTP` plugin** is reused for two purposes: verifying a new email address, and resetting a forgotten password - both send a 6-digit code (`sendEmail`, via [Resend](/tech-stack/external-integrations/#resend-transactional-email)) rather than a magic link. -- **`additionalFields`** all have `input: false` - this means a client **cannot** set these fields through the normal sign-up/update-profile API, even if it tries to send them in the request body. They can only be changed by server-side code writing to the database directly (e.g. an admin API route, or the seed script). This is the whole security model for roles: a user cannot promote themselves to admin by crafting a request. +- **`additionalFields`** both have `input: false` - this means a client **cannot** set these fields through the normal sign-up/update-profile API, even if it tries to send them in the request body. They can only be changed by server-side code writing to the database directly (e.g. `PATCH /api/admin/users/[id]`, or the seed script). This is what stops a user from approving or reactivating their own account by crafting a request. ### What each additional field means -| Field | Default | Meaning | -| ---------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `role` | `"member"` | `"member"` or `"admin"` - checked throughout the app wherever admin-only UI or API access is gated. | -| `isActive` | `true` | Set `false` to deactivate an account without deleting it - `middleware.ts` immediately redirects deactivated users to `/login` and clears their session cookies. | -| `approved` | `false` | New sign-ups aren't approved by default - unapproved users are redirected to `/pending` until an admin approves them. | -| `canAccessVault` | `false` | Whether this user can open the API Key Vault page at all (separate from _which_ vault entries they can read - see [Security](/tech-stack/security/)). | +| Field | Default | Meaning | +| ---------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `isActive` | `true` | Set `false` to deactivate an account without deleting it - `middleware.ts` immediately redirects deactivated users to `/login` and clears their session cookies. | +| `approved` | `false` | New sign-ups aren't approved by default - unapproved users are redirected to `/pending` until an existing member approves them from the Members page. | ### Regenerating the schema @@ -67,17 +70,16 @@ export const authClient = createAuthClient({ export const { signIn, signOut, signUp, useSession } = authClient; ``` -`inferAdditionalFields()` is what gives `useSession()` correct TypeScript types for `role`/`isActive`/`approved`/`canAccessVault` on the client, inferred from the same server config rather than redeclared. +`inferAdditionalFields()` is what gives `useSession()` correct TypeScript types for `isActive`/`approved` on the client, inferred from the same server config rather than redeclared. -## The three layers of access control +## The two layers of access control -This is covered in more depth in [Architecture Overview](/architecture/#how-a-page-request-is-authorized-three-layers) - summarized here from the auth side: +This is covered in more depth in [Architecture Overview](/architecture/#how-a-page-request-is-authorized-two-layers) - summarized here from the auth side: -1. **`middleware.ts`** - session exists? active? approved? (for non-admins) does a `userFeature` grant exist for this route, per `FEATURE_ROUTES` in `src/lib/features.ts`? -2. **`(dashboard)/admin/layout.tsx`** - re-checks `role === "admin"` as a server component. -3. **Every `api/admin/**/route.ts`and other sensitive route handler** - calls`getSessionUser()` (`src/lib/auth/session.ts`) and checks `role`/`isActive`itself, since middleware doesn't cover`/api/\*`. +1. **`middleware.ts`** - session exists? account active? approved? That's the whole check - once all three are true, a user can reach any page covered by the matcher, there's no further per-route gating. +2. **Individual API route handlers** each call `getSessionUser()` (`src/lib/auth/session.ts`) and re-check the session/`isActive` themselves, since `/api/*` isn't covered by the middleware at all. -There is currently no single shared `requireAdmin()` helper - the same-shaped check is repeated per route handler. If you're adding a new admin API route, copy the pattern from a neighboring `api/admin/*/route.ts` file rather than inventing a new shape. +There is no shared `requireAuth()` helper for route handlers - the same-shaped check is repeated per file. If you're adding a new API route, copy the pattern from a neighboring `route.ts` rather than inventing a new shape. ### Helpers in `src/lib/auth/session.ts` @@ -85,14 +87,9 @@ There is currently no single shared `requireAdmin()` helper - the same-shaped ch export async function getSessionUser() { /* wraps auth.api.getSession({ headers }) */ } -export function canUseVault(user): boolean { - /* admin, or user.canAccessVault */ -} export function canReadVaultEntry(user, entryId): boolean { - /* admin, or a matching vaultEntryAccess row */ + /* true only if a matching vaultEntryAccess row exists - no bypass */ } ``` -## Feature grants (`userFeature` table + `src/lib/features.ts`) - -Some routes aren't purely admin/member - they're gated behind a per-user, per-feature grant (e.g. a member might be granted access to `/minecraft` without being an admin). `FEATURE_ROUTES` maps a URL prefix to a feature key; `middleware.ts` checks whether the current user has a `userFeature` row for that key with `status: "granted"`. Users without a grant land on `/features?denied=`, where they can request access. +`canReadVaultEntry` is the one place authorization is still per-user rather than all-or-nothing - see [Security](/tech-stack/security/#the-api-key-vault) for how vault entry grants work. diff --git a/docs/tech-stack/database.mdx b/docs/tech-stack/database.mdx index 6518dae..4c9d988 100644 --- a/docs/tech-stack/database.mdx +++ b/docs/tech-stack/database.mdx @@ -30,9 +30,8 @@ Tables are defined with Drizzle's `sqliteTable()`. This file re-exports the auth | `giftFundLog`, `orderHistory` | Audit trails for balance changes and order status transitions | | `apiKey` | One-way hashed service API keys for the external simulation-script API (distinct from the vault - see [Security](/tech-stack/security/)) | | `vaultEntry` / `vaultEntryAccess` | The shared credential vault and its per-user read grants | -| `minecraftWhitelist` | Minecraft whitelist requests (username, status, admin review) | +| `minecraftWhitelist` | Minecraft whitelist entries (username, status, review note) - added directly or via an older member-request flow, see `addedDirectly` | | `networkJoinRequest` | Tailscale/network device join requests | -| `userFeature` | Per-user feature-flag grant requests (pending/granted/rejected) - what `middleware.ts` checks for non-admin routes | Money is always stored as integer cents (`unitCostCents`, `startingBalanceCents`, etc.), never floats - this avoids floating-point rounding errors in financial arithmetic. Timestamps are stored as millisecond integers via a shared SQL fragment: @@ -74,9 +73,9 @@ Generated files live in `drizzle/migrations/*.sql`, with a `meta/_journal.json` ## Seeding (`scripts/seed.ts`, run via `pnpm db:seed`) -Populates the 6 subteams, a gift fund/quarter/buckets row, an admin user (from `SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD`/`SEED_ADMIN_NAME` - throws if unset), and ~13 sample orders spanning every status, all prefixed `[seed] ` so they're easy to spot and distinguish from real data. +Populates the 6 subteams, a gift fund/quarter/buckets row, an approved+active seed user (from `SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD`/`SEED_ADMIN_NAME` - throws if unset), and sample orders spanning every status, all prefixed `[seed] ` so they're easy to spot and distinguish from real data. -It's **idempotent** - every insert is either `.onConflictDoNothing()` (teams) or an explicit select-then-insert/update check keyed on a unique name (gift fund, buckets, admin user, seed orders), so running `pnpm db:seed` again never creates duplicates. This is what makes `pnpm setup` (`db:migrate` + `db:seed`) safe to run repeatedly, including as part of the production deploy path. +It's **idempotent** - every insert is either `.onConflictDoNothing()` (teams) or an explicit select-then-insert/update check keyed on a unique name (gift fund, buckets, seed user, seed orders), so running `pnpm db:seed` again never creates duplicates. This is what makes `pnpm setup` (`db:migrate` + `db:seed`) safe to run repeatedly, including as part of the production deploy path. ## Studio - a GUI for the local database diff --git a/docs/tech-stack/external-integrations.mdx b/docs/tech-stack/external-integrations.mdx index 89db33b..6986961 100644 --- a/docs/tech-stack/external-integrations.mdx +++ b/docs/tech-stack/external-integrations.mdx @@ -1,6 +1,6 @@ --- title: External Integrations -description: How the code talks to Minecraft/RCON, GitHub, Resend, OnShape, Tailscale, and the host system - from a developer's perspective. +description: How the code talks to Minecraft/RCON, GitHub, Resend, OnShape, and Tailscale - from a developer's perspective. --- This page explains _how the integration code works_. For installing and configuring these services on the production server, see the [Server Integrations Setup guide](/guides/integrations/) instead - that page is ops-focused, this one is code-focused. @@ -35,21 +35,11 @@ export async function getBotNames(): Promise> { ### Sending server commands -`sendCommand()` in the same file is the generic RCON executor used by the admin server-control page (`/admin/server`) to run arbitrary console commands - it requires `MINECRAFT_RCON_PASSWORD` to be set and connects/logs in/executes/closes per call, same as `rconQuery()` but without the timeout race (used from an authenticated admin-only context, not a background poll). +`sendCommand()` in the same file is the generic RCON executor used by the server-control card on the `/minecraft` page to run arbitrary console commands - it requires `MINECRAFT_RCON_PASSWORD` to be set and connects/logs in/executes/closes per call, same as `rconQuery()` but without the timeout race (used from an authenticated session, not a background poll). -## Host system stats (`systeminformation`) +## GitHub org management (`src/lib/integrations/github.ts`) -Used in exactly one place: `src/app/api/system/stats/route.ts`, an authenticated `GET` endpoint backing an admin server-stats widget. It calls `currentLoad()`, `mem()`, and `fsSize()` from the [`systeminformation`](https://systeminformation.io/) package in parallel, plus Node's built-in `os.loadavg()`/`os.uptime()`, computes CPU/memory/disk percentages, and caches the result for 5 seconds: - -```ts -const [load, memory, disks] = await Promise.all([currentLoad(), mem(), fsSize()]); -``` - -`better-sqlite3` and `systeminformation` are both listed in `next.config.ts`'s `serverExternalPackages` because they rely on native bindings/OS calls that shouldn't be bundled by webpack - see [Next.js & TypeScript](/tech-stack/nextjs-typescript/#nextconfigts---the-notable-bits). - -## GitHub org admin (`src/lib/integrations/github.ts`) - -Backs the `/admin/github` page - lets admins view org members, pending invitations, and teams, and send new invitations, without leaving the dashboard. Talks directly to `api.github.com` with `Authorization: Bearer ` and the `2022-11-28` API version header. +Backs the GitHub tab on the `/members` page - lets members view org members, pending invitations, and teams, and send new invitations, without leaving the dashboard. Talks directly to `api.github.com` with `Authorization: Bearer ` and the `2022-11-28` API version header. The file's own doc comment states the security intent clearly: the token should be a **fine-grained PAT scoped only to "Organization → Members: Read and write"** - enough to list/invite/remove members, deliberately _not_ enough to promote anyone to org owner. Invitations are always sent as `direct_member`, and there is intentionally no role-promotion call in this client. If `GITHUB_TOKEN`/`GITHUB_ORG` are unset, or the API call fails, the page renders a "not configured"/"unreachable" empty state rather than erroring. @@ -74,7 +64,7 @@ Called from two places: `src/lib/auth/auth.ts` (email verification codes, passwo ## OnShape -`src/lib/integrations/onshape.ts` follows the same pattern as the GitHub client - a thin authenticated wrapper around OnShape's API (`ONSHAPE_BASE_URL`/`ONSHAPE_ACCESS_KEY`/`ONSHAPE_SECRET_KEY`/`ONSHAPE_COMPANY_ID`) backing the `/admin/onshape` page, degrading to a "not configured" state when unset. +`src/lib/integrations/onshape.ts` follows the same pattern as the GitHub client - a thin authenticated wrapper around OnShape's API (`ONSHAPE_BASE_URL`/`ONSHAPE_ACCESS_KEY`/`ONSHAPE_SECRET_KEY`/`ONSHAPE_COMPANY_ID`) backing the OnShape tab on the `/members` page, degrading to a "not configured" state when unset. ### OnShape simulation export proxy @@ -90,7 +80,7 @@ Callers generate their own key from the dashboard's Settings page (`SimApiKeyPan ## Tailscale (Network tab) -`src/lib/integrations/network.ts` talks to the Tailscale API (`TAILSCALE_API_KEY`/`TAILSCALE_TAILNET`) to list devices on the club's shared tailnet and surface join requests, backing the Network tab and `/admin/network`. See the [Server Integrations Setup guide](/guides/integrations/#tailscale-network-tab) for how the API key itself is generated and rotated. +`src/lib/integrations/network.ts` talks to the Tailscale API (`TAILSCALE_API_KEY`/`TAILSCALE_TAILNET`) to list devices on the club's shared tailnet and surface join requests, backing the `/network` page. See the [Server Integrations Setup guide](/guides/integrations/#tailscale-network-tab) for how the API key itself is generated and rotated. ## Pl3xMap (world map proxy) diff --git a/docs/tech-stack/forms-validation.mdx b/docs/tech-stack/forms-validation.mdx index 1757655..636f877 100644 --- a/docs/tech-stack/forms-validation.mdx +++ b/docs/tech-stack/forms-validation.mdx @@ -13,7 +13,7 @@ None of these are Next.js-specific - this is a general pattern for any React app ## The pattern used throughout this codebase -Every form-heavy component (`OrderForm`, `LoginForm`, `SettingsForm`, `JoinRequestForm`, `WhitelistRequestForm`, `VaultEntryDialog`) follows the same shape. Using `src/components/orders/OrderForm.tsx` as the concrete example: +Every form-heavy component (`OrderForm`, `LoginForm`, `SettingsForm`, `JoinRequestForm`, `VaultEntryDialog`) follows the same shape. Using `src/components/orders/OrderForm.tsx` as the concrete example: **1. Define a Zod schema at the top of the file.** `OrderForm` submits a list of items, so its schema wraps a per-item schema in an array: diff --git a/docs/tech-stack/nextjs-typescript.mdx b/docs/tech-stack/nextjs-typescript.mdx index 09ee480..08439ef 100644 --- a/docs/tech-stack/nextjs-typescript.mdx +++ b/docs/tech-stack/nextjs-typescript.mdx @@ -36,7 +36,7 @@ This codebase is server-first almost everywhere: `page.tsx` files are async serv ### Route handlers (API routes) -Files at `src/app/api/**/route.ts` are plain HTTP endpoints, used by client components that need to fetch or mutate data after the initial page load (e.g. a form's submit handler `POST`ing to `/api/orders`, or a status tile polling `/api/minecraft/status` every few seconds). They receive a standard `NextRequest`/`Request` and return a `NextResponse`/`Response` - there's no framework-specific request object to learn. Nearly every handler in this project independently checks the caller's session and role, since `/api/*` isn't covered by `middleware.ts` (see [Authentication & Authorization](/tech-stack/auth/)). +Files at `src/app/api/**/route.ts` are plain HTTP endpoints, used by client components that need to fetch or mutate data after the initial page load (e.g. a form's submit handler `POST`ing to `/api/orders`, or a status tile polling `/api/minecraft/status` every few seconds). They receive a standard `NextRequest`/`Request` and return a `NextResponse`/`Response` - there's no framework-specific request object to learn. Nearly every handler in this project independently checks the caller's session (and `isActive` status), since `/api/*` isn't covered by `middleware.ts` (see [Authentication & Authorization](/tech-stack/auth/)). ### Middleware diff --git a/docs/tech-stack/security.mdx b/docs/tech-stack/security.mdx index 68e3ff4..85ba5c0 100644 --- a/docs/tech-stack/security.mdx +++ b/docs/tech-stack/security.mdx @@ -13,10 +13,7 @@ Three distinct security mechanisms live under `src/lib/security/`, solving three ## The API Key Vault -The **API Keys** page is a shared credential store for admins to keep track of third-party API keys and service logins (Resend, Tailscale, etc.) in one place instead of a shared password doc. Two independent layers of access apply: - -- **Page visibility** - the `canAccessVault` field on a user (see [Authentication & Authorization](/tech-stack/auth/#what-each-additional-field-means)) controls whether they can open the vault page at all. Admins always can. -- **Per-secret access** - reading any _individual_ entry additionally requires a grant in the `vaultEntryAccess` table, set via that entry's "Manage access" action. Having vault-page access does **not** imply access to every entry in it. +The **API Keys** page is a shared credential store to keep track of third-party API keys and service logins (Resend, Tailscale, etc.) in one place instead of a shared password doc. Any logged-in member can open the page and see entry names/descriptions - reading a specific secret is the part that's gated: it requires a grant in the `vaultEntryAccess` table, set via that entry's "Manage access" action. There's no bypass - not even the entry's creator can read it back without an explicit grant for themselves (which the creation flow adds automatically). Each entry is either a `login` (username + password, revealed in the UI on demand) or an `api_key` (never rendered in the UI - only retrievable via the API endpoint below, e.g. for pasting into another tool's config). @@ -61,7 +58,7 @@ GET /api/vault/{id}/reveal # login entries only - returns { name, username, pa Both endpoints are authenticated by the caller's dashboard session cookie (not a separate API key) and both: -- Call `canReadVaultEntry(user, id)` (admin, or a matching `vaultEntryAccess` row). +- Call `canReadVaultEntry(user, id)` (a matching `vaultEntryAccess` row - no other bypass). - Independently re-check `isActive`, because `/api/*` isn't covered by `middleware.ts` (see [Architecture Overview](/architecture/#how-a-page-request-is-authorized-three-layers)). - Set `Cache-Control: no-store` so a decrypted secret is never cached by a browser or intermediary. @@ -81,7 +78,7 @@ export function generateApiKey(): { raw: string; hash: string; prefix: string } } ``` -The raw key (`tf_...`) is shown to the admin exactly once at creation time and never persisted - only its hash and a short `prefix` (for identifying which key is which in a UI list, without revealing the whole thing) are stored in the `apiKey` table. A caller authenticates by sending the raw key; the server hashes what it received and compares against the stored hash. +The raw key (`tf_...`) is shown to the user exactly once at creation time and never persisted - only its hash and a short `prefix` (for identifying which key is which in a UI list, without revealing the whole thing) are stored in the `apiKey` table. A caller authenticates by sending the raw key; the server hashes what it received and compares against the stored hash. ## Rate limiting (`src/lib/security/rate-limit.ts`) diff --git a/docs/tech-stack/styling-ui.mdx b/docs/tech-stack/styling-ui.mdx index 2b7a715..a61654d 100644 --- a/docs/tech-stack/styling-ui.mdx +++ b/docs/tech-stack/styling-ui.mdx @@ -58,7 +58,9 @@ The `@theme inline` block is what makes Tailwind aware of custom tokens like `bg } ``` -Installed primitives (`src/components/ui/`): `alert-dialog`, `badge`, `button`, `card`, `dialog`, `dropdown-menu`, `form`, `input`, `label`, `select`, `sheet`, `skeleton`, `sonner`, `table`, `textarea`. Run `pnpm dlx shadcn@latest add ` to pull in more. +Installed primitives (`src/components/ui/`): `alert-dialog`, `badge`, `button`, `card`, `collapsible`, `dialog`, `dropdown-menu`, `form`, `input`, `label`, `select`, `sheet`, `skeleton`, `sonner`, `table`, `tabs`, `textarea`. Run `pnpm dlx shadcn@latest add ` to pull in more. + +Two more files live in the same folder but aren't shadcn-generated - `empty-state.tsx` and `data-table-card.tsx` are small in-house compositions (plain `div`s + `cn()`, no headless primitive underneath) that standardize patterns shadcn doesn't ship a component for: `EmptyState` for an icon/title/description/action block when a list has nothing in it, `DataTableCard`/`DataTableCardHeader`/`DataTableCardToolbar` for the bordered card-with-header wrapper most data tables in this app sit inside. Follow their existing shape rather than one-off-styling a new empty state or table wrapper per feature. ### The component pattern diff --git a/drizzle/migrations/0013_lowly_dagger.sql b/drizzle/migrations/0013_lowly_dagger.sql new file mode 100644 index 0000000..f8427dc --- /dev/null +++ b/drizzle/migrations/0013_lowly_dagger.sql @@ -0,0 +1,3 @@ +DROP TABLE `user_feature`;--> statement-breakpoint +ALTER TABLE `user` DROP COLUMN `role`;--> statement-breakpoint +ALTER TABLE `user` DROP COLUMN `can_access_vault`; \ No newline at end of file diff --git a/drizzle/migrations/meta/0013_snapshot.json b/drizzle/migrations/meta/0013_snapshot.json new file mode 100644 index 0000000..858d166 --- /dev/null +++ b/drizzle/migrations/meta/0013_snapshot.json @@ -0,0 +1,1618 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "d73f20b6-bac3-4513-b5c9-2e02e429f275", + "prevId": "f785fb2f-0f17-4547-abc6-058c1af76eff", + "tables": { + "api_key": { + "name": "api_key", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_revoked": { + "name": "is_revoked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "api_key_key_hash_unique": { + "name": "api_key_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "finance_settings": { + "name": "finance_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tax_percent_bps": { + "name": "tax_percent_bps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1100 + }, + "shipping_percent_bps": { + "name": "shipping_percent_bps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2000 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "gift_fund": { + "name": "gift_fund", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "current_value_cents": { + "name": "current_value_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "gift_fund_log": { + "name": "gift_fund_log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "changed_by": { + "name": "changed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "change_type": { + "name": "change_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_value_cents": { + "name": "previous_value_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "new_value_cents": { + "name": "new_value_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order_id": { + "name": "order_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "gift_fund_log_changed_by_user_id_fk": { + "name": "gift_fund_log_changed_by_user_id_fk", + "tableFrom": "gift_fund_log", + "tableTo": "user", + "columnsFrom": [ + "changed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "gift_fund_log_order_id_orders_id_fk": { + "name": "gift_fund_log_order_id_orders_id_fk", + "tableFrom": "gift_fund_log", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "minecraft_whitelist": { + "name": "minecraft_whitelist", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "request_note": { + "name": "request_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "admin_note": { + "name": "admin_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "added_directly": { + "name": "added_directly", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "minecraft_whitelist_user_id_user_id_fk": { + "name": "minecraft_whitelist_user_id_user_id_fk", + "tableFrom": "minecraft_whitelist", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "minecraft_whitelist_reviewed_by_user_id_fk": { + "name": "minecraft_whitelist_reviewed_by_user_id_fk", + "tableFrom": "minecraft_whitelist", + "tableTo": "user", + "columnsFrom": [ + "reviewed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "network_join_request": { + "name": "network_join_request", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "machine_key": { + "name": "machine_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_note": { + "name": "request_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "admin_note": { + "name": "admin_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "network_join_request_user_id_user_id_fk": { + "name": "network_join_request_user_id_user_id_fk", + "tableFrom": "network_join_request", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "network_join_request_reviewed_by_user_id_fk": { + "name": "network_join_request_reviewed_by_user_id_fk", + "tableFrom": "network_join_request", + "tableTo": "user", + "columnsFrom": [ + "reviewed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "orders": { + "name": "orders", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fund_type": { + "name": "fund_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stf_bucket_id": { + "name": "stf_bucket_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "batch_id": { + "name": "batch_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quarter_id": { + "name": "quarter_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_name": { + "name": "item_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part_number": { + "name": "part_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "unit_cost_cents": { + "name": "unit_cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "denial_comment": { + "name": "denial_comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "orders_batch_id_idx": { + "name": "orders_batch_id_idx", + "columns": [ + "batch_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "orders_user_id_user_id_fk": { + "name": "orders_user_id_user_id_fk", + "tableFrom": "orders", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "orders_stf_bucket_id_stf_bucket_id_fk": { + "name": "orders_stf_bucket_id_stf_bucket_id_fk", + "tableFrom": "orders", + "tableTo": "stf_bucket", + "columnsFrom": [ + "stf_bucket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "orders_assigned_by_user_id_fk": { + "name": "orders_assigned_by_user_id_fk", + "tableFrom": "orders", + "tableTo": "user", + "columnsFrom": [ + "assigned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "orders_quarter_id_stf_quarter_id_fk": { + "name": "orders_quarter_id_stf_quarter_id_fk", + "tableFrom": "orders", + "tableTo": "stf_quarter", + "columnsFrom": [ + "quarter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "orders_reviewed_by_user_id_fk": { + "name": "orders_reviewed_by_user_id_fk", + "tableFrom": "orders", + "tableTo": "user", + "columnsFrom": [ + "reviewed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "order_history": { + "name": "order_history", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "order_id": { + "name": "order_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_status": { + "name": "from_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "to_status": { + "name": "to_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "changed_by": { + "name": "changed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changed_at": { + "name": "changed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "order_history_order_id_orders_id_fk": { + "name": "order_history_order_id_orders_id_fk", + "tableFrom": "order_history", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "order_history_changed_by_user_id_fk": { + "name": "order_history_changed_by_user_id_fk", + "tableFrom": "order_history", + "tableTo": "user", + "columnsFrom": [ + "changed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sim_export_cache": { + "name": "sim_export_cache", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "archive_path": { + "name": "archive_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "archive_size_bytes": { + "name": "archive_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cached_at": { + "name": "cached_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "access_count": { + "name": "access_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "sim_export_cache_key": { + "name": "sim_export_cache_key", + "columns": [ + "document_id", + "workspace_id", + "element_id" + ], + "isUnique": true + }, + "sim_export_cache_accessed": { + "name": "sim_export_cache_accessed", + "columns": [ + "last_accessed_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "stf_bucket": { + "name": "stf_bucket", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "quarter_id": { + "name": "quarter_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "starting_balance_cents": { + "name": "starting_balance_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "stf_bucket_quarter_id_stf_quarter_id_fk": { + "name": "stf_bucket_quarter_id_stf_quarter_id_fk", + "tableFrom": "stf_bucket", + "tableTo": "stf_quarter", + "columnsFrom": [ + "quarter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "stf_quarter": { + "name": "stf_quarter", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "stf_quarter_name_unique": { + "name": "stf_quarter_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "team": { + "name": "team", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "team_name_unique": { + "name": "team_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vault_entry": { + "name": "vault_entry", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "vault_entry_created_by_user_id_fk": { + "name": "vault_entry_created_by_user_id_fk", + "tableFrom": "vault_entry", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vault_entry_access": { + "name": "vault_entry_access", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "entry_id": { + "name": "entry_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "vault_entry_access_entry_user": { + "name": "vault_entry_access_entry_user", + "columns": [ + "entry_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "vault_entry_access_entry_id_vault_entry_id_fk": { + "name": "vault_entry_access_entry_id_vault_entry_id_fk", + "tableFrom": "vault_entry_access", + "tableTo": "vault_entry", + "columnsFrom": [ + "entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vault_entry_access_user_id_user_id_fk": { + "name": "vault_entry_access_user_id_user_id_fk", + "tableFrom": "vault_entry_access", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vault_entry_access_granted_by_user_id_fk": { + "name": "vault_entry_access_granted_by_user_id_fk", + "tableFrom": "vault_entry_access", + "tableTo": "user", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "approved": { + "name": "approved", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "name_changed_at": { + "name": "name_changed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index 9897df6..f005e09 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1787267817158, "tag": "0012_smiling_loa", "breakpoints": true + }, + { + "idx": 13, + "version": "6", + "when": 1787295619743, + "tag": "0013_lowly_dagger", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 15c7f05..a401b25 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "tsx scripts/dev.ts", "build": "next build", "start": "next start", "lint": "eslint", @@ -24,7 +24,7 @@ "setup": "pnpm db:migrate && pnpm db:seed", "staging": "bash scripts/stage.sh", "deploy": "bash scripts/deploy.sh", - "auth:generate": "pnpm dlx @better-auth/cli@latest generate --config src/lib/auth.ts --output src/lib/db/auth-schema.ts -y", + "auth:generate": "pnpm dlx @better-auth/cli@latest generate --config src/lib/auth/auth.ts --output src/lib/db/auth-schema.ts -y", "docs:dev": "npx -y trickfire-docs@latest dev", "prepare": "husky" }, diff --git a/scripts/copy-prod-db-for-staging.ts b/scripts/copy-prod-db-for-staging.ts new file mode 100644 index 0000000..e392d0b --- /dev/null +++ b/scripts/copy-prod-db-for-staging.ts @@ -0,0 +1,28 @@ +// Copies the live prod database into staging using SQLite's online backup +// API instead of a plain file copy. Prod runs in WAL mode, so most recent +// writes live in dashboard.db-wal rather than the base .db file - a plain +// `cp` of just the base file misses everything not yet checkpointed. The +// backup API reads a consistent snapshot (base file + WAL) without +// interrupting the live service. + +import Database from "better-sqlite3"; + +const prodPath = process.argv[2]; +const stagingPath = process.argv[3]; + +if (!prodPath || !stagingPath) { + console.error("Usage: tsx copy-prod-db-for-staging.ts "); + process.exit(1); +} + +async function main() { + const prod = new Database(prodPath, { readonly: true }); + await prod.backup(stagingPath); + prod.close(); + console.log(`Copied ${prodPath} -> ${stagingPath}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/dev.ts b/scripts/dev.ts new file mode 100644 index 0000000..be25093 --- /dev/null +++ b/scripts/dev.ts @@ -0,0 +1,18 @@ +import { spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +if (!process.env.PORT) { + const envPath = resolve(process.cwd(), ".env.local"); + if (existsSync(envPath)) { + const match = readFileSync(envPath, "utf8").match(/^NEXT_PUBLIC_APP_URL\s*=\s*(.+)$/m); + const port = match && new URL(match[1].trim()).port; + if (port) process.env.PORT = port; + } +} + +const child = spawn("next", ["dev"], { stdio: "inherit", env: process.env }); +child.on("exit", (code, signal) => { + if (signal) process.kill(process.pid, signal); + else process.exit(code ?? 0); +}); diff --git a/scripts/seed.ts b/scripts/seed.ts index ebc8299..fb635a0 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -63,6 +63,11 @@ async function main() { { name: "Mechanical", startingBalanceCents: 43_000 }, { name: "Electronics", startingBalanceCents: 12_050 }, { name: "Software", startingBalanceCents: 0 }, + { name: "Poggers", startingBalanceCents: 67 }, + { name: "Wind Turbines", startingBalanceCents: 999 }, + { name: "Some more", startingBalanceCents: 929 }, + { name: "Some more 2", startingBalanceCents: 8795 }, + { name: "I'm unoriginal ik", startingBalanceCents: 999 }, ]; for (const bucket of defaultBuckets) { const exists = db.select().from(stfBucket).where(eq(stfBucket.name, bucket.name)).get(); @@ -107,11 +112,11 @@ async function main() { } db.update(user) - .set({ role: "admin", isActive: true, emailVerified: true, approved: true }) + .set({ isActive: true, emailVerified: true, approved: true }) .where(eq(user.email, email)) .run(); - console.log(`Ensured ${email} has role=admin, isActive=true, approved=true.`); + console.log(`Ensured ${email} has isActive=true, approved=true.`); const adminUser = db.select().from(user).where(eq(user.email, email)).get(); const mechanical = db.select().from(stfBucket).where(eq(stfBucket.name, "Mechanical")).get(); diff --git a/scripts/stage-server.sh b/scripts/stage-server.sh deleted file mode 100755 index e082753..0000000 --- a/scripts/stage-server.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env bash -# Build and start a staging instance on port 3001 -# Uses the same .env.production config as the live service -set -euo pipefail - -echo "==> Building..." -rm -rf .next -pnpm build - -echo "==> Copying static assets into standalone output..." -cp -r .next/static .next/standalone/.next/static -cp -r public .next/standalone/public - -PROD_ENV="/home/trickfire/dashboard/.env.production" -STAGING_DB="$(pwd)/db/staging.db" - -FIRST_RUN=false -[ ! -f "$STAGING_DB" ] && FIRST_RUN=true - -echo "==> Migrating staging database..." -DATABASE_PATH="$STAGING_DB" pnpm exec drizzle-kit migrate - -if $FIRST_RUN; then - echo "==> Seeding staging database (first run)..." - DATABASE_PATH="$STAGING_DB" \ - SEED_ADMIN_EMAIL="admin@admin.local" \ - SEED_ADMIN_PASSWORD="trickfire" \ - pnpm exec tsx scripts/seed.ts -fi - -echo "==> Setting up HTTPS via Tailscale serve..." -TS_HOST=$(tailscale status --json | python3 -c "import sys,json; s=json.load(sys.stdin); print(s['Self']['DNSName'].rstrip('.'))") -STAGING_URL="https://$TS_HOST" -if ! sudo tailscale serve --bg 3001 2>&1; then - echo "" - echo " ERROR: Tailscale Serve failed. Check that Serve is enabled for this tailnet." - exit 1 -fi -trap 'sudo tailscale serve reset' EXIT - -echo "" -echo " ┌─────────────────────────────────────────────┐" -echo " │ Staging: $STAGING_URL│" -echo " └─────────────────────────────────────────────┘" -echo "" - -BETTER_AUTH_URL="$STAGING_URL" \ -PORT=3001 DATABASE_PATH="$STAGING_DB" \ -/usr/bin/node --env-file="$PROD_ENV" .next/standalone/server.js diff --git a/scripts/stage-server.ts b/scripts/stage-server.ts new file mode 100644 index 0000000..b1f581b --- /dev/null +++ b/scripts/stage-server.ts @@ -0,0 +1,202 @@ +// Builds and starts the staging instance on port 3001. Each phase runs behind +// a spinner; on success only a checkmark is printed, on failure the full +// captured output is dumped so the real error is still visible. The `next +// build` step is the one exception - its output is always shown, since the +// route table is worth seeing every run. +// +// BETTER_AUTH_SECRET/BETTER_AUTH_URL are passed into the build itself (not +// just the runtime start) so Better Auth has real config while collecting +// page data - without it, every route logs a "default secret" warning during +// build. + +import { spawn } from "node:child_process"; +import { cpSync, readFileSync, rmSync } from "node:fs"; +import path from "node:path"; +import { parseEnv } from "node:util"; + +const STAGING_ENV_PATH = "/home/trickfire/dashboard/.env.staging"; +const PROD_DB = "/home/trickfire/db/dashboard.db"; +const CWD = process.cwd(); +const STAGING_DB = path.join(CWD, "db", "staging.db"); +const PORT = "3001"; + +const GREEN = "\x1b[32m"; +const RED = "\x1b[31m"; +const RESET = "\x1b[0m"; +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +class CommandError extends Error { + constructor( + cmd: string, + readonly code: number, + readonly output: string + ) { + super(`${cmd} exited with code ${code}`); + } +} + +function runCmd( + cmd: string, + args: string[], + opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {} +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { + cwd: opts.cwd, + env: opts.env ?? process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + const chunks: Buffer[] = []; + child.stdout.on("data", (d: Buffer) => chunks.push(d)); + child.stderr.on("data", (d: Buffer) => chunks.push(d)); + child.on("error", reject); + child.on("close", (code) => { + const output = Buffer.concat(chunks).toString("utf8"); + if (code === 0) resolve(output); + else reject(new CommandError(`${cmd} ${args.join(" ")}`, code ?? 1, output)); + }); + }); +} + +function startSpinner(label: string) { + let i = 0; + process.stdout.write("\x1b[?25l"); + const timer = setInterval(() => { + process.stdout.write(`\r${SPINNER_FRAMES[(i = (i + 1) % SPINNER_FRAMES.length)]} ${label}`); + }, 80); + return () => { + clearInterval(timer); + process.stdout.write("\r\x1b[K\x1b[?25h"); + }; +} + +async function step(label: string, fn: () => Promise | T, alwaysShow = false): Promise { + const stopSpinner = startSpinner(label); + try { + const result = await fn(); + stopSpinner(); + console.log(`${GREEN}✓${RESET} ${label}`); + if (alwaysShow && typeof result === "string" && result.trim()) { + console.log(result.trimEnd()); + } + return result; + } catch (err) { + stopSpinner(); + console.log(`${RED}✗${RESET} ${label}`); + if (err instanceof CommandError) { + console.log(err.output.trimEnd()); + } else { + console.error(err); + } + process.exit(1); + } +} + +async function getStagingUrl(): Promise { + const raw = await runCmd("tailscale", ["status", "--json"]); + const status = JSON.parse(raw) as { Self: { DNSName: string } }; + return `https://${status.Self.DNSName.replace(/\.$/, "")}`; +} + +async function main() { + const stagingEnv = parseEnv(readFileSync(STAGING_ENV_PATH, "utf8")); + const stagingUrl = await step("Resolving Tailscale hostname", getStagingUrl); + + const buildEnv = { + ...process.env, + ...stagingEnv, + BETTER_AUTH_URL: stagingUrl, + DATABASE_PATH: STAGING_DB, + }; + + rmSync(path.join(CWD, ".next"), { recursive: true, force: true }); + await step("Building", () => runCmd("pnpm", ["build"], { cwd: CWD, env: buildEnv }), true); + + await step("Copying static assets", () => { + cpSync(path.join(CWD, ".next/static"), path.join(CWD, ".next/standalone/.next/static"), { + recursive: true, + }); + cpSync(path.join(CWD, "public"), path.join(CWD, ".next/standalone/public"), { + recursive: true, + }); + }); + + await step("Copying prod database into staging", () => { + rmSync(STAGING_DB, { force: true }); + rmSync(`${STAGING_DB}-wal`, { force: true }); + rmSync(`${STAGING_DB}-shm`, { force: true }); + return runCmd( + "pnpm", + ["exec", "tsx", "scripts/copy-prod-db-for-staging.ts", PROD_DB, STAGING_DB], + { cwd: CWD } + ); + }); + + const dbEnv = { ...process.env, DATABASE_PATH: STAGING_DB }; + await step("Migrating staging database", () => + runCmd("pnpm", ["exec", "drizzle-kit", "migrate"], { cwd: CWD, env: dbEnv }) + ); + await step("Wiping staging sessions", () => + runCmd("pnpm", ["exec", "tsx", "scripts/wipe-staging-sessions.ts"], { + cwd: CWD, + env: dbEnv, + }) + ); + + console.log("→ Setting up HTTPS via Tailscale Serve (sudo password may be required)"); + const serve = await new Promise((resolve, reject) => { + const child = spawn("sudo", ["tailscale", "serve", "--bg", PORT], { stdio: "inherit" }); + child.on("error", reject); + child.on("close", (code) => resolve(code ?? 1)); + }); + if (serve !== 0) { + console.log( + `${RED}ERROR: Tailscale Serve failed. Check that Serve is enabled for this tailnet.${RESET}` + ); + process.exit(1); + } + + let cleanedUp = false; + const cleanup = () => { + if (cleanedUp) return; + cleanedUp = true; + spawn("sudo", ["tailscale", "serve", "reset"], { stdio: "ignore" }); + }; + process.on("SIGINT", () => { + cleanup(); + process.exit(130); + }); + process.on("SIGTERM", () => { + cleanup(); + process.exit(143); + }); + process.on("exit", cleanup); + + const label = `Staging: ${stagingUrl}`; + console.log(""); + console.log(` ┌${"─".repeat(label.length + 2)}┐`); + console.log(` │ ${label} │`); + console.log(` └${"─".repeat(label.length + 2)}┘`); + console.log(""); + + const runtimeEnv = { + ...process.env, + ...stagingEnv, + BETTER_AUTH_URL: stagingUrl, + PORT, + DATABASE_PATH: STAGING_DB, + }; + await new Promise((resolve) => { + const server = spawn("/usr/bin/node", [".next/standalone/server.js"], { + cwd: CWD, + env: runtimeEnv, + stdio: "inherit", + }); + server.on("close", () => resolve()); + }); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/stage.sh b/scripts/stage.sh index d414222..c482ce3 100755 --- a/scripts/stage.sh +++ b/scripts/stage.sh @@ -20,4 +20,4 @@ rsync -az --delete \ . "$SERVER:$REMOTE_DIR/" echo "==> Building and starting staging..." -ssh -t "$SERVER" "cd '$REMOTE_DIR' && pnpm install --frozen-lockfile && bash scripts/stage-server.sh" +ssh -t "$SERVER" "cd '$REMOTE_DIR' && pnpm install --frozen-lockfile && pnpm exec tsx scripts/stage-server.ts" diff --git a/scripts/wipe-staging-sessions.ts b/scripts/wipe-staging-sessions.ts new file mode 100644 index 0000000..cbcf003 --- /dev/null +++ b/scripts/wipe-staging-sessions.ts @@ -0,0 +1,28 @@ +// Deletes all rows from the session table so a staging DB copied from prod +// never carries over a live, reusable prod session cookie. Refuses to delete +// anything unless DATABASE_PATH clearly points at a staging database, +// mirroring the predb:reset production guard in package.json. + +const dbPath = process.env.DATABASE_PATH ?? ""; +if (!dbPath.includes("staging")) { + console.error( + `ERROR: wipe-staging-sessions refused to run against DATABASE_PATH="${dbPath}". ` + + 'This script only runs against a database path containing "staging".' + ); + process.exit(1); +} + +import { db } from "../src/lib/db"; +import { session } from "../src/lib/db/schema"; + +async function main() { + const result = db.delete(session).run(); + console.log(`Wiped ${result.changes} staging session(s).`); +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index c5d05e8..90e3e54 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -19,7 +19,7 @@ export default async function LoginPage({ const notice = deactivated ? "Your account has been deactivated. Contact an admin." : undefined; return ( -
+
-
-

GitHub

-

- Manage TrickFire organization members, invitations, and teams. -

-
- - {!configured ? ( -
- GitHub is not configured. Set GITHUB_ORG and{" "} - GITHUB_TOKEN to enable management. -
- ) : !org ? ( -
- Couldn't reach the GitHub organization. Verify{" "} - GITHUB_ORG is correct and the token has the{" "} - Members: Read and write permission for the - org. -
- ) : ( - - )} -
- ); -} diff --git a/src/app/(dashboard)/admin/layout.tsx b/src/app/(dashboard)/admin/layout.tsx deleted file mode 100644 index d770fa6..0000000 --- a/src/app/(dashboard)/admin/layout.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { headers } from "next/headers"; -import { redirect } from "next/navigation"; - -import { auth } from "@/lib/auth/auth"; - -export default async function AdminLayout({ children }: { children: React.ReactNode }) { - const session = await auth.api.getSession({ headers: await headers() }); - - if (!session?.user) { - redirect("/login"); - } - if (session.user.role !== "admin") { - redirect("/dashboard"); - } - - return <>{children}; -} diff --git a/src/app/(dashboard)/admin/minecraft/page.tsx b/src/app/(dashboard)/admin/minecraft/page.tsx deleted file mode 100644 index efb84c2..0000000 --- a/src/app/(dashboard)/admin/minecraft/page.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { asc, desc, eq, sql } from "drizzle-orm"; - -import { WhitelistManager, type AdminWhitelistRow } from "@/components/admin/WhitelistManager"; -import { db } from "@/lib/db"; -import { minecraftWhitelist, user } from "@/lib/db/schema"; - -export default async function AdminMinecraftPage() { - const rows = db - .select({ - id: minecraftWhitelist.id, - username: minecraftWhitelist.username, - status: minecraftWhitelist.status, - requesterName: user.name, - requestNote: minecraftWhitelist.requestNote, - adminNote: minecraftWhitelist.adminNote, - addedDirectly: minecraftWhitelist.addedDirectly, - createdAt: minecraftWhitelist.createdAt, - }) - .from(minecraftWhitelist) - .leftJoin(user, eq(minecraftWhitelist.userId, user.id)) - .orderBy( - asc(sql`case when ${minecraftWhitelist.status} = 'pending' then 0 else 1 end`), - desc(minecraftWhitelist.createdAt) - ) - .all(); - - const requests: AdminWhitelistRow[] = rows.map((r) => ({ - ...r, - requesterName: r.requesterName ?? null, - })); - - return ( -
-
-

Whitelist

-

- Review whitelist requests and add usernames directly. -

-
- - -
- ); -} diff --git a/src/app/(dashboard)/admin/network/page.tsx b/src/app/(dashboard)/admin/network/page.tsx deleted file mode 100644 index f57103c..0000000 --- a/src/app/(dashboard)/admin/network/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { AdminNetworkManager } from "@/components/network/AdminNetworkManager"; - -export default async function AdminNetworkPage() { - return ( -
-
-

Network

-

Manage Tailscale devices.

-
- - -
- ); -} diff --git a/src/app/(dashboard)/admin/onshape/page.tsx b/src/app/(dashboard)/admin/onshape/page.tsx deleted file mode 100644 index ce0ff34..0000000 --- a/src/app/(dashboard)/admin/onshape/page.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { AdminOnshapeManager } from "@/components/onshape/AdminOnshapeManager"; -import { getOnshapeCompany, isOnshapeConfigured } from "@/lib/integrations/onshape"; - -export default async function AdminOnshapePage() { - const configured = isOnshapeConfigured(); - const company = configured ? await getOnshapeCompany() : null; - - return ( -
-
-

Onshape

-

- Manage Onshape company members, teams, and access. -

-
- - {!configured ? ( -
- Onshape is not configured. Set{" "} - ONSHAPE_ACCESS_KEY and{" "} - ONSHAPE_SECRET_KEY to enable management. -
- ) : !company ? ( -
- Couldn't reach the Onshape company. Verify the API key has access to a - Professional/Enterprise company and that{" "} - ONSHAPE_COMPANY_ID (if set) is correct. -
- ) : ( - - )} -
- ); -} diff --git a/src/app/(dashboard)/admin/orders/page.tsx b/src/app/(dashboard)/admin/orders/page.tsx deleted file mode 100644 index 989d80b..0000000 --- a/src/app/(dashboard)/admin/orders/page.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { asc, desc, eq, sql } from "drizzle-orm"; - -import { AdminOrderQueue, type AdminOrderRow } from "@/components/orders/AdminOrderQueue"; -import { db } from "@/lib/db"; -import { order, stfBucket, user } from "@/lib/db/schema"; -import { - ensureFinanceSettingsRow, - getOrderPricingSettings, - getStfBucketsWithBalances, -} from "@/lib/finance/finance"; -import { percentBpsToDisplay } from "@/lib/finance/order-pricing"; - -export default async function AdminOrdersPage() { - ensureFinanceSettingsRow(); - const pricing = getOrderPricingSettings(); - const rows: AdminOrderRow[] = db - .select({ - id: order.id, - itemName: order.itemName, - fundType: order.fundType, - stfBucketId: order.stfBucketId, - stfBucketName: stfBucket.name, - batchId: order.batchId, - requesterName: user.name, - requesterEmail: user.email, - quantity: order.quantity, - unitCostCents: order.unitCostCents, - vendor: order.vendor, - link: order.link, - notes: order.notes, - partNumber: order.partNumber, - status: order.status, - denialComment: order.denialComment, - createdAt: order.createdAt, - }) - .from(order) - .leftJoin(stfBucket, eq(order.stfBucketId, stfBucket.id)) - .leftJoin(user, eq(order.userId, user.id)) - .orderBy( - asc(sql`case when ${order.status} = 'pending' then 0 else 1 end`), - desc(order.createdAt) - ) - .all(); - - return ( -
-
-

Order Queue

-

- Review pending orders, manage approved batches, and browse ordered and denied - archives. -

-
- - -
- ); -} diff --git a/src/app/(dashboard)/admin/page.tsx b/src/app/(dashboard)/admin/page.tsx deleted file mode 100644 index bf2f0d4..0000000 --- a/src/app/(dashboard)/admin/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "next/navigation"; - -export default function AdminOverviewPage() { - redirect("/dashboard"); -} diff --git a/src/app/(dashboard)/admin/server/page.tsx b/src/app/(dashboard)/admin/server/page.tsx deleted file mode 100644 index eec63b7..0000000 --- a/src/app/(dashboard)/admin/server/page.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { redirect } from "next/navigation"; - -import { RunSettingsCard } from "@/components/admin/server/RunSettingsCard"; -import { ServerConfigEditor } from "@/components/admin/server/ServerConfigEditor"; -import { ServerControlCard } from "@/components/admin/server/ServerControlCard"; -import { ServerLogViewer } from "@/components/admin/server/ServerLogViewer"; -import { isConfigured, isRunning, readConfig } from "@/lib/integrations/azalea"; -import { getSessionUser } from "@/lib/auth/session"; - -export default async function AdminServerPage() { - const user = await getSessionUser(); - if (!user) redirect("/login"); - if (user.role !== "admin") redirect("/dashboard"); - - const configured = isConfigured(); - const running = isRunning(); - - let config = null; - let installedTag: string | null = null; - if (configured) { - try { - config = readConfig(); - installedTag = config.installed_tag; - } catch {} - } - - return ( -
-
-

Server

-

Manage the Minecraft server via azalea.

-
- -
-
- - {config && } - {!configured && ( -
- Set MINECRAFT_SERVER_PATH to enable - configuration. -
- )} -
- {/* relative+self-stretch makes this column stretch to the left column's height. - The inner absolute div fills that height without contributing to row sizing, - so the grid row height is driven only by the left column. */} -
-
- -
-
-
- - {config && } -
- ); -} diff --git a/src/app/(dashboard)/admin/users/page.tsx b/src/app/(dashboard)/admin/users/page.tsx deleted file mode 100644 index c408cb2..0000000 --- a/src/app/(dashboard)/admin/users/page.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { and, asc, eq } from "drizzle-orm"; -import { redirect } from "next/navigation"; - -import { FeatureRequests, type FeatureRequestRow } from "@/components/admin/FeatureRequests"; -import { PendingApprovals, type PendingUserRow } from "@/components/admin/PendingApprovals"; -import { UserTable, type AdminUserRow } from "@/components/admin/UserTable"; -import { db } from "@/lib/db"; -import { user, userFeature } from "@/lib/db/schema"; -import type { FeatureKey } from "@/lib/features"; -import { getSessionUser } from "@/lib/auth/session"; - -export default async function AdminUsersPage() { - const current = await getSessionUser(); - if (!current) redirect("/login"); - - // Pending approvals (unapproved users) - const pendingRows = db - .select({ id: user.id, name: user.name, email: user.email, createdAt: user.createdAt }) - .from(user) - .where(eq(user.approved, false)) - .orderBy(asc(user.createdAt)) - .all(); - - const pendingUsers: PendingUserRow[] = pendingRows.map((u) => ({ - id: u.id, - name: u.name, - email: u.email, - createdAt: u.createdAt, - })); - - // Approved members - const memberRows = db - .select({ - id: user.id, - name: user.name, - email: user.email, - role: user.role, - isActive: user.isActive, - canAccessVault: user.canAccessVault, - createdAt: user.createdAt, - }) - .from(user) - .where(eq(user.approved, true)) - .orderBy(asc(user.name)) - .all(); - - // Fetch granted features for each approved user - const allFeatures = db - .select({ userId: userFeature.userId, featureKey: userFeature.featureKey }) - .from(userFeature) - .where(eq(userFeature.status, "granted")) - .all(); - - const featuresByUser = new Map(); - for (const f of allFeatures) { - const list = featuresByUser.get(f.userId) ?? []; - list.push(f.featureKey as FeatureKey); - featuresByUser.set(f.userId, list); - } - - const members: AdminUserRow[] = memberRows.map((u) => ({ - id: u.id, - name: u.name, - email: u.email, - role: u.role === "admin" ? "admin" : "member", - isActive: u.isActive ?? true, - canAccessVault: u.canAccessVault ?? false, - grantedFeatures: featuresByUser.get(u.id) ?? [], - createdAt: u.createdAt, - })); - - // Pending feature requests - const featureReqRows = db - .select({ - id: userFeature.id, - userId: userFeature.userId, - featureKey: userFeature.featureKey, - requestNote: userFeature.requestNote, - requestedAt: userFeature.requestedAt, - userName: user.name, - userEmail: user.email, - }) - .from(userFeature) - .innerJoin(user, eq(userFeature.userId, user.id)) - .where(and(eq(userFeature.status, "pending"), eq(user.approved, true))) - .orderBy(asc(userFeature.requestedAt)) - .all(); - - const featureRequests: FeatureRequestRow[] = featureReqRows.map((r) => ({ - id: r.id, - userId: r.userId, - userName: r.userName, - userEmail: r.userEmail, - featureKey: r.featureKey, - requestNote: r.requestNote, - requestedAt: r.requestedAt, - })); - - return ( -
-
-

Users

-

- Manage member approvals, roles, and feature access. -

-
- - - - {featureRequests.length > 0 && } - -
-

Members

- -
-
- ); -} diff --git a/src/app/(dashboard)/api-keys/page.tsx b/src/app/(dashboard)/api-keys/page.tsx index 917c846..16178d7 100644 --- a/src/app/(dashboard)/api-keys/page.tsx +++ b/src/app/(dashboard)/api-keys/page.tsx @@ -5,14 +5,11 @@ import { VaultManager, type VaultMember } from "@/components/vault/VaultManager" import type { VaultEntryRow } from "@/components/vault/VaultEntryDialog"; import { db } from "@/lib/db"; import { user, vaultEntry, vaultEntryAccess } from "@/lib/db/schema"; -import { canUseVault, getSessionUser } from "@/lib/auth/session"; +import { getSessionUser } from "@/lib/auth/session"; export default async function ApiKeysPage() { const sessionUser = await getSessionUser(); if (!sessionUser) redirect("/login"); - if (!canUseVault(sessionUser)) redirect("/dashboard"); - - const isAdmin = sessionUser.role === "admin"; const rows = db .select({ @@ -29,42 +26,23 @@ export default async function ApiKeysPage() { const entries: VaultEntryRow[] = rows; - let members: VaultMember[] = []; - const grants: Record = {}; - if (isAdmin) { - members = db - .select({ id: user.id, name: user.name, email: user.email, role: user.role }) - .from(user) - .orderBy(asc(user.name)) - .all() - .map((m) => ({ - id: m.id, - name: m.name, - email: m.email, - isAdmin: m.role === "admin", - })); + const members: VaultMember[] = db + .select({ id: user.id, name: user.name, email: user.email }) + .from(user) + .orderBy(asc(user.name)) + .all(); - for (const g of db - .select({ entryId: vaultEntryAccess.entryId, userId: vaultEntryAccess.userId }) - .from(vaultEntryAccess) - .all()) { - (grants[g.entryId] ??= []).push(g.userId); - } + const grants: Record = {}; + for (const g of db + .select({ entryId: vaultEntryAccess.entryId, userId: vaultEntryAccess.userId }) + .from(vaultEntryAccess) + .all()) { + (grants[g.entryId] ??= []).push(g.userId); } return (
-
-

API Keys

-

- A secret vault for the club's shared credentials. - {isAdmin - ? " Create entries and manage per-person access." - : " Reveal logins, or fetch API keys from their endpoint."} -

-
- - +
); } diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index f2f93b5..f3d8dc4 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -1,249 +1,173 @@ -import { and, count, eq } from "drizzle-orm"; -import { AlertTriangle, Gamepad2, KeyRound, Network, Package } from "lucide-react"; +import { count, eq } from "drizzle-orm"; +import { + CheckCircle2, + ChevronRight, + DollarSign, + Gamepad2, + KeyRound, + Network, + Package, + ShoppingCart, + UserCheck, +} from "lucide-react"; import Link from "next/link"; import { redirect } from "next/navigation"; import { LiveClock } from "@/components/dashboard/LiveClock"; -import { MinecraftStatusTile } from "@/components/dashboard/MinecraftStatusTile"; -import { NetworkStatusTile } from "@/components/dashboard/NetworkStatusTile"; -import { SystemVitals } from "@/components/dashboard/SystemVitals"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { EmptyState } from "@/components/ui/empty-state"; import { db } from "@/lib/db"; -import { networkJoinRequest, minecraftWhitelist, order, user, userFeature } from "@/lib/db/schema"; +import { minecraftWhitelist, order, user } from "@/lib/db/schema"; import { getSessionUser } from "@/lib/auth/session"; +const quickLinks = [ + { + href: "/orders", + label: "Orders", + description: "Submit and track parts requests.", + icon: Package, + }, + { + href: "/finance", + label: "Finance", + description: "Budgets and gift fund.", + icon: DollarSign, + }, + { + href: "/members", + label: "Members", + description: "Approvals and org access.", + icon: UserCheck, + }, + { + href: "/api-keys", + label: "API Keys", + description: "Shared credentials vault.", + icon: KeyRound, + }, + { + href: "/network", + label: "Network", + description: "Private VPN device access.", + icon: Network, + }, + { + href: "/minecraft", + label: "Minecraft", + description: "Server status and whitelist.", + icon: Gamepad2, + }, +]; + export default async function DashboardHome() { const sessionUser = await getSessionUser(); if (!sessionUser) redirect("/login"); const firstName = sessionUser.name?.split(" ")[0] ?? "there"; - const isAdmin = sessionUser.role === "admin"; - - let pendingOrders = 0; - let adminStats: { label: string; value: number; href: string }[] = []; - - if (isAdmin) { - pendingOrders = - db.select({ value: count() }).from(order).where(eq(order.status, "pending")).get() - ?.value ?? 0; - const pendingApprovals = - db.select({ value: count() }).from(user).where(eq(user.approved, false)).get()?.value ?? - 0; - const activeMembers = - db - .select({ value: count() }) - .from(user) - .where(and(eq(user.isActive, true), eq(user.approved, true))) - .get()?.value ?? 0; - const pendingFeatureRequests = - db - .select({ value: count() }) - .from(userFeature) - .where(eq(userFeature.status, "pending")) - .get()?.value ?? 0; - const openWhitelist = - db - .select({ value: count() }) - .from(minecraftWhitelist) - .where(eq(minecraftWhitelist.status, "pending")) - .get()?.value ?? 0; - const pendingNetworkRequests = - db - .select({ value: count() }) - .from(networkJoinRequest) - .where(eq(networkJoinRequest.status, "pending")) - .get()?.value ?? 0; - - adminStats = [ - { label: "Pending approvals", value: pendingApprovals, href: "/admin/users" }, - { label: "Pending orders", value: pendingOrders, href: "/admin/orders" }, - { label: "Feature requests", value: pendingFeatureRequests, href: "/admin/users" }, - { label: "Active members", value: activeMembers, href: "/admin/users" }, - { label: "Open whitelist requests", value: openWhitelist, href: "/admin/minecraft" }, - { - label: "Network join requests", - value: pendingNetworkRequests, - href: "/admin/network", - }, - ]; - } - const urgentItems = adminStats.filter((s) => s.value > 0 && s.label !== "Active members"); + const pendingOrders = + db.select({ value: count() }).from(order).where(eq(order.status, "pending")).get()?.value ?? + 0; + const approvedOrders = + db.select({ value: count() }).from(order).where(eq(order.status, "approved")).get() + ?.value ?? 0; + const pendingApprovals = + db.select({ value: count() }).from(user).where(eq(user.approved, false)).get()?.value ?? 0; + const openWhitelist = + db + .select({ value: count() }) + .from(minecraftWhitelist) + .where(eq(minecraftWhitelist.status, "pending")) + .get()?.value ?? 0; + + const actionItems = [ + { + count: pendingOrders, + label: "orders need triage or approval", + href: "/orders", + icon: Package, + }, + { + count: approvedOrders, + label: "approved orders are awaiting purchase", + href: "/orders", + icon: ShoppingCart, + }, + { + count: pendingApprovals, + label: "members are waiting for approval", + href: "/members", + icon: UserCheck, + }, + { + count: openWhitelist, + label: "whitelist requests need review", + href: "/minecraft", + icon: Gamepad2, + }, + ].filter((item) => item.count > 0); return (
- {/* Hero row */}
-
-

Welcome back, {firstName}

-

Your TrickFire club dashboard.

-
+

Welcome back, {firstName}

- {/* Admin alert strip */} - {isAdmin && urgentItems.length > 0 && ( -
- - {urgentItems.map((item) => ( - - - {item.value} {item.label} → - - - ))} -
- )} - - {/* Server Health */}
-

Server Health

+

Action Items

- Live system vitals — updates every 10s + Things that need your attention.

- + {actionItems.length === 0 ? ( + + ) : ( +
+ {actionItems.map((item) => ( + +
+ +
+

+ + {item.count} + {" "} + {item.label} +

+ + + ))} +
+ )}
- {/* Quick Access — bento layout */}

Quick Access

Jump to common tools.

- -
- {/* Orders spotlight — left 2/3 on desktop */} - - - -
- -
-
- - Order a Part - - {isAdmin && pendingOrders > 0 && ( - - {pendingOrders} pending - - )} -
- - Submit a parts request for the team. Orders are tracked from - submission through approval and delivery. - -
- -

New order →

-
-
- - - {/* Right column — 3 stacked tiles */} -
- {/* Minecraft live tile */} - +
+ {quickLinks.map(({ href, label, description, icon: Icon }) => ( + -
- - Minecraft -
- -
- - - {/* Network live tile */} - - -
- - Network -
- -
- - - {/* API Keys pink accent tile — full width in the 2-col grid on mobile */} - - -
- -
-
- API Keys - - Developer Tool - +
+
- - Create keys for your sim scripts. - + {label} + {description} -
+ ))}
- - {/* Admin Overview */} - {isAdmin && ( -
-
-

Admin Overview

-

- Club-wide stats at a glance. -

-
-
- {adminStats.map((s) => { - const isUrgent = s.value > 0 && s.label !== "Active members"; - const isPositive = s.label === "Active members"; - return ( - - - - - {s.value} - - {isUrgent && ( -

- Action needed -

- )} - {s.label} -
-
- - ); - })} -
-
- )}
); } diff --git a/src/app/(dashboard)/features/page.tsx b/src/app/(dashboard)/features/page.tsx deleted file mode 100644 index e6a484f..0000000 --- a/src/app/(dashboard)/features/page.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { eq } from "drizzle-orm"; -import { redirect } from "next/navigation"; - -import { FeaturesPanel } from "@/components/features/FeaturesPanel"; -import { db } from "@/lib/db"; -import { userFeature } from "@/lib/db/schema"; -import { FEATURE_KEYS, FEATURES, type FeatureKey } from "@/lib/features"; -import { getSessionUser } from "@/lib/auth/session"; - -export default async function FeaturesPage({ - searchParams, -}: { - searchParams: Promise<{ denied?: string }>; -}) { - const user = await getSessionUser(); - if (!user) redirect("/login"); - - const isAdmin = user.role === "admin"; - - const features = isAdmin - ? FEATURE_KEYS.map((key) => ({ - key, - label: FEATURES[key].label, - description: FEATURES[key].description, - status: "granted" as const, - })) - : (() => { - const rows = db - .select({ featureKey: userFeature.featureKey, status: userFeature.status }) - .from(userFeature) - .where(eq(userFeature.userId, user.id)) - .all(); - const statusMap = Object.fromEntries( - rows.map((r) => [r.featureKey, r.status]) - ) as Record; - return FEATURE_KEYS.map((key) => ({ - key, - label: FEATURES[key].label, - description: FEATURES[key].description, - status: statusMap[key] ?? null, - })); - })(); - - const { denied } = await searchParams; - - return ( -
-
-

My Access

-

- {isAdmin - ? "Admins have full access to all features." - : "Request access to features. An admin will review and approve your requests."} -

-
- -
- ); -} diff --git a/src/app/(dashboard)/admin/finance/page.tsx b/src/app/(dashboard)/finance/page.tsx similarity index 84% rename from src/app/(dashboard)/admin/finance/page.tsx rename to src/app/(dashboard)/finance/page.tsx index 7addc06..73d4650 100644 --- a/src/app/(dashboard)/admin/finance/page.tsx +++ b/src/app/(dashboard)/finance/page.tsx @@ -28,13 +28,6 @@ export default async function AdminFinancePage() { return (
-
-

Finance

-

- Manage STF buckets, gift fund value, order pricing, and school year resets. -

-
- r.featureKey as FeatureKey); - return (
- +
- -
{children}
+ +
{children}
); diff --git a/src/app/(dashboard)/members/page.tsx b/src/app/(dashboard)/members/page.tsx new file mode 100644 index 0000000..b6a4c5b --- /dev/null +++ b/src/app/(dashboard)/members/page.tsx @@ -0,0 +1,154 @@ +import { Boxes, GitBranch, Users } from "lucide-react"; +import { asc, eq } from "drizzle-orm"; +import { redirect } from "next/navigation"; + +import { PendingApprovals, type PendingUserRow } from "@/components/admin/PendingApprovals"; +import { UserTable, type AdminUserRow } from "@/components/admin/UserTable"; +import { AdminGithubManager } from "@/components/github/AdminGithubManager"; +import { AdminOnshapeManager } from "@/components/onshape/AdminOnshapeManager"; +import { EmptyState } from "@/components/ui/empty-state"; +import { Tabs, TabsContent, TabsIndicator, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { db } from "@/lib/db"; +import { user } from "@/lib/db/schema"; +import { getSessionUser } from "@/lib/auth/session"; +import { getOrg, isGithubConfigured } from "@/lib/integrations/github"; +import { getOnshapeCompany, isOnshapeConfigured } from "@/lib/integrations/onshape"; + +const tabTriggerClass = + "flex-1 gap-2 rounded-lg px-6 py-3.5 text-sm font-semibold shadow-none transition-colors duration-200 data-active:border-transparent data-active:bg-transparent data-active:text-primary-foreground dark:data-active:border-transparent dark:data-active:bg-transparent dark:data-active:text-primary-foreground sm:flex-none sm:px-10"; + +export default async function MembersPage() { + const current = await getSessionUser(); + if (!current) redirect("/login"); + + const pendingRows = db + .select({ id: user.id, name: user.name, email: user.email, createdAt: user.createdAt }) + .from(user) + .where(eq(user.approved, false)) + .orderBy(asc(user.createdAt)) + .all(); + + const pendingUsers: PendingUserRow[] = pendingRows.map((u) => ({ + id: u.id, + name: u.name, + email: u.email, + createdAt: u.createdAt, + })); + + const memberRows = db + .select({ + id: user.id, + name: user.name, + email: user.email, + isActive: user.isActive, + createdAt: user.createdAt, + }) + .from(user) + .where(eq(user.approved, true)) + .orderBy(asc(user.name)) + .all(); + + const members: AdminUserRow[] = memberRows.map((u) => ({ + id: u.id, + name: u.name, + email: u.email, + isActive: u.isActive ?? true, + createdAt: u.createdAt, + })); + + const onshapeConfigured = isOnshapeConfigured(); + const onshapeCompany = onshapeConfigured ? await getOnshapeCompany() : null; + + const githubConfigured = isGithubConfigured(); + const githubOrg = githubConfigured ? await getOrg() : null; + + return ( +
+ + + + + + Users + + + + Onshape + + + + GitHub + + + + + + + + + + + {!onshapeConfigured ? ( + + Set ONSHAPE_ACCESS_KEY and{" "} + ONSHAPE_SECRET_KEY to enable + management. + + } + /> + ) : !onshapeCompany ? ( + + Verify the API key has access to a Professional/Enterprise + company and that{" "} + ONSHAPE_COMPANY_ID (if set) + is correct. + + } + /> + ) : ( + + )} + + + + {!githubConfigured ? ( + + Set GITHUB_ORG and{" "} + GITHUB_TOKEN to enable + management. + + } + /> + ) : !githubOrg ? ( + + Verify GITHUB_ORG is correct + and the token has the{" "} + Members: Read and write{" "} + permission for the org. + + } + /> + ) : ( + + )} + + +
+ ); +} diff --git a/src/app/(dashboard)/minecraft/page.tsx b/src/app/(dashboard)/minecraft/page.tsx index d018cc5..62d1a71 100644 --- a/src/app/(dashboard)/minecraft/page.tsx +++ b/src/app/(dashboard)/minecraft/page.tsx @@ -1,90 +1,121 @@ -import { desc, eq } from "drizzle-orm"; +import { ChevronDown } from "lucide-react"; +import { asc, desc, eq, sql } from "drizzle-orm"; import { redirect } from "next/navigation"; +import { WhitelistManager, type AdminWhitelistRow } from "@/components/admin/WhitelistManager"; +import { RunSettingsCard } from "@/components/admin/server/RunSettingsCard"; +import { ServerConfigEditor } from "@/components/admin/server/ServerConfigEditor"; +import { ServerControlCard } from "@/components/admin/server/ServerControlCard"; +import { ServerLogViewer } from "@/components/admin/server/ServerLogViewer"; import { Pl3xmapEmbed } from "@/components/minecraft/Pl3xmapEmbed"; import { PlaytimeLeaderboard } from "@/components/minecraft/PlaytimeLeaderboard"; import { ServerStatusSection } from "@/components/minecraft/ServerStatusSection"; -import { WhitelistRequestForm } from "@/components/minecraft/WhitelistRequestForm"; -import { WhitelistStatusBadge } from "@/components/minecraft/WhitelistStatusBadge"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { db } from "@/lib/db"; -import { minecraftWhitelist } from "@/lib/db/schema"; +import { minecraftWhitelist, user } from "@/lib/db/schema"; +import { isConfigured, isRunning, readConfig } from "@/lib/integrations/azalea"; import { getSessionUser } from "@/lib/auth/session"; -import { formatDate } from "@/lib/utils"; export default async function MinecraftPage() { - const user = await getSessionUser(); - if (!user) redirect("/login"); + const sessionUser = await getSessionUser(); + if (!sessionUser) redirect("/login"); - const requests = db + const whitelistRows = db .select({ id: minecraftWhitelist.id, username: minecraftWhitelist.username, status: minecraftWhitelist.status, + requesterName: user.name, + requestNote: minecraftWhitelist.requestNote, adminNote: minecraftWhitelist.adminNote, + addedDirectly: minecraftWhitelist.addedDirectly, createdAt: minecraftWhitelist.createdAt, }) .from(minecraftWhitelist) - .where(eq(minecraftWhitelist.userId, user.id)) - .orderBy(desc(minecraftWhitelist.createdAt)) + .leftJoin(user, eq(minecraftWhitelist.userId, user.id)) + .orderBy( + asc(sql`case when ${minecraftWhitelist.status} = 'pending' then 0 else 1 end`), + desc(minecraftWhitelist.createdAt) + ) .all(); + const whitelistRequests: AdminWhitelistRow[] = whitelistRows.map((r) => ({ + ...r, + requesterName: r.requesterName ?? null, + })); + + const serverConfigured = isConfigured(); + const running = isRunning(); + let serverConfig = null; + let installedTag: string | null = null; + if (serverConfigured) { + try { + serverConfig = readConfig(); + installedTag = serverConfig.installed_tag; + } catch {} + } + return (
-
-

Minecraft

-

Club server status and whitelist requests.

-
- {/* * ServerStatusSection uses display:contents so its two child cards - * (ServerInfoCard, OnlinePlayersCard) become direct grid items, - * giving us 3 equal columns with a single shared fetch. + * (ServerInfoCard, OnlinePlayersCard) become direct grid items. */} -
+
- - - - Whitelist - Request access to the club server. - - - - - {requests.length > 0 && ( -
-

- Your Requests -

-
    - {requests.map((r) => ( -
  • -
    -

    - {r.username} -

    -

    - {formatDate(r.createdAt)} - {r.adminNote ? ` · ${r.adminNote}` : ""} -

    -
    - -
  • - ))} -
-
- )} -
-
+ +
+
+

Whitelist

+

+ Add usernames and manage who can join the server. +

+
+ +
+ + + +
+

Server control

+

+ Start, stop, and configure the Minecraft server via azalea. +

+
+ +
+ +
+
+ + {serverConfig && } + {!serverConfigured && ( +
+ Set MINECRAFT_SERVER_PATH to + enable configuration. +
+ )} +
+ {/* relative+self-stretch makes this column stretch to the left column's height. + The inner absolute div fills that height without contributing to row sizing, + so the grid row height is driven only by the left column. */} +
+
+ +
+
+
+ + {serverConfig && } +
+
); } diff --git a/src/app/(dashboard)/network/page.tsx b/src/app/(dashboard)/network/page.tsx index 3158dbb..366aee0 100644 --- a/src/app/(dashboard)/network/page.tsx +++ b/src/app/(dashboard)/network/page.tsx @@ -1,7 +1,7 @@ import { redirect } from "next/navigation"; +import { AdminNetworkManager } from "@/components/network/AdminNetworkManager"; import { NetworkStatusCard } from "@/components/network/NetworkStatusCard"; -import { NodeList } from "@/components/network/NodeList"; import { getSessionUser } from "@/lib/auth/session"; export default async function NetworkPage() { @@ -9,14 +9,10 @@ export default async function NetworkPage() { if (!user) redirect("/login"); return ( -
-
-

Network

-

TrickFire private network status.

-
- +
- + +
); } diff --git a/src/app/(dashboard)/orders/[id]/edit/page.tsx b/src/app/(dashboard)/orders/[id]/edit/page.tsx index 42c29ca..774aeb2 100644 --- a/src/app/(dashboard)/orders/[id]/edit/page.tsx +++ b/src/app/(dashboard)/orders/[id]/edit/page.tsx @@ -32,15 +32,7 @@ export default async function EditOrderPage({ params }: PageProps) { return (
-
-
-

Edit Order

-

- {existing.status === "denied" - ? "Update your order and resubmit it for officer review." - : "Update your pending order before it is reviewed."} -

-
+
diff --git a/src/app/(dashboard)/orders/new/page.tsx b/src/app/(dashboard)/orders/new/page.tsx deleted file mode 100644 index bd14b3c..0000000 --- a/src/app/(dashboard)/orders/new/page.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import Link from "next/link"; - -import { OrderForm } from "@/components/orders/OrderForm"; -import { Button } from "@/components/ui/button"; - -export default function NewOrderPage() { - return ( -
-
-
-

New Order

-

- Submit a purchase request for officer approval. -

-
- -
- - -
- ); -} diff --git a/src/app/(dashboard)/orders/page.tsx b/src/app/(dashboard)/orders/page.tsx index 0535592..1769d6c 100644 --- a/src/app/(dashboard)/orders/page.tsx +++ b/src/app/(dashboard)/orders/page.tsx @@ -1,11 +1,11 @@ -import { desc, eq } from "drizzle-orm"; -import Link from "next/link"; +import { asc, desc, eq, sql } from "drizzle-orm"; import { redirect } from "next/navigation"; +import { AdminOrderQueue, type AdminOrderRow } from "@/components/orders/AdminOrderQueue"; import { OrderBalancesSummary } from "@/components/orders/OrderBalancesSummary"; +import { OrderFormDialog } from "@/components/orders/OrderFormDialog"; import { OrderTable, type MemberOrderRow } from "@/components/orders/OrderTable"; import { TeamOrderTable, type TeamOrderRow } from "@/components/orders/TeamOrderTable"; -import { Button } from "@/components/ui/button"; import { db } from "@/lib/db"; import { order, stfBucket, user as userTable } from "@/lib/db/schema"; import { @@ -22,6 +22,11 @@ export default async function OrdersPage() { if (!user) redirect("/login"); ensureFinanceSettingsRow(); + const pricing = getOrderPricingSettings(); + const orderPricing = { + taxPercent: percentBpsToDisplay(pricing.taxPercentBps), + shippingPercent: percentBpsToDisplay(pricing.shippingPercentBps), + }; const myOrders: MemberOrderRow[] = db .select({ @@ -60,32 +65,51 @@ export default async function OrdersPage() { .orderBy(desc(order.createdAt)) .all(); + const queueRows: AdminOrderRow[] = db + .select({ + id: order.id, + itemName: order.itemName, + fundType: order.fundType, + stfBucketId: order.stfBucketId, + stfBucketName: stfBucket.name, + batchId: order.batchId, + requesterName: userTable.name, + requesterEmail: userTable.email, + quantity: order.quantity, + unitCostCents: order.unitCostCents, + vendor: order.vendor, + link: order.link, + notes: order.notes, + partNumber: order.partNumber, + status: order.status, + denialComment: order.denialComment, + createdAt: order.createdAt, + }) + .from(order) + .leftJoin(stfBucket, eq(order.stfBucketId, stfBucket.id)) + .leftJoin(userTable, eq(order.userId, userTable.id)) + .orderBy( + asc(sql`case when ${order.status} = 'pending' then 0 else 1 end`), + desc(order.createdAt) + ) + .all(); + const giftBalanceCents = getGiftFundValueCents(); const stfBuckets = getStfBucketsWithBalances(); - const pricing = getOrderPricingSettings(); - const orderPricing = { - taxPercent: percentBpsToDisplay(pricing.taxPercentBps), - shippingPercent: percentBpsToDisplay(pricing.shippingPercentBps), - }; const orderedParts = teamOrders.filter((o) => o.status === "ordered"); return ( -
-
-
-

My Orders

-

- Track your orders and see everything the team has submitted. -

-
- +
+
+

+ Submit a new purchase request or review orders below. +

+
-
+

Your orders

@@ -95,7 +119,7 @@ export default async function OrdersPage() {

-
+

Team orders

@@ -106,7 +130,7 @@ export default async function OrdersPage() {

-
+

Ordered parts

@@ -120,6 +144,26 @@ export default async function OrdersPage() { emptyMessage="No parts have been ordered yet." />

+ +
+ +
+
+

Order queue

+

+ Review pending orders, manage approved batches, and browse ordered and + denied archives. +

+
+ +
); } diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 11e4a6d..6086855 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -41,10 +41,6 @@ export default async function SettingsPage() { return (
-
-

Account settings

-

Update your name and email address.

-
}) { - const admin = await getSessionUser(); - if (!admin) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (admin.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - - const id = parseInt((await params).id); - if (isNaN(id)) return NextResponse.json({ error: "Invalid id" }, { status: 400 }); - - const body = await req.json().catch(() => null); - const parsed = featureActionSchema.safeParse(body); - if (!parsed.success) return NextResponse.json({ error: "Invalid input" }, { status: 400 }); - - const existing = db.select().from(userFeature).where(eq(userFeature.id, id)).get(); - if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 }); - - const newStatus = parsed.data.action === "grant" ? "granted" : "rejected"; - const updated = db - .update(userFeature) - .set({ - status: newStatus, - adminNote: parsed.data.adminNote ?? null, - reviewedBy: admin.id, - reviewedAt: new Date(), - }) - .where(eq(userFeature.id, id)) - .returning() - .get(); - - return NextResponse.json({ feature: updated }); -} diff --git a/src/app/api/admin/features/route.ts b/src/app/api/admin/features/route.ts deleted file mode 100644 index 9c2fa85..0000000 --- a/src/app/api/admin/features/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { eq } from "drizzle-orm"; -import { NextResponse } from "next/server"; - -import { db } from "@/lib/db"; -import { user, userFeature } from "@/lib/db/schema"; -import { getSessionUser } from "@/lib/auth/session"; - -export async function GET() { - const admin = await getSessionUser(); - if (!admin) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (admin.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - - const pending = db - .select({ - id: userFeature.id, - featureKey: userFeature.featureKey, - status: userFeature.status, - requestNote: userFeature.requestNote, - requestedAt: userFeature.requestedAt, - userId: userFeature.userId, - userName: user.name, - userEmail: user.email, - }) - .from(userFeature) - .innerJoin(user, eq(userFeature.userId, user.id)) - .where(eq(userFeature.status, "pending")) - .all(); - - return NextResponse.json({ requests: pending }); -} diff --git a/src/app/api/admin/finance/buckets/[id]/route.ts b/src/app/api/admin/finance/buckets/[id]/route.ts index c772bd1..c9eca2e 100644 --- a/src/app/api/admin/finance/buckets/[id]/route.ts +++ b/src/app/api/admin/finance/buckets/[id]/route.ts @@ -9,7 +9,6 @@ import { stfBucketUpdateSchema } from "@/lib/validation"; export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const id = Number((await params).id); if (!Number.isInteger(id)) return NextResponse.json({ error: "Invalid id" }, { status: 400 }); diff --git a/src/app/api/admin/finance/buckets/route.ts b/src/app/api/admin/finance/buckets/route.ts index a88d945..9688652 100644 --- a/src/app/api/admin/finance/buckets/route.ts +++ b/src/app/api/admin/finance/buckets/route.ts @@ -9,7 +9,6 @@ import { stfBucketInputSchema } from "@/lib/validation"; export async function POST(req: NextRequest) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const body = await req.json().catch(() => null); const parsed = stfBucketInputSchema.safeParse(body); @@ -44,7 +43,6 @@ export async function POST(req: NextRequest) { export async function PUT(req: NextRequest) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const body = await req.json().catch(() => null); const quarterName = body?.quarterName?.trim(); diff --git a/src/app/api/admin/finance/gift/route.ts b/src/app/api/admin/finance/gift/route.ts index 5508684..c62026a 100644 --- a/src/app/api/admin/finance/gift/route.ts +++ b/src/app/api/admin/finance/gift/route.ts @@ -7,7 +7,6 @@ import { giftFundAdjustSchema } from "@/lib/validation"; export async function POST(req: NextRequest) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const body = await req.json().catch(() => null); const parsed = giftFundAdjustSchema.safeParse(body); diff --git a/src/app/api/admin/finance/quarter-reset/route.ts b/src/app/api/admin/finance/quarter-reset/route.ts index be8c3e8..5370a7b 100644 --- a/src/app/api/admin/finance/quarter-reset/route.ts +++ b/src/app/api/admin/finance/quarter-reset/route.ts @@ -10,7 +10,6 @@ import { quarterResetSchema } from "@/lib/validation"; export async function POST(req: NextRequest) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const body = await req.json().catch(() => null); const parsed = quarterResetSchema.safeParse(body); diff --git a/src/app/api/admin/finance/route.ts b/src/app/api/admin/finance/route.ts index b75a492..76a2471 100644 --- a/src/app/api/admin/finance/route.ts +++ b/src/app/api/admin/finance/route.ts @@ -16,7 +16,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function GET() { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); ensureGiftFundRow(); ensureFinanceSettingsRow(); diff --git a/src/app/api/admin/finance/settings/route.ts b/src/app/api/admin/finance/settings/route.ts index e235b89..2185fac 100644 --- a/src/app/api/admin/finance/settings/route.ts +++ b/src/app/api/admin/finance/settings/route.ts @@ -8,7 +8,6 @@ import { financeSettingsUpdateSchema } from "@/lib/validation"; export async function PATCH(req: NextRequest) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const body = await req.json().catch(() => null); const parsed = financeSettingsUpdateSchema.safeParse(body); diff --git a/src/app/api/admin/github/invitations/[id]/route.ts b/src/app/api/admin/github/invitations/[id]/route.ts index 653cdbc..3d39587 100644 --- a/src/app/api/admin/github/invitations/[id]/route.ts +++ b/src/app/api/admin/github/invitations/[id]/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const id = Number((await params).id); if (!Number.isInteger(id)) { diff --git a/src/app/api/admin/github/invitations/route.ts b/src/app/api/admin/github/invitations/route.ts index 28828f9..e99f689 100644 --- a/src/app/api/admin/github/invitations/route.ts +++ b/src/app/api/admin/github/invitations/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function GET() { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); try { const invitations = await getPendingInvitations(); diff --git a/src/app/api/admin/github/members/[username]/route.ts b/src/app/api/admin/github/members/[username]/route.ts index eaabc88..af77f9a 100644 --- a/src/app/api/admin/github/members/[username]/route.ts +++ b/src/app/api/admin/github/members/[username]/route.ts @@ -9,7 +9,6 @@ export async function DELETE( ) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const { username } = await params; try { diff --git a/src/app/api/admin/github/members/route.ts b/src/app/api/admin/github/members/route.ts index a879bd3..33ab487 100644 --- a/src/app/api/admin/github/members/route.ts +++ b/src/app/api/admin/github/members/route.ts @@ -7,7 +7,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function GET() { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); try { const members = await getOrgMembers(); @@ -43,7 +42,6 @@ const inviteSchema = z export async function POST(req: NextRequest) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const body = await req.json().catch(() => null); const parsed = inviteSchema.safeParse(body); diff --git a/src/app/api/admin/github/org/route.ts b/src/app/api/admin/github/org/route.ts index a062a1b..ad8b2de 100644 --- a/src/app/api/admin/github/org/route.ts +++ b/src/app/api/admin/github/org/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function GET() { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const org = await getOrg(); return NextResponse.json({ configured: isGithubConfigured(), org }); diff --git a/src/app/api/admin/github/teams/[slug]/members/route.ts b/src/app/api/admin/github/teams/[slug]/members/route.ts index ec0dfca..6fa6e0d 100644 --- a/src/app/api/admin/github/teams/[slug]/members/route.ts +++ b/src/app/api/admin/github/teams/[slug]/members/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function GET(_req: NextRequest, { params }: { params: Promise<{ slug: string }> }) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const { slug } = await params; try { diff --git a/src/app/api/admin/github/teams/route.ts b/src/app/api/admin/github/teams/route.ts index b694401..a487b89 100644 --- a/src/app/api/admin/github/teams/route.ts +++ b/src/app/api/admin/github/teams/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function GET() { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); try { const teams = await getOrgTeams(); diff --git a/src/app/api/admin/network/nodes/[id]/route.ts b/src/app/api/admin/network/nodes/[id]/route.ts index 87f06f9..82fd15a 100644 --- a/src/app/api/admin/network/nodes/[id]/route.ts +++ b/src/app/api/admin/network/nodes/[id]/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const { id } = await params; const ok = await deleteNetworkNode(id); diff --git a/src/app/api/admin/network/nodes/route.ts b/src/app/api/admin/network/nodes/route.ts index b4c2d44..2ab48dc 100644 --- a/src/app/api/admin/network/nodes/route.ts +++ b/src/app/api/admin/network/nodes/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function GET() { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const data = await getNetworkNodes(); if (!data) return NextResponse.json({ nodes: [] }); diff --git a/src/app/api/admin/onshape/company/route.ts b/src/app/api/admin/onshape/company/route.ts index 0e3f4a1..a23da6b 100644 --- a/src/app/api/admin/onshape/company/route.ts +++ b/src/app/api/admin/onshape/company/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function GET() { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const company = await getOnshapeCompany(); return NextResponse.json({ configured: isOnshapeConfigured(), company }); diff --git a/src/app/api/admin/onshape/members/[uid]/route.ts b/src/app/api/admin/onshape/members/[uid]/route.ts index ec9f719..aacdd27 100644 --- a/src/app/api/admin/onshape/members/[uid]/route.ts +++ b/src/app/api/admin/onshape/members/[uid]/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function DELETE(req: NextRequest, { params }: { params: Promise<{ uid: string }> }) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const { uid } = await params; try { diff --git a/src/app/api/admin/onshape/members/route.ts b/src/app/api/admin/onshape/members/route.ts index f753306..648f561 100644 --- a/src/app/api/admin/onshape/members/route.ts +++ b/src/app/api/admin/onshape/members/route.ts @@ -7,7 +7,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function GET() { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); try { const members = await getOnshapeMembers(); @@ -30,7 +29,6 @@ const addMemberSchema = z.object({ export async function POST(req: NextRequest) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const body = await req.json().catch(() => null); const parsed = addMemberSchema.safeParse(body); diff --git a/src/app/api/admin/onshape/teams/[id]/members/route.ts b/src/app/api/admin/onshape/teams/[id]/members/route.ts index 23f4690..bc788d3 100644 --- a/src/app/api/admin/onshape/teams/[id]/members/route.ts +++ b/src/app/api/admin/onshape/teams/[id]/members/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const { id } = await params; try { diff --git a/src/app/api/admin/onshape/teams/route.ts b/src/app/api/admin/onshape/teams/route.ts index 1922f92..ff85f16 100644 --- a/src/app/api/admin/onshape/teams/route.ts +++ b/src/app/api/admin/onshape/teams/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function GET() { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); try { const teams = await getOnshapeTeams(); diff --git a/src/app/api/admin/server/command/route.ts b/src/app/api/admin/server/command/route.ts index 06c8ec5..411df86 100644 --- a/src/app/api/admin/server/command/route.ts +++ b/src/app/api/admin/server/command/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function POST(req: NextRequest) { const admin = await getSessionUser(); if (!admin) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (admin.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const body = await req.json().catch(() => null); const cmd = typeof body?.command === "string" ? body.command.trim() : null; diff --git a/src/app/api/admin/server/config/route.ts b/src/app/api/admin/server/config/route.ts index 7fab254..ba78ade 100644 --- a/src/app/api/admin/server/config/route.ts +++ b/src/app/api/admin/server/config/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function GET() { const admin = await getSessionUser(); if (!admin) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (admin.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); try { return NextResponse.json(readConfig()); @@ -18,7 +17,6 @@ export async function GET() { export async function PUT(req: NextRequest) { const admin = await getSessionUser(); if (!admin) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (admin.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const body = await req.json().catch(() => null); if (!body || typeof body !== "object") { diff --git a/src/app/api/admin/server/logs/route.ts b/src/app/api/admin/server/logs/route.ts index 8cb2e79..b43cd8c 100644 --- a/src/app/api/admin/server/logs/route.ts +++ b/src/app/api/admin/server/logs/route.ts @@ -14,7 +14,6 @@ export const dynamic = "force-dynamic"; export async function GET(req: NextRequest) { const admin = await getSessionUser(); if (!admin) return new Response("Unauthorized", { status: 401 }); - if (admin.role !== "admin") return new Response("Forbidden", { status: 403 }); const encoder = new TextEncoder(); diff --git a/src/app/api/admin/server/start/route.ts b/src/app/api/admin/server/start/route.ts index 7fbdb33..da8f576 100644 --- a/src/app/api/admin/server/start/route.ts +++ b/src/app/api/admin/server/start/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function POST() { const admin = await getSessionUser(); if (!admin) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (admin.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const result = startServer(); if (!result.ok) { diff --git a/src/app/api/admin/server/status/route.ts b/src/app/api/admin/server/status/route.ts index 5966453..6b3fc6f 100644 --- a/src/app/api/admin/server/status/route.ts +++ b/src/app/api/admin/server/status/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function GET() { const admin = await getSessionUser(); if (!admin) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (admin.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const configured = isConfigured(); const running = isRunning(); diff --git a/src/app/api/admin/server/stop/route.ts b/src/app/api/admin/server/stop/route.ts index cef7ce3..0ca57d1 100644 --- a/src/app/api/admin/server/stop/route.ts +++ b/src/app/api/admin/server/stop/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function POST() { const admin = await getSessionUser(); if (!admin) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (admin.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const result = stopServer(); if (!result.ok) { diff --git a/src/app/api/admin/server/update/route.ts b/src/app/api/admin/server/update/route.ts index b4214ec..d52e65e 100644 --- a/src/app/api/admin/server/update/route.ts +++ b/src/app/api/admin/server/update/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function POST() { const admin = await getSessionUser(); if (!admin) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (admin.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const result = updateServer(); if (!result.ok) return NextResponse.json({ error: result.error }, { status: 400 }); diff --git a/src/app/api/admin/users/[id]/features/route.ts b/src/app/api/admin/users/[id]/features/route.ts deleted file mode 100644 index 9eb0e6b..0000000 --- a/src/app/api/admin/users/[id]/features/route.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { and, eq } from "drizzle-orm"; -import { NextResponse, type NextRequest } from "next/server"; - -import { db } from "@/lib/db"; -import { userFeature } from "@/lib/db/schema"; -import { getSessionUser } from "@/lib/auth/session"; -import { featureRequestSchema } from "@/lib/validation"; - -// GET all features for a user -export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const admin = await getSessionUser(); - if (!admin) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (admin.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - - const userId = (await params).id; - const features = db.select().from(userFeature).where(eq(userFeature.userId, userId)).all(); - return NextResponse.json({ features }); -} - -// POST to directly grant a feature to a user (no request flow) -export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const admin = await getSessionUser(); - if (!admin) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (admin.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - - const userId = (await params).id; - const body = await req.json().catch(() => null); - const parsed = featureRequestSchema.safeParse(body); - if (!parsed.success) return NextResponse.json({ error: "Invalid input" }, { status: 400 }); - - const { featureKey, requestNote } = parsed.data; - - const existing = db - .select() - .from(userFeature) - .where(and(eq(userFeature.userId, userId), eq(userFeature.featureKey, featureKey))) - .get(); - - if (existing) { - const updated = db - .update(userFeature) - .set({ - status: "granted", - adminNote: requestNote ?? null, - reviewedBy: admin.id, - reviewedAt: new Date(), - }) - .where(eq(userFeature.id, existing.id)) - .returning() - .get(); - return NextResponse.json({ feature: updated }); - } - - const created = db - .insert(userFeature) - .values({ - userId, - featureKey, - status: "granted", - reviewedBy: admin.id, - reviewedAt: new Date(), - }) - .returning() - .get(); - - return NextResponse.json({ feature: created }, { status: 201 }); -} - -// DELETE to revoke a feature from a user -export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const admin = await getSessionUser(); - if (!admin) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (admin.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - - const userId = (await params).id; - const body = await req.json().catch(() => null); - const featureKey = body?.featureKey as string | undefined; - if (!featureKey) return NextResponse.json({ error: "featureKey required" }, { status: 400 }); - - db.delete(userFeature) - .where(and(eq(userFeature.userId, userId), eq(userFeature.featureKey, featureKey))) - .run(); - - return new NextResponse(null, { status: 204 }); -} diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index 667fc13..766ccaf 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -11,9 +11,6 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id if (!admin) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - if (admin.role !== "admin") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } const userId = (await params).id; @@ -26,16 +23,11 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id ); } - // An admin cannot demote or deactivate themselves - avoids locking the club - // out of its own admin panel. - if ( - userId === admin.id && - (parsed.data.role === "member" || - parsed.data.isActive === false || - parsed.data.approved === false) - ) { + // A member cannot deactivate or unapprove themselves - avoids locking the + // club out of its own member list. + if (userId === admin.id && (parsed.data.isActive === false || parsed.data.approved === false)) { return NextResponse.json( - { error: "You cannot change your own role, active status, or approval" }, + { error: "You cannot change your own active status or approval" }, { status: 400 } ); } @@ -48,19 +40,13 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id const updated = db .update(user) .set({ - ...(parsed.data.role !== undefined ? { role: parsed.data.role } : {}), ...(parsed.data.isActive !== undefined ? { isActive: parsed.data.isActive } : {}), - ...(parsed.data.canAccessVault !== undefined - ? { canAccessVault: parsed.data.canAccessVault } - : {}), ...(parsed.data.approved !== undefined ? { approved: parsed.data.approved } : {}), }) .where(eq(user.id, userId)) .returning({ id: user.id, - role: user.role, isActive: user.isActive, - canAccessVault: user.canAccessVault, approved: user.approved, }) .get(); diff --git a/src/app/api/admin/vault/[id]/access/route.ts b/src/app/api/admin/vault/[id]/access/route.ts index b2ca4e1..c29bbab 100644 --- a/src/app/api/admin/vault/[id]/access/route.ts +++ b/src/app/api/admin/vault/[id]/access/route.ts @@ -6,12 +6,10 @@ import { user, vaultEntry, vaultEntryAccess } from "@/lib/db/schema"; import { getSessionUser } from "@/lib/auth/session"; import { vaultAccessSchema } from "@/lib/validation"; -async function requireAdmin() { +async function requireSession() { const sessionUser = await getSessionUser(); if (!sessionUser) return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) }; - if (sessionUser.role !== "admin") - return { error: NextResponse.json({ error: "Forbidden" }, { status: 403 }) }; return { user: sessionUser }; } @@ -48,7 +46,7 @@ async function loadEntryAndBody(req: NextRequest, idRaw: string) { } export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const auth = await requireAdmin(); + const auth = await requireSession(); if (auth.error) return auth.error; const loaded = await loadEntryAndBody(req, (await params).id); @@ -67,7 +65,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: } export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const auth = await requireAdmin(); + const auth = await requireSession(); if (auth.error) return auth.error; const loaded = await loadEntryAndBody(req, (await params).id); diff --git a/src/app/api/admin/vault/[id]/route.ts b/src/app/api/admin/vault/[id]/route.ts index 63389c5..2c61df1 100644 --- a/src/app/api/admin/vault/[id]/route.ts +++ b/src/app/api/admin/vault/[id]/route.ts @@ -7,11 +7,9 @@ import { getSessionUser } from "@/lib/auth/session"; import { encryptSecret } from "@/lib/security/vault-crypto"; import { vaultEntryUpdateSchema } from "@/lib/validation"; -async function requireAdmin() { +async function requireSession() { const user = await getSessionUser(); if (!user) return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) }; - if (user.role !== "admin") - return { error: NextResponse.json({ error: "Forbidden" }, { status: 403 }) }; return { user }; } @@ -21,7 +19,7 @@ function parseId(raw: string) { } export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const auth = await requireAdmin(); + const auth = await requireSession(); if (auth.error) return auth.error; const id = parseId((await params).id); @@ -59,7 +57,7 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id } export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const auth = await requireAdmin(); + const auth = await requireSession(); if (auth.error) return auth.error; const id = parseId((await params).id); diff --git a/src/app/api/admin/vault/route.ts b/src/app/api/admin/vault/route.ts index 081d4ab..891244e 100644 --- a/src/app/api/admin/vault/route.ts +++ b/src/app/api/admin/vault/route.ts @@ -1,7 +1,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { db } from "@/lib/db"; -import { vaultEntry } from "@/lib/db/schema"; +import { vaultEntry, vaultEntryAccess } from "@/lib/db/schema"; import { getSessionUser } from "@/lib/auth/session"; import { encryptSecret } from "@/lib/security/vault-crypto"; import { vaultEntrySchema } from "@/lib/validation"; @@ -11,9 +11,6 @@ export async function POST(req: NextRequest) { if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - if (user.role !== "admin") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } const body = await req.json().catch(() => null); const parsed = vaultEntrySchema.safeParse(body); @@ -38,5 +35,11 @@ export async function POST(req: NextRequest) { .returning({ id: vaultEntry.id }) .get(); + // Grant the creator read access to their own entry - there's no more + // always-on admin bypass in canReadVaultEntry. + db.insert(vaultEntryAccess) + .values({ entryId: created.id, userId: user.id, grantedBy: user.id }) + .run(); + return NextResponse.json({ id: created.id }, { status: 201 }); } diff --git a/src/app/api/admin/whitelist/[id]/action/route.ts b/src/app/api/admin/whitelist/[id]/action/route.ts index 9dd7dcd..3fc8423 100644 --- a/src/app/api/admin/whitelist/[id]/action/route.ts +++ b/src/app/api/admin/whitelist/[id]/action/route.ts @@ -14,9 +14,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: if (!admin) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - if (admin.role !== "admin") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } const id = Number((await params).id); if (!Number.isInteger(id)) { diff --git a/src/app/api/admin/whitelist/[id]/route.ts b/src/app/api/admin/whitelist/[id]/route.ts index 2cfbcf2..0c66645 100644 --- a/src/app/api/admin/whitelist/[id]/route.ts +++ b/src/app/api/admin/whitelist/[id]/route.ts @@ -11,9 +11,6 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i if (!admin) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - if (admin.role !== "admin") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } const id = Number((await params).id); if (!Number.isInteger(id)) { diff --git a/src/app/api/minecraft/whitelist/route.ts b/src/app/api/admin/whitelist/route.ts similarity index 56% rename from src/app/api/minecraft/whitelist/route.ts rename to src/app/api/admin/whitelist/route.ts index bacb365..fd6f813 100644 --- a/src/app/api/minecraft/whitelist/route.ts +++ b/src/app/api/admin/whitelist/route.ts @@ -2,17 +2,18 @@ import { NextResponse, type NextRequest } from "next/server"; import { db } from "@/lib/db"; import { minecraftWhitelist } from "@/lib/db/schema"; +import { sendCommand } from "@/lib/integrations/azalea"; import { getSessionUser } from "@/lib/auth/session"; -import { whitelistRequestSchema } from "@/lib/validation"; +import { whitelistDirectAddSchema } from "@/lib/validation"; export async function POST(req: NextRequest) { - const user = await getSessionUser(); - if (!user) { + const admin = await getSessionUser(); + if (!admin) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const body = await req.json().catch(() => null); - const parsed = whitelistRequestSchema.safeParse(body); + const parsed = whitelistDirectAddSchema.safeParse(body); if (!parsed.success) { return NextResponse.json( { error: "Invalid input", issues: parsed.error.flatten() }, @@ -20,12 +21,20 @@ export async function POST(req: NextRequest) { ); } + const rcon = await sendCommand(`whitelist add ${parsed.data.username}`); + if (!rcon.ok) { + return NextResponse.json({ error: `RCON failed: ${rcon.error}` }, { status: 502 }); + } + const created = db .insert(minecraftWhitelist) .values({ - userId: user.id, username: parsed.data.username, - requestNote: parsed.data.requestNote ?? null, + status: "approved", + addedDirectly: true, + adminNote: parsed.data.adminNote ?? null, + reviewedBy: admin.id, + reviewedAt: new Date(), }) .returning() .get(); diff --git a/src/app/api/orders/[id]/action/route.ts b/src/app/api/orders/[id]/action/route.ts index 801572c..49d8a3c 100644 --- a/src/app/api/orders/[id]/action/route.ts +++ b/src/app/api/orders/[id]/action/route.ts @@ -19,9 +19,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: if (!sessionUser) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - if (sessionUser.role !== "admin") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } const orderId = Number((await params).id); if (!Number.isInteger(orderId)) { diff --git a/src/app/api/orders/[id]/route.ts b/src/app/api/orders/[id]/route.ts index 85052ec..44e19b2 100644 --- a/src/app/api/orders/[id]/route.ts +++ b/src/app/api/orders/[id]/route.ts @@ -107,12 +107,7 @@ export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: "Order not found" }, { status: 404 }); } - const isAdmin = user.role === "admin"; - if (isLockedOrderStatus(existing.status)) { - if (!isAdmin) { - return NextResponse.json({ error: "This order cannot be deleted" }, { status: 403 }); - } - } else if (!memberCanModifyOrder(existing, user.id)) { + if (!isLockedOrderStatus(existing.status) && !memberCanModifyOrder(existing, user.id)) { return NextResponse.json({ error: "This order cannot be deleted" }, { status: 403 }); } diff --git a/src/app/api/orders/assign/route.ts b/src/app/api/orders/assign/route.ts index 462dbb1..c39defb 100644 --- a/src/app/api/orders/assign/route.ts +++ b/src/app/api/orders/assign/route.ts @@ -17,9 +17,6 @@ export async function POST(req: NextRequest) { if (!sessionUser) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - if (sessionUser.role !== "admin") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } const body = await req.json().catch(() => null); const parsed = orderAssignSchema.safeParse(body); diff --git a/src/app/api/orders/bulk-action/route.ts b/src/app/api/orders/bulk-action/route.ts index 08139d9..c261815 100644 --- a/src/app/api/orders/bulk-action/route.ts +++ b/src/app/api/orders/bulk-action/route.ts @@ -19,9 +19,6 @@ export async function POST(req: NextRequest) { if (!sessionUser) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - if (sessionUser.role !== "admin") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } const body = await req.json().catch(() => null); const parsed = orderBulkActionSchema.safeParse(body); diff --git a/src/app/api/orders/mark-ordered/route.ts b/src/app/api/orders/mark-ordered/route.ts index d25b16a..8c4c8e1 100644 --- a/src/app/api/orders/mark-ordered/route.ts +++ b/src/app/api/orders/mark-ordered/route.ts @@ -9,9 +9,6 @@ export async function POST(req: NextRequest) { if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - if (user.role !== "admin") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } const body = await req.json().catch(() => ({})); const parsed = markOrderedSchema.safeParse(body); diff --git a/src/app/api/system/stats/route.ts b/src/app/api/system/stats/route.ts deleted file mode 100644 index 074dd45..0000000 --- a/src/app/api/system/stats/route.ts +++ /dev/null @@ -1,61 +0,0 @@ -import os from "os"; - -import { currentLoad, fsSize, mem } from "systeminformation"; - -import { getSessionUser } from "@/lib/auth/session"; - -export type SystemStats = { - cpu: { loadPercent: number }; - memory: { usedGb: number; totalGb: number; usedPercent: number }; - disk: { usedGb: number; totalGb: number; usedPercent: number }; - uptime: { seconds: number }; - loadAvg: { one: number; five: number; fifteen: number }; - cachedAt: number; -}; - -let statsCache: { data: SystemStats; expiresAt: number } | null = null; - -export async function GET() { - const sessionUser = await getSessionUser(); - if (!sessionUser) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - - if (statsCache && statsCache.expiresAt > Date.now()) { - return Response.json(statsCache.data); - } - - try { - const [load, memory, disks] = await Promise.all([currentLoad(), mem(), fsSize()]); - - const rootDisk = disks.find((d) => d.mount === "/") ?? disks[0]; - const GB = 1024 ** 3; - const [one, five, fifteen] = os.loadavg(); - - const data: SystemStats = { - cpu: { loadPercent: Math.round(load.currentLoad) }, - memory: { - usedGb: Math.round(((memory.total - memory.available) / GB) * 10) / 10, - totalGb: Math.round((memory.total / GB) * 10) / 10, - usedPercent: Math.round(((memory.total - memory.available) / memory.total) * 100), - }, - disk: { - usedGb: Math.round((rootDisk.used / GB) * 10) / 10, - totalGb: Math.round((rootDisk.size / GB) * 10) / 10, - usedPercent: Math.round((rootDisk.used / rootDisk.size) * 100), - }, - uptime: { seconds: Math.floor(os.uptime()) }, - loadAvg: { - one: Math.round(one * 100) / 100, - five: Math.round(five * 100) / 100, - fifteen: Math.round(fifteen * 100) / 100, - }, - cachedAt: Date.now(), - }; - - statsCache = { data, expiresAt: Date.now() + 5_000 }; - return Response.json(data); - } catch { - return Response.json({ error: "stats unavailable" }, { status: 503 }); - } -} diff --git a/src/app/api/users/features/route.ts b/src/app/api/users/features/route.ts deleted file mode 100644 index f884dd4..0000000 --- a/src/app/api/users/features/route.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { and, eq } from "drizzle-orm"; -import { NextResponse, type NextRequest } from "next/server"; - -import { db } from "@/lib/db"; -import { userFeature } from "@/lib/db/schema"; -import { getSessionUser } from "@/lib/auth/session"; -import { featureRequestSchema } from "@/lib/validation"; - -export async function GET() { - const user = await getSessionUser(); - if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - - const features = db.select().from(userFeature).where(eq(userFeature.userId, user.id)).all(); - - return NextResponse.json({ features }); -} - -export async function POST(req: NextRequest) { - const user = await getSessionUser(); - if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - - const body = await req.json().catch(() => null); - const parsed = featureRequestSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid input" }, { status: 400 }); - } - - const { featureKey, requestNote } = parsed.data; - - const existing = db - .select() - .from(userFeature) - .where(and(eq(userFeature.userId, user.id), eq(userFeature.featureKey, featureKey))) - .get(); - - if (existing?.status === "granted") { - return NextResponse.json({ error: "Feature already granted" }, { status: 409 }); - } - if (existing?.status === "pending") { - return NextResponse.json({ error: "Request already pending" }, { status: 409 }); - } - - if (existing) { - // Re-request after rejection - const updated = db - .update(userFeature) - .set({ - status: "pending", - requestNote: requestNote ?? null, - adminNote: null, - reviewedBy: null, - reviewedAt: null, - }) - .where(eq(userFeature.id, existing.id)) - .returning() - .get(); - return NextResponse.json({ feature: updated }); - } - - const created = db - .insert(userFeature) - .values({ - userId: user.id, - featureKey, - status: "pending", - requestNote: requestNote ?? null, - }) - .returning() - .get(); - - return NextResponse.json({ feature: created }, { status: 201 }); -} diff --git a/src/app/api/vault/[id]/reveal/route.ts b/src/app/api/vault/[id]/reveal/route.ts index aae2c00..7505d71 100644 --- a/src/app/api/vault/[id]/reveal/route.ts +++ b/src/app/api/vault/[id]/reveal/route.ts @@ -7,7 +7,7 @@ import { canReadVaultEntry, getSessionUser } from "@/lib/auth/session"; import { decryptSecret } from "@/lib/security/vault-crypto"; // Login secrets never ship in the page payload - they are decrypted on demand -// here, only for users who hold a per-entry grant (or are an admin). +// here, only for users who hold a per-entry grant. export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const user = await getSessionUser(); if (!user) { @@ -44,7 +44,7 @@ export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: { status: 403 } ); } - // Per-entry access: admin or an explicit grant for this login. + // Per-entry access: an explicit grant for this login. if (!canReadVaultEntry(user, id)) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } diff --git a/src/app/globals.css b/src/app/globals.css index b2a2ec1..83e139d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -56,23 +56,23 @@ */ :root { color-scheme: dark; - --background: #222222; + --background: #131313; --foreground: #cccccc; - --card: #2a2a2a; + --card: #1a1a1a; --card-foreground: #cccccc; - --popover: #2a2a2a; + --popover: #1a1a1a; --popover-foreground: #cccccc; --primary: #00fe00; --primary-foreground: #0a0a0a; --secondary: #e93cac; --secondary-foreground: #ffffff; - --muted: #2e2e2e; + --muted: #1e1e1e; --muted-foreground: #9a9a9a; --accent: #313131; --accent-foreground: #ffffff; --destructive: #ff5c5c; - --border: #3a3a3a; - --input: #3a3a3a; + --border: #2c2c2c; + --input: #2c2c2c; --ring: #00fe00; --chart-1: #00fe00; --chart-2: #e93cac; @@ -80,20 +80,20 @@ --chart-4: #f178c4; --chart-5: #999999; --radius: 0.5rem; - --sidebar: #1c1c1c; + --sidebar: #0f0f0f; --sidebar-foreground: #cccccc; --sidebar-primary: #00fe00; --sidebar-primary-foreground: #0a0a0a; - --sidebar-accent: #2e2e2e; + --sidebar-accent: #202020; --sidebar-accent-foreground: #ffffff; - --sidebar-border: #3a3a3a; + --sidebar-border: #2c2c2c; --sidebar-ring: #00fe00; } @layer base { * { @apply border-border outline-ring/50; - scrollbar-color: #3a3a3a #2a2a2a; + scrollbar-color: #2c2c2c #1a1a1a; scrollbar-width: thin; } ::-webkit-scrollbar { @@ -101,14 +101,14 @@ height: 8px; } ::-webkit-scrollbar-track { - background: #2a2a2a; + background: #1a1a1a; } ::-webkit-scrollbar-thumb { - background: #3a3a3a; + background: #2c2c2c; border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { - background: #4a4a4a; + background: #3a3a3a; } html, body { @@ -132,3 +132,42 @@ color: #ffffff; } } + +/* + * Layered CSS-only background for the sign-in page: two soft radial color + * blobs (::before) plus a slowly drifting dot grid (::after). Both sit + * behind the content via negative z-index rather than a wrapper element. + */ +.auth-background { + position: relative; + isolation: isolate; +} + +.auth-background::before { + content: ""; + position: absolute; + inset: 0; + z-index: -2; + background: + radial-gradient(ellipse 60% 50% at 15% 20%, rgb(0 254 0 / 12%), transparent 60%), + radial-gradient(ellipse 60% 50% at 85% 80%, rgb(233 60 172 / 12%), transparent 60%); +} + +.auth-background::after { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + background-image: radial-gradient(circle, rgb(255 255 255 / 6%) 1px, transparent 1px); + background-size: 28px 28px; + animation: auth-grid-drift 40s linear infinite; +} + +@keyframes auth-grid-drift { + from { + background-position: 0 0; + } + to { + background-position: 56px 56px; + } +} diff --git a/src/components/admin/FeatureRequests.tsx b/src/components/admin/FeatureRequests.tsx deleted file mode 100644 index b5013af..0000000 --- a/src/components/admin/FeatureRequests.tsx +++ /dev/null @@ -1,121 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; -import { useState } from "react"; -import { toast } from "sonner"; - -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { FEATURES } from "@/lib/features"; -import { formatDate } from "@/lib/utils"; - -export type FeatureRequestRow = { - id: number; - userId: string; - userName: string; - userEmail: string; - featureKey: string; - requestNote: string | null; - requestedAt: Date; -}; - -export function FeatureRequests({ requests }: { requests: FeatureRequestRow[] }) { - const router = useRouter(); - const [busy, setBusy] = useState(null); - - if (requests.length === 0) return null; - - async function act(id: number, action: "grant" | "reject") { - setBusy(id); - try { - const res = await fetch(`/api/admin/features/${id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action }), - }); - if (!res.ok) { - const data = await res.json().catch(() => null); - throw new Error(data?.error ?? "Action failed"); - } - toast.success(action === "grant" ? "Access granted" : "Request rejected"); - router.refresh(); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Something went wrong"); - } finally { - setBusy(null); - } - } - - return ( -
-
-

Feature Requests

- {requests.length} -
-
- - - - User - Feature - Note - Requested - Actions - - - - {requests.map((r) => { - const featureLabel = - FEATURES[r.featureKey as keyof typeof FEATURES]?.label ?? - r.featureKey; - return ( - - -

{r.userName}

-

- {r.userEmail} -

-
- {featureLabel} - - {r.requestNote ?? "-"} - - - {formatDate(r.requestedAt)} - - -
- - -
-
-
- ); - })} -
-
-
-
- ); -} diff --git a/src/components/admin/PendingApprovals.tsx b/src/components/admin/PendingApprovals.tsx index aeb33e9..0808c51 100644 --- a/src/components/admin/PendingApprovals.tsx +++ b/src/components/admin/PendingApprovals.tsx @@ -92,7 +92,7 @@ export function PendingApprovals({ users }: { users: PendingUserRow[] }) { {u.name} {u.email} - + {formatDate(u.createdAt)} diff --git a/src/components/admin/UserTable.tsx b/src/components/admin/UserTable.tsx index f0c2c55..ea33acb 100644 --- a/src/components/admin/UserTable.tsx +++ b/src/components/admin/UserTable.tsx @@ -1,19 +1,12 @@ "use client"; -import { Fragment } from "react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { DataTableCard, DataTableCardHeader } from "@/components/ui/data-table-card"; import { Table, TableBody, @@ -22,23 +15,16 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { FEATURES, type FeatureKey } from "@/lib/features"; import { formatDate } from "@/lib/utils"; export type AdminUserRow = { id: string; name: string; email: string; - role: "member" | "admin"; isActive: boolean; - canAccessVault: boolean; - grantedFeatures: FeatureKey[]; createdAt: Date; }; -const ROLE_ITEMS = { member: "Member", admin: "Admin" }; -const ALL_FEATURES = Object.keys(FEATURES) as FeatureKey[]; - export function UserTable({ users, currentUserId, @@ -48,7 +34,6 @@ export function UserTable({ }) { const router = useRouter(); const [busy, setBusy] = useState(null); - const [expanded, setExpanded] = useState(null); async function patchUser(id: string, body: Record) { setBusy(id); @@ -71,44 +56,15 @@ export function UserTable({ } } - async function toggleFeature(userId: string, featureKey: FeatureKey, granted: boolean) { - setBusy(`${userId}-${featureKey}`); - try { - if (granted) { - const res = await fetch(`/api/admin/users/${userId}/features`, { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ featureKey }), - }); - if (!res.ok) throw new Error("Failed to revoke"); - toast.success("Feature revoked"); - } else { - const res = await fetch(`/api/admin/users/${userId}/features`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ featureKey }), - }); - if (!res.ok) throw new Error("Failed to grant"); - toast.success("Feature granted"); - } - router.refresh(); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Something went wrong"); - } finally { - setBusy(null); - } - } - return ( -
+ + Name Email - Role Status - Vault access Joined Actions @@ -116,129 +72,44 @@ export function UserTable({ {users.map((u) => { const isSelf = u.id === currentUserId; - const isExpanded = expanded === u.id; return ( - - - - {u.name} - {isSelf ? ( - - (you) - - ) : null} - - - {u.email} - - - - - - {u.isActive ? ( - Active - ) : ( - Deactivated - )} - - - {u.role === "admin" ? ( - Always (admin) - ) : ( - - )} - - - {formatDate(u.createdAt)} - - -
- {u.role !== "admin" && ( - - )} - -
-
-
- {isExpanded && u.role !== "admin" && ( - - -

- Feature access for {u.name} -

-
- {ALL_FEATURES.map((key) => { - const granted = u.grantedFeatures.includes(key); - const busyKey = `${u.id}-${key}`; - return ( - - ); - })} -
-
-
- )} -
+ + + {u.name} + {isSelf ? ( + + (you) + + ) : null} + + + {u.email} + + + {u.isActive ? ( + Active + ) : ( + Deactivated + )} + + + {formatDate(u.createdAt)} + + + + + ); })}
-
+ ); } diff --git a/src/components/admin/WhitelistManager.tsx b/src/components/admin/WhitelistManager.tsx index 016b522..0901d20 100644 --- a/src/components/admin/WhitelistManager.tsx +++ b/src/components/admin/WhitelistManager.tsx @@ -1,11 +1,14 @@ "use client"; +import { Gamepad2, Plus } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; import { WhitelistStatusBadge } from "@/components/minecraft/WhitelistStatusBadge"; import { Button } from "@/components/ui/button"; +import { EmptyState } from "@/components/ui/empty-state"; +import { Input } from "@/components/ui/input"; import { Table, TableBody, @@ -32,6 +35,32 @@ export function WhitelistManager({ requests }: { requests: AdminWhitelistRow[] } const router = useRouter(); const [busy, setBusy] = useState(null); const [removing, setRemoving] = useState(null); + const [username, setUsername] = useState(""); + const [adding, setAdding] = useState(false); + + async function addDirect() { + const trimmed = username.trim(); + if (!trimmed) return; + setAdding(true); + try { + const res = await fetch("/api/admin/whitelist", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: trimmed }), + }); + if (!res.ok) { + const data = await res.json().catch(() => null); + throw new Error(data?.error ?? "Failed to add"); + } + toast.success(`${trimmed} whitelisted`); + setUsername(""); + router.refresh(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Something went wrong"); + } finally { + setAdding(false); + } + } async function act(id: number, action: "approve" | "reject") { setBusy(id); @@ -72,11 +101,29 @@ export function WhitelistManager({ requests }: { requests: AdminWhitelistRow[] } } return ( -
+
+
+ setUsername(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") addDirect(); + }} + placeholder="Minecraft username" + className="max-w-64 flex-1" + /> + +
+ {requests.length === 0 ? ( -
- No whitelist requests yet. -
+ ) : (
@@ -109,7 +156,7 @@ export function WhitelistManager({ requests }: { requests: AdminWhitelistRow[] } - + {formatDate(r.createdAt)} diff --git a/src/components/dashboard/SystemVitals.tsx b/src/components/dashboard/SystemVitals.tsx deleted file mode 100644 index 9016fe1..0000000 --- a/src/components/dashboard/SystemVitals.tsx +++ /dev/null @@ -1,122 +0,0 @@ -"use client"; - -import { Cpu, HardDrive, MemoryStick, Timer } from "lucide-react"; -import { useCallback, useState } from "react"; - -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; -import type { SystemStats } from "@/app/api/system/stats/route"; -import { usePoll } from "@/lib/use-poll"; - -function metricColor(pct: number): string { - if (pct > 85) return "var(--destructive)"; - if (pct > 70) return "#f59e0b"; - return "var(--primary)"; -} - -function ProgressBar({ pct }: { pct: number }) { - return ( -
-
-
- ); -} - -function formatUptime(s: number): string { - const d = Math.floor(s / 86400); - const h = Math.floor((s % 86400) / 3600); - const m = Math.floor((s % 3600) / 60); - if (d > 0) return `${d}d ${h}h`; - if (h > 0) return `${h}h ${m}m`; - return `${m}m`; -} - -type MetricCardProps = { - icon: React.ReactNode; - title: string; - value: string; - subtext: string; - pct?: number; -}; - -function MetricCard({ icon, title, value, subtext, pct }: MetricCardProps) { - return ( - - -
- {icon} - {title} -
- {value} -
- - {pct !== undefined && } -

{subtext}

-
-
- ); -} - -export function SystemVitals() { - const [stats, setStats] = useState(null); - - const load = useCallback(async () => { - try { - const res = await fetch("/api/system/stats", { cache: "no-store" }); - if (res.ok) setStats((await res.json()) as SystemStats); - } catch { - // Keep previous data on transient error. - } - }, []); - - usePoll(load, 10_000); - - if (!stats) { - return ( -
- {[...Array(4)].map((_, i) => ( - - - - - - ))} -
- ); - } - - return ( -
- } - title="CPU Load" - value={`${stats.cpu.loadPercent}%`} - pct={stats.cpu.loadPercent} - subtext={`${stats.loadAvg.one} / ${stats.loadAvg.five} / ${stats.loadAvg.fifteen} avg`} - /> - } - title="Memory" - value={`${stats.memory.usedGb} / ${stats.memory.totalGb} GB`} - pct={stats.memory.usedPercent} - subtext={`${stats.memory.usedPercent}% used`} - /> - } - title="Disk" - value={`${stats.disk.usedGb} / ${stats.disk.totalGb} GB`} - pct={stats.disk.usedPercent} - subtext={`${stats.disk.usedPercent}% used`} - /> - } - title="Uptime" - value={formatUptime(stats.uptime.seconds)} - subtext="Server uptime" - /> -
- ); -} diff --git a/src/components/features/FeaturesPanel.tsx b/src/components/features/FeaturesPanel.tsx deleted file mode 100644 index 17f04cf..0000000 --- a/src/components/features/FeaturesPanel.tsx +++ /dev/null @@ -1,145 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { toast } from "sonner"; - -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import type { FeatureKey } from "@/lib/features"; -import { FEATURES } from "@/lib/features"; - -type FeatureRow = { - key: FeatureKey; - label: string; - description: string; - status: "pending" | "granted" | "rejected" | null; -}; - -function statusBadge(status: FeatureRow["status"]) { - if (status === "granted") return Active; - if (status === "pending") return Pending; - if (status === "rejected") return Rejected; - return null; -} - -export function FeaturesPanel({ - features, - deniedKey, -}: { - features: FeatureRow[]; - deniedKey: string | null; -}) { - const router = useRouter(); - const [busy, setBusy] = useState(null); - const [notes, setNotes] = useState>( - {} as Record - ); - const deniedRef = useRef(null); - - useEffect(() => { - if (deniedKey && deniedRef.current) { - deniedRef.current.scrollIntoView({ behavior: "smooth", block: "center" }); - } - }, [deniedKey]); - - async function requestFeature(key: FeatureKey) { - setBusy(key); - try { - const res = await fetch("/api/users/features", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ featureKey: key, requestNote: notes[key] || undefined }), - }); - if (!res.ok) { - const data = await res.json().catch(() => null); - throw new Error(data?.error ?? "Request failed"); - } - toast.success("Access requested"); - router.refresh(); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Something went wrong"); - } finally { - setBusy(null); - } - } - - return ( -
- {features.map((f) => { - const isDenied = deniedKey === f.key; - const canRequest = f.status === null || f.status === "rejected"; - const isGranted = f.status === "granted"; - const card = ( - - -
- {f.label} - {statusBadge(f.status)} -
- {f.description} - {isDenied && ( -

- You don't have access to this feature yet. -

- )} -
- {canRequest && ( - <> - -