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
+ );