This commit is contained in:
lovebird 2026-04-05 19:01:17 +02:00
parent 431203c542
commit 75c3bbfb0a
5 changed files with 942 additions and 292 deletions

View File

@ -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:** 23 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.

View File

@ -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 = () => {
<Route path="/products/gridsearch" element={<React.Suspense fallback={<div>Loading...</div>}><GridSearch /></React.Suspense>} />
<Route path="/products/places/detail/:place_id" element={<React.Suspense fallback={<div>Loading...</div>}><LocationDetail /></React.Suspense>} />
{enablePlaygrounds && <Route path="/products/places/*" element={<React.Suspense fallback={<div>Loading...</div>}><PlacesModule /></React.Suspense>} />}
<Route path="/types-editor" element={<React.Suspense fallback={<div>Loading...</div>}><TypesPlayground /></React.Suspense>} />
@ -268,18 +264,16 @@ const App = () => {
<ActionProvider>
<BrowserRouter>
<DragDropProvider>
<OrganizationProvider>
<ProfilesProvider>
<WebSocketProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}>
<StreamProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}>
<StreamInvalidator />
<FeedCacheProvider>
<AppWrapper />
</FeedCacheProvider>
</StreamProvider>
</WebSocketProvider>
</ProfilesProvider>
</OrganizationProvider>
<ProfilesProvider>
<WebSocketProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}>
<StreamProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}>
<StreamInvalidator />
<FeedCacheProvider>
<AppWrapper />
</FeedCacheProvider>
</StreamProvider>
</WebSocketProvider>
</ProfilesProvider>
</DragDropProvider>
</BrowserRouter>
</ActionProvider>

View File

