mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
feat(settings): user profile management
Adds a profile tab to /settings with the five things every B2B SaaS
user expects:
- Display name — edits profiles.full_name.
- Avatar — upload / replace / remove, stored in a new `avatars`
Supabase Storage bucket with RLS scoped to the owner's folder.
Migration 008_profile_avatars_storage.sql creates the bucket (public
read, 2 MB cap, image mimes only) and the four storage.objects
policies.
- Email — goes through supabase.auth.updateUser({ email }), which
confirms to both the old and new addresses. UI surfaces a "check
both inboxes" banner after save.
- Password — three-field form with a current-password gate implemented
via a throwaway signInWithPassword() before calling updateUser.
- Active sessions — one-click "sign out of all devices" using
supabase.auth.signOut({ scope: 'global' }), with a confirm dialog.
Plus read-only role, joined date, and user id for operator debugging.
The header avatar and sidebar user row are now both dropdown menus
linking to /settings?tab=profile and /settings?tab=whatsapp; the
settings page reads ?tab= to land on the right tab. useAuth gets a
refreshProfile() callback so the header/sidebar update immediately
after a save without a full reload.
Deferred (separate work): account deletion, 2FA, phone/timezone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c2839e6305
commit
72a5e4cf70
@ -1,37 +1,95 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Settings, MessageSquare, Tag } from 'lucide-react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { Settings, MessageSquare, Tag, User } from 'lucide-react';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
import { WhatsAppConfig } from '@/components/settings/whatsapp-config';
|
import { WhatsAppConfig } from '@/components/settings/whatsapp-config';
|
||||||
import { TemplateManager } from '@/components/settings/template-manager';
|
import { TemplateManager } from '@/components/settings/template-manager';
|
||||||
import { TagManager } from '@/components/settings/tag-manager';
|
import { TagManager } from '@/components/settings/tag-manager';
|
||||||
|
import { ProfileForm } from '@/components/settings/profile-form';
|
||||||
|
import { PasswordForm } from '@/components/settings/password-form';
|
||||||
|
import { SessionsCard } from '@/components/settings/sessions-card';
|
||||||
|
|
||||||
|
const TAB_VALUES = ['profile', 'whatsapp', 'templates', 'tags'] as const;
|
||||||
|
type TabValue = (typeof TAB_VALUES)[number];
|
||||||
|
|
||||||
|
function isTabValue(v: string | null): v is TabValue {
|
||||||
|
return !!v && (TAB_VALUES as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const initialTab = (() => {
|
||||||
|
const q = searchParams.get('tab');
|
||||||
|
return isTabValue(q) ? q : 'profile';
|
||||||
|
})();
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<TabValue>(initialTab);
|
||||||
|
|
||||||
|
// Keep the URL in sync with the active tab so the user-menu deep links
|
||||||
|
// work and the tab is bookmarkable / shareable.
|
||||||
|
useEffect(() => {
|
||||||
|
const q = searchParams.get('tab');
|
||||||
|
if (isTabValue(q) && q !== tab) setTab(q);
|
||||||
|
}, [searchParams, tab]);
|
||||||
|
|
||||||
|
const onChange = (next: TabValue) => {
|
||||||
|
setTab(next);
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.set('tab', next);
|
||||||
|
router.replace(`/settings?${params.toString()}`, { scroll: false });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white">Settings</h1>
|
<h1 className="text-2xl font-bold text-white">Settings</h1>
|
||||||
<p className="text-sm text-slate-400 mt-1">
|
<p className="text-sm text-slate-400 mt-1">
|
||||||
Manage your WhatsApp integration, message templates, and tags.
|
Manage your profile, WhatsApp integration, message templates, and
|
||||||
|
tags.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="whatsapp">
|
<Tabs value={tab} onValueChange={(v) => onChange(v as TabValue)}>
|
||||||
<TabsList className="bg-slate-900 border border-slate-700">
|
<TabsList className="bg-slate-900 border border-slate-700">
|
||||||
<TabsTrigger value="whatsapp" className="data-active:bg-slate-800 data-active:text-emerald-400 text-slate-400">
|
<TabsTrigger
|
||||||
|
value="profile"
|
||||||
|
className="data-active:bg-slate-800 data-active:text-emerald-400 text-slate-400"
|
||||||
|
>
|
||||||
|
<User className="size-4" />
|
||||||
|
Profile
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="whatsapp"
|
||||||
|
className="data-active:bg-slate-800 data-active:text-emerald-400 text-slate-400"
|
||||||
|
>
|
||||||
<Settings className="size-4" />
|
<Settings className="size-4" />
|
||||||
WhatsApp Config
|
WhatsApp Config
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="templates" className="data-active:bg-slate-800 data-active:text-emerald-400 text-slate-400">
|
<TabsTrigger
|
||||||
|
value="templates"
|
||||||
|
className="data-active:bg-slate-800 data-active:text-emerald-400 text-slate-400"
|
||||||
|
>
|
||||||
<MessageSquare className="size-4" />
|
<MessageSquare className="size-4" />
|
||||||
Templates
|
Templates
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="tags" className="data-active:bg-slate-800 data-active:text-emerald-400 text-slate-400">
|
<TabsTrigger
|
||||||
|
value="tags"
|
||||||
|
className="data-active:bg-slate-800 data-active:text-emerald-400 text-slate-400"
|
||||||
|
>
|
||||||
<Tag className="size-4" />
|
<Tag className="size-4" />
|
||||||
Tags
|
Tags
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="profile" className="space-y-6">
|
||||||
|
<ProfileForm />
|
||||||
|
<PasswordForm />
|
||||||
|
<SessionsCard />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="whatsapp">
|
<TabsContent value="whatsapp">
|
||||||
<WhatsAppConfig />
|
<WhatsAppConfig />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@ -1,8 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { LogOut, Menu } from "lucide-react";
|
import { LogOut, Menu, Settings as SettingsIcon, User } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
} from "@/components/ui/avatar";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
const pageTitles: Record<string, string> = {
|
const pageTitles: Record<string, string> = {
|
||||||
"/dashboard": "Dashboard",
|
"/dashboard": "Dashboard",
|
||||||
@ -33,6 +46,11 @@ export function Header({ onOpenSidebar }: HeaderProps) {
|
|||||||
const { profile, signOut } = useAuth();
|
const { profile, signOut } = useAuth();
|
||||||
const title = getPageTitle(pathname);
|
const title = getPageTitle(pathname);
|
||||||
|
|
||||||
|
const initial =
|
||||||
|
profile?.full_name?.charAt(0)?.toUpperCase() ??
|
||||||
|
profile?.email?.charAt(0)?.toUpperCase() ??
|
||||||
|
"U";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex h-14 shrink-0 items-center justify-between gap-3 border-b border-slate-800 bg-slate-950 px-4 lg:px-6">
|
<header className="flex h-14 shrink-0 items-center justify-between gap-3 border-b border-slate-800 bg-slate-950 px-4 lg:px-6">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
@ -50,26 +68,72 @@ export function Header({ onOpenSidebar }: HeaderProps) {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
<DropdownMenu>
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
<DropdownMenuTrigger
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-500/10 text-sm font-medium text-emerald-500">
|
className="flex items-center gap-2 rounded-md px-1 py-1 transition-colors hover:bg-slate-800/70 focus:bg-slate-800/70 focus:outline-none data-popup-open:bg-slate-800/70 sm:gap-3 sm:pl-1 sm:pr-3"
|
||||||
{profile?.full_name?.charAt(0)?.toUpperCase() ?? "U"}
|
aria-label="Open account menu"
|
||||||
</div>
|
>
|
||||||
<div className="hidden sm:block">
|
<Avatar className="size-8">
|
||||||
<p className="text-sm font-medium text-white">
|
{profile?.avatar_url ? (
|
||||||
|
<AvatarImage
|
||||||
|
src={profile.avatar_url}
|
||||||
|
alt={profile.full_name ?? "Avatar"}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<AvatarFallback className="bg-emerald-500/10 text-sm font-medium text-emerald-500">
|
||||||
|
{initial}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="hidden text-sm font-medium text-white sm:inline">
|
||||||
|
{profile?.full_name ?? "User"}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
sideOffset={6}
|
||||||
|
className="min-w-56 bg-slate-900 text-slate-100 ring-slate-700"
|
||||||
|
>
|
||||||
|
<div className="px-2 py-1.5">
|
||||||
|
<p className="truncate text-sm font-medium text-white">
|
||||||
{profile?.full_name ?? "User"}
|
{profile?.full_name ?? "User"}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="truncate text-xs text-slate-400">
|
||||||
|
{profile?.email ?? ""}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<DropdownMenuSeparator className="bg-slate-800" />
|
||||||
<button
|
<DropdownMenuItem
|
||||||
onClick={signOut}
|
render={
|
||||||
className="flex h-10 w-10 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
|
<Link
|
||||||
title="Sign out"
|
href="/settings?tab=profile"
|
||||||
aria-label="Sign out"
|
className="text-slate-200 focus:bg-slate-800 focus:text-white"
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4" />
|
<User className="size-4" />
|
||||||
</button>
|
Profile
|
||||||
</div>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
render={
|
||||||
|
<Link
|
||||||
|
href="/settings?tab=whatsapp"
|
||||||
|
className="text-slate-200 focus:bg-slate-800 focus:text-white"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SettingsIcon className="size-4" />
|
||||||
|
Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator className="bg-slate-800" />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={signOut}
|
||||||
|
className="text-slate-200 focus:bg-slate-800 focus:text-white"
|
||||||
|
>
|
||||||
|
<LogOut className="size-4" />
|
||||||
|
Sign out
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,8 +15,21 @@ import {
|
|||||||
Zap,
|
Zap,
|
||||||
Settings,
|
Settings,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
User,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
} from "@/components/ui/avatar";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||||
@ -180,10 +193,21 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
|||||||
|
|
||||||
{/* User section */}
|
{/* User section */}
|
||||||
<div className="shrink-0 border-t border-slate-800 p-3">
|
<div className="shrink-0 border-t border-slate-800 p-3">
|
||||||
<div className="flex items-center gap-3 rounded-lg px-3 py-2">
|
<DropdownMenu>
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-emerald-500/10 text-sm font-medium text-emerald-500">
|
<DropdownMenuTrigger className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors hover:bg-slate-800/60 focus:bg-slate-800/60 focus:outline-none data-popup-open:bg-slate-800/60">
|
||||||
{profile?.full_name?.charAt(0)?.toUpperCase() ?? "U"}
|
<Avatar className="size-8 shrink-0">
|
||||||
</div>
|
{profile?.avatar_url ? (
|
||||||
|
<AvatarImage
|
||||||
|
src={profile.avatar_url}
|
||||||
|
alt={profile.full_name ?? "Avatar"}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<AvatarFallback className="bg-emerald-500/10 text-sm font-medium text-emerald-500">
|
||||||
|
{profile?.full_name?.charAt(0)?.toUpperCase() ??
|
||||||
|
profile?.email?.charAt(0)?.toUpperCase() ??
|
||||||
|
"U"}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-medium text-white">
|
<p className="truncate text-sm font-medium text-white">
|
||||||
{profile?.full_name ?? "User"}
|
{profile?.full_name ?? "User"}
|
||||||
@ -192,14 +216,47 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
|||||||
{profile?.email ?? ""}
|
{profile?.email ?? ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
</DropdownMenuTrigger>
|
||||||
onClick={signOut}
|
<DropdownMenuContent
|
||||||
className="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
|
align="end"
|
||||||
title="Sign out"
|
side="top"
|
||||||
|
sideOffset={6}
|
||||||
|
className="min-w-56 bg-slate-900 text-slate-100 ring-slate-700"
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4" />
|
<DropdownMenuItem
|
||||||
</button>
|
render={
|
||||||
</div>
|
<Link
|
||||||
|
href="/settings?tab=profile"
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-slate-200 focus:bg-slate-800 focus:text-white"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<User className="size-4" />
|
||||||
|
Profile
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
render={
|
||||||
|
<Link
|
||||||
|
href="/settings?tab=whatsapp"
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-slate-200 focus:bg-slate-800 focus:text-white"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Settings className="size-4" />
|
||||||
|
Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator className="bg-slate-800" />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={signOut}
|
||||||
|
className="text-slate-200 focus:bg-slate-800 focus:text-white"
|
||||||
|
>
|
||||||
|
<LogOut className="size-4" />
|
||||||
|
Sign out
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
|
|||||||
171
src/components/settings/password-form.tsx
Normal file
171
src/components/settings/password-form.tsx
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Loader2, KeyRound } from 'lucide-react';
|
||||||
|
|
||||||
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
|
||||||
|
const MIN_PASSWORD = 8;
|
||||||
|
|
||||||
|
export function PasswordForm() {
|
||||||
|
const { profile } = useAuth();
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
|
const [current, setCurrent] = useState('');
|
||||||
|
const [next, setNext] = useState('');
|
||||||
|
const [confirm, setConfirm] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [confirmError, setConfirmError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const onSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!profile?.email) {
|
||||||
|
toast.error('Cannot change password without a current email');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (next.length < MIN_PASSWORD) {
|
||||||
|
setConfirmError(`Password must be at least ${MIN_PASSWORD} characters`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (next !== confirm) {
|
||||||
|
setConfirmError('New password and confirmation do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfirmError(null);
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Supabase doesn't expose a "verify password without issuing a
|
||||||
|
// session" API, so we re-authenticate with the provided current
|
||||||
|
// password. If it matches, the session refreshes silently; if it
|
||||||
|
// doesn't, we abort before calling updateUser.
|
||||||
|
const { error: signInError } = await supabase.auth.signInWithPassword({
|
||||||
|
email: profile.email,
|
||||||
|
password: current,
|
||||||
|
});
|
||||||
|
if (signInError) {
|
||||||
|
toast.error('Current password is incorrect');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: updateError } = await supabase.auth.updateUser({
|
||||||
|
password: next,
|
||||||
|
});
|
||||||
|
if (updateError) {
|
||||||
|
toast.error(`Password update failed: ${updateError.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrent('');
|
||||||
|
setNext('');
|
||||||
|
setConfirm('');
|
||||||
|
toast.success('Password updated');
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
toast.error(msg);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-slate-900/40 border-slate-800">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-white">
|
||||||
|
<KeyRound className="size-4 text-emerald-400" />
|
||||||
|
Password
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-slate-400">
|
||||||
|
Use at least {MIN_PASSWORD} characters. You will stay signed in on
|
||||||
|
this device after changing it.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="current-password" className="text-slate-200">
|
||||||
|
Current password
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="current-password"
|
||||||
|
type="password"
|
||||||
|
value={current}
|
||||||
|
onChange={(e) => setCurrent(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
disabled={saving}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="new-password" className="text-slate-200">
|
||||||
|
New password
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="new-password"
|
||||||
|
type="password"
|
||||||
|
value={next}
|
||||||
|
onChange={(e) => setNext(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={MIN_PASSWORD}
|
||||||
|
disabled={saving}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirm-password" className="text-slate-200">
|
||||||
|
Confirm new password
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="confirm-password"
|
||||||
|
type="password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={MIN_PASSWORD}
|
||||||
|
disabled={saving}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{confirmError && (
|
||||||
|
<p className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-300">
|
||||||
|
{confirmError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving || !current || !next || !confirm}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Updating…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Update password'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
359
src/components/settings/profile-form.tsx
Normal file
359
src/components/settings/profile-form.tsx
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Loader2, Upload, Trash2, Mail, CircleAlert } from 'lucide-react';
|
||||||
|
|
||||||
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
} from '@/components/ui/avatar';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
|
||||||
|
const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
|
||||||
|
const ALLOWED_MIME = new Set([
|
||||||
|
'image/png',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/webp',
|
||||||
|
'image/gif',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Rough email shape check — the real validator is Supabase Auth, which
|
||||||
|
// rejects anything malformed when we call updateUser({ email }). We
|
||||||
|
// just want to stop obvious typos before making a network call.
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
export function ProfileForm() {
|
||||||
|
const { user, profile, refreshProfile } = useAuth();
|
||||||
|
const supabase = createClient();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [fullName, setFullName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [pendingAvatar, setPendingAvatar] = useState<File | null>(null);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [removeAvatar, setRemoveAvatar] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [emailChangePending, setEmailChangePending] = useState(false);
|
||||||
|
|
||||||
|
// Seed form state once the profile loads.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!profile) return;
|
||||||
|
setFullName(profile.full_name ?? '');
|
||||||
|
setEmail(profile.email ?? '');
|
||||||
|
}, [profile]);
|
||||||
|
|
||||||
|
// Cleanup object URLs to avoid leaks.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||||
|
};
|
||||||
|
}, [previewUrl]);
|
||||||
|
|
||||||
|
const currentAvatar =
|
||||||
|
previewUrl ?? (!removeAvatar ? profile?.avatar_url ?? null : null);
|
||||||
|
|
||||||
|
const initial = (fullName || profile?.full_name || profile?.email || 'U')
|
||||||
|
.charAt(0)
|
||||||
|
.toUpperCase();
|
||||||
|
|
||||||
|
const onPickFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = ''; // reset so the same file can be re-picked
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (!ALLOWED_MIME.has(file.type)) {
|
||||||
|
toast.error('Unsupported image type', {
|
||||||
|
description: 'Use PNG, JPG, WebP, or GIF.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > MAX_AVATAR_BYTES) {
|
||||||
|
toast.error('Image is too large', {
|
||||||
|
description: 'Maximum 2 MB.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||||
|
setPendingAvatar(file);
|
||||||
|
setPreviewUrl(URL.createObjectURL(file));
|
||||||
|
setRemoveAvatar(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRemoveAvatar = () => {
|
||||||
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||||
|
setPendingAvatar(null);
|
||||||
|
setPreviewUrl(null);
|
||||||
|
setRemoveAvatar(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!user || !profile) return;
|
||||||
|
|
||||||
|
const trimmedName = fullName.trim();
|
||||||
|
if (!trimmedName) {
|
||||||
|
toast.error('Display name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmedEmail = email.trim();
|
||||||
|
if (!EMAIL_RE.test(trimmedEmail)) {
|
||||||
|
toast.error('Enter a valid email address');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
let nextAvatarUrl: string | null = profile.avatar_url ?? null;
|
||||||
|
|
||||||
|
// Upload a newly-staged image, if any.
|
||||||
|
if (pendingAvatar) {
|
||||||
|
const ext =
|
||||||
|
pendingAvatar.name.split('.').pop()?.toLowerCase() || 'png';
|
||||||
|
const path = `${user.id}/avatar-${Date.now()}.${ext}`;
|
||||||
|
const { error: uploadError } = await supabase.storage
|
||||||
|
.from('avatars')
|
||||||
|
.upload(path, pendingAvatar, {
|
||||||
|
cacheControl: '3600',
|
||||||
|
upsert: true,
|
||||||
|
contentType: pendingAvatar.type,
|
||||||
|
});
|
||||||
|
if (uploadError) {
|
||||||
|
throw new Error(`Upload failed: ${uploadError.message}`);
|
||||||
|
}
|
||||||
|
const {
|
||||||
|
data: { publicUrl },
|
||||||
|
} = supabase.storage.from('avatars').getPublicUrl(path);
|
||||||
|
nextAvatarUrl = publicUrl;
|
||||||
|
} else if (removeAvatar) {
|
||||||
|
nextAvatarUrl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist name + avatar to profiles.
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.update({
|
||||||
|
full_name: trimmedName,
|
||||||
|
avatar_url: nextAvatarUrl,
|
||||||
|
})
|
||||||
|
.eq('user_id', user.id);
|
||||||
|
if (updateError) {
|
||||||
|
throw new Error(`Save failed: ${updateError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email change goes through Supabase Auth, which emails a
|
||||||
|
// confirmation to both the old and new addresses. We don't
|
||||||
|
// touch profiles.email — Supabase will push the change there
|
||||||
|
// after the user clicks the link (handled by the handle_new_user
|
||||||
|
// trigger pattern in production deployments).
|
||||||
|
let emailSent = false;
|
||||||
|
if (trimmedEmail.toLowerCase() !== profile.email.toLowerCase()) {
|
||||||
|
const { error: emailError } = await supabase.auth.updateUser({
|
||||||
|
email: trimmedEmail,
|
||||||
|
});
|
||||||
|
if (emailError) {
|
||||||
|
// Partial success: name/avatar saved but email didn't.
|
||||||
|
toast.success('Profile saved');
|
||||||
|
toast.error(`Email change failed: ${emailError.message}`);
|
||||||
|
setSaving(false);
|
||||||
|
await refreshProfile();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emailSent = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEmailChangePending(emailSent);
|
||||||
|
setPendingAvatar(null);
|
||||||
|
setPreviewUrl(null);
|
||||||
|
setRemoveAvatar(false);
|
||||||
|
await refreshProfile();
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
emailSent
|
||||||
|
? 'Profile saved — check your email to confirm the address change'
|
||||||
|
: 'Profile saved',
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
toast.error(msg);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dirty =
|
||||||
|
!!profile &&
|
||||||
|
(fullName.trim() !== (profile.full_name ?? '') ||
|
||||||
|
email.trim().toLowerCase() !== (profile.email ?? '').toLowerCase() ||
|
||||||
|
pendingAvatar !== null ||
|
||||||
|
removeAvatar);
|
||||||
|
|
||||||
|
const joined = user?.created_at
|
||||||
|
? new Date(user.created_at).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
: '—';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-slate-900/40 border-slate-800">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white">Profile</CardTitle>
|
||||||
|
<CardDescription className="text-slate-400">
|
||||||
|
How you show up across WaCRM. Your avatar and name appear in the
|
||||||
|
header, sidebar, and anywhere your teammates see you.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={onSubmit} className="space-y-6">
|
||||||
|
{/* Avatar row */}
|
||||||
|
<div className="flex flex-wrap items-center gap-5">
|
||||||
|
<Avatar size="lg" className="size-16">
|
||||||
|
{currentAvatar ? (
|
||||||
|
<AvatarImage src={currentAvatar} alt={fullName || 'Avatar'} />
|
||||||
|
) : null}
|
||||||
|
<AvatarFallback className="bg-emerald-500/10 text-base text-emerald-400">
|
||||||
|
{initial}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||||
|
className="hidden"
|
||||||
|
onChange={onPickFile}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<Upload className="size-4" />
|
||||||
|
{currentAvatar ? 'Change photo' : 'Upload photo'}
|
||||||
|
</Button>
|
||||||
|
{currentAvatar && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onRemoveAvatar}
|
||||||
|
disabled={saving}
|
||||||
|
className="text-slate-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<p className="w-full text-xs text-slate-500">
|
||||||
|
PNG, JPG, WebP, or GIF. Up to 2 MB.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="profile-full-name" className="text-slate-200">
|
||||||
|
Display name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="profile-full-name"
|
||||||
|
value={fullName}
|
||||||
|
onChange={(e) => setFullName(e.target.value)}
|
||||||
|
placeholder="Ada Lovelace"
|
||||||
|
maxLength={120}
|
||||||
|
disabled={saving}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="profile-email" className="text-slate-200">
|
||||||
|
Email
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="profile-email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
disabled={saving}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{emailChangePending && (
|
||||||
|
<p className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-300">
|
||||||
|
<Mail className="mt-0.5 size-3.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
Check the inbox for <strong>{profile?.email}</strong> and{' '}
|
||||||
|
<strong>{email}</strong> — both need to confirm before the
|
||||||
|
change takes effect.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Read-only block */}
|
||||||
|
<div className="rounded-lg border border-slate-800 bg-slate-900/60 p-4">
|
||||||
|
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
|
Account details
|
||||||
|
</p>
|
||||||
|
<dl className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<dt className="text-slate-500">Role</dt>
|
||||||
|
<dd className="mt-0.5 font-mono text-slate-200">
|
||||||
|
{profile?.role ?? 'user'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-slate-500">Joined</dt>
|
||||||
|
<dd className="mt-0.5 text-slate-200">{joined}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<dt className="text-slate-500">User ID</dt>
|
||||||
|
<dd className="mt-0.5 break-all font-mono text-xs text-slate-400">
|
||||||
|
{user?.id ?? '—'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!profile && (
|
||||||
|
<p className="flex items-center gap-2 text-sm text-slate-400">
|
||||||
|
<CircleAlert className="size-4" />
|
||||||
|
Loading your profile…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit" disabled={saving || !dirty || !profile}>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Saving…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Save changes'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
src/components/settings/sessions-card.tsx
Normal file
110
src/components/settings/sessions-card.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Loader2, LogOut } from 'lucide-react';
|
||||||
|
|
||||||
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
export function SessionsCard() {
|
||||||
|
const supabase = createClient();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [signingOut, setSigningOut] = useState(false);
|
||||||
|
|
||||||
|
const onConfirm = async () => {
|
||||||
|
setSigningOut(true);
|
||||||
|
try {
|
||||||
|
// scope: 'global' revokes every refresh token for this user
|
||||||
|
// across all devices; the next auth-state change on this tab
|
||||||
|
// triggers the usual redirect.
|
||||||
|
const { error } = await supabase.auth.signOut({ scope: 'global' });
|
||||||
|
if (error) {
|
||||||
|
toast.error(`Sign-out failed: ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = '/login';
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
toast.error(msg);
|
||||||
|
} finally {
|
||||||
|
setSigningOut(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="bg-slate-900/40 border-slate-800">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-white">
|
||||||
|
<LogOut className="size-4 text-emerald-400" />
|
||||||
|
Active sessions
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-slate-400">
|
||||||
|
Sign out of every device where you're logged in to WaCRM —
|
||||||
|
including this one. Useful if you lost a laptop or shared your
|
||||||
|
password.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<LogOut className="size-4" />
|
||||||
|
Sign out of all devices
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Sign out everywhere?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Every device logged into this account will be signed out and
|
||||||
|
will need to log in again. You will be redirected to the login
|
||||||
|
page.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
disabled={signingOut}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={onConfirm} disabled={signingOut}>
|
||||||
|
{signingOut ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Signing out…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Sign out everywhere'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -16,6 +16,7 @@ interface Profile {
|
|||||||
full_name: string | null;
|
full_name: string | null;
|
||||||
email: string;
|
email: string;
|
||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
|
role: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
@ -23,6 +24,10 @@ interface AuthContextValue {
|
|||||||
profile: Profile | null;
|
profile: Profile | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
|
/** Re-fetch the current user's profile row — call after a save from
|
||||||
|
* the settings form so header/sidebar reflect the change without a
|
||||||
|
* full page reload. */
|
||||||
|
refreshProfile: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
@ -37,22 +42,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const [profile, setProfile] = useState<Profile | null>(null);
|
const [profile, setProfile] = useState<Profile | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
// Shared across init, auth-state-change listener, and the exposed
|
||||||
|
// refreshProfile() callback. Reads the current session's user id and
|
||||||
|
// pulls the matching profile row.
|
||||||
|
const fetchProfile = useCallback(async (userId: string) => {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
let mounted = true;
|
|
||||||
|
|
||||||
const safetyTimer = setTimeout(() => {
|
|
||||||
if (mounted) {
|
|
||||||
console.warn("[AuthProvider] getSession() timed out after 3s");
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
const fetchProfile = async (userId: string) => {
|
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("profiles")
|
.from("profiles")
|
||||||
.select("id, full_name, email, avatar_url")
|
.select("id, full_name, email, avatar_url, role")
|
||||||
.eq("user_id", userId)
|
.eq("user_id", userId)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
@ -66,11 +64,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data && mounted) setProfile(data);
|
if (data) setProfile(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[AuthProvider] fetchProfile threw:", err);
|
console.error("[AuthProvider] fetchProfile threw:", err);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const supabase = createClient();
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
const safetyTimer = setTimeout(() => {
|
||||||
|
if (mounted) {
|
||||||
|
console.warn("[AuthProvider] getSession() timed out after 3s");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
try {
|
try {
|
||||||
@ -131,8 +140,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const refreshProfile = useCallback(async () => {
|
||||||
|
if (!user?.id) return;
|
||||||
|
await fetchProfile(user.id);
|
||||||
|
}, [user?.id, fetchProfile]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, profile, loading, signOut }}>
|
<AuthContext.Provider
|
||||||
|
value={{ user, profile, loading, signOut, refreshProfile }}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
@ -154,6 +170,7 @@ export function useAuth(): AuthContextValue {
|
|||||||
signOut: async () => {
|
signOut: async () => {
|
||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
},
|
},
|
||||||
|
refreshProfile: async () => {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return ctx;
|
return ctx;
|
||||||
|
|||||||
59
supabase/migrations/008_profile_avatars_storage.sql
Normal file
59
supabase/migrations/008_profile_avatars_storage.sql
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 008_profile_avatars_storage.sql
|
||||||
|
--
|
||||||
|
-- Creates the `avatars` Supabase Storage bucket and the RLS policies
|
||||||
|
-- that let each user manage only their own avatar file while letting
|
||||||
|
-- everyone read (so rendering <img> tags without signed URLs works).
|
||||||
|
--
|
||||||
|
-- File path convention used by the app:
|
||||||
|
-- avatars/{auth.uid()}/avatar-<timestamp>.<ext>
|
||||||
|
-- The policies rely on the first path segment matching auth.uid()::text.
|
||||||
|
--
|
||||||
|
-- Idempotent — safe to re-run.
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
|
||||||
|
VALUES (
|
||||||
|
'avatars',
|
||||||
|
'avatars',
|
||||||
|
TRUE,
|
||||||
|
2097152, -- 2 MB
|
||||||
|
ARRAY['image/png', 'image/jpeg', 'image/webp', 'image/gif']
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO UPDATE
|
||||||
|
SET
|
||||||
|
public = EXCLUDED.public,
|
||||||
|
file_size_limit = EXCLUDED.file_size_limit,
|
||||||
|
allowed_mime_types = EXCLUDED.allowed_mime_types;
|
||||||
|
|
||||||
|
-- Policies live on storage.objects. Drop-if-exists because Postgres
|
||||||
|
-- has no CREATE POLICY IF NOT EXISTS, and we want this migration to
|
||||||
|
-- re-run cleanly.
|
||||||
|
DROP POLICY IF EXISTS "Avatars are publicly readable" ON storage.objects;
|
||||||
|
CREATE POLICY "Avatars are publicly readable"
|
||||||
|
ON storage.objects FOR SELECT
|
||||||
|
USING (bucket_id = 'avatars');
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "Users can upload their own avatar" ON storage.objects;
|
||||||
|
CREATE POLICY "Users can upload their own avatar"
|
||||||
|
ON storage.objects FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
bucket_id = 'avatars'
|
||||||
|
AND auth.uid()::text = (storage.foldername(name))[1]
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "Users can update their own avatar" ON storage.objects;
|
||||||
|
CREATE POLICY "Users can update their own avatar"
|
||||||
|
ON storage.objects FOR UPDATE
|
||||||
|
USING (
|
||||||
|
bucket_id = 'avatars'
|
||||||
|
AND auth.uid()::text = (storage.foldername(name))[1]
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "Users can delete their own avatar" ON storage.objects;
|
||||||
|
CREATE POLICY "Users can delete their own avatar"
|
||||||
|
ON storage.objects FOR DELETE
|
||||||
|
USING (
|
||||||
|
bucket_id = 'avatars'
|
||||||
|
AND auth.uid()::text = (storage.foldername(name))[1]
|
||||||
|
);
|
||||||
Loading…
Reference in New Issue
Block a user