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';
|
||||
|
||||
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 { WhatsAppConfig } from '@/components/settings/whatsapp-config';
|
||||
import { TemplateManager } from '@/components/settings/template-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() {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Settings</h1>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="whatsapp">
|
||||
<Tabs value={tab} onValueChange={(v) => onChange(v as TabValue)}>
|
||||
<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" />
|
||||
WhatsApp Config
|
||||
</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" />
|
||||
Templates
|
||||
</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" />
|
||||
Tags
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="profile" className="space-y-6">
|
||||
<ProfileForm />
|
||||
<PasswordForm />
|
||||
<SessionsCard />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="whatsapp">
|
||||
<WhatsAppConfig />
|
||||
</TabsContent>
|
||||
|
||||
@ -1,8 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
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> = {
|
||||
"/dashboard": "Dashboard",
|
||||
@ -33,6 +46,11 @@ export function Header({ onOpenSidebar }: HeaderProps) {
|
||||
const { profile, signOut } = useAuth();
|
||||
const title = getPageTitle(pathname);
|
||||
|
||||
const initial =
|
||||
profile?.full_name?.charAt(0)?.toUpperCase() ??
|
||||
profile?.email?.charAt(0)?.toUpperCase() ??
|
||||
"U";
|
||||
|
||||
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">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
@ -50,26 +68,72 @@ export function Header({ onOpenSidebar }: HeaderProps) {
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-500/10 text-sm font-medium text-emerald-500">
|
||||
{profile?.full_name?.charAt(0)?.toUpperCase() ?? "U"}
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<p className="text-sm font-medium text-white">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
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"
|
||||
aria-label="Open account menu"
|
||||
>
|
||||
<Avatar className="size-8">
|
||||
{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"}
|
||||
</p>
|
||||
<p className="truncate text-xs text-slate-400">
|
||||
{profile?.email ?? ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={signOut}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
|
||||
title="Sign out"
|
||||
aria-label="Sign out"
|
||||
<DropdownMenuSeparator className="bg-slate-800" />
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link
|
||||
href="/settings?tab=profile"
|
||||
className="text-slate-200 focus:bg-slate-800 focus:text-white"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<User className="size-4" />
|
||||
Profile
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -15,8 +15,21 @@ import {
|
||||
Zap,
|
||||
Settings,
|
||||
LogOut,
|
||||
User,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||
@ -180,10 +193,21 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
||||
|
||||
{/* User section */}
|
||||
<div className="shrink-0 border-t border-slate-800 p-3">
|
||||
<div className="flex items-center gap-3 rounded-lg px-3 py-2">
|
||||
<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">
|
||||
{profile?.full_name?.charAt(0)?.toUpperCase() ?? "U"}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<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">
|
||||
<Avatar className="size-8 shrink-0">
|
||||
{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">
|
||||
<p className="truncate text-sm font-medium text-white">
|
||||
{profile?.full_name ?? "User"}
|
||||
@ -192,14 +216,47 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
||||
{profile?.email ?? ""}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={signOut}
|
||||
className="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
|
||||
title="Sign out"
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
side="top"
|
||||
sideOffset={6}
|
||||
className="min-w-56 bg-slate-900 text-slate-100 ring-slate-700"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<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>
|
||||
</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;
|
||||
email: string;
|
||||
avatar_url: string | null;
|
||||
role: string | null;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
@ -23,6 +24,10 @@ interface AuthContextValue {
|
||||
profile: Profile | null;
|
||||
loading: boolean;
|
||||
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);
|
||||
@ -37,22 +42,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
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();
|
||||
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 {
|
||||
const { data, error } = await supabase
|
||||
.from("profiles")
|
||||
.select("id, full_name, email, avatar_url")
|
||||
.select("id, full_name, email, avatar_url, role")
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
|
||||
@ -66,11 +64,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data && mounted) setProfile(data);
|
||||
if (data) setProfile(data);
|
||||
} catch (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 () => {
|
||||
try {
|
||||
@ -131,8 +140,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
window.location.href = "/login";
|
||||
}, []);
|
||||
|
||||
const refreshProfile = useCallback(async () => {
|
||||
if (!user?.id) return;
|
||||
await fetchProfile(user.id);
|
||||
}, [user?.id, fetchProfile]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, profile, loading, signOut }}>
|
||||
<AuthContext.Provider
|
||||
value={{ user, profile, loading, signOut, refreshProfile }}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
@ -154,6 +170,7 @@ export function useAuth(): AuthContextValue {
|
||||
signOut: async () => {
|
||||
window.location.href = "/login";
|
||||
},
|
||||
refreshProfile: async () => {},
|
||||
};
|
||||
}
|
||||
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