@ -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<string>(filterByType || 'all');
// Selection state
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(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
<span className={cn("text-sm", isPicked && "font-semibold text-primary")}>{cat.name}</span>
</div>
{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))
}
</div>
@ -328,7 +336,7 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
</div>
</div>
{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))
}
</div>
@ -346,13 +354,23 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
<T>Choose one or more categories for your page.</T>
</DialogDescription>
</DialogHeader>
<div className="px-1 py-2">
<Tabs value={ownershipTab} onValueChange={(val: any) => setOwnershipTab(val)} className="w-full">
<TabsList className="h-8 w-full justify-start rounded-md bg-muted p-1">
<TabsTrigger value="system" className="text-xs px-4 py-1"><T>System</T></TabsTrigger>
<TabsTrigger value="own" className="text-xs px-4 py-1"><T>Own</T></TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="flex-1 overflow-y-auto min-h-0 border rounded-md p-2">
{loading ? (
<div className="flex justify-center p-4"><Loader2 className="h-6 w-6 animate-spin" /></div>
) : (
<div className="space-y-1">
{categories.map(cat => renderPickerItem(cat))}
{categories.length === 0 && <div className="text-center text-sm text-muted-foreground py-8"><T>No categories found.</T></div>}
{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 && <div className="text-center text-sm text-muted-foreground py-8"><T>No categories found.</T></div>}
</div>
)}
</div>
@ -365,262 +383,308 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
);
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-5xl h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle><T>Category Manager</T></DialogTitle>
<DialogDescription>
<T>Manage categories and organize your content structure.</T>
</DialogDescription>
</DialogHeader>
const innerContent = (
<>
<div className={asView ? "mb-4" : "mb-4"}>
{asView ? (
<div>
<h2 className="text-lg font-semibold"><T>Category Manager</T></h2>
<p className="text-sm text-muted-foreground"><T>Manage categories and organize your content structure.</T></p>
</div>
) : (
<DialogHeader>
<DialogTitle><T>Category Manager</T></DialogTitle>
<DialogDescription>
<T>Manage categories and organize your content structure.</T>
</DialogDescription>
</DialogHeader>
)}
</div>
<div className="flex-1 flex flex-col md:flex-row gap-4 min-h-0">
{/* Left: Category Tree */}
<div className="flex-1 border rounded-md p-2 overflow-y-auto min-h-0 basis-[60%] md:basis-auto">
<div className="flex justify-between items-center mb-2 px-2">
<span className="text-sm font-semibold text-muted-foreground"><T>Category Hierarchy</T></span>
<div className="flex-1 flex flex-col md:flex-row gap-4 min-h-0">
{/* Left: Category Tree */}
<div className="flex-1 border rounded-md p-2 overflow-y-auto min-h-0 basis-[60%] md:basis-auto">
<div className="flex justify-between items-center mb-2 px-2 gap-2">
<Tabs value={ownershipTab} onValueChange={(val: any) => setOwnershipTab(val)} className="-ml-2">
<TabsList className="h-8 bg-transparent">
<TabsTrigger value="system" className="text-xs px-3 py-1 data-[state=active]:bg-muted"><T>System</T></TabsTrigger>
<TabsTrigger value="own" className="text-xs px-3 py-1 data-[state=active]:bg-muted"><T>Own</T></TabsTrigger>
</TabsList>
</Tabs>
<div className="flex items-center gap-2">
{(!filterByType) && (
<Select value={activeFilter} onValueChange={setActiveFilter}>
<SelectTrigger className="h-8 w-[120px]">
<SelectValue placeholder="Type..." />
</SelectTrigger>
<SelectContent>
{ENTITY_TYPES.map(t => (
<SelectItem key={t} value={t}><T>{t === 'all' ? 'All Types' : t.charAt(0).toUpperCase() + t.slice(1)}</T></SelectItem>
))}
</SelectContent>
</Select>
)}
<Button variant="ghost" size="sm" onClick={() => handleCreateStart(null)}>
<Plus className="h-3 w-3 mr-1" />
<T>Root Category</T>
</Button>
</div>
{loading ? (
<div className="flex justify-center p-4"><Loader2 className="h-6 w-6 animate-spin" /></div>
) : (
<div className="space-y-1">
{categories.map(cat => renderCategoryItem(cat))}
{categories.length === 0 && <div className="text-center text-sm text-muted-foreground py-8"><T>No categories found.</T></div>}
</div>
)}
</div>
{/* Right: Editor or Actions */}
<div className="border rounded-md p-4 flex flex-col gap-4 overflow-y-auto bg-muted/10 w-full md:w-[450px] border-t md:border-l-0 md:border-l min-h-[50%] md:min-h-0">
{editingCategory ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm">{isCreating ? translate("New Category") : translate("Edit Category")}</h3>
<Button variant="ghost" size="sm" onClick={() => setEditingCategory(null)}><X className="h-4 w-4" /></Button>
</div>
<div className="space-y-2">
<Label><T>Name</T></Label>
<Input
value={editingCategory.name}
onChange={(e) => {
const name = e.target.value;
const slug = isCreating ? name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') : editingCategory.slug;
setEditingCategory({ ...editingCategory, name, slug })
}}
/>
</div>
<div className="space-y-2">
<Label><T>Slug</T></Label>
<Input
value={editingCategory.slug}
onChange={(e) => setEditingCategory({ ...editingCategory, slug: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label><T>Visibility</T></Label>
<Select
value={editingCategory.visibility}
onValueChange={(val: any) => setEditingCategory({ ...editingCategory, visibility: val })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="public"><T>Public</T></SelectItem>
<SelectItem value="unlisted"><T>Unlisted</T></SelectItem>
<SelectItem value="private"><T>Private</T></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Input
value={editingCategory.description || ''}
onChange={(e) => setEditingCategory({ ...editingCategory, description: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label><T>Variables</T></Label>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="w-full justify-start"
onClick={() => setShowVariablesEditor(true)}
>
<Hash className="mr-2 h-4 w-4" />
<T>Manage Variables</T>
{editingCategory.meta?.variables && Object.keys(editingCategory.meta.variables).length > 0 && (
<span className="ml-auto text-xs text-muted-foreground">
{Object.keys(editingCategory.meta.variables).length} defined
</span>
)}
</Button>
</div>
</div>
<div className="space-y-2">
<Label><T>Assigned Types</T></Label>
<div className="border rounded-md p-2 max-h-40 overflow-y-auto space-y-2">
{types.length === 0 && <div className="text-xs text-muted-foreground p-1"><T>No assignable types found.</T></div>}
{types.map(type => (
<div key={type.id} className="flex items-center gap-2">
<Checkbox
id={`type-${type.id}`}
checked={(editingCategory.meta?.assignedTypes || []).includes(type.id)}
onCheckedChange={(checked) => {
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
}
});
}}
/>
<Label htmlFor={`type-${type.id}`} className="text-sm font-normal cursor-pointer">
{type.name} <span className="text-xs text-muted-foreground">({type.kind})</span>
</Label>
</div>
))}
</div>
<div className="text-[10px] text-muted-foreground">
<T>Assign types to allow using them in this category.</T>
</div>
</div>
{/* Translate button — only for existing categories */}
{!isCreating && editingCategory.id && (
<div className="space-y-2">
<Label><T>Translations</T></Label>
<Button
variant="outline"
className="w-full justify-start"
onClick={() => setShowTranslationDialog(true)}
>
<Languages className="mr-2 h-4 w-4" />
<T>Translate</T>
</Button>
</div>
)}
<Button className="w-full" onClick={handleSave} disabled={actionLoading}>
{actionLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<T>Save</T>
</Button>
</div>
) : selectedCategoryId ? (
<div className="space-y-4">
<div className="border-b pb-2">
<h3 className="font-semibold text-lg">{categories.find(c => c.id === selectedCategoryId)?.name || 'Selected'}</h3>
<p className="text-xs text-muted-foreground mb-1">{selectedCategoryId}</p>
{categories.find(c => c.id === selectedCategoryId)?.description && (
<p className="text-sm text-foreground/80 italic">
{categories.find(c => c.id === selectedCategoryId)?.description}
</p>
)}
</div>
{currentPageId && (
<div className="bg-background rounded p-3 border space-y-2">
<Label className="text-xs uppercase text-muted-foreground"><T>Current Page Link</T></Label>
{linkedCategoryIds.includes(selectedCategoryId) ? (
<div className="text-sm">
<div className="flex items-center gap-2 text-green-600 mb-2">
<Check className="h-4 w-4" />
<T>Page linked to this category</T>
</div>
<Button size="sm" variant="outline" className="w-full" onClick={handleUnlinkPage} disabled={actionLoading}>
<T>Remove from Category</T>
</Button>
</div>
) : (
<Button size="sm" className="w-full" onClick={handleLinkPage} disabled={actionLoading}>
<LinkIcon className="mr-2 h-4 w-4" />
<T>Add to Category</T>
</Button>
)}
</div>
)}
<div className="text-xs text-muted-foreground">
<T>Select a category to see actions or click edit/add icons in the tree.</T>
</div>
{/* Category ACL / Permissions */}
<CollapsibleSection
title={<span className="flex items-center gap-1.5"><Shield className="h-3.5 w-3.5" /><T>Permissions</T></span>}
initiallyOpen={false}
storageKey="cat-acl-open"
minimal
>
<ACLEditorContent
resourceType="category"
resourceId={selectedCategoryId}
resourceName={categories.find(c => c.id === selectedCategoryId)?.name || ''}
availablePermissions={['read', 'list', 'write', 'delete']}
/>
</CollapsibleSection>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm text-center">
<T>Select a category to manage or link.</T>
</div>
)}
</div>
{loading ? (
<div className="flex justify-center p-4"><Loader2 className="h-6 w-6 animate-spin" /></div>
) : (
<div className="space-y-1">
{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 && <div className="text-center text-sm text-muted-foreground py-8"><T>No categories found.</T></div>}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}><T>Close</T></Button>
</DialogFooter>
{/* Nested Dialog for Variables */}
<Dialog open={showVariablesEditor} onOpenChange={setShowVariablesEditor}>
<DialogContent className="max-w-4xl h-[70vh] flex flex-col">
<DialogHeader>
<DialogTitle><T>Category Variables</T></DialogTitle>
<DialogDescription>
<T>Define variables available to pages in this category.</T>
</DialogDescription>
</DialogHeader>
<div className="flex-1 min-h-0">
{editingCategory && (
<VariablesEditor
onLoad={async () => {
return (editingCategory.meta?.variables as Record<string, any>) || {};
}}
onSave={async (newVars) => {
setEditingCategory({
...editingCategory,
meta: {
...(editingCategory.meta || {}),
variables: newVars
}
});
setShowVariablesEditor(false);
{/* Right: Editor or Actions */}
<div className="border rounded-md p-4 flex flex-col gap-4 overflow-y-auto bg-muted/10 w-full md:w-[450px] border-t md:border-l-0 md:border-l min-h-[50%] md:min-h-0">
{editingCategory ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm">{isCreating ? translate("New Category") : translate("Edit Category")}</h3>
<Button variant="ghost" size="sm" onClick={() => setEditingCategory(null)}><X className="h-4 w-4" /></Button>
</div>
<div className="space-y-2">
<Label><T>Name</T></Label>
<Input
value={editingCategory.name}
onChange={(e) => {
const name = e.target.value;
const slug = isCreating ? name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') : editingCategory.slug;
setEditingCategory({ ...editingCategory, name, slug })
}}
/>
)}
</div>
</DialogContent>
</Dialog>
</div>
<div className="space-y-2">
<Label><T>Slug</T></Label>
<Input
value={editingCategory.slug}
onChange={(e) => setEditingCategory({ ...editingCategory, slug: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label><T>Visibility</T></Label>
<Select
value={editingCategory.visibility}
onValueChange={(val: any) => setEditingCategory({ ...editingCategory, visibility: val })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="public"><T>Public</T></SelectItem>
<SelectItem value="unlisted"><T>Unlisted</T></SelectItem>
<SelectItem value="private"><T>Private</T></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Input
value={editingCategory.description || ''}
onChange={(e) => setEditingCategory({ ...editingCategory, description: e.target.value })}
/>
</div>
{/* Category Translation Dialog */}
{showTranslationDialog && editingCategory?.id && (
<CategoryTranslationDialog
open={showTranslationDialog}
onOpenChange={setShowTranslationDialog}
categoryId={editingCategory.id}
categoryName={editingCategory.name || ''}
categoryDescription={editingCategory.description}
/>
)}
<div className="space-y-2">
<Label><T>Variables</T></Label>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="w-full justify-start"
onClick={() => setShowVariablesEditor(true)}
>
<Hash className="mr-2 h-4 w-4" />
<T>Manage Variables</T>
{editingCategory.meta?.variables && Object.keys(editingCategory.meta.variables).length > 0 && (
<span className="ml-auto text-xs text-muted-foreground">
{Object.keys(editingCategory.meta.variables).length} defined
</span>
)}
</Button>
</div>
</div>
<div className="space-y-2">
<Label><T>Assigned Types</T></Label>
<div className="border rounded-md p-2 max-h-40 overflow-y-auto space-y-2">
{types.length === 0 && <div className="text-xs text-muted-foreground p-1"><T>No assignable types found.</T></div>}
{types.map(type => (
<div key={type.id} className="flex items-center gap-2">
<Checkbox
id={`type-${type.id}`}
checked={(editingCategory.meta?.assignedTypes || []).includes(type.id)}
onCheckedChange={(checked) => {
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
}
});
}}
/>
<Label htmlFor={`type-${type.id}`} className="text-sm font-normal cursor-pointer">
{type.name} <span className="text-xs text-muted-foreground">({type.kind})</span>
</Label>
</div>
))}
</div>
<div className="text-[10px] text-muted-foreground">
<T>Assign types to allow using them in this category.</T>
</div>
</div>
{/* Translate button — only for existing categories */}
{!isCreating && editingCategory.id && (
<div className="space-y-2">
<Label><T>Translations</T></Label>
<Button
variant="outline"
className="w-full justify-start"
onClick={() => setShowTranslationDialog(true)}
>
<Languages className="mr-2 h-4 w-4" />
<T>Translate</T>
</Button>
</div>
)}
<Button className="w-full" onClick={handleSave} disabled={actionLoading}>
{actionLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<T>Save</T>
</Button>
</div>
) : selectedCategoryId ? (
<div className="space-y-4">
<div className="border-b pb-2">
<h3 className="font-semibold text-lg">{categories.find(c => c.id === selectedCategoryId)?.name || 'Selected'}</h3>
<p className="text-xs text-muted-foreground mb-1">{selectedCategoryId}</p>
{categories.find(c => c.id === selectedCategoryId)?.description && (
<p className="text-sm text-foreground/80 italic">
{categories.find(c => c.id === selectedCategoryId)?.description}
</p>
)}
</div>
{currentPageId && (
<div className="bg-background rounded p-3 border space-y-2">
<Label className="text-xs uppercase text-muted-foreground"><T>Current Page Link</T></Label>
{linkedCategoryIds.includes(selectedCategoryId) ? (
<div className="text-sm">
<div className="flex items-center gap-2 text-green-600 mb-2">
<Check className="h-4 w-4" />
<T>Page linked to this category</T>
</div>
<Button size="sm" variant="outline" className="w-full" onClick={handleUnlinkPage} disabled={actionLoading}>
<T>Remove from Category</T>
</Button>
</div>
) : (
<Button size="sm" className="w-full" onClick={handleLinkPage} disabled={actionLoading}>
<LinkIcon className="mr-2 h-4 w-4" />
<T>Add to Category</T>
</Button>
)}
</div>
)}
<div className="text-xs text-muted-foreground">
<T>Select a category to see actions or click edit/add icons in the tree.</T>
</div>
{/* Category ACL / Permissions */}
<CollapsibleSection
title={<span className="flex items-center gap-1.5"><Shield className="h-3.5 w-3.5" /><T>Permissions</T></span>}
initiallyOpen={false}
storageKey="cat-acl-open"
minimal
>
<ACLEditorContent
resourceType="category"
resourceId={selectedCategoryId}
resourceName={categories.find(c => c.id === selectedCategoryId)?.name || ''}
availablePermissions={['read', 'list', 'write', 'delete']}
/>
</CollapsibleSection>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm text-center">
<T>Select a category to manage or link.</T>
</div>
)}
</div>
</div>
{!asView && (
<DialogFooter className="mt-4">
<Button variant="outline" onClick={onClose}><T>Close</T></Button>
</DialogFooter>
)}
{/* Nested Dialog for Variables */}
<Dialog open={showVariablesEditor} onOpenChange={setShowVariablesEditor}>
<DialogContent className="max-w-4xl h-[70vh] flex flex-col">
<DialogHeader>
<DialogTitle><T>Category Variables</T></DialogTitle>
<DialogDescription>
<T>Define variables available to pages in this category.</T>
</DialogDescription>
</DialogHeader>
<div className="flex-1 min-h-0">
{editingCategory && (
<VariablesEditor
onLoad={async () => {
return (editingCategory.meta?.variables as Record<string, any>) || {};
}}
onSave={async (newVars) => {
setEditingCategory({
...editingCategory,
meta: {
...(editingCategory.meta || {}),
variables: newVars
}
});
setShowVariablesEditor(false);
}}
/>
)}
</div>
</DialogContent>
</Dialog>
{/* Category Translation Dialog */}
{showTranslationDialog && editingCategory?.id && (
<CategoryTranslationDialog
open={showTranslationDialog}
onOpenChange={setShowTranslationDialog}
categoryId={editingCategory.id}
categoryName={editingCategory.name || ''}
categoryDescription={editingCategory.description}
/>
)}
</>
);
if (asView) {
return <div className="flex flex-col h-full">{innerContent}</div>;
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-5xl h-[80vh] flex flex-col">
{innerContent}
</DialogContent>
</Dialog>
);

View File

@ -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 },

View File

@ -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 = () => {
<main className="flex-1 p-4 overflow-auto">
<div className="mx-auto">
<div className="mb-8">
<h1 className="text-4xl font-bold mb-2">
<span className="bg-gradient-primary bg-clip-text text-transparent">
<T>Profile Settings</T>
</span>
</h1>
<p className="text-muted-foreground text-lg">
<T>Manage your account settings and preferences</T>
</p>
</div>
<Routes>
<Route path="/" element={
<Card>
@ -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));
}
}}
/>
</React.Suspense>
</Card>
@ -250,6 +244,19 @@ const Profile = () => {
</Card>
} />
<Route path="categories" element={
<Card className="flex flex-col h-[75vh] min-h-[600px]">
<CardHeader>
<CardTitle><T>Categories</T></CardTitle>
</CardHeader>
<div className="flex-1 min-h-0 pl-6 pr-6 pb-6">
<React.Suspense fallback={<div className="flex items-center justify-center py-12 text-muted-foreground"><T>Loading...</T></div>}>
<CategoryManager isOpen={true} onClose={() => { }} asView={true} />
</React.Suspense>
</div>
</Card>
} />
<Route path="integrations" element={
<Card>
<CardHeader>