From 75c3bbfb0a92b52c88ee183b4f009001e2bec833 Mon Sep 17 00:00:00 2001 From: Babayaga Date: Sun, 5 Apr 2026 19:01:17 +0200 Subject: [PATCH] sunday --- packages/ui/docs/deploy/ditch-supabase.md | 584 ++++++++++++++++++ packages/ui/src/App.tsx | 28 +- .../components/widgets/CategoryManager.tsx | 580 +++++++++-------- packages/ui/src/modules/profile/types.ts | 5 +- packages/ui/src/pages/Profile.tsx | 37 +- 5 files changed, 942 insertions(+), 292 deletions(-) create mode 100644 packages/ui/docs/deploy/ditch-supabase.md diff --git a/packages/ui/docs/deploy/ditch-supabase.md b/packages/ui/docs/deploy/ditch-supabase.md new file mode 100644 index 00000000..fba91442 --- /dev/null +++ b/packages/ui/docs/deploy/ditch-supabase.md @@ -0,0 +1,584 @@ +# Ditching Supabase — Research & Migration Plan + +> **Status**: Research / RFC +> **Constraints**: No Docker. Postgres only. Future Rust/C++ server possible. + +--- + +## 1. Current Supabase Usage Audit + +### 1.1 What We Actually Use + +| Feature | Used? | Where | Migration Effort | +|:---|:---:|:---|:---:| +| **Auth (GoTrue)** | ✅ Heavy | `useAuth.tsx`, `server/commons/supabase.ts` | 🔴 High | +| **PostgREST query builder** (`.from().select()`) | ✅ Heavy | ~27 server files, 1 client file (`db.ts`) | 🟡 Medium | +| **Supabase JS client** (token get, session mgmt) | ✅ Heavy | ~46 `supabase.auth.getSession()` calls across client modules | 🟡 Medium | +| **OAuth (GitHub, Google)** | ✅ | `useAuth.tsx` | 🟡 Medium | +| **Email/Password auth** | ✅ | `useAuth.tsx` | 🟡 Medium | +| **Password reset flow** | ✅ | `useAuth.tsx` | 🟡 Medium | +| **Token refresh / session persistence** | ✅ | `supabase/client.ts` | 🟡 Medium | +| **RLS** | ⚠️ Enabled on tables | Migration SQL files | 🟢 Low | +| **Realtime** (`supabase.channel`) | ❌ | Not used (SSE via our server instead) | — | +| **Storage** (`supabase.storage`) | ❌ | Not used (VFS on our server) | — | +| **Edge Functions** | ❌ | Not used | — | +| **Supabase CLI / Migrations** | ✅ | `supabase/migrations/`, `supabase db push` | 🟢 Low | +| **Type generation** | ✅ | `supabase gen types` → `types.ts` | 🟢 Low | + +### 1.2 Server-Side Supabase Dependency Map + +**`server/src/commons/supabase.ts`** — central hub, exports: +- `supabase` — service-role client used as a PostgREST query builder +- `getUserCached(token)` — validates JWT via `supabase.auth.getUser(token)` +- `isAdmin(userId)` — queries `user_roles` table via PostgREST +- `flushAuthCache()` — in-process auth cache management +- `testSupabaseConnection()` — health check + +**~27 server files** import `supabase` and call `.from('table').select/insert/update/delete`. + +**`server/src/commons/auth.ts`** — test auth helper, calls `supabase.auth.signInWithPassword`. + +### 1.3 Client-Side Supabase Dependency Map + +**`src/integrations/supabase/client.ts`** — creates the anon-key client (hardcoded URL + key). + +**`src/hooks/useAuth.tsx`** — the auth context. Uses: +- `supabase.auth.onAuthStateChange` — session listener +- `supabase.auth.getSession` / `getUser` +- `supabase.auth.signUp` / `signInWithPassword` / `signInWithOAuth` / `signOut` +- `supabase.auth.resetPasswordForEmail` / `updateUser` + +**`src/lib/db.ts`** — `getAuthToken()` calls `supabase.auth.getSession()` to extract the access token, then passes it as `Bearer` header to our own Hono server. + +**~46 other client files** call `supabase.auth.getSession()` solely to extract the Bearer token for API calls to our server. + +### 1.4 Key Insight: We Don't Query Supabase From Client + +The client **never** calls `supabase.from()` for data. All data goes through our Hono API server. The only client-side Supabase usage is: +1. **Auth flows** (login/signup/OAuth/session) +2. **Token extraction** (to send as Bearer to our Hono server) + +This is excellent news — it means the client only needs an auth replacement, not a data layer replacement. + +--- + +## 2. Auth Stack Replacement + +### 2.1 Option A: **Better Auth** ⭐ RECOMMENDED + +[better-auth.com](https://www.better-auth.com/) — TypeScript-first, framework-agnostic auth library. + +**Why it fits perfectly:** +- ✅ Framework-agnostic — works with Hono out of the box +- ✅ First-class Postgres support (Drizzle adapter or raw pg) +- ✅ Email/Password, OAuth (GitHub, Google), 2FA, Passkeys +- ✅ Session management built-in (cookie or Bearer token) +- ✅ No Docker, no external service — runs in your Node.js process +- ✅ React client library for frontend integration +- ✅ **Future-proof**: auth logic is just Postgres tables + JWT signing — trivially portable to Rust/C++ +- ✅ Generates its own DB tables via CLI (`npx @better-auth/cli generate`) + +**Hono integration:** +```typescript +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; + +export const auth = betterAuth({ + database: drizzleAdapter(db, { provider: "pg" }), + emailAndPassword: { enabled: true }, + socialProviders: { + github: { clientId: ENV.GITHUB_CLIENT_ID, clientSecret: ENV.GITHUB_CLIENT_SECRET }, + google: { clientId: ENV.GOOGLE_CLIENT_ID, clientSecret: ENV.GOOGLE_CLIENT_SECRET }, + }, +}); + +// Mount in Hono +app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw)); +``` + +**Client-side:** +```typescript +import { createAuthClient } from "better-auth/react"; + +export const authClient = createAuthClient({ + baseURL: import.meta.env.VITE_SERVER_URL, // your Hono server +}); + +// Usage in React — replaces useAuth.tsx +const { data: session } = authClient.useSession(); +await authClient.signIn.email({ email, password }); +await authClient.signIn.social({ provider: "github" }); +await authClient.signOut(); +``` + +**Migration effort for `useAuth.tsx`:** +- Replace `supabase.auth.*` calls with `authClient.*` equivalents +- Session shape changes: `session.user` instead of Supabase's `User` type +- `getAuthToken()` in `db.ts` changes to reading from Better Auth's session (cookie-based or token-based) + +--- + +### 2.2 Option B: **Auth.js (NextAuth.js v5)** + +- Mature, widely used +- ❌ Tightly coupled to Next.js — can work with Hono but requires adapters +- ❌ Session model is different (server-side sessions via DB by default) +- Not ideal for a future Rust/C++ server migration + +### 2.3 Option C: **Custom Auth (DIY)** + +Roll your own with `jose` (JWT) + `argon2` (password hashing) + Postgres. + +- ✅ Maximum control, trivially portable to Rust/C++ +- ✅ No dependency on any auth library +- ❌ You own all the security surface area (password reset flows, OAuth dance, CSRF, session rotation) +- ❌ Significant engineering effort to get right + +**Verdict:** Only if you need extreme customization. Better Auth gives you 95% of this with 10% of the effort. + +### 2.4 Option D: **SuperTokens (self-hosted)** + +- Requires running a "SuperTokens Core" (Java process) — though it can run without Docker +- ❌ Extra process to manage +- ❌ Heavier than Better Auth for your use case + +--- + +### Auth Recommendation: **Better Auth** + +| Criteria | Better Auth | Auth.js | DIY | SuperTokens | +|:---|:---:|:---:|:---:|:---:| +| TypeScript-first | ✅ | ✅ | ✅ | ⚠️ | +| Hono support | ✅ native | ❌ adapter | ✅ | ⚠️ | +| No Docker | ✅ | ✅ | ✅ | ⚠️ | +| OAuth (GitHub/Google) | ✅ | ✅ | 🔨 DIY | ✅ | +| Postgres-native | ✅ | ✅ | ✅ | ✅ | +| Rust/C++ portability | ✅ data is standard | ⚠️ | ✅ | ❌ | +| Maintenance burden | Low | Low | High | Medium | + +--- + +## 3. Database Query Layer: Replacing `supabase.from()` + +### 3.1 Current Pattern (server) + +```typescript +// All 27+ server files do this: +import { supabase } from '../commons/supabase.js'; +const { data, error } = await supabase.from('posts').select('*').eq('user_id', userId); +``` + +This uses the Supabase JS client as a PostgREST HTTP client to query the **remote Supabase-hosted Postgres** over HTTPS. + +### 3.2 Options + +#### A. **Drizzle ORM** ⭐ RECOMMENDED + +```typescript +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; + +const sql = postgres(process.env.DATABASE_URL!); +export const db = drizzle(sql); + +// Query +const posts = await db.select().from(postsTable).where(eq(postsTable.userId, userId)); +``` + +**Pros:** +- ✅ TypeScript-first, schemas are TS code +- ✅ Full SQL power — joins, transactions, CTEs, raw SQL escape hatch +- ✅ Direct connection to Postgres (no PostgREST middleman) +- ✅ `drizzle-kit pull` can introspect your existing schema +- ✅ `drizzle-kit generate` + `drizzle-kit migrate` for migrations (replaces `supabase db push`) +- ✅ Lightweight — no code generation step for types +- ✅ Works with any Postgres (raw, managed, self-hosted) + +**Cons:** +- API differs from `supabase.from().select().eq()` syntax — requires rewriting queries +- Schema definition is manual (but `drizzle-kit pull` bootstraps it) + +#### B. **Kysely** (query builder, no ORM) + +```typescript +const posts = await db.selectFrom('posts').where('user_id', '=', userId).selectAll().execute(); +``` + +- ✅ Closer to raw SQL feel, minimal abstraction +- ✅ Great TypeScript inference +- ❌ Less ecosystem (fewer adapters, plugins) +- ⚠️ Schema types are inferred from DB, but requires manual or generated type definitions + +#### C. **postgrest-js standalone** (keep the same API) + +```bash +npm install @supabase/postgrest-js +``` + +```typescript +import { PostgrestClient } from '@supabase/postgrest-js'; +const postgrest = new PostgrestClient('https://your-self-hosted-postgrest:3000'); +const { data } = await postgrest.from('posts').select('*').eq('user_id', userId); +``` + +- ✅ **Zero code changes** in your server — same `.from().select().eq()` API +- ❌ Requires running PostgREST as a separate process (it's a Haskell binary, ~5MB) +- ❌ PostgREST is still an HTTP layer between your server and DB (latency) +- ❌ No transactions, limited join support +- ⚠️ PostgREST is what Supabase runs under the hood + +#### D. **Keep using `@supabase/supabase-js`** (just change the URL) + +You can point `createClient()` at your own PostgREST or even continue using Supabase's Postgres, just swapping out auth. + +- ✅ Literally zero code changes to queries +- ❌ Still depends on PostgREST (whether Supabase-hosted or self-hosted) +- ❌ Still coupled to `@supabase/supabase-js` package +- ⚠️ Not a real "ditch" — you're just self-hostinga dependency + +### Query Layer Recommendation: **Drizzle ORM** + +The server has ~27 files with `supabase.from()` calls. The migration is mechanical: + +| Supabase PostgREST | Drizzle ORM | +|:---|:---| +| `supabase.from('posts').select('*')` | `db.select().from(posts)` | +| `.eq('user_id', id)` | `.where(eq(posts.userId, id))` | +| `.insert({ ... })` | `db.insert(posts).values({ ... })` | +| `.update({ ... }).eq('id', id)` | `db.update(posts).set({ ... }).where(eq(posts.id, id))` | +| `.delete().eq('id', id)` | `db.delete(posts).where(eq(posts.id, id))` | +| `.order('created_at', { ascending: false })` | `.orderBy(desc(posts.createdAt))` | +| `.maybeSingle()` | `.limit(1)` + `[0] \|\| null` | + +--- + +## 4. RLS (Row-Level Security) — and the `@polymech/acl` Angle + +### 4.1 Situation: What is currently doing authorization? + +**Two separate systems run in parallel today:** + +**System A — Supabase RLS (database-layer)** +- Enabled on Postgres tables via migration SQL +- Policies reference `auth.uid()` — a Supabase/PostgREST JWT function +- Currently **not the enforcing layer** because the client never hits PostgREST directly +- Will break the moment you swap the auth provider (no `auth.uid()` without GoTrue) + +**System B — `@polymech/acl` (application-layer)** +- The `Acl` engine from `@polymech/acl` (MemoryBackend) already runs inside `db-acl.ts` +- Drives `productAclMiddleware`, the `/api/acl/*` route suite, and VFS permissions +- Backed by `db-acl-db.ts` → `resource_acl` + `acl_groups` + `acl_group_members` tables +- Supports: users, groups with inheritance, path-scoped grants, wildcard `*` +- Already has **175 passing tests** and a published Postgres RLS integration guide + +**Conclusion: System B is already your real authorization layer. System A (Supabase RLS) is dead weight post-migration.** + +--- + +### 4.2 What `@polymech/acl` covers today + +``` +server/src/acl/ +├── db-acl.ts ← orchestrator: evaluateAcl(), fetchAclSettings(), +│ grantAcl(), revokeAcl(), fetchUserEffectiveGroups() +└── db-acl-db.ts ← IAclBackend: reads/writes resource_acl table via supabase.from() +``` + +The `evaluateAcl()` function in `db-acl.ts` already: +1. Resolves virtual roles (`anonymous`, `authenticated`, `admin`) +2. Loads DB group memberships from `acl_group_members` (with parent inheritance) +3. Loads path-scoped grants from `AclSettings` +4. Runs `@polymech/acl` in-memory to check permissions with path-upward walk + +The `IAclBackend` interface (`db-acl.ts`) makes the storage pluggable — currently two backends: +- `DbAclBackend` (default) → `resource_acl` table via `supabase.from()` +- VFS file backend → `vfs-settings.json` on disk + +--- + +### 4.3 The only Supabase coupling in the ACL layer + +The ACL system uses `supabase.from()` in exactly **4 places** inside `db-acl.ts` and `db-acl-db.ts`: + +| Location | Query | +|:---|:---| +| `db-acl-db.ts` `read()` | `supabase.from('resource_acl').select('*').eq(...)` | +| `db-acl-db.ts` `write()` | `supabase.from('resource_acl').delete()` + `.insert()` | +| `db-acl.ts` `fetchGlobalGroups()` | `supabase.from('acl_groups').select('*')` | +| `db-acl.ts` `putGlobalGroup()` | `supabase.from('acl_groups').upsert/insert` | +| `db-acl.ts` `deleteGlobalGroup()` | `supabase.from('acl_groups').delete()` | +| `db-acl.ts` `fetchGroupMembers()` | `supabase.from('acl_group_members').select(...)` | +| `db-acl.ts` `addUserToGroup()` | `supabase.from('acl_group_members').upsert()` | +| `db-acl.ts` `removeUserFromGroup()` | `supabase.from('acl_group_members').delete()` | +| `db-acl.ts` `fetchUserEffectiveGroups()` | `supabase.from('acl_group_members').select(...)` + parent traverse | +| `db-acl.ts` `isUserAdmin()` | `supabase.from('user_roles').select('role').eq(...)` | +| `endpoints/acl.ts` `handleListUserGroups()` | `supabase.from('acl_group_members').select(...)` | +| `endpoints/acl.ts` `handleListUserEffectiveGroups()` | `supabase.from('acl_groups').select(...)` parent traverse | +| `endpoints/acl.ts` `handleListAclResources()` | `supabase.from('resource_acl').select(...)` | + +**All of these are mechanical Drizzle replacements** — no logic changes at all. + +Also: `requireAuthUser()` in `db-acl.ts` calls `getUserCached(token)` from `supabase.ts`. After the auth migration, this becomes a Better Auth session lookup. + +--- + +### 4.4 `@polymech/acl` as the RLS replacement for Postgres tables + +The ACL package already ships a documented, tested pattern for this in: +> `packages/acl/docs/postgres-rls-vfs.md` + +The approach: instead of Supabase's `auth.uid()` RLS at the DB level, use `@polymech/acl` at the **application layer** with Postgres `LISTEN/NOTIFY` for cache invalidation. + +**Architecture after migration:** + +``` +Request → Hono auth middleware (Better Auth JWT) + → productAclMiddleware → evaluateAcl() → @polymech/acl MemoryBackend + ↑ loaded from Postgres tables + ↑ invalidated via LISTEN/NOTIFY + → Route handler (Drizzle queries, no RLS) +``` + +**What to do with Supabase RLS policies:** + +| Table | Decision | Reason | +|:---|:---|:---| +| `resource_acl` | Drop RLS | ACL engine itself manages access; server is the only writer | +| `acl_groups` | Drop RLS | Admin-only via middleware | +| `acl_group_members` | Drop RLS | Admin-only via middleware | +| `user_roles` | Drop RLS | `isUserAdmin()` runs server-side with cached result | +| `user_secrets` | Keep RLS or move to app-layer | Sensitive — consider Option B below | +| `profiles` | Drop RLS | Filtered by userId in route handlers | +| `posts`, `pictures`, `pages` | Drop RLS | Filtered by userId in route handlers | +| `vfs_index`, `vfs_mounts` | Drop RLS | VFS uses `@polymech/acl` AclVfsClient already | + +**Option B for sensitive tables** — if you want defense-in-depth on `user_secrets`: +```sql +-- Replace auth.uid() with custom session variable +CREATE POLICY "user_secrets_isolation" ON user_secrets + USING (user_id = current_setting('app.current_user_id', true)::uuid); +``` +Server sets via Drizzle at start of request: +```typescript +await db.execute(sql`SET LOCAL app.current_user_id = ${userId}`); +``` +Per `packages/acl/docs/postgres-rls-vfs.md` — this is the documented Supabase-free RLS pattern. + +--- + +### 4.5 Adding Postgres LISTEN/NOTIFY to the ACL cache + +The `db-acl.ts` currently uses `appCache` with a 5-minute TTL. The ACL package's Postgres guide shows how to replace polling with push invalidation — add a trigger + LISTEN in the existing pg-boss connection: + +```sql +-- migration: add NOTIFY trigger to acl tables +CREATE OR REPLACE FUNCTION notify_acl_change() +RETURNS trigger AS $$ +BEGIN + PERFORM pg_notify('acl_changed', ''); + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER acl_groups_notify + AFTER INSERT OR UPDATE OR DELETE ON acl_groups + FOR EACH ROW EXECUTE FUNCTION notify_acl_change(); + +CREATE TRIGGER acl_group_members_notify + AFTER INSERT OR UPDATE OR DELETE ON acl_group_members + FOR EACH ROW EXECUTE FUNCTION notify_acl_change(); + +CREATE TRIGGER resource_acl_notify + AFTER INSERT OR UPDATE OR DELETE ON resource_acl + FOR EACH ROW EXECUTE FUNCTION notify_acl_change(); +``` + +```typescript +// server/src/acl/acl-listener.ts (new file) +import { Client } from 'pg'; +import { appCache } from '@/cache.js'; + +export async function startAclListener() { + const listener = new Client({ connectionString: process.env.DATABASE_URL }); + await listener.connect(); + await listener.query('LISTEN acl_changed'); + listener.on('notification', async () => { + await appCache.flush('acl-*'); + await appCache.flush('user-groups-*'); + await appCache.flush('user-admin-*'); + }); +} +``` + +This replaces the 5-minute TTL with instant invalidation — same pattern used by the VFS layer. + +--- + +### 4.6 Summary: RLS options ranked + +| Option | Effort | Safety | Rust-portable | +|:---|:---:|:---:|:---:| +| **A. Drop RLS, rely on `@polymech/acl` + middleware** | 🟢 Low | ✅ Strong (175-test engine) | ✅ | +| **B. Hybrid — `@polymech/acl` + `SET LOCAL` RLS on secrets only** | 🟡 Medium | ✅✅ Defense in depth | ✅ | +| **C. Keep full Supabase RLS, port to `app.current_user_id`** | 🔴 High | ✅✅ Defense in depth | ⚠️ | + +**Recommendation: Option A with LISTEN/NOTIFY invalidation.** + +`@polymech/acl` already implements the access control model at the right layer with proper test coverage. Dropping Supabase RLS removes ~50 lines of SQL policy maintenance while the actual enforcement is already handled by the engine you own. + +--- + +## 5. Can We Keep Using `@supabase/supabase-js`? + +**Short answer:** Partially, but it's not worth it. + +### What the client currently does with it: +1. **Auth** → Being replaced entirely (by Better Auth) +2. **Token extraction for API calls** → Replaced by Better Auth's session +3. **Type imports** (`User`, `Session`) → Replace with Better Auth's types +4. **One data query** (`checkLikeStatus` in `db.ts`) → Move to server API + +### What the server currently does with it: +1. **Auth validation** (`getUser(token)`) → Replaced by Better Auth +2. **PostgREST queries** (`.from().select()`) → Replaced by Drizzle + +**Verdict:** After migration, there's nothing left to justify the `@supabase/supabase-js` dependency. Remove it. + +--- + +## 6. Migration to Self-Hosted Postgres + +### 6.1 Database Hosting Options (No Docker) + +| Option | Cost | Effort | Notes | +|:---|:---|:---|:---| +| **Keep Supabase-hosted Postgres** | Free tier / $25/mo | None | Just stop using their JS client. Connect via `DATABASE_URL` directly. | +| **Neon** (serverless Postgres) | Free tier | Low | Great DX, branching, no Docker | +| **Railway** | $5/mo | Low | Managed Postgres, simple | +| **Render** | Free tier | Low | Managed Postgres | +| **Raw VPS (Hetzner/OVH)** | ~$5/mo | Medium | Install Postgres on your server via apt | +| **Any managed Postgres** | Varies | Low | AWS RDS, DigitalOcean, etc. | + +**Recommendation:** Phase 1 — keep Supabase-hosted Postgres (free tier), just connect via `DATABASE_URL`. Your data stays where it is. Phase 2 — migrate DB to your VPS or Neon when ready. + +### 6.2 Migrations Tooling Replacement + +| Current (Supabase CLI) | Replacement | +|:---|:---| +| `npx supabase migration new` | `npx drizzle-kit generate` | +| `npx supabase db push` | `npx drizzle-kit migrate` | +| `npx supabase gen types` | Built-in with Drizzle (schema IS the types) | +| `supabase/migrations/*.sql` | `drizzle/*.sql` (or keep both during transition) | + +--- + +## 7. Rust/C++ Server Portability + +### Auth data portability +Better Auth stores users in standard Postgres tables (`user`, `session`, `account`). A Rust server (e.g., Axum) can: +- Read sessions from the same `session` table +- Validate JWTs using the same `BETTER_AUTH_SECRET` +- Use `jsonwebtoken` crate for JWT validation + +### Query layer portability +Drizzle schemas are TypeScript — but the underlying Postgres schema is the same. Rust alternatives: +- **SQLx** (compile-time checked SQL) +- **Diesel** (Rust ORM) +- **SeaORM** (async Rust ORM) + +The Postgres schema + migrations carry over regardless of ORM. + +--- + +## 8. Migration Plan (Phases) + +### Phase 0: Preparation +- [ ] Add `DATABASE_URL` to env (Supabase's direct connection string) +- [ ] Install Drizzle: `npm install drizzle-orm postgres` + `npm install -D drizzle-kit` +- [ ] Install Better Auth: `npm install better-auth` +- [ ] Run `npx drizzle-kit pull` to generate schema from existing DB +- [ ] Run `npx @better-auth/cli generate` to create auth tables + +### Phase 1: Auth Migration (Server) +- [ ] Set up Better Auth with Hono (`/api/auth/*` routes) +- [ ] Create auth middleware that validates Better Auth sessions instead of `supabase.auth.getUser()` +- [ ] Migrate `getUserCached()` to use Better Auth's `auth.api.getSession()` +- [ ] Migrate `isAdmin()` to query `user_roles` via Drizzle instead of PostgREST +- [ ] Update `loginAdminUser()` test helper +- [ ] Keep both auth systems running in parallel during transition + +### Phase 2: Auth Migration (Client) +- [ ] Replace `src/integrations/supabase/client.ts` with Better Auth client +- [ ] Rewrite `useAuth.tsx` using Better Auth React hooks +- [ ] Update `getAuthToken()` in `db.ts` to use Better Auth session +- [ ] Update all ~46 files that call `supabase.auth.getSession()` for token extraction + - Most of these should already use `getAuthToken()` from `db.ts` — centralize the rest +- [ ] Update `UpdatePassword.tsx` to use Better Auth's password update +- [ ] Test OAuth flows (GitHub, Google) end-to-end + +### Phase 3: Query Layer Migration (Server) +- [ ] Create `server/src/db/index.ts` with Drizzle client +- [ ] Create `server/src/db/schema.ts` from `drizzle-kit pull` output +- [ ] Migrate server files **one module at a time**: + - `db-posts.ts` → Drizzle + - `db-pictures.ts` → Drizzle + - `db-types.ts` → Drizzle + - `db-categories.ts` → Drizzle + - `db-i18n.ts` → Drizzle + - `db-user.ts` → Drizzle + - `db-layouts.ts` → Drizzle + - `db-search.ts` → Drizzle + - `vfs.ts` → Drizzle + - `places/db.ts` → Drizzle + - `contacts/index.ts` → Drizzle + - `campaigns/index.ts` → Drizzle + - ... (remaining files) +- [ ] Each file: replace `supabase.from()` calls with Drizzle equivalents +- [ ] Run E2E tests after each module migration + +### Phase 4: Cleanup +- [ ] Remove `@supabase/supabase-js` from both `package.json` files +- [ ] Remove `src/integrations/supabase/` directory +- [ ] Remove `server/src/commons/supabase.ts` +- [ ] Drop RLS policies (or rewrite without `auth.uid()`) +- [ ] Remove `SUPABASE_URL`, `SUPABASE_SERVICE_KEY`, `SUPABASE_PUBLISHABLE_KEY` from env +- [ ] Update migration tooling to use Drizzle Kit +- [ ] Move `checkLikeStatus` from client-side Supabase query to server API + +### Phase 5: Database Migration (Optional, Later) +- [ ] Export data from Supabase Postgres (`pg_dump`) +- [ ] Import into self-hosted Postgres or Neon +- [ ] Update `DATABASE_URL` +- [ ] Verify all connections work + +--- + +## 9. Risk Assessment + +| Risk | Impact | Mitigation | +|:---|:---|:---| +| Auth migration breaks login | 🔴 High | Run both auth systems in parallel. Feature flag. | +| OAuth redirect URIs change | 🟡 Medium | Update GitHub/Google OAuth app settings | +| Existing user sessions invalidated | 🟡 Medium | Plan a "re-login required" release | +| Drizzle query bugs | 🟡 Medium | Migrate one module at a time, run E2E tests | +| RLS removal exposes data | 🟢 Low | Your middleware already handles authz | +| `supabase gen types` gone | 🟢 Low | Drizzle schema IS the types | + +--- + +## 10. Summary + +| Layer | Current | Target | Effort | +|:---|:---|:---|:---| +| **Auth** | Supabase GoTrue + JS client | **Better Auth** (Hono + React) | 🔴 High | +| **Query Builder** | `@supabase/supabase-js` PostgREST | **Drizzle ORM** (direct pg) | 🟡 Medium | +| **Migrations** | `supabase db push` | **Drizzle Kit** | 🟢 Low | +| **Type Gen** | `supabase gen types` | **Drizzle schema** (TS-native) | 🟢 Low | +| **RLS** | Supabase `auth.uid()` policies | **Drop** (middleware handles authz) | 🟢 Low | +| **Database** | Supabase-hosted Postgres | Keep same, migrate later | — | +| **Realtime** | Not used | N/A | — | +| **Storage** | Not used (own VFS) | N/A | — | + +**Total estimated effort:** 2–3 weeks for a full migration, done incrementally. +**Biggest single task:** Rewriting ~27 server files from PostgREST to Drizzle. +**Highest risk:** Auth migration — must run both systems in parallel during transition. diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 954795e5..5c90b4ed 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -7,7 +7,6 @@ import { queryClient } from "@/lib/queryClient"; import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom"; import { AuthProvider, useAuth } from "@/hooks/useAuth"; -import { OrganizationProvider } from "@/contexts/OrganizationContext"; import { LogProvider } from "@/contexts/LogContext"; import { MediaRefreshProvider } from "@/contexts/MediaRefreshContext"; @@ -57,7 +56,6 @@ let VariablePlayground: any; let I18nPlayground: any; let PlaygroundChat: any; let GridSearch: any; -let PlacesModule: any; let LocationDetail: any; let Tetris: any; @@ -81,7 +79,6 @@ if (enablePlaygrounds) { VariablePlayground = React.lazy(() => import("./components/variables/VariablesEditor").then(module => ({ default: module.VariablesEditor }))); I18nPlayground = React.lazy(() => import("./components/playground/I18nPlayground")); PlaygroundChat = React.lazy(() => import("./pages/PlaygroundChat")); - SupportChat = React.lazy(() => import("./pages/SupportChat")); } @@ -89,7 +86,6 @@ if (enablePlaygrounds) { Tetris = React.lazy(() => import("./apps/tetris/Tetris")); FileBrowser = React.lazy(() => import("./apps/filebrowser/FileBrowser")); - const VersionMap = React.lazy(() => import("./pages/VersionMap")); const UserCollections = React.lazy(() => import("./pages/UserCollections")); const Collections = React.lazy(() => import("./pages/Collections")); @@ -182,7 +178,7 @@ const AppWrapper = () => { Loading...}>} /> Loading...}>} /> - {enablePlaygrounds && Loading...}>} />} + Loading...}>} /> @@ -268,18 +264,16 @@ const App = () => { - - - - - - - - - - - - + + + + + + + + + + diff --git a/packages/ui/src/components/widgets/CategoryManager.tsx b/packages/ui/src/components/widgets/CategoryManager.tsx index e701b046..11a88d3e 100644 --- a/packages/ui/src/components/widgets/CategoryManager.tsx +++ b/packages/ui/src/components/widgets/CategoryManager.tsx @@ -18,6 +18,8 @@ import { updatePageMeta } from "@/modules/pages/client-pages"; import { fetchTypes } from "@/modules/types/client-types"; import { CategoryTranslationDialog } from "@/modules/i18n/CategoryTranslationDialog"; import { useAppConfig } from '@/hooks/useSystemInfo'; +import { useAuth } from "@/hooks/useAuth"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; interface CategoryManagerProps { isOpen: boolean; @@ -33,10 +35,20 @@ interface CategoryManagerProps { onPick?: (categoryIds: string[], categories?: Category[]) => void; /** Pre-selected category IDs for pick mode */ selectedCategoryIds?: string[]; + /** If true, renders as a standalone view rather than a Dialog */ + asView?: boolean; } -export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMeta, onPageMetaUpdate, filterByType, defaultMetaType, mode = 'manage', onPick, selectedCategoryIds: externalSelectedIds }: CategoryManagerProps) => { +export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMeta, onPageMetaUpdate, filterByType, defaultMetaType, mode = 'manage', onPick, selectedCategoryIds: externalSelectedIds, asView }: CategoryManagerProps) => { const [actionLoading, setActionLoading] = useState(false); + const { user } = useAuth(); + const systemUserId = import.meta.env.VITE_SYSTEM_USER_ID; + const [ownershipTab, setOwnershipTab] = useState<'system'|'own'>('system'); + + // Entity Types selector state + const envTypes = import.meta.env.VITE_ENTITY_TYPES; + const ENTITY_TYPES = envTypes ? ['all', ...envTypes.split(',').map(s => s.trim())] : ['all', 'pages', 'posts', 'pictures', 'types', 'products']; + const [activeFilter, setActiveFilter] = useState(filterByType || 'all'); // Selection state const [selectedCategoryId, setSelectedCategoryId] = useState(null); @@ -68,13 +80,8 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet queryKey: ['categories'], queryFn: async () => { const data = await fetchCategories({ includeChildren: true, sourceLang: srcLang }); - // Filter by type if specified - let filtered = filterByType - ? data.filter(cat => (cat as any).meta?.type === filterByType) - : data; - // Only show root-level categories (those without a parent) - return filtered.filter(cat => !cat.parent_category_id); + return data.filter(cat => !cat.parent_category_id); } }); @@ -121,11 +128,12 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet relationType: 'generalization' }; - // Set meta.type if defaultMetaType is provided - if (defaultMetaType) { + // Set meta.type if defaultMetaType is provided or derived from activeFilter + const targetType = defaultMetaType || (activeFilter !== 'all' ? activeFilter : undefined); + if (targetType) { categoryData.meta = { ...(categoryData.meta || {}), - type: defaultMetaType + type: targetType }; } @@ -281,7 +289,7 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet {cat.name} {cat.children - ?.filter(childRel => !filterByType || (childRel.child as any).meta?.type === filterByType) + ?.filter(childRel => activeFilter === 'all' || (childRel.child as any).meta?.type === activeFilter) .map(childRel => renderPickerItem(childRel.child, level + 1)) } @@ -328,7 +336,7 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet {cat.children - ?.filter(childRel => !filterByType || (childRel.child as any).meta?.type === filterByType) + ?.filter(childRel => activeFilter === 'all' || (childRel.child as any).meta?.type === activeFilter) .map(childRel => renderCategoryItem(childRel.child, level + 1)) } @@ -346,13 +354,23 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet Choose one or more categories for your page. +
+ setOwnershipTab(val)} className="w-full"> + + System + Own + + +
{loading ? (
) : (
- {categories.map(cat => renderPickerItem(cat))} - {categories.length === 0 &&
No categories found.
} + {categories + .filter(cat => ownershipTab === 'system' ? cat.owner_id === systemUserId : cat.owner_id === user?.id) + .map(cat => renderPickerItem(cat))} + {categories.filter(cat => ownershipTab === 'system' ? cat.owner_id === systemUserId : cat.owner_id === user?.id).length === 0 &&
No categories found.
}
)}
@@ -365,262 +383,308 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet ); } - return ( - - - - Category Manager - - Manage categories and organize your content structure. - - + const innerContent = ( + <> +
+ {asView ? ( +
+

Category Manager

+

Manage categories and organize your content structure.

+
+ ) : ( + + Category Manager + + Manage categories and organize your content structure. + + + )} +
-
- {/* Left: Category Tree */} -
-
- Category Hierarchy +
+ {/* Left: Category Tree */} +
+
+ setOwnershipTab(val)} className="-ml-2"> + + System + Own + + +
+ {(!filterByType) && ( + + )}
- {loading ? ( -
- ) : ( -
- {categories.map(cat => renderCategoryItem(cat))} - {categories.length === 0 &&
No categories found.
} -
- )} -
- - {/* Right: Editor or Actions */} -
- {editingCategory ? ( -
-
-

{isCreating ? translate("New Category") : translate("Edit Category")}

- -
-
- - { - const name = e.target.value; - const slug = isCreating ? name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') : editingCategory.slug; - setEditingCategory({ ...editingCategory, name, slug }) - }} - /> -
-
- - setEditingCategory({ ...editingCategory, slug: e.target.value })} - /> -
-
- - -
-
- setEditingCategory({ ...editingCategory, description: e.target.value })} - /> -
- -
- -
- -
-
- -
- -
- {types.length === 0 &&
No assignable types found.
} - {types.map(type => ( -
- { - const current = editingCategory.meta?.assignedTypes || []; - const newTypes = checked - ? [...current, type.id] - : current.filter((id: string) => id !== type.id); - - setEditingCategory({ - ...editingCategory, - meta: { - ...(editingCategory.meta || {}), - assignedTypes: newTypes - } - }); - }} - /> - -
- ))} -
-
- Assign types to allow using them in this category. -
-
- {/* Translate button — only for existing categories */} - {!isCreating && editingCategory.id && ( -
- - -
- )} - - -
- ) : selectedCategoryId ? ( -
-
-

{categories.find(c => c.id === selectedCategoryId)?.name || 'Selected'}

-

{selectedCategoryId}

- {categories.find(c => c.id === selectedCategoryId)?.description && ( -

- {categories.find(c => c.id === selectedCategoryId)?.description} -

- )} -
- - {currentPageId && ( -
- - {linkedCategoryIds.includes(selectedCategoryId) ? ( -
-
- - Page linked to this category -
- -
- ) : ( - - )} -
- )} - -
- Select a category to see actions or click edit/add icons in the tree. -
- - {/* Category ACL / Permissions */} - Permissions} - initiallyOpen={false} - storageKey="cat-acl-open" - minimal - > - c.id === selectedCategoryId)?.name || ''} - availablePermissions={['read', 'list', 'write', 'delete']} - /> - -
- ) : ( -
- Select a category to manage or link. -
- )}
+ {loading ? ( +
+ ) : ( +
+ {categories + .filter(cat => activeFilter === 'all' || (cat as any).meta?.type === activeFilter) + .filter(cat => ownershipTab === 'system' ? cat.owner_id === systemUserId : cat.owner_id === user?.id) + .map(cat => renderCategoryItem(cat))} + {categories + .filter(cat => activeFilter === 'all' || (cat as any).meta?.type === activeFilter) + .filter(cat => ownershipTab === 'system' ? cat.owner_id === systemUserId : cat.owner_id === user?.id) + .length === 0 &&
No categories found.
} +
+ )}
- - - - - {/* Nested Dialog for Variables */} - - - - Category Variables - - Define variables available to pages in this category. - - -
- {editingCategory && ( - { - return (editingCategory.meta?.variables as Record) || {}; - }} - onSave={async (newVars) => { - setEditingCategory({ - ...editingCategory, - meta: { - ...(editingCategory.meta || {}), - variables: newVars - } - }); - setShowVariablesEditor(false); + {/* Right: Editor or Actions */} +
+ {editingCategory ? ( +
+
+

{isCreating ? translate("New Category") : translate("Edit Category")}

+ +
+
+ + { + const name = e.target.value; + const slug = isCreating ? name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') : editingCategory.slug; + setEditingCategory({ ...editingCategory, name, slug }) }} /> - )} -
- -
+
+
+ + setEditingCategory({ ...editingCategory, slug: e.target.value })} + /> +
+
+ + +
+
+ setEditingCategory({ ...editingCategory, description: e.target.value })} + /> +
- {/* Category Translation Dialog */} - {showTranslationDialog && editingCategory?.id && ( - - )} +
+ +
+ +
+
+ +
+ +
+ {types.length === 0 &&
No assignable types found.
} + {types.map(type => ( +
+ { + const current = editingCategory.meta?.assignedTypes || []; + const newTypes = checked + ? [...current, type.id] + : current.filter((id: string) => id !== type.id); + + setEditingCategory({ + ...editingCategory, + meta: { + ...(editingCategory.meta || {}), + assignedTypes: newTypes + } + }); + }} + /> + +
+ ))} +
+
+ Assign types to allow using them in this category. +
+
+ {/* Translate button — only for existing categories */} + {!isCreating && editingCategory.id && ( +
+ + +
+ )} + + +
+ ) : selectedCategoryId ? ( +
+
+

{categories.find(c => c.id === selectedCategoryId)?.name || 'Selected'}

+

{selectedCategoryId}

+ {categories.find(c => c.id === selectedCategoryId)?.description && ( +

+ {categories.find(c => c.id === selectedCategoryId)?.description} +

+ )} +
+ + {currentPageId && ( +
+ + {linkedCategoryIds.includes(selectedCategoryId) ? ( +
+
+ + Page linked to this category +
+ +
+ ) : ( + + )} +
+ )} + +
+ Select a category to see actions or click edit/add icons in the tree. +
+ + {/* Category ACL / Permissions */} + Permissions} + initiallyOpen={false} + storageKey="cat-acl-open" + minimal + > + c.id === selectedCategoryId)?.name || ''} + availablePermissions={['read', 'list', 'write', 'delete']} + /> + +
+ ) : ( +
+ Select a category to manage or link. +
+ )} +
+
+ + {!asView && ( + + + + )} + + {/* Nested Dialog for Variables */} + + + + Category Variables + + Define variables available to pages in this category. + + +
+ {editingCategory && ( + { + return (editingCategory.meta?.variables as Record) || {}; + }} + onSave={async (newVars) => { + setEditingCategory({ + ...editingCategory, + meta: { + ...(editingCategory.meta || {}), + variables: newVars + } + }); + setShowVariablesEditor(false); + }} + /> + )} +
+
+
+ + {/* Category Translation Dialog */} + {showTranslationDialog && editingCategory?.id && ( + + )} + + ); + + if (asView) { + return
{innerContent}
; + } + + return ( + + + {innerContent} ); diff --git a/packages/ui/src/modules/profile/types.ts b/packages/ui/src/modules/profile/types.ts index 2a3a5919..9b950a85 100644 --- a/packages/ui/src/modules/profile/types.ts +++ b/packages/ui/src/modules/profile/types.ts @@ -1,7 +1,7 @@ -import { User, Key, Hash, MapPin, Building2, BookUser, Send, Plug, ShoppingBag, Images } from "lucide-react"; +import { User, Key, Hash, MapPin, Building2, BookUser, Send, Plug, ShoppingBag, Images, FolderTree } from "lucide-react"; import { translate } from "@/i18n"; -export type ActiveSection = 'general' | 'api-keys' | 'variables' | 'addresses' | 'vendor' | 'gallery' | 'purchases' | 'contacts' | 'campaigns' | 'integrations' | 'smtp-servers'; +export type ActiveSection = 'general' | 'api-keys' | 'variables' | 'addresses' | 'vendor' | 'gallery' | 'purchases' | 'contacts' | 'campaigns' | 'integrations' | 'smtp-servers' | 'categories'; export interface ProfileData { username: string; @@ -15,6 +15,7 @@ export const PROFILE_MENU_ITEMS = [ { id: 'general' as ActiveSection, label: 'General', icon: User }, { id: 'api-keys' as ActiveSection, label: 'API Keys', icon: Key }, { id: 'variables' as ActiveSection, label: 'Hash Variables', icon: Hash }, + { id: 'categories' as ActiveSection, label: 'Categories', icon: FolderTree }, { id: 'addresses' as ActiveSection, label: 'Shipping Addresses', icon: MapPin }, { id: 'vendor' as ActiveSection, label: 'Vendor Profiles', icon: Building2 }, { id: 'contacts' as ActiveSection, label: 'Contacts', icon: BookUser }, diff --git a/packages/ui/src/pages/Profile.tsx b/packages/ui/src/pages/Profile.tsx index d41f421e..b2e85a33 100644 --- a/packages/ui/src/pages/Profile.tsx +++ b/packages/ui/src/pages/Profile.tsx @@ -44,6 +44,9 @@ const GmailIntegrations = React.lazy(() => const SmtpIntegrations = React.lazy(() => import('@/components/SmtpIntegrations').then(m => ({ default: m.SmtpIntegrations })) ); +const CategoryManager = React.lazy(() => + import('@/components/widgets/CategoryManager').then(m => ({ default: m.CategoryManager })) +); const Profile = () => { const { user, loading, resetPassword } = useAuth(); @@ -112,17 +115,6 @@ const Profile = () => {
-
-

- - Profile Settings - -

-

- Manage your account settings and preferences -

-
- @@ -135,7 +127,7 @@ const Profile = () => { profile={profile} setProfile={setProfile} selectedLanguage={selectedLanguage} - setSelectedLanguage={setSelectedLanguage} + setSelectedLanguage={(lang) => setSelectedLanguage(lang as any)} resetPassword={resetPassword} onSubmit={handleProfileUpdate} updating={updatingProfile} @@ -220,9 +212,11 @@ const Profile = () => { return listTransactions(); }} onNavigate={navigate} - toast={{ error: (msg: string) => { - import("sonner").then(m => m.toast.error(msg)); - }}} + toast={{ + error: (msg: string) => { + import("sonner").then(m => m.toast.error(msg)); + } + }} /> @@ -250,6 +244,19 @@ const Profile = () => { } /> + + + Categories + +
+ Loading...
}> + { }} asView={true} /> + +
+ + } /> +