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:
Arnas Donauskas 2026-04-20 17:40:39 +03:00
parent c2839e6305
commit 72a5e4cf70
No known key found for this signature in database
8 changed files with 963 additions and 68 deletions

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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>
</>

View 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>
);
}

View 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>
);
}

View 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&apos;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>
</>
);
}

View File

@ -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;

View 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]
);