sunday
This commit is contained in:
parent
431203c542
commit
75c3bbfb0a
584
packages/ui/docs/deploy/ditch-supabase.md
Normal file
584
packages/ui/docs/deploy/ditch-supabase.md
Normal 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:** 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.
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user