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/hooks/use-auth.ts b/src/hooks/use-auth.ts index bcf6104..dfcbeff 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(); @@ -67,16 +86,32 @@ 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) { + // 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: session.user, - profile, - loading: false, - isNewUser: !profile?.first_name, - }); + user, + loading: freshLogin ? true : s.loading, + })); + setTimeout(() => { + fetchProfile(user.id).then((profile) => { + setState((s) => ({ + ...s, + profile, + isNewUser: !profile?.first_name, + loading: false, + })); + }); + }, 0); } else { setState({ session: null, @@ -95,7 +130,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 +143,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({ @@ -136,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(); @@ -150,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 }; } 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/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..5204432 --- /dev/null +++ b/supabase/migrations/00002_enable_rls_otp_and_archive.sql @@ -0,0 +1,31 @@ +-- ===================================================== +-- 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. (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() +); + +-- 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 +-- 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()); 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; +$$; 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; 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 + );