Added full profile uploads
X-Lovable-Edit-ID: edt-2292e8de-6ff4-48ea-84ad-23dc6978a9ec Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import {
|
||||
Briefcase,
|
||||
Users,
|
||||
@@ -17,6 +20,11 @@ import type { ReactNode } from "react";
|
||||
import { useBubbleCounts } from "@/lib/use-bubble-counts";
|
||||
import { NotificationBell } from "@/components/notifications/notification-bell";
|
||||
|
||||
function initials(name: string, email: string) {
|
||||
const src = (name || email || "").trim();
|
||||
return src.split(/\s+/).map((p) => p[0]).filter(Boolean).slice(0, 2).join("").toUpperCase();
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
@@ -49,6 +57,33 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const counts = useBubbleCounts();
|
||||
const [profile, setProfile] = useState<{ full_name: string; avatar_url: string | null } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
supabase
|
||||
.from("profiles")
|
||||
.select("full_name, avatar_url")
|
||||
.eq("id", user.id)
|
||||
.maybeSingle()
|
||||
.then(({ data }) => {
|
||||
if (data) setProfile({ full_name: data.full_name || "", avatar_url: (data as any).avatar_url ?? null });
|
||||
});
|
||||
const ch = supabase
|
||||
.channel(`profile-self-${user.id}`)
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{ event: "UPDATE", schema: "public", table: "profiles", filter: `id=eq.${user.id}` },
|
||||
(payload) => {
|
||||
const r = payload.new as any;
|
||||
setProfile({ full_name: r.full_name || "", avatar_url: r.avatar_url ?? null });
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
return () => {
|
||||
supabase.removeChannel(ch);
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut();
|
||||
@@ -95,10 +130,25 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
</nav>
|
||||
|
||||
<div className="px-4 py-4 border-t border-sidebar-border">
|
||||
<div className="text-xs text-sidebar-foreground/70 truncate mb-0.5">{user?.email}</div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-sidebar-foreground/50 mb-3">
|
||||
{roles.join(" · ") || "no role"}
|
||||
</div>
|
||||
<Link
|
||||
to="/settings/profile"
|
||||
className="flex items-center gap-2.5 mb-3 group rounded-md -mx-1 px-1 py-1 hover:bg-sidebar-accent/60"
|
||||
>
|
||||
<Avatar className="h-9 w-9">
|
||||
{profile?.avatar_url ? <AvatarImage src={profile.avatar_url} alt="" /> : null}
|
||||
<AvatarFallback className="text-[11px] bg-sidebar-accent text-sidebar-accent-foreground">
|
||||
{initials(profile?.full_name || "", user?.email || "")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1 leading-tight">
|
||||
<div className="text-xs font-medium truncate text-sidebar-foreground group-hover:text-sidebar-accent-foreground">
|
||||
{profile?.full_name || user?.email}
|
||||
</div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-sidebar-foreground/50 truncate">
|
||||
{roles.join(" · ") || "no role"}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -1746,27 +1746,42 @@ export type Database = {
|
||||
}
|
||||
profiles: {
|
||||
Row: {
|
||||
avatar_url: string | null
|
||||
bio: string | null
|
||||
created_at: string
|
||||
email: string
|
||||
full_name: string
|
||||
hourly_rate: number | null
|
||||
id: string
|
||||
phone: string | null
|
||||
timezone: string | null
|
||||
title: string | null
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
avatar_url?: string | null
|
||||
bio?: string | null
|
||||
created_at?: string
|
||||
email?: string
|
||||
full_name?: string
|
||||
hourly_rate?: number | null
|
||||
id: string
|
||||
phone?: string | null
|
||||
timezone?: string | null
|
||||
title?: string | null
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
avatar_url?: string | null
|
||||
bio?: string | null
|
||||
created_at?: string
|
||||
email?: string
|
||||
full_name?: string
|
||||
hourly_rate?: number | null
|
||||
id?: string
|
||||
phone?: string | null
|
||||
timezone?: string | null
|
||||
title?: string | null
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: []
|
||||
|
||||
@@ -26,6 +26,7 @@ import { Route as ClientsIndexRouteImport } from './routes/clients.index'
|
||||
import { Route as CasesIndexRouteImport } from './routes/cases.index'
|
||||
import { Route as SettingsWorkflowsRouteImport } from './routes/settings.workflows'
|
||||
import { Route as SettingsWorkflowRouteImport } from './routes/settings.workflow'
|
||||
import { Route as SettingsProfileRouteImport } from './routes/settings.profile'
|
||||
import { Route as SettingsFeesRouteImport } from './routes/settings.fees'
|
||||
import { Route as InvoicesInvoiceIdRouteImport } from './routes/invoices.$invoiceId'
|
||||
import { Route as ContactsContactIdRouteImport } from './routes/contacts.$contactId'
|
||||
@@ -124,6 +125,11 @@ const SettingsWorkflowRoute = SettingsWorkflowRouteImport.update({
|
||||
path: '/workflow',
|
||||
getParentRoute: () => SettingsRoute,
|
||||
} as any)
|
||||
const SettingsProfileRoute = SettingsProfileRouteImport.update({
|
||||
id: '/profile',
|
||||
path: '/profile',
|
||||
getParentRoute: () => SettingsRoute,
|
||||
} as any)
|
||||
const SettingsFeesRoute = SettingsFeesRouteImport.update({
|
||||
id: '/fees',
|
||||
path: '/fees',
|
||||
@@ -199,6 +205,7 @@ export interface FileRoutesByFullPath {
|
||||
'/contacts/$contactId': typeof ContactsContactIdRoute
|
||||
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
|
||||
'/settings/fees': typeof SettingsFeesRoute
|
||||
'/settings/profile': typeof SettingsProfileRoute
|
||||
'/settings/workflow': typeof SettingsWorkflowRoute
|
||||
'/settings/workflows': typeof SettingsWorkflowsRoute
|
||||
'/cases/': typeof CasesIndexRoute
|
||||
@@ -229,6 +236,7 @@ export interface FileRoutesByTo {
|
||||
'/contacts/$contactId': typeof ContactsContactIdRoute
|
||||
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
|
||||
'/settings/fees': typeof SettingsFeesRoute
|
||||
'/settings/profile': typeof SettingsProfileRoute
|
||||
'/settings/workflow': typeof SettingsWorkflowRoute
|
||||
'/settings/workflows': typeof SettingsWorkflowsRoute
|
||||
'/cases': typeof CasesIndexRoute
|
||||
@@ -261,6 +269,7 @@ export interface FileRoutesById {
|
||||
'/contacts/$contactId': typeof ContactsContactIdRoute
|
||||
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
|
||||
'/settings/fees': typeof SettingsFeesRoute
|
||||
'/settings/profile': typeof SettingsProfileRoute
|
||||
'/settings/workflow': typeof SettingsWorkflowRoute
|
||||
'/settings/workflows': typeof SettingsWorkflowsRoute
|
||||
'/cases/': typeof CasesIndexRoute
|
||||
@@ -294,6 +303,7 @@ export interface FileRouteTypes {
|
||||
| '/contacts/$contactId'
|
||||
| '/invoices/$invoiceId'
|
||||
| '/settings/fees'
|
||||
| '/settings/profile'
|
||||
| '/settings/workflow'
|
||||
| '/settings/workflows'
|
||||
| '/cases/'
|
||||
@@ -324,6 +334,7 @@ export interface FileRouteTypes {
|
||||
| '/contacts/$contactId'
|
||||
| '/invoices/$invoiceId'
|
||||
| '/settings/fees'
|
||||
| '/settings/profile'
|
||||
| '/settings/workflow'
|
||||
| '/settings/workflows'
|
||||
| '/cases'
|
||||
@@ -355,6 +366,7 @@ export interface FileRouteTypes {
|
||||
| '/contacts/$contactId'
|
||||
| '/invoices/$invoiceId'
|
||||
| '/settings/fees'
|
||||
| '/settings/profile'
|
||||
| '/settings/workflow'
|
||||
| '/settings/workflows'
|
||||
| '/cases/'
|
||||
@@ -523,6 +535,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof SettingsWorkflowRouteImport
|
||||
parentRoute: typeof SettingsRoute
|
||||
}
|
||||
'/settings/profile': {
|
||||
id: '/settings/profile'
|
||||
path: '/profile'
|
||||
fullPath: '/settings/profile'
|
||||
preLoaderRoute: typeof SettingsProfileRouteImport
|
||||
parentRoute: typeof SettingsRoute
|
||||
}
|
||||
'/settings/fees': {
|
||||
id: '/settings/fees'
|
||||
path: '/fees'
|
||||
@@ -612,6 +631,7 @@ declare module '@tanstack/react-router' {
|
||||
|
||||
interface SettingsRouteChildren {
|
||||
SettingsFeesRoute: typeof SettingsFeesRoute
|
||||
SettingsProfileRoute: typeof SettingsProfileRoute
|
||||
SettingsWorkflowRoute: typeof SettingsWorkflowRoute
|
||||
SettingsWorkflowsRoute: typeof SettingsWorkflowsRoute
|
||||
SettingsIndexRoute: typeof SettingsIndexRoute
|
||||
@@ -619,6 +639,7 @@ interface SettingsRouteChildren {
|
||||
|
||||
const SettingsRouteChildren: SettingsRouteChildren = {
|
||||
SettingsFeesRoute: SettingsFeesRoute,
|
||||
SettingsProfileRoute: SettingsProfileRoute,
|
||||
SettingsWorkflowRoute: SettingsWorkflowRoute,
|
||||
SettingsWorkflowsRoute: SettingsWorkflowsRoute,
|
||||
SettingsIndexRoute: SettingsIndexRoute,
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Loader2, Upload, Trash2, User as UserIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/settings/profile")({
|
||||
component: ProfilePage,
|
||||
});
|
||||
|
||||
const EMPTY = {
|
||||
full_name: "",
|
||||
title: "",
|
||||
phone: "",
|
||||
bio: "",
|
||||
timezone: "",
|
||||
avatar_url: "" as string | null | "",
|
||||
};
|
||||
|
||||
function initials(name: string, email: string) {
|
||||
const src = name?.trim() || email || "";
|
||||
return src.split(/\s+/).map((p) => p[0]).filter(Boolean).slice(0, 2).join("").toUpperCase();
|
||||
}
|
||||
|
||||
function ProfilePage() {
|
||||
const { user } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [form, setForm] = useState({ ...EMPTY });
|
||||
const [email, setEmail] = useState("");
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = async () => {
|
||||
if (!user) return;
|
||||
setLoading(true);
|
||||
const { data } = await supabase
|
||||
.from("profiles")
|
||||
.select("*")
|
||||
.eq("id", user.id)
|
||||
.maybeSingle();
|
||||
if (data) {
|
||||
setEmail(data.email || user.email || "");
|
||||
setForm({
|
||||
full_name: data.full_name ?? "",
|
||||
title: (data as any).title ?? "",
|
||||
phone: (data as any).phone ?? "",
|
||||
bio: (data as any).bio ?? "",
|
||||
timezone: (data as any).timezone ?? "",
|
||||
avatar_url: (data as any).avatar_url ?? "",
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user?.id]);
|
||||
|
||||
const update = (k: keyof typeof EMPTY, v: string) =>
|
||||
setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
const onAvatarFile = async (file: File) => {
|
||||
if (!user) return;
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error("Image must be under 5 MB");
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
const ext = (file.name.split(".").pop() || "png").toLowerCase();
|
||||
// Path must start with the user's id for the storage policy to allow it.
|
||||
const path = `${user.id}/avatar-${Date.now()}.${ext}`;
|
||||
const { error } = await supabase.storage
|
||||
.from("avatars")
|
||||
.upload(path, file, { upsert: true, contentType: file.type, cacheControl: "3600" });
|
||||
if (error) {
|
||||
setUploading(false);
|
||||
toast.error("Upload failed", { description: error.message });
|
||||
return;
|
||||
}
|
||||
const { data: pub } = supabase.storage.from("avatars").getPublicUrl(path);
|
||||
const url = pub.publicUrl;
|
||||
// Persist immediately so it appears in the sidebar without a save click.
|
||||
const { error: upErr } = await supabase
|
||||
.from("profiles")
|
||||
.update({ avatar_url: url })
|
||||
.eq("id", user.id);
|
||||
setUploading(false);
|
||||
if (upErr) {
|
||||
toast.error("Could not save avatar", { description: upErr.message });
|
||||
return;
|
||||
}
|
||||
setForm((f) => ({ ...f, avatar_url: url }));
|
||||
toast.success("Profile photo updated");
|
||||
};
|
||||
|
||||
const removeAvatar = async () => {
|
||||
if (!user) return;
|
||||
const { error } = await supabase
|
||||
.from("profiles")
|
||||
.update({ avatar_url: null })
|
||||
.eq("id", user.id);
|
||||
if (error) {
|
||||
toast.error("Could not remove", { description: error.message });
|
||||
return;
|
||||
}
|
||||
setForm((f) => ({ ...f, avatar_url: "" }));
|
||||
toast.success("Photo removed");
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!user) return;
|
||||
setSaving(true);
|
||||
const { error } = await supabase
|
||||
.from("profiles")
|
||||
.update({
|
||||
full_name: form.full_name || "",
|
||||
title: form.title || null,
|
||||
phone: form.phone || null,
|
||||
bio: form.bio || null,
|
||||
timezone: form.timezone || null,
|
||||
})
|
||||
.eq("id", user.id);
|
||||
setSaving(false);
|
||||
if (error) {
|
||||
toast.error("Could not save", { description: error.message });
|
||||
return;
|
||||
}
|
||||
toast.success("Profile saved");
|
||||
load();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-16">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
<Card className="border-border/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-serif text-base flex items-center gap-2">
|
||||
<UserIcon className="h-4 w-4 text-muted-foreground" /> Profile photo
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap items-center gap-6">
|
||||
<Avatar className="h-24 w-24">
|
||||
{form.avatar_url ? <AvatarImage src={form.avatar_url} alt="Avatar" /> : null}
|
||||
<AvatarFallback className="text-lg">
|
||||
{initials(form.full_name, email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) onAvatarFile(f);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Upload photo
|
||||
</Button>
|
||||
{form.avatar_url && (
|
||||
<Button variant="ghost" size="sm" onClick={removeAvatar}>
|
||||
<Trash2 className="h-4 w-4 mr-2" /> Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">PNG or JPG, up to 5 MB.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-serif text-base">Personal information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Full name">
|
||||
<Input
|
||||
value={form.full_name}
|
||||
onChange={(e) => update("full_name", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Email">
|
||||
<Input value={email} disabled />
|
||||
</Field>
|
||||
<Field label="Job title">
|
||||
<Input
|
||||
value={form.title}
|
||||
onChange={(e) => update("title", e.target.value)}
|
||||
placeholder="Attorney, Paralegal, etc."
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Phone">
|
||||
<Input
|
||||
value={form.phone}
|
||||
onChange={(e) => update("phone", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Timezone">
|
||||
<Input
|
||||
value={form.timezone}
|
||||
onChange={(e) => update("timezone", e.target.value)}
|
||||
placeholder="America/New_York"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Bio" className="sm:col-span-2">
|
||||
<Textarea
|
||||
rows={4}
|
||||
value={form.bio}
|
||||
onChange={(e) => update("bio", e.target.value)}
|
||||
placeholder="A short introduction shown on your profile."
|
||||
/>
|
||||
</Field>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={save} disabled={saving}>
|
||||
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={`space-y-1.5 ${className ?? ""}`}>
|
||||
<Label className="text-xs">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { createFileRoute, Link, useLocation } from "@tanstack/react-router";
|
||||
import { ProtectedLayout } from "@/components/protected-layout";
|
||||
import { PageContainer, PageHeader } from "@/components/app-shell";
|
||||
import { Outlet } from "@tanstack/react-router";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Route = createFileRoute("/settings")({
|
||||
@@ -9,22 +10,26 @@ export const Route = createFileRoute("/settings")({
|
||||
});
|
||||
|
||||
const TABS = [
|
||||
{ to: "/settings/profile", label: "My profile", anyone: true },
|
||||
{ to: "/settings", label: "Company", exact: true },
|
||||
{ to: "/settings/fees", label: "Fee schedule" },
|
||||
{ to: "/settings/workflow", label: "Collections workflow" },
|
||||
{ to: "/settings/workflows", label: "Task workflows" },
|
||||
];
|
||||
|
||||
function SettingsLayout() {
|
||||
const location = useLocation();
|
||||
const { isAdmin } = useAuth();
|
||||
const visibleTabs = TABS.filter((t) => t.anyone || isAdmin);
|
||||
return (
|
||||
<ProtectedLayout adminOnly>
|
||||
<ProtectedLayout>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Settings"
|
||||
description="Configure firm-wide information and billing defaults."
|
||||
/>
|
||||
<div className="flex gap-1 border-b mb-6">
|
||||
{TABS.map((t) => {
|
||||
<div className="flex gap-1 border-b mb-6 overflow-x-auto">
|
||||
{visibleTabs.map((t) => {
|
||||
const active = t.exact
|
||||
? location.pathname === t.to
|
||||
: location.pathname.startsWith(t.to);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
-- Profile fields
|
||||
ALTER TABLE public.profiles
|
||||
ADD COLUMN IF NOT EXISTS avatar_url text,
|
||||
ADD COLUMN IF NOT EXISTS title text,
|
||||
ADD COLUMN IF NOT EXISTS phone text,
|
||||
ADD COLUMN IF NOT EXISTS bio text,
|
||||
ADD COLUMN IF NOT EXISTS timezone text;
|
||||
|
||||
-- Ensure RLS on profiles
|
||||
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Recreate policies idempotently
|
||||
DROP POLICY IF EXISTS profiles_select_auth ON public.profiles;
|
||||
DROP POLICY IF EXISTS profiles_update_self_or_admin ON public.profiles;
|
||||
DROP POLICY IF EXISTS profiles_insert_self ON public.profiles;
|
||||
|
||||
CREATE POLICY profiles_select_auth ON public.profiles
|
||||
FOR SELECT TO authenticated USING (true);
|
||||
|
||||
CREATE POLICY profiles_update_self_or_admin ON public.profiles
|
||||
FOR UPDATE TO authenticated
|
||||
USING (id = auth.uid() OR public.is_admin(auth.uid()));
|
||||
|
||||
CREATE POLICY profiles_insert_self ON public.profiles
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (id = auth.uid() OR public.is_admin(auth.uid()));
|
||||
|
||||
-- Avatars bucket (public)
|
||||
INSERT INTO storage.buckets (id, name, public)
|
||||
VALUES ('avatars', 'avatars', true)
|
||||
ON CONFLICT (id) DO UPDATE SET public = true;
|
||||
|
||||
-- Storage policies for avatars
|
||||
DROP POLICY IF EXISTS "avatars_public_read" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "avatars_user_insert" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "avatars_user_update" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "avatars_user_delete" ON storage.objects;
|
||||
|
||||
CREATE POLICY "avatars_public_read" ON storage.objects
|
||||
FOR SELECT USING (bucket_id = 'avatars');
|
||||
|
||||
CREATE POLICY "avatars_user_insert" ON storage.objects
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (bucket_id = 'avatars' AND auth.uid()::text = (storage.foldername(name))[1]);
|
||||
|
||||
CREATE POLICY "avatars_user_update" ON storage.objects
|
||||
FOR UPDATE TO authenticated
|
||||
USING (bucket_id = 'avatars' AND auth.uid()::text = (storage.foldername(name))[1]);
|
||||
|
||||
CREATE POLICY "avatars_user_delete" ON storage.objects
|
||||
FOR DELETE TO authenticated
|
||||
USING (bucket_id = 'avatars' AND auth.uid()::text = (storage.foldername(name))[1]);
|
||||
Reference in New Issue
Block a user