- Use StopTableClient in server page component
- Add view mode toggle (table/card) matching products page
- Match stats cards styling to products page
- Add consistent filter bar layout with AdminViewModeTabs
- Add card view for stops with inline delete confirm
- Improve row actions with Edit button + dropdown menu
- Add 'Today' badge and past stop dimming in card view
- Add EditStopModal for unified add/edit stop workflow
- Enhance StopTableClient with sortable columns (date, city, location, status)
- Add stats bar showing total, active, upcoming, and draft counts
- Improve pagination with 'Showing X-Y of Z' indicator
- Add 'Today' badge for current-day stops
- Dim past stops visually
- Add Edit action to row menu with icons
- Reduce page size to 25 for better UX
- AdminSidebar now renders BrandSelector when brands are available
- Stops page uses getActiveBrandId() instead of adminUser.brand_id
directly, so platform admins see all stops and brand selection
via the sidebar picker works correctly
- BrandSelector already existed and was wired up in the layout,
just never rendered in the sidebar
The page was importing from @/lib/supabase which is a mock client that
returns empty results. Replaced with pool.query() against the real
Postgres stops table.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Fetch dashboard stats server-side in admin/page.tsx to eliminate
client-side useEffect waterfall and the '—' placeholder flash
- Pass pre-fetched stats as props to DashboardClient
- Lazy-load UpgradePlanModal via next/dynamic (only loads when needed)
- Redesign stats cards with compact layout, smaller icons, Fraunces serif values
- Improve Quick Actions + Usage row: side-by-side on desktop, stacked mobile
- Clean up Recent Orders as a proper list with icon/name/time/amount/badge
- Add staggered fade-up entrance animations (0/60/120/180/240ms delays)
- Consolidate loading.tsx skeleton to match actual dashboard structure
- Mobile-responsive: 2-col stats on mobile, stacked usage, collapsible header
- LandingPageWrapper: Use dynamic year for copyright
- HeroSection: Add float-slow/float-slow-delayed keyframes, add prefers-reduced-motion media query
- TuxedoVideoHero: Add reduced-motion JS guard and CSS media query for accessibility
- getAdminUser now properly supports platform_admin with 0 brand links and loads full brand_ids
- createAdminUser action now inserts into admin_user_brands join table
- Admin layout surfaces the signed-in email on Access Denied
- AdminAccessDenied links to /login instead of dead-end /admin
- Main dashboard uses direct pool query instead of dead supabase shim
- Improved provision-admin.ts script for prod bootstrap (loads .env.production too)
The membership query in getAdminUser() was selecting role from
adminUserBrands.adminUserId, which is wrong - admin_user_brands has no
role column. Role is stored in admin_users. Also added try/catch
around withPlatformAdmin to prevent DB errors from throwing.
- Add MinIO/S3-compatible storage client (src/lib/storage.ts) with uploadObject,
deleteObject, presigned URL helpers, and BUCKETS constant
- Wire product images, brand logos, and water log photos to MinIO via the
new storage client
- Migrate forgot-password to Neon Auth (remove Supabase /auth/v1/recover call)
- Migrate send-scheduled cron to direct Postgres + Resend (remove Supabase Edge
Function proxy)
- Add logoUrl to email types (OrderReceipt, Welcome, PasswordReset) and pass
brand_settings.logo_url from all call sites
- Update email templates to use dynamic logoUrl instead of hardcoded Supabase
bucket URLs
- Remove hardcoded Supabase URLs from TuxedoVideoHero, TuxedoAboutPage,
TimeTrackingFieldClient; use brand_settings props + local public/ fallback
- Download brand logos (3) and tuxedo-hero.mp4 (36MB) from Supabase bucket to
public/ for local development
- Add MinIO env vars to .env.example (endpoint, access key, secret, buckets)
- Fix TimeTrackingFieldClient to destructure logoUrl and brandAccent props
- Fix admin/users.ts logoUrl type (null → undefined for optional string)
- Remove stale sb- cookie from wholesale-auth
- Migrate tuxedo/about page to remove supabase import and use pool query for
wholesale_settings lookup
The products list page at /admin/products still queried the legacy Supabase
mock client, which returns an empty array. Combined with the SaaS schema
renaming brands → tenants, the platform admin's brand picker for new
products was silently failing (getBrands SELECTed from a non-existent
'brands' table), making 'Add Product to catalog' look like a no-op.
- src/app/admin/products/page.tsx: replace supabase.from('products') with
a Drizzle query using withPlatformAdmin (cross-tenant) or
withTenant(brandId, ...) (scoped). Map new columns back to the legacy
Product shape the existing UI consumes:
* price_cents (integer) → price (dollars)
* tenant_id → brand_id
* product_images LEFT JOIN for first image per product
* default type='pickup', is_taxable=false (columns not in SaaS schema)
- src/actions/admin/users.ts (getBrands): query tenants table instead of
the legacy brands table.
Verified locally against the seeded dev DB: 8 products across 2 tenants
now render in the catalog, and the brand picker populates with both
tenants for platform admins.
Previously, any non-empty AUTH_GOOGLE_ID + AUTH_GOOGLE_SECRET would
trigger the Google button. That broke dev/CI setups where the env
vars are placeholder strings (e.g. 'dummy-google-client-id') — the
button would render and immediately 401 from Google with 'invalid_client'.
Real Google OAuth client IDs always end in '.apps.googleusercontent.com'.
Gate hasGoogle on that suffix so the login page falls back to the
credentials form (or the 'not configured' message) when the values
aren't real.
- analytics.ts: rewrite getReportsSummary, getRevenueChart, getSalesByProduct,
getContactGrowth, getRecentOrders, getConversionFunnel against pool + new
orders/customers schema. Drops retired columns (subtotal, pickup_complete)
and re-implements the SQL by hand.
- import-orders.ts: bulk import via withTx using orders + orderItems + customers
Drizzle tables, computes total_cents from current product prices.
- import-products.ts: rewrite to use withTenant(brandId) and Drizzle products
table.
- products/create-product.ts, update-product.ts, upload-image.ts: switch to
withTenant + Drizzle; image_url moves to product_images table.
- reports.ts: rewrite against pool + new orders schema.
- route-trace/lots.ts: stub functions (route-trace feature retired from SaaS
rebuild — harvest_lots table not in db/schema). Uses discriminated union
return types so consumer narrowing works in both branches.
- settings/features.ts: switch to withTenant + Drizzle brandSettings.
- shipping.ts: switch to pool + Drizzle orders/orderItems.
- api/v1/referrals/route.ts: fix typecheck (referred_user_id undefined → 'anonymous').
Typecheck: clean. Tests: 22/22 pass. Build: succeeds.
Next.js 16 enforces 'one of middleware.ts OR proxy.ts' - both can't
coexist. Our src/middleware.ts (Auth.js v5) is the canonical one;
src/proxy.ts was the legacy dev_session auto-issuer.
- db/seed.ts: upserts admin@route-commerce.local with a scrypt password
hash (default password 'admin', override with SEED_ADMIN_PASSWORD)
and grants them the platform_admin role on the Tuxedo tenant so
getAdminUser() resolves it for the Credentials provider's authorize
function
- src/app/login/page.tsx: reads ?error=... from the URL and passes
hasCredentials + seededEmail + error to the client so the form
pre-fills in dev and surfaces 'Invalid email or password' cleanly
- src/app/login/LoginClient.tsx: adds the email + password form below
the Google button, with a divider, dev-mode pre-fill, and local error
handling for client-side failures (server-side failures come back
through the ?error=... param)
- src/auth.config.ts: edge-safe Auth.js config (no pg, no Credentials)
imported by the middleware; exports authConfig + isDevLoginEnabled
- src/lib/auth.ts: extends authConfig with the Credentials provider
(Node-only) for email + password sign-in via users.password_hash
- src/middleware.ts: uses authConfig (edge-safe) instead of the full
src/lib/auth.ts so the edge bundle stays small
- src/actions/auth-actions.ts: adds signInWithCredentials that calls
the Credentials provider; signInWithGoogle stays the production path
- Migration 0002 adds nullable password_hash to users (idempotent)
- src/lib/passwords.ts: encode/verify with self-describing format
(algo$N$salt$hash) so we can migrate to a stronger KDF later
- Schema adds passwordHash column; OAuth-only users leave it null
- Create src/lib/db.ts (shared pg.Pool, query/withTx helpers, server-only)
- Add @types/pg devDep
- Migration 204: add email, auth_provider, auth_subject columns to
admin_users; backfill from auth.users; new upsert_admin_user accepts
multi-provider args; new get_admin_user_for_session RPC resolves
Auth.js session id (UUID or Google sub) to an admin row
- Refactor getAdminUser() to use pg + new RPC; auto-provisions on first
Google sign-in using session.user.email
- Refactor updatePasswordAction to call update_user_password via pg
(drops the Supabase REST hop)
- Delete orphaned src/actions/login.ts (replaced by auth-actions.ts)
- Drop remaining DEV_FORCE_UID references in users.ts; dev path now
uses dev_session cookie (the only cookie the dev flow can set)
- Update AdminUser type: user_id is now string | null (Google users
have no Supabase auth id); add email + auth_provider fields
- Fix downstream type errors: StopProductAssignment.callerUid accepts
null; pickup.ts performedBy widens to string | null
- Bump vitest config, tests/, and other earlier cleanup changes
The /admin page was throwing a Server Components render error (digest
4266906817) in production. The exact throw point was not visible from
the browser (Next.js hides it for security) and server logs were not
available at debugging time.
The layout and page both call data-loading functions (getActiveBrandId,
listBrandsForAdmin, supabase brands lookup) that could throw on a
transient DB/network failure, and these calls had no try/catch. A
single failed call would crash the entire admin shell.
Wrap each call in try/catch with console.error so:
1. The page renders with sensible defaults if a call fails
2. The actual error is logged server-side (visible via the digest in
the admin error boundary or PM2/Docker logs)
3. The admin shell stays functional even if a single data source is
down
No behavior change on the happy path.
Cleanup after Auth.js v5 became the only sign-in path. The platform
had three overlapping auth modes (dev cookie, legacy rc_auth_uid, Auth.js
JWT) and a pile of dead-code pages/routes that only existed to support
the legacy path.
What changed:
* getAdminUser() now has only two auth paths:
1. dev_session cookie (auto-issued by src/proxy.ts for /admin/* when
ALLOW_DEV_LOGIN is enabled)
2. Auth.js v5 JWT (the encrypted cookie + auth() lookup)
The legacy rc_auth_uid/rc_uid branch and the Supabase REST fetch
against admin_users are gone.
* The signIn callback in src/lib/auth.ts enforces ADMIN_ALLOWED_EMAILS
when set. Unset = open mode (backward compatible with demo/dev). Dev
credentials provider is exempt. The new env var is wired through
.env.example and .gitea/workflows/deploy.yml (read from
secrets.ADMIN_ALLOWED_EMAILS, written to the server .env file).
* change-password/page.tsx now uses auth() server-side instead of
fetching the deleted /api/auth/uid endpoint. The form is split into
page.tsx (server component, auth check) + ChangePasswordForm.tsx
(client component, form state). updatePasswordAction now reads the
user id from auth() instead of the rc_auth_uid cookie.
* Deleted 14 dead-code files:
- Pages: login2, logout, auth/callback, admin/debug-auth,
admin/test-auth
- API routes: api/login, api/logout, api/auth/uid, api/force-admin,
api/set-auth-cookie, api/debug-cookie, api/debug-me,
api/debug-auth
- Actions: src/actions/login.ts
These were the old email/password login, the old Supabase OAuth
callback, the old /api/auth/uid probe, and a pile of debug endpoints
that have been superseded by the new proxy + the new /login page.
* next.config.ts: set outputFileTracingRoot: '.' to silence the
Next.js 16 lockfile-inference warning. Without this the build
walked up from package.json looking for a lockfile, found the
homelab runner's stale act cache at /home/tyler/.cache/act/.../package-lock.json,
and warned on every build. '. resolves to the project root in both
dev and CI, so it's the right answer.
Out of scope (deferred):
* src/actions/admin/users.ts still uses rc_auth_uid internally for its
dev-bypass logic. It works (the rc_auth_uid branch is gated on
NODE_ENV != 'production' and DEV_FORCE_UID), but it's now genuinely
unreachable in production. Clean up in a follow-up.
Pre-flight:
* npx tsc --noEmit: clean
* npm run lint (touched files): clean
* npm run build: clean — proxy picked up, no lockfile warning, all
93 static pages generated.
Next.js 16 renamed middleware.ts to proxy.ts and only allows one of the
two. Delete src/middleware.ts and rewrite src/proxy.ts to do the dev
auto-login + /login bounce + /admin gate explicitly (no NextAuth auth()
wrapper — auth.handler() returns a NextMiddleware taking (req, event),
which makes wrapping it awkward when we also need to set cookies on the
response).
Drop the now-dead 'authorized' callback from auth.config.ts (it was only
fired by the NextAuth wrapper, which we no longer use).
Migration 209: add a defensive unique constraint on admin_users.user_id
so the ON CONFLICT (user_id) clause in the new RPC resolves regardless
of whether the Supabase-dashboard-created table shipped with one.
After a user signs in with Google, they land on /admin but see
'Your account does not have admin access' because getAdminUser()
only checked the legacy dev_session and rc_auth_uid cookies.
This completes the Auth.js path:
- New src/lib/db.ts: shared pg.Pool singleton (extracted from
src/lib/auth.ts). The single connection pool for the whole app
— server actions, API routes, and Auth.js all import from here.
- src/lib/auth.ts: imports the shared pool, signIn event now calls
the new upsert_admin_user_for_authjs RPC (idempotent) to
auto-create a platform_admin row on first sign-in.
- New supabase/migrations/209_authjs_auto_create_admin.sql:
- Defensive ALTER TABLE ADD COLUMN IF NOT EXISTS for
can_manage_settings (was likely dashboard-added, not in any
tracked migration)
- SECURITY DEFINER RPC upsert_admin_user_for_authjs(p_user_id UUID)
that inserts a platform_admin row with all permissions true
and ON CONFLICT (user_id) DO NOTHING
- NOTIFY pgrst to reload PostgREST schema cache
- src/lib/admin-permissions.ts: new Auth.js session check between
dev_session and rc_auth_uid. Uses auth() from @/lib/auth to
decrypt the JWT cookie server-side, then getAdminUserFromPool()
queries admin_users + admin_user_brands via the shared pool.
Legacy rc_auth_uid path unchanged (deferred).
- src/middleware.ts: recognizes Auth.js session cookies
(authjs.session-token and __Secure-authjs.session-token) at the
edge so signed-in users aren't bounced to /login.
Flow after this change:
Dev/demo: visit /admin → middleware auto-issues dev_session → in
Prod: click Google → Auth.js OAuth → signIn event creates
admin_users row → redirect to /admin → getAdminUser()
reads JWT, queries pool, returns platform_admin.
The dev login flow was redirecting back to /login because:
- src/middleware.ts didn't exist, so the Auth.js authorized
callback in auth.config.ts never ran
- Even if it had, it only checked the Auth.js JWT, not dev_session
- Clicking the demo buttons set the cookie via document.cookie,
but the admin layout (via getAdminUser) was the only thing
reading it — no edge gate
Fix:
- New src/middleware.ts: gates /admin/* and /login at the edge.
Auto-issues dev_session=platform_admin when ALLOW_DEV_LOGIN is
enabled (default on, set to 'false' in prod). No buttons, no
client-side cookie games.
- LoginClient.tsx: stripped to a single Google OAuth button.
Removed email/password form, dev credentials form, and the
/login?demo=1 three-button picker.
- Removed signInWithDev from auth-signin.ts (no longer called).
- Removed dead /dev-login page and /api/dev-login route.
Net result: one sign-in path (Google), invisible dev auto-login
via middleware, no more three modes.
Two errors were aborting the Gitea build:
1. DYNAMIC_SERVER_USAGE on /admin/settings/square-sync (and any admin page):
getAdminUser() reads cookies() via next/headers. The admin layout tried
to prerender statically, so the first child page that hit cookies()
aborted the build. Added 'export const dynamic = "force-dynamic"' to
src/app/admin/layout.tsx so the whole admin tree opts out of static
prerender.
2. Prerender ECONNREFUSED on /indian-river-direct/stops and the sitemap:
getPublicStopsForBrand / getActiveStopsForSitemap / getBrandSettingsPublic
fetch NEXT_PUBLIC_SUPABASE_URL at build time. The Gitea runner sets the
Supabase env vars (so the existing env-var guard passes) but the URL
is unreachable, so fetch throws ECONNREFUSED and the prerender aborts.
Wrapped each fetch in try/catch returning [] / {success: false} so the
prerender completes; runtime behavior is unchanged when the fetch
succeeds.
Also added force-dynamic to the square-sync page itself as belt-and-braces
in case the layout change doesn't propagate.
The /indian-river-direct/stops page and sitemap prerender at build time
and call getPublicStopsForBrand / getActiveStopsForSitemap / getBrandSettingsPublic.
Those actions fetch NEXT_PUBLIC_SUPABASE_URL via Supabase REST. During
the GitHub/Gitea build, the Supabase secret is unset (or the value is
".supabase.co" which doesn't resolve), so the fetch errors with
ECONNREFUSED and the build aborts.
Return [] / not-configured when the env vars are missing so the prerender
can complete. Runtime behavior is unchanged when the vars are set.
Wire up NextAuth v5 with @auth/pg-adapter, JWT sessions (edge-friendly),
and a dev Credentials provider for local testing without Google OAuth.
Stack
- next-auth@5.0.0-beta.31, @auth/pg-adapter@1.11.2, @types/pg
- Google OAuth provider via GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET
(falls back to AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET)
- Postgres adapter wired to a single pg.Pool in src/lib/db.ts style —
reads DATABASE_URL with SUPABASE_DB_URL / POSTGRES_URL fallbacks
- JWT session strategy (edge-safe) so the proxy can verify sessions
without a DB round-trip
Files
- src/auth.config.ts edge-safe config (Google + authorized cb)
- src/lib/auth.ts server config (adapter + dev Credentials)
- src/proxy.ts Next.js 16 proxy (was middleware.ts)
- src/app/api/auth/[...nextauth]/route.ts
catch-all handler
- src/app/protected-example/page.tsx
demo page that renders auth() session
- src/actions/auth-signin.ts
signInWithGoogle, signInWithDev,
signOutAction server actions
- src/app/login/LoginClient.tsx
added "Sign in with Google" + dev form
- supabase/migrations/204_authjs_tables.sql
users / accounts / sessions /
verification_token schema (UUID-keyed)
- .env.example AUTH_SECRET, AUTH_URL, GOOGLE_CLIENT_*,
DATABASE_URL, ALLOW_DEV_LOGIN
Removed
- src/middleware.ts deleted; Next.js 16 only runs one proxy
(the new src/proxy.ts is canonical)
Routes
- /login, /admin, /admin/*, /protected-example
proxy matcher
- /api/auth/{providers,csrf,signin/<provider>,callback/<provider>,
session,signout}
standard Auth.js endpoints
Local dev
- npm run dev (now runs on port 4000)
- push migration 204 then visit /login
- dev signin works with any non-empty username/password
(hidden when ALLOW_DEV_LOGIN=false)
- Google signin requires real GOOGLE_CLIENT_ID + redirect URI
http://localhost:4000/api/auth/callback/google
Verified
- tsc --noEmit clean
- /admin, /admin/orders, /protected-example → 307 to /login
when unauthenticated
- /api/auth/session returns user after signin
- /protected-example renders session info
- /api/auth/providers returns google + dev-login
Docs
- CLAUDE.md and MEMORY.md updated to reflect the Supabase → Postgres
+ Auth.js v5 pivot
Gradual migration in progress
- src/lib/admin-permissions.ts still uses dev_session / rc_auth_uid;
the admin shell will show 'Access Denied' for Auth.js-only
sessions until each page is flipped over
- @supabase/* packages remain in package.json for the same reason
- production deployment (AUTH_URL=https://, __Secure- cookies) is
out of scope for this pass