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.
-
- 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.
-
- 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. */}
-
- 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."}
-
+
+
+
+
+
+ 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 && (
-
+ 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 (
-