diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index e9ed10e..32eda1b 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -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(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 (

Settings

- Manage your WhatsApp integration, message templates, and tags. + Manage your profile, WhatsApp integration, message templates, and + tags.

- + onChange(v as TabValue)}> - + + + Profile + + WhatsApp Config - + Templates - + Tags + + + + + + diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx index 3030b86..f00cfb2 100644 --- a/src/components/layout/header.tsx +++ b/src/components/layout/header.tsx @@ -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 = { "/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 (
@@ -50,26 +68,72 @@ export function Header({ onOpenSidebar }: HeaderProps) {
-
-
-
- {profile?.full_name?.charAt(0)?.toUpperCase() ?? "U"} -
-
-

+ + + + {profile?.avatar_url ? ( + + ) : null} + + {initial} + + + + {profile?.full_name ?? "User"} + + + +

+

{profile?.full_name ?? "User"}

+

+ {profile?.email ?? ""} +

-
- -
+ + + } + > + + Profile + + + } + > + + Settings + + + + + Sign out + + +
); } diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index f01d2cb..4bf91a8 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -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,26 +193,70 @@ export function Sidebar({ open = false, onClose }: SidebarProps) { {/* User section */}
-
-
- {profile?.full_name?.charAt(0)?.toUpperCase() ?? "U"} -
-
-

- {profile?.full_name ?? "User"} -

-

- {profile?.email ?? ""} -

-
- -
+ + } + > + + Profile + + + } + > + + Settings + + + + + Sign out + + +
diff --git a/src/components/settings/password-form.tsx b/src/components/settings/password-form.tsx new file mode 100644 index 0000000..9706808 --- /dev/null +++ b/src/components/settings/password-form.tsx @@ -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(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 ( + + + + + Password + + + Use at least {MIN_PASSWORD} characters. You will stay signed in on + this device after changing it. + + + + +
+
+ + setCurrent(e.target.value)} + autoComplete="current-password" + disabled={saving} + required + /> +
+ +
+
+ + setNext(e.target.value)} + autoComplete="new-password" + minLength={MIN_PASSWORD} + disabled={saving} + required + /> +
+
+ + setConfirm(e.target.value)} + autoComplete="new-password" + minLength={MIN_PASSWORD} + disabled={saving} + required + /> +
+
+ + {confirmError && ( +

+ {confirmError} +

+ )} + +
+ +
+
+
+
+ ); +} diff --git a/src/components/settings/profile-form.tsx b/src/components/settings/profile-form.tsx new file mode 100644 index 0000000..773a3c5 --- /dev/null +++ b/src/components/settings/profile-form.tsx @@ -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(null); + + const [fullName, setFullName] = useState(''); + const [email, setEmail] = useState(''); + const [pendingAvatar, setPendingAvatar] = useState(null); + const [previewUrl, setPreviewUrl] = useState(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) => { + 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 ( + + + Profile + + How you show up across WaCRM. Your avatar and name appear in the + header, sidebar, and anywhere your teammates see you. + + + + +
+ {/* Avatar row */} +
+ + {currentAvatar ? ( + + ) : null} + + {initial} + + + +
+ + + {currentAvatar && ( + + )} +

+ PNG, JPG, WebP, or GIF. Up to 2 MB. +

+
+
+ + {/* Name */} +
+ + setFullName(e.target.value)} + placeholder="Ada Lovelace" + maxLength={120} + disabled={saving} + required + /> +
+ + {/* Email */} +
+ + setEmail(e.target.value)} + disabled={saving} + required + /> + {emailChangePending && ( +

+ + + Check the inbox for {profile?.email} and{' '} + {email} — both need to confirm before the + change takes effect. + +

+ )} +
+ + {/* Read-only block */} +
+

+ Account details +

+
+
+
Role
+
+ {profile?.role ?? 'user'} +
+
+
+
Joined
+
{joined}
+
+
+
User ID
+
+ {user?.id ?? '—'} +
+
+
+
+ + {!profile && ( +

+ + Loading your profile… +

+ )} + +
+ +
+
+
+
+ ); +} diff --git a/src/components/settings/sessions-card.tsx b/src/components/settings/sessions-card.tsx new file mode 100644 index 0000000..20306c2 --- /dev/null +++ b/src/components/settings/sessions-card.tsx @@ -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 ( + <> + + + + + Active sessions + + + 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. + + + + + + + + + + + Sign out everywhere? + + 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. + + + + + + + + + + ); +} diff --git a/src/hooks/use-auth.tsx b/src/hooks/use-auth.tsx index 5085b04..b187a93 100644 --- a/src/hooks/use-auth.tsx +++ b/src/hooks/use-auth.tsx @@ -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; + /** 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; } const AuthContext = createContext(null); @@ -37,6 +42,34 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(true); + // 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(); + try { + const { data, error } = await supabase + .from("profiles") + .select("id, full_name, email, avatar_url, role") + .eq("user_id", userId) + .maybeSingle(); + + if (error) { + console.error("[AuthProvider] fetchProfile error:", { + message: error.message, + details: error.details, + hint: error.hint, + code: error.code, + }); + return; + } + + if (data) setProfile(data); + } catch (err) { + console.error("[AuthProvider] fetchProfile threw:", err); + } + }, []); + useEffect(() => { const supabase = createClient(); let mounted = true; @@ -48,30 +81,6 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }, 3000); - const fetchProfile = async (userId: string) => { - try { - const { data, error } = await supabase - .from("profiles") - .select("id, full_name, email, avatar_url") - .eq("user_id", userId) - .maybeSingle(); - - if (error) { - console.error("[AuthProvider] fetchProfile error:", { - message: error.message, - details: error.details, - hint: error.hint, - code: error.code, - }); - return; - } - - if (data && mounted) setProfile(data); - } catch (err) { - console.error("[AuthProvider] fetchProfile threw:", err); - } - }; - const init = async () => { try { const { @@ -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 ( - + {children} ); @@ -154,6 +170,7 @@ export function useAuth(): AuthContextValue { signOut: async () => { window.location.href = "/login"; }, + refreshProfile: async () => {}, }; } return ctx; diff --git a/supabase/migrations/008_profile_avatars_storage.sql b/supabase/migrations/008_profile_avatars_storage.sql new file mode 100644 index 0000000..9fe02fc --- /dev/null +++ b/supabase/migrations/008_profile_avatars_storage.sql @@ -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 tags without signed URLs works). +-- +-- File path convention used by the app: +-- avatars/{auth.uid()}/avatar-. +-- 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] + );