diff --git a/packages/ui/docs/migrate-rls.md b/packages/ui/docs/migrate-rls.md new file mode 100644 index 00000000..2a52cc33 --- /dev/null +++ b/packages/ui/docs/migrate-rls.md @@ -0,0 +1,192 @@ +# Backup from Supabase → Restore to plain Postgres + +> **Status**: Active — in-flight migration from Supabase to plain Postgres + Zitadel +> **Goal**: One correct, portable snapshot restorable on any plain Postgres instance with RLS intact. + +--- + +## Why a raw dump doesn't work on plain Postgres + +A `pg_dump` of a Supabase project contains things that don't exist on vanilla Postgres: + +| Problem | Where it appears | Effect | +|:---|:---|:---| +| `\restrict` / `\unrestrict` tokens | Line 1 and last line of every dump | `psql` errors on unknown meta-command | +| `auth.users` table | FK constraints (`REFERENCES auth.users(id)`) | restore fails — schema `auth` doesn't exist | +| `auth.uid()` function | RLS policies, trigger functions | policies fail to create | +| `authenticated`, `anon`, `service_role` roles | `TO authenticated` in policies | role not found error | + +All four are solved before the first `psql` command runs — no manual file editing required. + +--- + +## How it works on plain Postgres + +`cli-ts/schemas/auth_setup.sql` creates: + +- `anon`, `authenticated`, `service_role` roles — so `TO authenticated` in policies parses cleanly +- `auth.uid()` — a real function that reads `current_setting('app.current_user_id', true)::uuid` +- `auth.role()`, `auth.jwt()` stubs for any policy that calls them + +`cli-ts/schemas/auth_users.sql` creates `auth.users` with all 34 columns that Supabase's `pg_dump` emits in its `COPY` statement — no column-mismatch errors on restore. + +`backup-site` strips `\restrict`/`\unrestrict` tokens automatically after every dump. + +**Result:** the structure dump restores as-is, RLS policies compile and evaluate against the session variable the server sets at request start — no policy rewriting, no policy dropping. + +--- + +## Step 1 — Take the backup + +```bash +pm-cli-cms backup-site --type all --include-auth --target ./backups/service.polymech.info +``` + +Produces three files in `backups/service.polymech.info/backups/db/`: + +| File | Contents | +|:---|:---| +| `structure-YY_MM_DD.sql` | Full public schema (DDL + RLS + indexes). Tokens already stripped. | +| `data-YY_MM_DD.sql` | All public data. `vfs_index` + `vfs_document_chunks` rows excluded (see below). | +| `auth-users-YY_MM_DD.sql` | `auth.users` rows only (ids, emails, hashed passwords). Tokens already stripped. | + +> The `26_04_10` backup in `backups/service.polymech.info/backups/db/` is the current clean baseline — tokens already stripped, ready to restore. + +--- + +## Step 2 — One-time target setup (per Postgres instance) + +Run these once on any new target. Both scripts are idempotent (`CREATE IF NOT EXISTS`). + +```bash +# Roles + auth.uid() + auth schema +psql "$TARGET_DATABASE_URL" -f cli-ts/schemas/auth_setup.sql + +# auth.users table (34-column definition matching pg_dump output) +psql "$TARGET_DATABASE_URL" -f cli-ts/schemas/auth_users.sql +``` + +--- + +## Step 3 — Restore + +```bash +# 1. Seed auth.users rows (must come before public schema — FK targets must exist) +psql "$TARGET_DATABASE_URL" \ + -f backups/service.polymech.info/backups/db/auth-users-26_04_10.sql + +# 2. Restore public schema (RLS policies restore as-is, auth.uid() already exists) +pm-cli-cms db-import \ + --schema backups/service.polymech.info/backups/db/structure-26_04_10.sql \ + --env ./server/.env.production + +# 3. Restore public data +pm-cli-cms db-import \ + --data backups/service.polymech.info/backups/db/data-26_04_10.sql \ + --env ./server/.env.production +``` + +**Full wipe + restore** — `--clear` drops/recreates `public` only; the `auth` schema is untouched: + +```bash +psql "$TARGET_DATABASE_URL" -f backups/service.polymech.info/backups/db/auth-users-26_04_10.sql + +pm-cli-cms db-import \ + --schema backups/service.polymech.info/backups/db/structure-26_04_10.sql \ + --data backups/service.polymech.info/backups/db/data-26_04_10.sql \ + --clear \ + --env ./server/.env.production +``` + +--- + +## Step 4 — Verify + +```sql +-- Public tables present +SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY 1; + +-- vfs_index structure present, data empty (intentional) +\d public.vfs_index +\d public.vfs_document_chunks + +-- auth.uid() resolves (returns NULL — no session var set yet, expected) +SELECT auth.uid(); + +-- RLS policies present +SELECT tablename, policyname FROM pg_policies WHERE schemaname = 'public' ORDER BY 1, 2; + +-- auth.users rows seeded +SELECT id, email, created_at FROM auth.users ORDER BY created_at; + +-- Row counts +SELECT + (SELECT count(*) FROM public.posts) AS posts, + (SELECT count(*) FROM public.pictures) AS pictures, + (SELECT count(*) FROM public.pages) AS pages, + (SELECT count(*) FROM public.places) AS places; +``` + +--- + +## Notes + +### Tables excluded from data dumps by default + +| Table | Reason | +|:---|:---| +| `vfs_index` | Can be huge; re-populated by the VFS indexer | +| `vfs_document_chunks` | Embedding vectors; re-generated by the vectorisation pipeline | + +To include them: `--exclude-data-tables ""` + +### How RLS works without Supabase + +| Layer | Supabase | Plain Postgres (this setup) | +|:---|:---|:---| +| `auth.uid()` | GoTrue JWT claim injected by PostgREST | `current_setting('app.current_user_id', true)::uuid` | +| Set by | PostgREST on every request | Server: `SET LOCAL app.current_user_id = $1` at request start (application pool) | +| Service pool | `postgres` superuser → bypasses RLS | Same — bypasses RLS | +| Policy syntax | Unchanged | Unchanged — no rewriting needed | + +### Stripping `\restrict` tokens from existing dumps + +Going forward `backup-site` handles this automatically. For any dump already on disk: + +```bash +# bash +sed -i '/^\\(un\)\?restrict /d' file.sql + +# PowerShell +(Get-Content file.sql) -replace '(?m)^\\(un)?restrict\s+\S+\s*$','' | Set-Content file.sql +``` + +### Duplicating this baseline to another instance + +```bash +# One-time setup on staging +psql "$STAGING_DATABASE_URL" -f cli-ts/schemas/auth_setup.sql +psql "$STAGING_DATABASE_URL" -f cli-ts/schemas/auth_users.sql + +# Seed users + restore +psql "$STAGING_DATABASE_URL" -f backups/service.polymech.info/backups/db/auth-users-26_04_10.sql + +pm-cli-cms db-import \ + --schema backups/service.polymech.info/backups/db/structure-26_04_10.sql \ + --data backups/service.polymech.info/backups/db/data-26_04_10.sql \ + --clear \ + --env ./server/.env.staging +``` + +--- + +## Future: when Zitadel is the only auth provider + +`server/src/commons/zitadel.ts` + `postgres.ts` are already live. `resolveAppUserId()` maps Zitadel `sub` → `profiles.user_id` UUID with `auth.users` as an optional fallback (guarded by `hasAuthUsersTable()`). + +When ready to fully drop the Supabase user store: + +1. Ensure all active users have `profiles.zitadel_sub` set. +2. `auth.users` is now a plain Postgres table — keep it as a thin identity table or migrate emails into `profiles`. +3. Update `REFERENCES auth.users(id)` FKs in migrations to point to `public.profiles(user_id)` if dropping it. +4. `auth.uid()` already reads from `app.current_user_id` — no policy changes needed. diff --git a/packages/ui/docs/supabase/rls-leaving-supabase.md b/packages/ui/docs/supabase/rls-leaving-supabase.md new file mode 100644 index 00000000..e94dbd5b --- /dev/null +++ b/packages/ui/docs/supabase/rls-leaving-supabase.md @@ -0,0 +1,294 @@ +# RLS (Leaving Supabase) + +## Overview + +Chapter in the **Leaving Supabase** series: Row-Level Security moves from Supabase's GoTrue-coupled model (`auth.uid()` + PostgREST) to **plain Postgres RLS** backed by a session variable, while keeping every existing policy intact — no rewrites, no policy drops. + +**Contents** + +1. **§1 — What Supabase RLS actually is** — The GoTrue coupling, what breaks when you leave. +2. **§2 — The five portability problems** — Tokens, `auth.users`, `auth.uid()`, missing roles, BYPASSRLS. +3. **§3 — The workarounds** — How each problem is solved without modifying policies. +4. **§4 — How RLS evaluates in the new architecture** — Session variable, pool separation. +5. **§5 — The `auth` schema on plain Postgres** — Minimal `auth.users`, no GoTrue. +6. **References** — Related chapters. + +--- + +## 1. What Supabase RLS actually is + +Supabase RLS is standard **Postgres Row-Level Security** — nothing proprietary — but it is wired through two Supabase-specific pieces: + +### 1.1 PostgREST sets the JWT context + +When a browser calls the Supabase API, PostgREST: +1. Validates the GoTrue JWT +2. Calls `SET LOCAL request.jwt.claims = ''` on the Postgres connection +3. Sets `ROLE authenticated` (or `anon`) for that transaction + +Supabase then provides `auth.uid()` as a thin wrapper around that claim: + +```sql +-- Supabase's actual auth.uid() implementation (GoTrue / PostgREST) +CREATE OR REPLACE FUNCTION auth.uid() +RETURNS uuid AS $$ + SELECT COALESCE( + current_setting('request.jwt.claims', true)::jsonb ->> 'sub', + (current_setting('request.jwt.claims', true)::jsonb ->> 'id') + )::uuid +$$ LANGUAGE sql STABLE; +``` + +### 1.2 All policies reference that function + +```sql +-- Typical Supabase RLS policy +CREATE POLICY "owner can read" ON public.posts + FOR SELECT TO authenticated + USING (user_id = auth.uid()); +``` + +### 1.3 What breaks when you leave + +When you connect to Postgres directly (no PostgREST, no GoTrue JWT pipe): + +| What fails | Why | +|:---|:---| +| `auth.uid()` | Function doesn't exist outside Supabase | +| `TO authenticated` | Role doesn't exist on plain Postgres | +| `REFERENCES auth.users(id)` | Schema `auth` doesn't exist | +| `\restrict` / `\unrestrict` in dumps | Supabase-custom psql tokens, unknown to standard `psql` | +| Server-side admin queries | Service user lacks `BYPASSRLS` → RLS blocks its own queries | + +--- + +## 2. The five portability problems + +### Problem 1 — `\restrict` tokens in pg_dump output + +Supabase injects a security token on line 1 and the last line of every `pg_dump` file: + +``` +\restrict CTr7scG9HDQwlW5lApN1nPGSJZnEc9H... +... +\unrestrict CTr7scG9HDQwlW5lApN1nPGSJZnEc9H... +``` + +Standard `psql` doesn't recognise these meta-commands and will error. + +**Fix:** strip them before restoring: + +```bash +# bash +sed -i '/^\\(un\)\?restrict /d' dump.sql + +# PowerShell +(Get-Content dump.sql) -replace '(?m)^\\(un)?restrict\s+\S+\s*$','' | Set-Content dump.sql +``` + +This is handled automatically by the backup tooling on every new dump. + +--- + +### Problem 2 — `auth.users` doesn't exist + +Every FK that references `auth.users(id)` fails during structure restore: + +``` +ERROR: relation "auth.users" does not exist +``` + +**Fix:** create a minimal `auth.users` table. The table needs all 34 columns that appear in Supabase's `pg_dump COPY` statement — all nullable or defaulted, no GoTrue functions needed. + +```sql +CREATE SCHEMA IF NOT EXISTS auth; + +CREATE TABLE IF NOT EXISTS auth.users ( + id uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + email text UNIQUE, + encrypted_password text, + -- ... 31 more GoTrue bookkeeping columns (all nullable / defaulted) + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +``` + +The full definition lives in `cli-ts/schemas/auth_users.sql`. + +> **Restore order:** `auth` schema + table must be created AFTER `db-import --clear` creates the database, but the `auth` schema sits outside `public` so `--clear` never touches it on subsequent restores. + +--- + +### Problem 3 — `auth.uid()` doesn't exist + +Every policy that calls `auth.uid()` fails to create: + +``` +ERROR: function auth.uid() does not exist +``` + +**Fix:** create `auth.uid()` as a real Postgres function that reads a session variable set by the server: + +```sql +CREATE OR REPLACE FUNCTION auth.uid() +RETURNS uuid +LANGUAGE sql STABLE AS $$ + SELECT NULLIF(current_setting('app.current_user_id', true), '')::uuid +$$; +``` + +The server sets this variable at the start of each request on the application pool connection: + +```typescript +await db.query('SET LOCAL app.current_user_id = $1', [userId]); +``` + +`SET LOCAL` scopes the variable to the current transaction — automatically cleared at transaction end, no cross-request leakage. + +--- + +### Problem 4 — `authenticated` / `anon` roles don't exist + +Policies with `TO authenticated` or `TO anon` fail with: + +``` +ERROR: role "authenticated" does not exist +``` + +**Fix:** create the roles. They never need to log in — they only exist so policy DDL parses without error: + +```sql +CREATE ROLE anon NOLOGIN NOINHERIT; +CREATE ROLE authenticated NOLOGIN NOINHERIT; +CREATE ROLE service_role NOLOGIN NOINHERIT BYPASSRLS; +``` + +--- + +### Problem 5 — service user can't read its own tables (BYPASSRLS) + +This is the most subtle problem. After restore, server-side queries like `isAdmin` or role lookups return empty results even though the data is there: + +``` +GET /api/me/identity → { "roles": [] } -- user_roles has rows, but RLS blocks them +``` + +**Why:** The server's database user (`service.polymech`) is subject to RLS. Policies on `user_roles` check `auth.uid()`. For **server-side** queries that don't represent an end-user request — `isAdmin()`, role resolution, pg-boss startup — there is no session variable set, so `auth.uid()` returns `NULL`. Every RLS policy that checks `auth.uid()` then blocks all rows. + +On Supabase this never occurred because the server connected as `postgres` (superuser) which bypasses RLS unconditionally. + +**Fix:** grant `BYPASSRLS` to the service user: + +```sql +ALTER ROLE "service.polymech" WITH BYPASSRLS; +``` + +This is included automatically in the provisioning step (`db-import --create-user`). For an existing role: + +```bash +psql "postgresql://root:...@host:5432/postgres" \ + -c 'ALTER ROLE "service.polymech" WITH BYPASSRLS;' +``` + +`BYPASSRLS` does not make the user a superuser — it only skips RLS enforcement for that role. The server still enforces authorisation at the application layer via `@polymech/acl` and middleware checks. + +--- + +## 3. The workarounds in sequence + +``` +1. db-import --clear → creates DB if missing, wipes public schema, restores structure + data +2. DROP SCHEMA IF EXISTS auth CASCADE +3. CREATE SCHEMA auth +4. CREATE ROLE anon / authenticated / service_role +5. CREATE FUNCTION auth.uid() → reads app.current_user_id +6. CREATE FUNCTION auth.role() → stub +7. CREATE FUNCTION auth.jwt() → stub (returns '{}') +8. CREATE TABLE auth.users → 34-column definition matching pg_dump COPY output +9. COPY auth.users rows → real user identities from the Supabase backup +10. ALTER ROLE "service.polymech" WITH BYPASSRLS → (handled by --create-user in db-import) +``` + +After step 9, every RLS policy is live and evaluates correctly. After step 10, server-side queries work without a session variable. + +--- + +## 4. How RLS evaluates in the new architecture + +### Pool separation + +The server maintains two Postgres connection pools: + +| Pool | Connects as | RLS | Used for | +|:---|:---|:---|:---| +| **service pool** | `service.` + `BYPASSRLS` | **Bypassed** | All server-side queries: `isAdmin`, role lookups, data reads/writes | +| **pg-boss** | `root` via `DATABASE_URL_SERVICE` | **Bypassed** (superuser) | Schema provisioning, job queue DDL | + +`BYPASSRLS` on `service.` gives the server the same effective privilege as Supabase's `postgres` superuser connection — without needing a superuser account for routine queries. RLS remains enforced for any direct external connection that doesn't have `BYPASSRLS`. + +### Request lifecycle + +``` +Incoming request + → verify Zitadel JWT (zitadel.ts) + → resolveAppUserId(sub, email) → app UUID + → SET LOCAL app.current_user_id = '' + → query executes + → auth.uid() returns that UUID + → RLS policy evaluates → row visible / blocked + → transaction ends → app.current_user_id cleared automatically +``` + +For **server-internal** queries (no end-user context): `BYPASSRLS` means the policy is never evaluated — the server reads/writes freely and enforces access control at the application layer. + +### Why this is equivalent to Supabase RLS + +| Supabase | Plain Postgres | +|:---|:---| +| PostgREST validates GoTrue JWT | Server validates Zitadel JWT | +| `SET LOCAL request.jwt.claims = ''` | `SET LOCAL app.current_user_id = ''` | +| `auth.uid()` reads JWT `sub` claim | `auth.uid()` reads session variable | +| `postgres` superuser bypasses RLS for server | `service.polymech` + `BYPASSRLS` bypasses RLS for server | +| `ROLE authenticated` set by PostgREST | Application pool user is always "authenticated" by definition | + +--- + +## 5. The `auth` schema on plain Postgres + +After running the compatibility setup, the `auth` schema contains: + +``` +auth +├── users — plain table, 34 columns, no GoTrue triggers/functions +├── uid() — reads current_setting('app.current_user_id', true)::uuid +├── role() — stub, returns current_setting('app.current_role', true) +└── jwt() — stub, returns '{}'::jsonb +``` + +**`auth.users` is now just a table.** No GoTrue functions, no triggers, no event hooks: +- Queried directly: `SELECT id, email FROM auth.users` +- Written directly: `INSERT INTO auth.users (id, email, ...) VALUES (...)` +- Backed up with standard `pg_dump` +- Migrated to `public.profiles` or dropped when no longer needed + +### Transition path + +`resolveAppUserId()` in `postgres.ts` chains through three identity paths: + +1. `profiles.zitadel_sub` — Zitadel numeric `sub` (preferred, no `auth.users` needed) +2. `auth.users.email` join — legacy Supabase email match (guarded by `hasAuthUsersTable()`) +3. `profiles.username` — final fallback + +As users accumulate `profiles.zitadel_sub` entries, dependency on `auth.users` fades naturally. When all active users have a `zitadel_sub`, `auth.users` can be dropped and its FKs replaced with `public.profiles(user_id)`. + +--- + +## References + +- `docs/supabase/auth-zitadel.md` — Identity migration: Supabase Auth → Zitadel OIDC +- `docs/deploy/ditch-supabase.md` — Full migration plan, phases, risk assessment +- `docs/migrate-rls.md` — Backup / restore operational runbook +- `server/src/commons/zitadel.ts` — JWT verification, `resolveAppUserId` +- `server/src/commons/postgres.ts` — Pool setup, `hasAuthUsersTable`, `isAdmin` +- `cli-ts/schemas/auth_setup.sql` — Roles + `auth.uid()` setup script +- `cli-ts/schemas/auth_users.sql` — Minimal `auth.users` table definition (34 columns) diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index e10ee9d0..11f68463 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -50,7 +50,6 @@ let VideoPlayerPlaygroundIntern: any; let PlaygroundImages: any; let PlaygroundImageEditor: any; let VideoGenPlayground: any; -let PlaygroundCanvas: any; let TypesPlayground: any; let VariablePlayground: any; let I18nPlayground: any; @@ -200,7 +199,6 @@ const AppWrapper = () => { Loading...}>} /> Loading...}>} /> Loading...}>} /> - Loading...}>} /> Loading...}>} /> Loading...}>} /> Loading...}>} /> diff --git a/packages/ui/src/components/TopNavigation.tsx b/packages/ui/src/components/TopNavigation.tsx index 2565aa10..1975de72 100644 --- a/packages/ui/src/components/TopNavigation.tsx +++ b/packages/ui/src/components/TopNavigation.tsx @@ -21,13 +21,22 @@ const CreationWizardPopup = lazy(() => import('./CreationWizardPopup').then(m => const TopNavigation = () => { const { user, signOut, roles } = useAuth(); - console.log('user', user); const { fetchProfile, profiles } = useProfiles(); const location = useLocation(); const navigate = useNavigate(); const [searchQuery, setSearchQuery] = useState(''); const searchInputRef = useRef(null); const currentLang = getCurrentLang(); + + // Keep nav search in sync with URL when on /search + useEffect(() => { + if (location.pathname === '/search') { + const params = new URLSearchParams(location.search); + setSearchQuery(params.get('q') || ''); + } else { + setSearchQuery(''); + } + }, [location.pathname, location.search]); const { creationWizardOpen, setCreationWizardOpen, wizardInitialImage, creationWizardMode } = useWizardContext(); // Lazy-load ecommerce cart store to keep the heavy ecommerce bundle out of the initial load diff --git a/packages/ui/src/components/widgets/HomeWidget.tsx b/packages/ui/src/components/widgets/HomeWidget.tsx index 8f82122a..a6db225d 100644 --- a/packages/ui/src/components/widgets/HomeWidget.tsx +++ b/packages/ui/src/components/widgets/HomeWidget.tsx @@ -195,65 +195,83 @@ const HomeWidget: React.FC = ({
- - Latest + + Latest - - Top + + Top - - Categories + + Categories - { - if (!v || v === 'all') handleContentTypeChange(undefined); - else handleContentTypeChange(v as 'posts' | 'pages' | 'pictures' | 'files' | 'places'); - }}> - - All - All - - - - Posts - - - - Pages - - {searchQuery && ( - - - Places - - )} - {isOwnProfile && ( - - - Pictures - - )} - - - Files - - - {isOwnProfile && ( - - - - Invisible - - - - Private - - - )} + {/* Content type + visibility — collapsed into a compact dropdown */} + + + + + + Content type + { + if (v === 'all') handleContentTypeChange(undefined); + else handleContentTypeChange(v as 'posts' | 'pages' | 'pictures' | 'files' | 'places'); + }}> + + All + + + Posts + + + Pages + + {searchQuery && ( + + Places + + )} + {isOwnProfile && ( + + Pictures + + )} + + Files + + + {isOwnProfile && ( + <> + + Visibility + + + All + + + Invisible + + + Private + + + + )} + +
); diff --git a/packages/ui/src/modules/pages/NewPage.tsx b/packages/ui/src/modules/pages/NewPage.tsx index 966f0f79..7a6ab5dc 100644 --- a/packages/ui/src/modules/pages/NewPage.tsx +++ b/packages/ui/src/modules/pages/NewPage.tsx @@ -186,16 +186,8 @@ const NewPage = () => { }; return ( -
+
- {/* Header */} -
- -
- {/* Form Card */} diff --git a/packages/ui/src/modules/pages/PageCard.tsx b/packages/ui/src/modules/pages/PageCard.tsx index 25ebb600..9d1915c9 100644 --- a/packages/ui/src/modules/pages/PageCard.tsx +++ b/packages/ui/src/modules/pages/PageCard.tsx @@ -194,14 +194,6 @@ const PageCard: React.FC = ({
)}
- - {/* Mobile title below thumbnail */} -
-
- {title} -
-
- {/* Info bar below image (preset-driven) */} {(preset?.showTitle || preset?.showDescription) && (title || description) && (
diff --git a/packages/ui/src/modules/posts/views/renderers/components/MobileGroupItem.tsx b/packages/ui/src/modules/posts/views/renderers/components/MobileGroupItem.tsx index 95145692..e1620d74 100644 --- a/packages/ui/src/modules/posts/views/renderers/components/MobileGroupItem.tsx +++ b/packages/ui/src/modules/posts/views/renderers/components/MobileGroupItem.tsx @@ -234,24 +234,21 @@ export const MobileGroupItem: React.FC = ({ {(item.likes_count || 0) > 0 &&
{item.likes_count} likes
} - {(item.title || item.description) && ( + {item.description && (
- {item.title && !isLikelyFilename(item.title) &&
{item.title}
} - {item.description && ( -
-
- -
- {item.description.length > 100 && !expandedDescriptions[item.id] && ( - - )} +
+
+
- )} + {item.description.length > 100 && !expandedDescriptions[item.id] && ( + + )} +
{formatDate(item.created_at)}
diff --git a/packages/ui/src/pages/SearchResults.tsx b/packages/ui/src/pages/SearchResults.tsx index 4c482645..db1c39e7 100644 --- a/packages/ui/src/pages/SearchResults.tsx +++ b/packages/ui/src/pages/SearchResults.tsx @@ -60,9 +60,9 @@ const SearchResults = () => { if (!query.trim()) { return ( -
+
-
+
{ } return ( -
-
+
+