From d07a45da859514af0608c13da5ade955db172442 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 13:58:45 +0000 Subject: [PATCH 1/9] =?UTF-8?q?fix:=20OTP-Login=20lauff=C3=A4hig=20machen?= =?UTF-8?q?=20(verify-otp/send-otp=20konsistent)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root Cause: send-otp speicherte die Telegram request_id in otp_requests und gab nur { ok: true } zurueck, waehrend verify-otp die request_id zwingend im Body erwartete. Das Frontend uebergab undefined -> 400 "Request-ID erforderlich". Deshalb war noch keine Registrierung moeglich (alle Tabellen leer). - verify-otp: request_id aus otp_requests nachschlagen wenn nicht im Body; User per E-Mail-Alias (@phone.kommit.app) finden/anlegen; Session robust ueber properties.hashed_token minten; otp_requests nach Erfolg leeren. - send-otp: gibt request_id zusaetzlich zurueck (Defense-in-Depth), self-contained. - Beide Functions inline-CORS, keine relativen Imports. - Migration 00002: otp_requests-Definition nachgezogen + RLS aktiviert (otp_requests ohne Policy, listings_archive mit Admin-SELECT-Policy). Deployed: verify-otp v4, send-otp v5. https://claude.ai/code/session_01G1v5ZGvnrS5hb6eX2hfxKb --- supabase/functions/send-otp/index.ts | 95 ++++++---- supabase/functions/verify-otp/index.ts | 177 +++++++++++------- .../00002_enable_rls_otp_and_archive.sql | 25 +++ 3 files changed, 190 insertions(+), 107 deletions(-) create mode 100644 supabase/migrations/00002_enable_rls_otp_and_archive.sql diff --git a/supabase/functions/send-otp/index.ts b/supabase/functions/send-otp/index.ts index bcddca3..cd0ab03 100644 --- a/supabase/functions/send-otp/index.ts +++ b/supabase/functions/send-otp/index.ts @@ -1,9 +1,13 @@ -import { serve } from "https://deno.land/std@0.208.0/http/server.ts"; -import { corsHeaders } from "../_shared/cors.ts"; +// supabase/functions/send-otp/index.ts +import { createClient } from "jsr:@supabase/supabase-js@2"; -const TELEGRAM_API_URL = "https://gatewayapi.telegram.org/sendVerificationMessage"; +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": + "authorization, x-client-info, apikey, content-type", +}; -serve(async (req) => { +Deno.serve(async (req) => { if (req.method === "OPTIONS") { return new Response("ok", { headers: corsHeaders }); } @@ -11,51 +15,72 @@ serve(async (req) => { try { const { phone } = await req.json(); - if (!phone || typeof phone !== "string") { + if (!phone || !/^\+\d{8,15}$/.test(phone)) { return new Response( - JSON.stringify({ error: "Telefonnummer ist erforderlich." }), - { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + JSON.stringify({ error: "Ungueltige Telefonnummer" }), + { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }, ); } - const telegramToken = Deno.env.get("TELEGRAM_GATEWAY_TOKEN"); - if (!telegramToken) { - return new Response( - JSON.stringify({ error: "Server-Konfigurationsfehler." }), - { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }, - ); - } - - const response = await fetch(TELEGRAM_API_URL, { - method: "POST", - headers: { - "Authorization": `Bearer ${telegramToken}`, - "Content-Type": "application/json", + // Telegram Gateway aufrufen + const tgResponse = await fetch( + "https://gatewayapi.telegram.org/sendVerificationMessage", + { + method: "POST", + headers: { + "Authorization": `Bearer ${Deno.env.get("TELEGRAM_GATEWAY_TOKEN")}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + phone_number: phone, + code_length: 6, + ttl: 300, // 5 Min gueltig + }), }, - body: JSON.stringify({ - phone_number: phone, - code_length: 6, - ttl: 300, - }), - }); + ); - const data = await response.json(); + const tgData = await tgResponse.json(); - if (!data.ok) { + if (!tgData.ok) { return new Response( - JSON.stringify({ error: data.error || "Code konnte nicht gesendet werden." }), - { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + JSON.stringify({ error: tgData.error || "Telegram-Fehler" }), + { + status: 502, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }, ); } + // request_id temporaer speichern (mit Service-Role-Key, umgeht RLS) + const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!, + ); + + await supabase.from("otp_requests").upsert( + { + phone, + request_id: tgData.result.request_id, + created_at: new Date().toISOString(), + }, + { onConflict: "phone" }, + ); + + // request_id auch zurueckgeben (verify-otp schlaegt sie sonst selbst nach) return new Response( - JSON.stringify({ request_id: data.result.request_id }), - { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + JSON.stringify({ ok: true, request_id: tgData.result.request_id }), + { headers: { ...corsHeaders, "Content-Type": "application/json" } }, ); - } catch (error) { + } catch (err) { return new Response( - JSON.stringify({ error: (error as Error).message }), - { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + JSON.stringify({ error: (err as Error).message }), + { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }, ); } }); diff --git a/supabase/functions/verify-otp/index.ts b/supabase/functions/verify-otp/index.ts index d007b7d..e5bedb2 100644 --- a/supabase/functions/verify-otp/index.ts +++ b/supabase/functions/verify-otp/index.ts @@ -1,113 +1,146 @@ -import { serve } from "https://deno.land/std@0.208.0/http/server.ts"; -import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1"; -import { corsHeaders } from "../_shared/cors.ts"; - -const TELEGRAM_VERIFY_URL = "https://gatewayapi.telegram.org/checkVerificationStatus"; +// supabase/functions/verify-otp/index.ts +import { createClient } from "jsr:@supabase/supabase-js@2"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": + "authorization, x-client-info, apikey, content-type", +}; + +const TELEGRAM_VERIFY_URL = + "https://gatewayapi.telegram.org/checkVerificationStatus"; +const EMAIL_DOMAIN = "phone.kommit.app"; + +function json(payload: unknown, status: number): Response { + return new Response(JSON.stringify(payload), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +} + +function emailForPhone(phone: string): string { + return `${phone.replace(/\D/g, "")}@${EMAIL_DOMAIN}`; +} + +// deno-lint-ignore no-explicit-any +async function findUserByEmail(supabase: any, email: string) { + let page = 1; + const perPage = 200; + while (true) { + const { data, error } = await supabase.auth.admin.listUsers({ + page, + perPage, + }); + if (error) throw error; + // deno-lint-ignore no-explicit-any + const found = data.users.find((u: any) => u.email === email); + if (found) return found; + if (data.users.length < perPage) return null; + page++; + } +} -serve(async (req) => { +Deno.serve(async (req) => { if (req.method === "OPTIONS") { return new Response("ok", { headers: corsHeaders }); } try { - const { phone, code, request_id } = await req.json(); + const body = await req.json(); + const phone: string | undefined = body.phone; + const code: string | undefined = body.code; + let requestId: string | undefined = body.request_id; - if (!phone || !code || !request_id) { - return new Response( - JSON.stringify({ error: "Telefonnummer, Code und Request-ID sind erforderlich." }), - { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }, - ); + if (!phone || !code) { + return json({ error: "Telefonnummer und Code sind erforderlich." }, 400); } const telegramToken = Deno.env.get("TELEGRAM_GATEWAY_TOKEN"); if (!telegramToken) { - return new Response( - JSON.stringify({ error: "Server-Konfigurationsfehler." }), - { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + return json( + { error: "Server-Konfigurationsfehler (Telegram-Token fehlt)." }, + 500, + ); + } + + const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!, + { auth: { autoRefreshToken: false, persistSession: false } }, + ); + + // request_id ggf. aus otp_requests nachschlagen (Frontend uebergibt sie nicht) + if (!requestId) { + const { data: otp } = await supabase + .from("otp_requests") + .select("request_id") + .eq("phone", phone) + .maybeSingle(); + requestId = otp?.request_id; + } + if (!requestId) { + return json( + { error: "Kein Code angefordert. Bitte fordere einen neuen Code an." }, + 400, ); } + // Code bei Telegram Gateway pruefen const verifyResponse = await fetch(TELEGRAM_VERIFY_URL, { method: "POST", headers: { "Authorization": `Bearer ${telegramToken}`, "Content-Type": "application/json", }, - body: JSON.stringify({ - request_id, - code, - }), + body: JSON.stringify({ request_id: requestId, code }), }); - const verifyData = await verifyResponse.json(); - if (!verifyData.ok || verifyData.result?.verification_status?.status !== "code_valid") { - return new Response( - JSON.stringify({ error: "Hm, der Code passt nicht. Nochmal?" }), - { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }, - ); + if ( + !verifyData.ok || + verifyData.result?.verification_status?.status !== "code_valid" + ) { + return json({ error: "Hm, der Code passt nicht. Nochmal?" }, 401); } - const supabaseUrl = Deno.env.get("SUPABASE_URL")!; - const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; - const supabase = createClient(supabaseUrl, supabaseServiceKey, { - auth: { autoRefreshToken: false, persistSession: false }, - }); - - const { data: existingUsers } = await supabase.auth.admin.listUsers(); - const existingUser = existingUsers?.users?.find((u) => u.phone === phone); + const email = emailForPhone(phone); - let session; + // Bestehenden User finden, sonst neu anlegen + const existingUser = await findUserByEmail(supabase, email); let isNewUser = false; - if (existingUser) { - const { data, error } = await supabase.auth.admin.generateLink({ - type: "magiclink", - email: `${phone.replace(/\+/g, "")}@phone.kommit.app`, - }); - if (error) throw error; - - const tokenHash = new URL(data.properties.action_link).searchParams.get("token"); - const { data: sessionData, error: verifyError } = await supabase.auth.verifyOtp({ - token_hash: tokenHash!, - type: "email", - }); - if (verifyError) throw verifyError; - session = sessionData.session; - } else { + if (!existingUser) { isNewUser = true; - const { data, error } = await supabase.auth.admin.createUser({ + const { error: createErr } = await supabase.auth.admin.createUser({ + email, + email_confirm: true, phone, phone_confirm: true, user_metadata: { first_name: "", last_name: "" }, }); - if (error) throw error; + if (createErr) throw createErr; + } - const { data: linkData, error: linkError } = await supabase.auth.admin.generateLink({ - type: "magiclink", - email: `${phone.replace(/\+/g, "")}@phone.kommit.app`, - }); - if (linkError) throw linkError; + // Session per Magiclink-Token minten + const { data: linkData, error: linkErr } = await supabase.auth.admin + .generateLink({ type: "magiclink", email }); + if (linkErr) throw linkErr; - const tokenHash = new URL(linkData.properties.action_link).searchParams.get("token"); - const { data: sessionData, error: verifyError } = await supabase.auth.verifyOtp({ - token_hash: tokenHash!, - type: "email", - }); - if (verifyError) throw verifyError; - session = sessionData.session; + const tokenHash = linkData.properties?.hashed_token; + if (!tokenHash) throw new Error("Session-Token konnte nicht erzeugt werden."); - void data; - } + const { data: sessionData, error: verifyErr } = await supabase.auth + .verifyOtp({ type: "email", token_hash: tokenHash }); + if (verifyErr) throw verifyErr; - return new Response( - JSON.stringify({ session, is_new_user: isNewUser }), - { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + // Verbrauchte OTP-Anfrage entfernen + await supabase.from("otp_requests").delete().eq("phone", phone); + + return json( + { session: sessionData.session, is_new_user: isNewUser }, + 200, ); } catch (error) { - return new Response( - JSON.stringify({ error: (error as Error).message }), - { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }, - ); + return json({ error: (error as Error).message }, 500); } }); diff --git a/supabase/migrations/00002_enable_rls_otp_and_archive.sql b/supabase/migrations/00002_enable_rls_otp_and_archive.sql new file mode 100644 index 0000000..6a5fbbe --- /dev/null +++ b/supabase/migrations/00002_enable_rls_otp_and_archive.sql @@ -0,0 +1,25 @@ +-- ===================================================== +-- Kommit — otp_requests-Tabelle + RLS-Haertung +-- ===================================================== + +-- OTP-Anfragen: haelt pro Telefonnummer die aktuelle Telegram request_id, +-- bis der Code verifiziert wurde. Nur die Edge Functions (Service-Role) +-- greifen darauf zu. +CREATE TABLE IF NOT EXISTS public.otp_requests ( + phone TEXT PRIMARY KEY, + request_id TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- otp_requests wird ausschliesslich von den Edge Functions +-- (send-otp / verify-otp) mit dem Service-Role-Key beschrieben/gelesen. +-- RLS ohne Policies blockiert anon/authenticated, Service-Role umgeht RLS. +ALTER TABLE public.otp_requests ENABLE ROW LEVEL SECURITY; + +-- listings_archive: Cron befuellt per Service-Role; im Frontend liest nur der +-- Admin-Bereich daraus -> RLS an + SELECT-Policy fuer Admins. +ALTER TABLE public.listings_archive ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS archive_select_admin ON public.listings_archive; +CREATE POLICY archive_select_admin ON public.listings_archive + FOR SELECT TO authenticated USING (public.is_admin()); From 2ba97f23dbefecd2b9dce71fd8d438e29d990d24 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 14:11:33 +0000 Subject: [PATCH 2/9] fix: echte Edge-Function-Fehlermeldung im Login anzeigen supabase-js verpackt Function-Fehler in "Edge Function returned a non-2xx status code" und verbirgt den echten Grund. edgeErrorMessage() liest den Fehlertext aus dem Response-Body (error.context) aus, sodass send-otp/ verify-otp-Fehler (z.B. "Kein Code angefordert", "Telegram-Token fehlt") sichtbar werden statt der generischen Meldung. https://claude.ai/code/session_01G1v5ZGvnrS5hb6eX2hfxKb --- src/hooks/use-auth.ts | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index bcf6104..cd78e25 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -18,6 +18,25 @@ interface AuthState { isNewUser: boolean; } +// supabase-js verpackt Edge-Function-Fehler in eine generische Meldung +// ("Edge Function returned a non-2xx status code"). Der echte Fehlertext +// steckt im Response-Body von error.context — den holen wir hier raus. +async function edgeErrorMessage( + error: unknown, + fallback: string, +): Promise { + const ctx = (error as { context?: Response } | null)?.context; + if (ctx && typeof ctx.clone === "function") { + try { + const body = await ctx.clone().json(); + if (body?.error) return String(body.error); + } catch { + // Body ist kein JSON — Fallback auf die generische Meldung + } + } + return (error as Error)?.message || fallback; +} + export function useAuth() { const demo = isDemoMode(); @@ -95,7 +114,11 @@ export function useAuth() { const { data, error } = await supabase.functions.invoke("send-otp", { body: { phone }, }); - if (error) throw new Error(error.message); + if (error) { + throw new Error( + await edgeErrorMessage(error, "Code konnte nicht gesendet werden."), + ); + } return data as { request_id: string }; }, []); @@ -104,7 +127,11 @@ export function useAuth() { const { data, error } = await supabase.functions.invoke("verify-otp", { body: { phone, code, request_id: requestId }, }); - if (error) throw new Error(error.message); + if (error) { + throw new Error( + await edgeErrorMessage(error, "Hm, der Code passt nicht. Nochmal?"), + ); + } if (data.session) { await supabase.auth.setSession({ From e999276ef10c7d919aafd961a2bb021740200c62 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 14:22:45 +0000 Subject: [PATCH 3/9] fix: fehlende otp_requests-Grants fuer service_role ergaenzen Postgres-Log zeigte 42501 "permission denied for table otp_requests": Die manuell angelegte Tabelle hatte der service_role keine Rechte gewaehrt, weshalb send-otp/verify-otp beim Zugriff scheiterten und die Tabelle leer blieb. Migration 00002 gewaehrt jetzt SELECT/INSERT/UPDATE/DELETE an service_role und sperrt anon/authenticated aus. https://claude.ai/code/session_01G1v5ZGvnrS5hb6eX2hfxKb --- .../00002_enable_rls_otp_and_archive.sql | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/supabase/migrations/00002_enable_rls_otp_and_archive.sql b/supabase/migrations/00002_enable_rls_otp_and_archive.sql index 6a5fbbe..5204432 100644 --- a/supabase/migrations/00002_enable_rls_otp_and_archive.sql +++ b/supabase/migrations/00002_enable_rls_otp_and_archive.sql @@ -1,19 +1,25 @@ -- ===================================================== --- Kommit — otp_requests-Tabelle + RLS-Haertung +-- Kommit — otp_requests-Rechte + RLS-Haertung -- ===================================================== -- OTP-Anfragen: haelt pro Telefonnummer die aktuelle Telegram request_id, -- bis der Code verifiziert wurde. Nur die Edge Functions (Service-Role) --- greifen darauf zu. +-- greifen darauf zu. (Tabelle wurde urspruenglich manuell angelegt, ohne +-- die noetigen Grants — daher schlugen send-otp/verify-otp mit 42501 fehl.) CREATE TABLE IF NOT EXISTS public.otp_requests ( phone TEXT PRIMARY KEY, request_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); --- otp_requests wird ausschliesslich von den Edge Functions --- (send-otp / verify-otp) mit dem Service-Role-Key beschrieben/gelesen. --- RLS ohne Policies blockiert anon/authenticated, Service-Role umgeht RLS. +-- service_role (Edge Functions send-otp/verify-otp) braucht vollen Zugriff +GRANT SELECT, INSERT, UPDATE, DELETE ON public.otp_requests TO service_role; + +-- anon/authenticated duerfen NICHT auf otp_requests zugreifen +REVOKE ALL ON public.otp_requests FROM anon, authenticated; + +-- Defense-in-depth: RLS an (service_role umgeht RLS ohnehin, anon/authenticated +-- sind ohne Grant + ohne Policy komplett ausgesperrt) ALTER TABLE public.otp_requests ENABLE ROW LEVEL SECURITY; -- listings_archive: Cron befuellt per Service-Role; im Frontend liest nur der From a82353569b5b5614881648b81e126696fc392bea Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 14:26:39 +0000 Subject: [PATCH 4/9] fix: handle_new_user() gegen search_path absichern (public.profiles) Postgres-Log zeigte 42P01 "relation profiles does not exist" im Trigger handle_new_user beim Anlegen des auth-Users. Die Funktion lief als supabase_auth_admin (search_path ohne public) und referenzierte profiles unqualifiziert. Fix: public.profiles + SET search_path = ''. https://claude.ai/code/session_01G1v5ZGvnrS5hb6eX2hfxKb --- .../00003_fix_handle_new_user_search_path.sql | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 supabase/migrations/00003_fix_handle_new_user_search_path.sql diff --git a/supabase/migrations/00003_fix_handle_new_user_search_path.sql b/supabase/migrations/00003_fix_handle_new_user_search_path.sql new file mode 100644 index 0000000..3f7f581 --- /dev/null +++ b/supabase/migrations/00003_fix_handle_new_user_search_path.sql @@ -0,0 +1,30 @@ +-- ===================================================== +-- Kommit — handle_new_user() robust gegen search_path machen +-- ===================================================== +-- Der Trigger laeuft beim Anlegen eines auth-Users als Rolle +-- supabase_auth_admin, deren search_path public NICHT enthaelt. Die +-- unqualifizierte Referenz auf "profiles" schlug daher mit 42P01 +-- ("relation profiles does not exist") fehl. +-- +-- Fix: Tabelle voll qualifizieren (public.profiles) UND search_path der +-- Funktion fest auf '' setzen (pg_catalog bleibt implizit verfuegbar, +-- alle uebrigen Referenzen sind qualifiziert). Behebt zugleich die +-- function_search_path_mutable-Sicherheitswarnung fuer diese Funktion. +CREATE OR REPLACE FUNCTION public.handle_new_user() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + INSERT INTO public.profiles (id, first_name, last_name, phone, avatar_color) + VALUES ( + NEW.id, + COALESCE(NEW.raw_user_meta_data->>'first_name', ''), + COALESCE(NEW.raw_user_meta_data->>'last_name', ''), + NEW.phone, + '#' || lpad(to_hex(abs(hashtext(NEW.id::text)) % 16777215), 6, '0') + ); + RETURN NEW; +END; +$$; From 7ea61f5c3bd921ad1bcb82d421fcfce8750b6dfd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 15:14:28 +0000 Subject: [PATCH 5/9] fix: supabase-js Deadlock in onAuthStateChange beheben Nach erfolgreichem verify-otp blieb der Login auf "Wird geprueft..." haengen. Ursache: Der onAuthStateChange-Callback war async und rief direkt await fetchProfile() (supabase.from) auf. Der Callback haelt den internen Auth-Lock, den supabase.from zum Anhaengen des Tokens braucht -> Deadlock, setSession() wurde nie fertig. Fix: Callback synchron machen, Session sofort setzen (isAuthenticated wird true), Profil per setTimeout(0) ausserhalb des Locks nachladen. https://claude.ai/code/session_01G1v5ZGvnrS5hb6eX2hfxKb --- src/hooks/use-auth.ts | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index cd78e25..b3c0f9c 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -86,16 +86,27 @@ export function useAuth() { const { data: { subscription }, - } = supabase.auth.onAuthStateChange(async (_event, session) => { - if (session?.user) { - const profile = await fetchProfile(session.user.id); - setState({ + } = supabase.auth.onAuthStateChange((_event, session) => { + // WICHTIG: keine awaitenden supabase-Aufrufe direkt im Callback — + // der Auth-Lock wird sonst nicht freigegeben und supabase.from(...) + // deadlockt. Session sofort (synchron) setzen, Profil verzoegert laden. + const user = session?.user; + if (user) { + setState((s) => ({ + ...s, session, - user: session.user, - profile, + user, loading: false, - isNewUser: !profile?.first_name, - }); + })); + setTimeout(() => { + fetchProfile(user.id).then((profile) => { + setState((s) => ({ + ...s, + profile, + isNewUser: !profile?.first_name, + })); + }); + }, 0); } else { setState({ session: null, From fc135348d2bb32522e3e6e1216f3956c8b913b13 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 15:15:55 +0000 Subject: [PATCH 6/9] fix: neuen User zuverlaessig zum Profil-Setup leiten Bei frischem Login (SIGNED_IN/INITIAL_SESSION) bleibt loading true, bis das Profil geladen ist. Sonst koennte ein neuer User mit noch unbekanntem isNewUser kurz zur Liste navigiert werden statt zum Profil-Setup. Bei Token-Refresh wird loading nicht angefasst (kein Spinner-Flackern). https://claude.ai/code/session_01G1v5ZGvnrS5hb6eX2hfxKb --- src/hooks/use-auth.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index b3c0f9c..5d52a00 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -86,17 +86,21 @@ export function useAuth() { const { data: { subscription }, - } = supabase.auth.onAuthStateChange((_event, session) => { + } = supabase.auth.onAuthStateChange((event, session) => { // WICHTIG: keine awaitenden supabase-Aufrufe direkt im Callback — // der Auth-Lock wird sonst nicht freigegeben und supabase.from(...) // deadlockt. Session sofort (synchron) setzen, Profil verzoegert laden. const user = session?.user; if (user) { + // Bei frischem Login loading true halten, bis das Profil geladen ist — + // sonst wuerde ein neuer User (isNewUser noch nicht bekannt) kurz zur + // Liste navigiert statt zum Profil-Setup. Bei Token-Refresh kein Flackern. + const freshLogin = event === "SIGNED_IN" || event === "INITIAL_SESSION"; setState((s) => ({ ...s, session, user, - loading: false, + loading: freshLogin ? true : s.loading, })); setTimeout(() => { fetchProfile(user.id).then((profile) => { @@ -104,6 +108,7 @@ export function useAuth() { ...s, profile, isNewUser: !profile?.first_name, + loading: false, })); }); }, 0); From 1ada7572bc0a4a95341cfeec3fc47a7779fedaed Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 15:23:46 +0000 Subject: [PATCH 7/9] fix: Tabellen-Grants fuer alle App-Tabellen setzen Nach dem otp_requests-Grant kam 42501 "permission denied for table profiles": der Rolle authenticated fehlten die table-level Grants auf profiles (und analog listings/push_subscriptions/listings_archive). Postgres prueft Tabellenrechte vor RLS, daher scheiterte der Zugriff trotz vorhandener Policies. Migration 00004 setzt die Grants fuer authenticated + service_role und ergaenzt ALTER DEFAULT PRIVILEGES fuer kuenftige Tabellen. https://claude.ai/code/session_01G1v5ZGvnrS5hb6eX2hfxKb --- .../00004_grant_table_privileges.sql | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 supabase/migrations/00004_grant_table_privileges.sql diff --git a/supabase/migrations/00004_grant_table_privileges.sql b/supabase/migrations/00004_grant_table_privileges.sql new file mode 100644 index 0000000..c7f1b48 --- /dev/null +++ b/supabase/migrations/00004_grant_table_privileges.sql @@ -0,0 +1,33 @@ +-- ===================================================== +-- Kommit — fehlende Tabellen-Grants fuer alle App-Tabellen +-- ===================================================== +-- Die Tabellen wurden urspruenglich angelegt, ohne die Supabase-Standard- +-- Grants an die PostgREST-Rollen zu vergeben. Postgres prueft Tabellen- +-- Privilegien VOR den RLS-Policies -> trotz vorhandener Policies scheiterten +-- Zugriffe mit 42501 "permission denied" (zuerst otp_requests, dann profiles, +-- als naechstes waeren listings/push_subscriptions drangewesen). +-- +-- Loesung: table-level Grants setzen. Die RLS-Policies schraenken die +-- tatsaechlich sichtbaren/aenderbaren Zeilen weiterhin ein. + +-- authenticated: darf auf die App-Tabellen zugreifen (RLS regelt die Zeilen) +GRANT SELECT, INSERT, UPDATE, DELETE ON public.profiles TO authenticated; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.listings TO authenticated; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.push_subscriptions TO authenticated; +GRANT SELECT ON public.listings_archive TO authenticated; + +-- service_role: voller Zugriff (Edge Functions + Cron), umgeht RLS ohnehin +GRANT ALL ON public.otp_requests TO service_role; +GRANT ALL ON public.profiles TO service_role; +GRANT ALL ON public.listings TO service_role; +GRANT ALL ON public.listings_archive TO service_role; +GRANT ALL ON public.push_subscriptions TO service_role; + +-- otp_requests bleibt fuer anon/authenticated gesperrt (nur Edge Functions) +REVOKE ALL ON public.otp_requests FROM anon, authenticated; + +-- Kuenftig angelegte Tabellen automatisch mit denselben Grants versehen +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO authenticated; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT ALL ON TABLES TO service_role; From 27dd38e7b6a4e02c3840f27a94def3b4f8d7fda5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 15:35:00 +0000 Subject: [PATCH 8/9] fix: Settings-Toggles schalten sofort um (Profil-State aktualisieren) updateProfile schrieb bisher nur in die DB, ohne den lokalen Profil-State im Auth-Context zu aendern -> der "Telefonnummer anzeigen"-Toggle sprang optisch nicht um. updateProfile liegt jetzt in useAuth und aktualisiert den State optimistisch (demo-faehig, mit Rollback bei Fehler). Vorbereitet fuer avatar_url/avatar_color. https://claude.ai/code/session_01G1v5ZGvnrS5hb6eX2hfxKb --- src/hooks/use-auth.ts | 32 ++++++++++++++++++++++++++++++++ src/hooks/use-profile.ts | 29 +---------------------------- 2 files changed, 33 insertions(+), 28 deletions(-) diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 5d52a00..dfcbeff 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -179,6 +179,37 @@ export function useAuth() { [state.user, fetchProfile], ); + const updateProfile = useCallback( + async (updates: { + first_name?: string; + last_name?: string; + show_phone?: boolean; + avatar_url?: string | null; + avatar_color?: string; + }) => { + // Optimistisch: lokalen State sofort aktualisieren, damit die UI + // (z.B. Toggles) unmittelbar umschaltet. + setState((s) => + s.profile ? { ...s, profile: { ...s.profile, ...updates } } : s, + ); + + if (demo) return; // Demo: nur lokal, keine DB-Schreibung + + if (!state.user) throw new Error("Nicht eingeloggt."); + const { error } = await supabase + .from("profiles") + .update(updates) + .eq("id", state.user.id); + if (error) { + // Rollback der optimistischen Aenderung + const profile = await fetchProfile(state.user.id); + setState((s) => ({ ...s, profile })); + throw error; + } + }, + [demo, state.user, fetchProfile], + ); + const signOut = useCallback(async () => { if (demo) { disableDemoMode(); @@ -193,6 +224,7 @@ export function useAuth() { sendOtp, verifyOtp, completeProfile, + updateProfile, signOut, isAuthenticated: !!state.session, }; diff --git a/src/hooks/use-profile.ts b/src/hooks/use-profile.ts index b9e8b40..6c27cde 100644 --- a/src/hooks/use-profile.ts +++ b/src/hooks/use-profile.ts @@ -1,33 +1,6 @@ -import { useCallback } from "react"; -import { supabase } from "@/lib/supabase"; import { useAuthContext } from "@/hooks/use-auth-context"; -import { isDemoMode } from "@/lib/demo"; export function useProfile() { - const { profile, user } = useAuthContext(); - - const updateProfile = useCallback( - async (updates: { - first_name?: string; - last_name?: string; - show_phone?: boolean; - }) => { - if (!user) throw new Error("Nicht eingeloggt."); - - if (isDemoMode()) { - // Aenderungen verfallen mit Reload — Demo-Profile bleibt unveraendert - return; - } - - const { error } = await supabase - .from("profiles") - .update(updates) - .eq("id", user.id); - - if (error) throw error; - }, - [user], - ); - + const { profile, updateProfile } = useAuthContext(); return { profile, updateProfile }; } From 76d709d149dc6ba7b9125648729e97d748ff01b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 15:42:53 +0000 Subject: [PATCH 9/9] =?UTF-8?q?feat:=20Profilbild=20=E2=80=94=20Foto-Uploa?= =?UTF-8?q?d/-Aufnahme=20+=20illustrierte=20Avatar-Galerie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - profiles.avatar_url (Migration 00005) + Storage-Bucket "avatars" mit Policies (public read, User schreibt nur eigenen Ordner) - 8 gebuendelte illustrierte Avatare (public/avatars/) + lib/avatars.ts - Avatar-Komponente rendert Bild bei avatar_url, sonst Initialen - AvatarPicker (Bottom-Sheet): Foto hochladen/aufnehmen (Canvas-Resize auf 256px, Upload nach Storage bzw. DataURL im Demo), Galerie-Auswahl, Entfernen - Kamera-Button auf dem Profil-Avatar oeffnet den Picker - Avatare ueberall angezeigt (Header, Liste, Detail); Demo-Profile mit Presets - eslint: supabase/functions (Deno) vom Frontend-Lint ausgeschlossen Setup: Migration 00005 im Dashboard ausfuehren (Spalte + Bucket + Policies). https://claude.ai/code/session_01G1v5ZGvnrS5hb6eX2hfxKb --- eslint.config.js | 4 +- public/avatars/avatar-1.svg | 10 + public/avatars/avatar-2.svg | 10 + public/avatars/avatar-3.svg | 10 + public/avatars/avatar-4.svg | 10 + public/avatars/avatar-5.svg | 10 + public/avatars/avatar-6.svg | 10 + public/avatars/avatar-7.svg | 9 + public/avatars/avatar-8.svg | 10 + src/components/common/avatar.tsx | 17 ++ src/components/layout/header.tsx | 1 + src/components/listing/listing-card.tsx | 1 + src/components/listing/listing-detail.tsx | 1 + src/components/profile/avatar-picker.tsx | 204 ++++++++++++++++++ src/components/profile/profile-view.tsx | 28 ++- src/lib/avatars.ts | 16 ++ src/lib/demo.ts | 5 + src/types/database.ts | 3 + .../00005_avatar_url_and_storage.sql | 42 ++++ 19 files changed, 393 insertions(+), 8 deletions(-) create mode 100644 public/avatars/avatar-1.svg create mode 100644 public/avatars/avatar-2.svg create mode 100644 public/avatars/avatar-3.svg create mode 100644 public/avatars/avatar-4.svg create mode 100644 public/avatars/avatar-5.svg create mode 100644 public/avatars/avatar-6.svg create mode 100644 public/avatars/avatar-7.svg create mode 100644 public/avatars/avatar-8.svg create mode 100644 src/components/profile/avatar-picker.tsx create mode 100644 src/lib/avatars.ts create mode 100644 supabase/migrations/00005_avatar_url_and_storage.sql diff --git a/eslint.config.js b/eslint.config.js index 79a552e..7b7d795 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,7 +5,9 @@ import reactRefresh from "eslint-plugin-react-refresh"; import tseslint from "typescript-eslint"; export default tseslint.config( - { ignores: ["dist"] }, + // supabase/functions ist Deno-Code (eigene Runtime/Globals) — nicht mit der + // Browser/React-ESLint-Config linten. + { ignores: ["dist", "supabase/functions"] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ["**/*.{ts,tsx}"], diff --git a/public/avatars/avatar-1.svg b/public/avatars/avatar-1.svg new file mode 100644 index 0000000..b6fa070 --- /dev/null +++ b/public/avatars/avatar-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/avatars/avatar-2.svg b/public/avatars/avatar-2.svg new file mode 100644 index 0000000..64f6c08 --- /dev/null +++ b/public/avatars/avatar-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/avatars/avatar-3.svg b/public/avatars/avatar-3.svg new file mode 100644 index 0000000..9d8906a --- /dev/null +++ b/public/avatars/avatar-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/avatars/avatar-4.svg b/public/avatars/avatar-4.svg new file mode 100644 index 0000000..5fe1eca --- /dev/null +++ b/public/avatars/avatar-4.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/avatars/avatar-5.svg b/public/avatars/avatar-5.svg new file mode 100644 index 0000000..a046df0 --- /dev/null +++ b/public/avatars/avatar-5.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/avatars/avatar-6.svg b/public/avatars/avatar-6.svg new file mode 100644 index 0000000..fb8754c --- /dev/null +++ b/public/avatars/avatar-6.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/avatars/avatar-7.svg b/public/avatars/avatar-7.svg new file mode 100644 index 0000000..93504f4 --- /dev/null +++ b/public/avatars/avatar-7.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/avatars/avatar-8.svg b/public/avatars/avatar-8.svg new file mode 100644 index 0000000..b342119 --- /dev/null +++ b/public/avatars/avatar-8.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/components/common/avatar.tsx b/src/components/common/avatar.tsx index c98512d..ebcf07f 100644 --- a/src/components/common/avatar.tsx +++ b/src/components/common/avatar.tsx @@ -4,6 +4,7 @@ interface AvatarProps { firstName: string; lastName: string; color: string; + avatarUrl?: string | null; size?: "sm" | "md" | "lg"; className?: string; } @@ -18,12 +19,28 @@ export function Avatar({ firstName, lastName, color, + avatarUrl, size = "md", className, }: AvatarProps) { const initials = `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase() || "?"; + if (avatarUrl) { + return ( + + ); + } + return (
diff --git a/src/components/listing/listing-card.tsx b/src/components/listing/listing-card.tsx index fc713a5..f6b79f0 100644 --- a/src/components/listing/listing-card.tsx +++ b/src/components/listing/listing-card.tsx @@ -26,6 +26,7 @@ export function ListingCard({ listing, profile }: ListingCardProps) { firstName={profile?.first_name ?? "?"} lastName={profile?.last_name ?? ""} color={profile?.avatar_color ?? "#ccc"} + avatarUrl={profile?.avatar_url} />
diff --git a/src/components/listing/listing-detail.tsx b/src/components/listing/listing-detail.tsx index 6cb7cc2..59124f5 100644 --- a/src/components/listing/listing-detail.tsx +++ b/src/components/listing/listing-detail.tsx @@ -63,6 +63,7 @@ export function ListingDetail({ firstName={profile.first_name} lastName={profile.last_name} color={profile.avatar_color} + avatarUrl={profile.avatar_url} size="lg" />
diff --git a/src/components/profile/avatar-picker.tsx b/src/components/profile/avatar-picker.tsx new file mode 100644 index 0000000..561ef22 --- /dev/null +++ b/src/components/profile/avatar-picker.tsx @@ -0,0 +1,204 @@ +import { useRef, useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { Camera, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Avatar } from "@/components/common/avatar"; +import { useProfile } from "@/hooks/use-profile"; +import { useAuthContext } from "@/hooks/use-auth-context"; +import { supabase } from "@/lib/supabase"; +import { isDemoMode } from "@/lib/demo"; +import { PRESET_AVATARS } from "@/lib/avatars"; + +interface AvatarPickerProps { + open: boolean; + onClose: () => void; +} + +const MAX_SIZE = 256; + +async function resizeToBlob(file: File): Promise { + const bitmap = await createImageBitmap(file); + const scale = Math.min(1, MAX_SIZE / Math.max(bitmap.width, bitmap.height)); + const w = Math.round(bitmap.width * scale); + const h = Math.round(bitmap.height * scale); + const canvas = document.createElement("canvas"); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Canvas nicht verfuegbar."); + ctx.drawImage(bitmap, 0, 0, w, h); + return await new Promise((resolve, reject) => { + canvas.toBlob( + (b) => (b ? resolve(b) : reject(new Error("Bild konnte nicht verarbeitet werden."))), + "image/jpeg", + 0.85, + ); + }); +} + +function blobToDataUrl(blob: Blob): Promise { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.readAsDataURL(blob); + }); +} + +export function AvatarPicker({ open, onClose }: AvatarPickerProps) { + const { profile, updateProfile } = useProfile(); + const { user } = useAuthContext(); + const fileRef = useRef(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + if (!profile) return null; + + const handleFile = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ""; // gleicher File erneut waehlbar + if (!file) return; + + setError(""); + setBusy(true); + try { + const blob = await resizeToBlob(file); + + if (isDemoMode() || !user) { + const dataUrl = await blobToDataUrl(blob); + await updateProfile({ avatar_url: dataUrl }); + } else { + const path = `${user.id}/avatar.jpg`; + const { error: upErr } = await supabase.storage + .from("avatars") + .upload(path, blob, { upsert: true, contentType: "image/jpeg" }); + if (upErr) throw upErr; + const { + data: { publicUrl }, + } = supabase.storage.from("avatars").getPublicUrl(path); + // Cache-Busting, da der Pfad gleich bleibt + await updateProfile({ avatar_url: `${publicUrl}?t=${Date.now()}` }); + } + onClose(); + } catch (err) { + setError((err as Error).message || "Upload fehlgeschlagen."); + } finally { + setBusy(false); + } + }; + + const pickPreset = async (url: string) => { + setError(""); + setBusy(true); + try { + await updateProfile({ avatar_url: url }); + onClose(); + } catch (err) { + setError((err as Error).message || "Konnte nicht gespeichert werden."); + } finally { + setBusy(false); + } + }; + + const removeAvatar = async () => { + setError(""); + setBusy(true); + try { + await updateProfile({ avatar_url: null }); + onClose(); + } catch (err) { + setError((err as Error).message || "Konnte nicht entfernt werden."); + } finally { + setBusy(false); + } + }; + + return ( + + {open && ( + <> + + +
+

Profilbild

+ +
+ +
+ + + + +

+ oder Avatar waehlen +

+
+ {PRESET_AVATARS.map((url) => { + const active = profile.avatar_url === url; + return ( + + ); + })} +
+ + {profile.avatar_url && ( + + )} + + {error && ( +

{error}

+ )} + + + )} + + ); +} diff --git a/src/components/profile/profile-view.tsx b/src/components/profile/profile-view.tsx index 6f88347..1b5da37 100644 --- a/src/components/profile/profile-view.tsx +++ b/src/components/profile/profile-view.tsx @@ -1,9 +1,10 @@ import { useState } from "react"; -import { Check, LogOut, Pencil } from "lucide-react"; +import { Camera, Check, LogOut, Pencil } from "lucide-react"; import { Card } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Avatar } from "@/components/common/avatar"; +import { AvatarPicker } from "@/components/profile/avatar-picker"; import { formatPhone } from "@/lib/format"; import type { Profile } from "@/types"; @@ -18,6 +19,7 @@ export function ProfileView({ profile, onUpdate, onSignOut }: ProfileViewProps) const [firstName, setFirstName] = useState(profile.first_name); const [lastName, setLastName] = useState(profile.last_name); const [saving, setSaving] = useState(false); + const [pickerOpen, setPickerOpen] = useState(false); const handleSave = async () => { setSaving(true); @@ -32,12 +34,24 @@ export function ProfileView({ profile, onUpdate, onSignOut }: ProfileViewProps) return (
- + + setPickerOpen(false)} /> {editing ? (
diff --git a/src/lib/avatars.ts b/src/lib/avatars.ts new file mode 100644 index 0000000..d46d88c --- /dev/null +++ b/src/lib/avatars.ts @@ -0,0 +1,16 @@ +// Vordefinierte, gebuendelte Avatar-Illustrationen (liegen in public/avatars/). +// avatar_url speichert einfach den Pfad; die Avatar-Komponente rendert ihn als . +export const PRESET_AVATARS = [ + "/avatars/avatar-1.svg", + "/avatars/avatar-2.svg", + "/avatars/avatar-3.svg", + "/avatars/avatar-4.svg", + "/avatars/avatar-5.svg", + "/avatars/avatar-6.svg", + "/avatars/avatar-7.svg", + "/avatars/avatar-8.svg", +] as const; + +export function isPresetAvatar(url: string | null | undefined): boolean { + return !!url && url.startsWith("/avatars/"); +} diff --git a/src/lib/demo.ts b/src/lib/demo.ts index 33512f8..21b95ab 100644 --- a/src/lib/demo.ts +++ b/src/lib/demo.ts @@ -57,6 +57,7 @@ export const DEMO_PROFILE: Profile = { telegram_chat_id: null, role: "admin", avatar_color: "#FF8A4C", + avatar_url: "/avatars/avatar-1.svg", created_at: daysAgo(120), updated_at: daysAgo(1), }; @@ -71,6 +72,7 @@ const OTHER_PROFILES: Profile[] = [ telegram_chat_id: null, role: "user", avatar_color: "#3DA9FC", + avatar_url: "/avatars/avatar-2.svg", created_at: daysAgo(80), updated_at: daysAgo(2), }, @@ -83,6 +85,7 @@ const OTHER_PROFILES: Profile[] = [ telegram_chat_id: null, role: "user", avatar_color: "#10B981", + avatar_url: "/avatars/avatar-4.svg", created_at: daysAgo(45), updated_at: daysAgo(5), }, @@ -95,6 +98,7 @@ const OTHER_PROFILES: Profile[] = [ telegram_chat_id: null, role: "user", avatar_color: "#F97066", + avatar_url: "/avatars/avatar-3.svg", created_at: daysAgo(15), updated_at: daysAgo(3), }, @@ -107,6 +111,7 @@ const OTHER_PROFILES: Profile[] = [ telegram_chat_id: null, role: "user", avatar_color: "#A855F7", + avatar_url: "/avatars/avatar-5.svg", created_at: daysAgo(30), updated_at: daysAgo(4), }, diff --git a/src/types/database.ts b/src/types/database.ts index 3175ec6..e120373 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -14,6 +14,7 @@ export interface Database { telegram_chat_id: number | null; role: UserRole; avatar_color: string; + avatar_url: string | null; created_at: string; updated_at: string; }; @@ -26,6 +27,7 @@ export interface Database { telegram_chat_id?: number | null; role?: UserRole; avatar_color: string; + avatar_url?: string | null; created_at?: string; updated_at?: string; }; @@ -36,6 +38,7 @@ export interface Database { telegram_chat_id?: number | null; role?: UserRole; avatar_color?: string; + avatar_url?: string | null; }; }; listings: { diff --git a/supabase/migrations/00005_avatar_url_and_storage.sql b/supabase/migrations/00005_avatar_url_and_storage.sql new file mode 100644 index 0000000..422e05e --- /dev/null +++ b/supabase/migrations/00005_avatar_url_and_storage.sql @@ -0,0 +1,42 @@ +-- ===================================================== +-- Kommit — Profilbild: avatar_url-Spalte + Storage-Bucket +-- ===================================================== + +-- Spalte fuer die Bild-URL (Preset-Pfad oder hochgeladenes Foto). NULL = Initialen. +ALTER TABLE public.profiles ADD COLUMN IF NOT EXISTS avatar_url TEXT; + +-- Oeffentlich lesbarer Bucket fuer hochgeladene Profilfotos +INSERT INTO storage.buckets (id, name, public) +VALUES ('avatars', 'avatars', true) +ON CONFLICT (id) DO NOTHING; + +-- Policies auf storage.objects fuer den avatars-Bucket: +-- Jeder darf lesen (public), authentifizierte Nutzer nur ihren eigenen Ordner +-- (Pfad: /avatar.jpg) schreiben/aktualisieren/loeschen. +DROP POLICY IF EXISTS "avatars_public_read" ON storage.objects; +CREATE POLICY "avatars_public_read" ON storage.objects + FOR SELECT USING (bucket_id = 'avatars'); + +DROP POLICY IF EXISTS "avatars_user_insert" ON storage.objects; +CREATE POLICY "avatars_user_insert" ON storage.objects + FOR INSERT TO authenticated + WITH CHECK ( + bucket_id = 'avatars' + AND (storage.foldername(name))[1] = auth.uid()::text + ); + +DROP POLICY IF EXISTS "avatars_user_update" ON storage.objects; +CREATE POLICY "avatars_user_update" ON storage.objects + FOR UPDATE TO authenticated + USING ( + bucket_id = 'avatars' + AND (storage.foldername(name))[1] = auth.uid()::text + ); + +DROP POLICY IF EXISTS "avatars_user_delete" ON storage.objects; +CREATE POLICY "avatars_user_delete" ON storage.objects + FOR DELETE TO authenticated + USING ( + bucket_id = 'avatars' + AND (storage.foldername(name))[1] = auth.uid()::text + );