801 lines
31 KiB
TypeScript
801 lines
31 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText, UserCog, AlertTriangle, Clock, Plug, Palette, ShieldCheck } from 'lucide-react'
|
|
import { PageMeta } from '@/components/common/PageMeta'
|
|
import { BrandingSettings } from '@/components/settings/BrandingSettings'
|
|
import { accountsApi } from '@/api/accounts'
|
|
import type { Account, AccountMember, AccountInvite } from '@/types'
|
|
import { TransferOwnershipModal } from '@/components/account/TransferOwnershipModal'
|
|
import { LeaveAccountModal } from '@/components/account/LeaveAccountModal'
|
|
import { DeleteAccountModal } from '@/components/account/DeleteAccountModal'
|
|
import { Button } from '@/components/ui/Button'
|
|
import { Spinner } from '@/components/common/Spinner'
|
|
import { cn } from '@/lib/utils'
|
|
import { usePermissions } from '@/hooks/usePermissions'
|
|
import { useSubscription } from '@/hooks/useSubscription'
|
|
import { useAuthStore } from '@/store/authStore'
|
|
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
|
import { CheckoutButton } from '@/components/subscription/CheckoutButton'
|
|
import { toast } from '@/lib/toast'
|
|
|
|
export function AccountSettingsPage() {
|
|
const { isAccountOwner } = usePermissions()
|
|
const { plan, limits, usage } = useSubscription()
|
|
const { defaultExportFormat, setDefaultExportFormat } = useUserPreferencesStore()
|
|
const user = useAuthStore((s) => s.user)
|
|
const subscription = useAuthStore((s) => s.subscription)
|
|
|
|
const [account, setAccount] = useState<Account | null>(null)
|
|
const [members, setMembers] = useState<AccountMember[]>([])
|
|
const [invites, setInvites] = useState<AccountInvite[]>([])
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
// Account name editing
|
|
const [isEditingName, setIsEditingName] = useState(false)
|
|
const [editedName, setEditedName] = useState('')
|
|
const [isSavingName, setIsSavingName] = useState(false)
|
|
|
|
// Modals
|
|
const [showTransferModal, setShowTransferModal] = useState(false)
|
|
const [showLeaveModal, setShowLeaveModal] = useState(false)
|
|
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
|
|
|
// Invite form
|
|
const [inviteEmail, setInviteEmail] = useState('')
|
|
const [inviteRole, setInviteRole] = useState('engineer')
|
|
const [isInviting, setIsInviting] = useState(false)
|
|
const [inviteError, setInviteError] = useState<string | null>(null)
|
|
const [inviteSuccess, setInviteSuccess] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
loadData()
|
|
}, [])
|
|
|
|
const loadData = async () => {
|
|
setIsLoading(true)
|
|
setError(null)
|
|
try {
|
|
const accountData = await accountsApi.getMyAccount()
|
|
setAccount(accountData)
|
|
setEditedName(accountData.name)
|
|
|
|
if (isAccountOwner) {
|
|
const [membersData, invitesData] = await Promise.all([
|
|
accountsApi.getMembers(),
|
|
accountsApi.getInvites(),
|
|
])
|
|
setMembers(membersData)
|
|
setInvites(invitesData)
|
|
}
|
|
} catch (err) {
|
|
setError('Failed to load account information')
|
|
console.error(err)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleSaveName = async () => {
|
|
if (!editedName.trim() || editedName === account?.name) {
|
|
setIsEditingName(false)
|
|
return
|
|
}
|
|
setIsSavingName(true)
|
|
try {
|
|
const updated = await accountsApi.updateMyAccount({ name: editedName.trim() })
|
|
setAccount(updated)
|
|
setIsEditingName(false)
|
|
} catch (err) {
|
|
console.error('Failed to update account name:', err)
|
|
} finally {
|
|
setIsSavingName(false)
|
|
}
|
|
}
|
|
|
|
const handleInvite = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (!inviteEmail.trim()) return
|
|
|
|
setIsInviting(true)
|
|
setInviteError(null)
|
|
setInviteSuccess(null)
|
|
try {
|
|
await accountsApi.createInvite({ email: inviteEmail.trim(), role: inviteRole })
|
|
setInviteSuccess(`Invitation sent to ${inviteEmail}`)
|
|
setInviteEmail('')
|
|
// Refresh invites list
|
|
const invitesData = await accountsApi.getInvites()
|
|
setInvites(invitesData)
|
|
} catch (err) {
|
|
setInviteError('Failed to send invitation')
|
|
console.error(err)
|
|
} finally {
|
|
setIsInviting(false)
|
|
}
|
|
}
|
|
|
|
const [resendingId, setResendingId] = useState<string | null>(null)
|
|
|
|
const handleResendInvite = async (inviteId: string) => {
|
|
setResendingId(inviteId)
|
|
try {
|
|
await accountsApi.resendInvite(inviteId)
|
|
toast.success('Invite resent with a new code')
|
|
const invitesData = await accountsApi.getInvites()
|
|
setInvites(invitesData)
|
|
} catch {
|
|
toast.error('Failed to resend invite')
|
|
} finally {
|
|
setResendingId(null)
|
|
}
|
|
}
|
|
|
|
const handleRemoveMember = async (userId: string) => {
|
|
try {
|
|
await accountsApi.removeMember(userId)
|
|
setMembers(members.filter((m) => m.id !== userId))
|
|
} catch (err) {
|
|
console.error('Failed to remove member:', err)
|
|
}
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex justify-center py-12">
|
|
<Spinner />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="rounded-md border border-red-400/20 bg-red-400/10 p-4 text-red-400">
|
|
<div className="flex items-center gap-2">
|
|
<AlertCircle className="h-5 w-5" />
|
|
{error}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const sub = subscription?.subscription
|
|
|
|
return (
|
|
<>
|
|
<PageMeta title="Account Settings" />
|
|
<div>
|
|
<div className="mb-8">
|
|
<div className="flex items-center gap-3">
|
|
<Building2 className="h-8 w-8 text-[#848b9b]" />
|
|
<h1 className="text-2xl font-bold font-heading text-[#e2e5eb] sm:text-3xl">Account Settings</h1>
|
|
</div>
|
|
<p className="mt-2 text-[#848b9b]">
|
|
Manage your account, subscription, and team
|
|
</p>
|
|
</div>
|
|
|
|
<div className="max-w-3xl space-y-6">
|
|
{/* Account Info Section */}
|
|
<div className="bg-[#14161d] border border-[#1e2130] rounded-xl p-4 sm:p-6">
|
|
<h2 className="text-lg font-semibold text-[#e2e5eb]">Account Information</h2>
|
|
|
|
<div className="mt-4 space-y-4">
|
|
{/* Account Name */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#e2e5eb]">
|
|
Account Name
|
|
</label>
|
|
{isEditingName ? (
|
|
<div className="mt-1 flex items-center gap-2">
|
|
<input
|
|
type="text"
|
|
value={editedName}
|
|
onChange={(e) => setEditedName(e.target.value)}
|
|
className={cn(
|
|
'flex-1 rounded-md border border-[#1e2130] bg-[#14161d] px-3 py-2',
|
|
'text-[#e2e5eb] placeholder:text-[#848b9b]',
|
|
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
|
)}
|
|
autoFocus
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') handleSaveName()
|
|
if (e.key === 'Escape') {
|
|
setEditedName(account?.name ?? '')
|
|
setIsEditingName(false)
|
|
}
|
|
}}
|
|
/>
|
|
<Button
|
|
onClick={handleSaveName}
|
|
loading={isSavingName}
|
|
size="icon-sm"
|
|
>
|
|
<Check className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="icon-sm"
|
|
onClick={() => {
|
|
setEditedName(account?.name ?? '')
|
|
setIsEditingName(false)
|
|
}}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="mt-1 flex items-center gap-2">
|
|
<span className="text-sm text-[#e2e5eb]">{account?.name}</span>
|
|
{isAccountOwner && (
|
|
<button
|
|
onClick={() => setIsEditingName(true)}
|
|
className="text-xs text-[#e2e5eb] hover:underline"
|
|
>
|
|
Edit
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Display Code */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#e2e5eb]">
|
|
Display Code
|
|
</label>
|
|
<p className="mt-1 text-sm font-mono text-[#848b9b]">
|
|
{account?.display_code}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Subscription Section */}
|
|
<div className="bg-[#14161d] border border-[#1e2130] rounded-xl p-4 sm:p-6">
|
|
<h2 className="text-lg font-semibold text-[#e2e5eb]">Subscription</h2>
|
|
|
|
<div className="mt-4 space-y-4">
|
|
{/* Plan & Status */}
|
|
<div className="flex items-center gap-3">
|
|
<span
|
|
className={cn(
|
|
'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium',
|
|
plan === 'free' && 'bg-accent text-[#848b9b]',
|
|
plan === 'pro' && 'bg-accent text-[#e2e5eb]',
|
|
plan === 'team' && 'bg-accent text-[#e2e5eb]'
|
|
)}
|
|
>
|
|
<Crown className="h-3.5 w-3.5" />
|
|
{plan.charAt(0).toUpperCase() + plan.slice(1)} Plan
|
|
</span>
|
|
{sub && (
|
|
<span
|
|
className={cn(
|
|
'inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium',
|
|
sub.status === 'active' && 'bg-green-500/10 text-emerald-400',
|
|
sub.status === 'trialing' && 'bg-blue-500/10 text-blue-400',
|
|
sub.status === 'past_due' && 'bg-yellow-500/10 text-yellow-400',
|
|
sub.status === 'canceled' && 'bg-red-400/10 text-red-400',
|
|
sub.status === 'orphaned' && 'bg-accent text-[#848b9b]'
|
|
)}
|
|
>
|
|
{sub.status.charAt(0).toUpperCase() + sub.status.slice(1).replace('_', ' ')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{sub?.current_period_end && (
|
|
<p className="text-sm text-[#848b9b]">
|
|
Current period ends: {new Date(sub.current_period_end).toLocaleDateString()}
|
|
</p>
|
|
)}
|
|
|
|
{/* Usage Stats */}
|
|
{limits && usage && (
|
|
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
|
<UsageStat
|
|
label="Trees"
|
|
current={usage.tree_count}
|
|
max={limits.max_trees}
|
|
/>
|
|
<UsageStat
|
|
label="Sessions / month"
|
|
current={usage.session_count_this_month}
|
|
max={limits.max_sessions_per_month}
|
|
/>
|
|
<UsageStat
|
|
label="Team members"
|
|
current={usage.user_count}
|
|
max={limits.max_users}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Upgrade buttons */}
|
|
{plan === 'free' && (
|
|
<div className="mt-4 flex gap-3">
|
|
<CheckoutButton plan="pro" />
|
|
<CheckoutButton plan="team" />
|
|
</div>
|
|
)}
|
|
{plan === 'pro' && (
|
|
<div className="mt-4">
|
|
<CheckoutButton plan="team" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Team Members Section (owners only) */}
|
|
{isAccountOwner && (
|
|
<div className="bg-[#14161d] border border-[#1e2130] rounded-xl p-4 sm:p-6">
|
|
<div className="flex items-center gap-2">
|
|
<Users className="h-5 w-5 text-[#848b9b]" />
|
|
<h2 className="text-lg font-semibold text-[#e2e5eb]">Team Members</h2>
|
|
</div>
|
|
|
|
{members.length === 0 ? (
|
|
<p className="mt-4 text-sm text-[#848b9b]">No team members yet.</p>
|
|
) : (
|
|
<div className="mt-4 divide-y divide-border">
|
|
{members.map((member) => (
|
|
<div
|
|
key={member.id}
|
|
className="flex items-center justify-between py-3 first:pt-0 last:pb-0"
|
|
>
|
|
<div>
|
|
<p className="text-sm font-medium text-[#e2e5eb]">{member.name}</p>
|
|
<p className="text-xs text-[#848b9b]">{member.email}</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{member.account_role === 'owner' ? (
|
|
<span className="rounded-full px-2.5 py-0.5 text-xs font-medium bg-accent text-[#e2e5eb]">
|
|
owner
|
|
</span>
|
|
) : (
|
|
<select
|
|
value={member.account_role}
|
|
onChange={async (e) => {
|
|
try {
|
|
const updated = await accountsApi.updateMemberRole(member.id, e.target.value)
|
|
setMembers(members.map((m) => m.id === member.id ? { ...m, account_role: updated.account_role } : m))
|
|
toast.success(`Role updated to ${updated.account_role}`)
|
|
} catch {
|
|
toast.error('Failed to update role')
|
|
}
|
|
}}
|
|
className={cn(
|
|
'rounded-md border border-[#1e2130] bg-[#14161d] px-2 py-0.5 text-xs',
|
|
'text-[#e2e5eb] focus:border-primary focus:outline-hidden'
|
|
)}
|
|
>
|
|
<option value="engineer">engineer</option>
|
|
<option value="viewer">viewer</option>
|
|
</select>
|
|
)}
|
|
{!member.is_active && (
|
|
<span className="rounded-full bg-red-400/10 px-2 py-0.5 text-xs text-red-400">
|
|
Inactive
|
|
</span>
|
|
)}
|
|
{member.account_role !== 'owner' && (
|
|
<button
|
|
onClick={() => handleRemoveMember(member.id)}
|
|
className="text-[#848b9b] hover:text-red-400"
|
|
title="Remove member"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Invite Member Section (owners only) */}
|
|
{isAccountOwner && (
|
|
<div className="bg-[#14161d] border border-[#1e2130] rounded-xl p-4 sm:p-6">
|
|
<div className="flex items-center gap-2">
|
|
<Mail className="h-5 w-5 text-[#848b9b]" />
|
|
<h2 className="text-lg font-semibold text-[#e2e5eb]">Invite Member</h2>
|
|
</div>
|
|
|
|
<form onSubmit={handleInvite} className="mt-4 space-y-3">
|
|
<div className="flex flex-col gap-3 sm:flex-row">
|
|
<input
|
|
type="email"
|
|
placeholder="Email address"
|
|
value={inviteEmail}
|
|
onChange={(e) => setInviteEmail(e.target.value)}
|
|
required
|
|
className={cn(
|
|
'flex-1 rounded-md border border-[#1e2130] bg-[#14161d] px-3 py-2',
|
|
'text-[#e2e5eb] placeholder:text-[#848b9b]',
|
|
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
|
)}
|
|
/>
|
|
<select
|
|
value={inviteRole}
|
|
onChange={(e) => setInviteRole(e.target.value)}
|
|
className={cn(
|
|
'rounded-md border border-[#1e2130] bg-[#14161d] px-3 py-2',
|
|
'text-[#e2e5eb] focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
|
)}
|
|
>
|
|
<option value="engineer">Engineer</option>
|
|
<option value="viewer">Viewer</option>
|
|
</select>
|
|
<Button
|
|
type="submit"
|
|
disabled={!inviteEmail.trim()}
|
|
loading={isInviting}
|
|
>
|
|
{isInviting ? 'Sending...' : 'Send Invite'}
|
|
</Button>
|
|
</div>
|
|
|
|
{inviteError && (
|
|
<p className="text-sm text-red-400">{inviteError}</p>
|
|
)}
|
|
{inviteSuccess && (
|
|
<p className="text-sm text-emerald-400">{inviteSuccess}</p>
|
|
)}
|
|
</form>
|
|
|
|
{/* Pending Invites */}
|
|
{invites.length > 0 && (
|
|
<div className="mt-6">
|
|
<h3 className="text-sm font-medium text-[#e2e5eb]">Pending Invites</h3>
|
|
<div className="mt-2 divide-y divide-border">
|
|
{invites
|
|
.filter((inv) => !inv.used_at)
|
|
.map((invite) => (
|
|
<div
|
|
key={invite.id}
|
|
className="flex items-center justify-between py-2"
|
|
>
|
|
<div>
|
|
<p className="text-sm text-[#e2e5eb]">{invite.email}</p>
|
|
<p className="text-xs text-[#848b9b]">
|
|
{invite.expires_at
|
|
? `Expires ${new Date(invite.expires_at).toLocaleDateString()}`
|
|
: 'No expiration'}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="rounded-full bg-accent px-2.5 py-0.5 text-xs text-[#848b9b]">
|
|
{invite.role}
|
|
</span>
|
|
<button
|
|
onClick={() => handleResendInvite(invite.id)}
|
|
disabled={resendingId === invite.id}
|
|
className="text-[#848b9b] hover:text-[#e2e5eb] disabled:opacity-50"
|
|
title="Resend invite"
|
|
>
|
|
{resendingId === invite.id ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="h-4 w-4" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Profile Settings Link */}
|
|
<Link
|
|
to="/account/profile"
|
|
className="bg-[#14161d] border border-[#1e2130] rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-[#1e2130] transition-all"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<UserCog className="h-5 w-5 text-[#848b9b]" />
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-[#e2e5eb]">Profile Settings</h2>
|
|
<p className="text-sm text-[#848b9b]">Update your name, email, and personal details</p>
|
|
</div>
|
|
</div>
|
|
<span className="text-[#848b9b] group-hover:text-[#e2e5eb] transition-colors">→</span>
|
|
</Link>
|
|
|
|
{/* Team Categories Link (owners only) */}
|
|
{isAccountOwner && (
|
|
<Link
|
|
to="/account/categories"
|
|
className="bg-[#14161d] border border-[#1e2130] rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-[#1e2130] transition-all"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<FolderTree className="h-5 w-5 text-[#848b9b]" />
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-[#e2e5eb]">Team Categories</h2>
|
|
<p className="text-sm text-[#848b9b]">Manage tree categories for your team</p>
|
|
</div>
|
|
</div>
|
|
<span className="text-[#848b9b] group-hover:text-[#e2e5eb] transition-colors">→</span>
|
|
</Link>
|
|
)}
|
|
|
|
{/* Target Lists Link (owners only) */}
|
|
{isAccountOwner && (
|
|
<Link
|
|
to="/account/target-lists"
|
|
className="bg-[#14161d] border border-[#1e2130] rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-[#1e2130] transition-all"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<Server className="h-5 w-5 text-[#848b9b]" />
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-[#e2e5eb]">Target Lists</h2>
|
|
<p className="text-sm text-[#848b9b]">Saved server lists for maintenance flow batch launching</p>
|
|
</div>
|
|
</div>
|
|
<span className="text-[#848b9b] group-hover:text-[#e2e5eb] transition-colors">→</span>
|
|
</Link>
|
|
)}
|
|
|
|
{/* Chat Retention Link (owners only) */}
|
|
{isAccountOwner && (
|
|
<Link
|
|
to="/account/chat-retention"
|
|
className="bg-[#14161d] border border-[#1e2130] rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-[#1e2130] transition-all"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<Clock className="h-5 w-5 text-[#848b9b]" />
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-[#e2e5eb]">Chat Retention</h2>
|
|
<p className="text-sm text-[#848b9b]">Configure AI assistant conversation retention policies</p>
|
|
</div>
|
|
</div>
|
|
<span className="text-[#848b9b] group-hover:text-[#e2e5eb] transition-colors">→</span>
|
|
</Link>
|
|
)}
|
|
|
|
{/* Integrations Link (owners only) */}
|
|
{isAccountOwner && (
|
|
<Link
|
|
to="/account/integrations"
|
|
className="bg-[#14161d] border border-[#1e2130] rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-[#1e2130] transition-all"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<Plug className="h-5 w-5 text-[#848b9b]" />
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-[#e2e5eb]">Integrations</h2>
|
|
<p className="text-sm text-[#848b9b]">Connect your PSA to sync session documentation to tickets</p>
|
|
</div>
|
|
</div>
|
|
<span className="text-[#848b9b] group-hover:text-[#e2e5eb] transition-colors">→</span>
|
|
</Link>
|
|
)}
|
|
|
|
{/* Branding Link (owners only) */}
|
|
{isAccountOwner && (
|
|
<Link
|
|
to="/account/branding"
|
|
className="bg-[#14161d] border border-[#1e2130] rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-[#1e2130] transition-all"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<Palette className="h-5 w-5 text-[#848b9b]" />
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-[#e2e5eb]">Branding</h2>
|
|
<p className="text-sm text-[#848b9b]">Customize logo, accent color, and company name</p>
|
|
</div>
|
|
</div>
|
|
<span className="text-[#848b9b] group-hover:text-[#e2e5eb] transition-colors">→</span>
|
|
</Link>
|
|
)}
|
|
|
|
{/* Feedback Link (all users) */}
|
|
<Link
|
|
to="/feedback"
|
|
className="bg-[#14161d] border border-[#1e2130] rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-[#1e2130] transition-all"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<MessageSquareText className="h-5 w-5 text-[#848b9b]" />
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-[#e2e5eb]">Send Feedback</h2>
|
|
<p className="text-sm text-[#848b9b]">Report bugs, request features, or share your thoughts</p>
|
|
</div>
|
|
</div>
|
|
<span className="text-[#848b9b] group-hover:text-[#e2e5eb] transition-colors">→</span>
|
|
</Link>
|
|
|
|
{/* Branding Section (owners only) */}
|
|
{isAccountOwner && user?.team_id && (
|
|
<BrandingSettings teamId={user.team_id} />
|
|
)}
|
|
|
|
{/* Preferences Section */}
|
|
<div className="bg-[#14161d] border border-[#1e2130] rounded-xl p-4 sm:p-6">
|
|
<div className="flex items-center gap-2">
|
|
<Settings className="h-5 w-5 text-[#848b9b]" />
|
|
<h2 className="text-lg font-semibold text-[#e2e5eb]">Preferences</h2>
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
<label
|
|
htmlFor="export-format"
|
|
className="block text-sm font-medium text-[#e2e5eb]"
|
|
>
|
|
Default Export Format
|
|
</label>
|
|
<p className="text-sm text-[#848b9b]">
|
|
This format will be pre-selected when exporting sessions
|
|
</p>
|
|
<select
|
|
id="export-format"
|
|
value={defaultExportFormat}
|
|
onChange={(e) => {
|
|
setDefaultExportFormat(e.target.value as 'markdown' | 'text' | 'html')
|
|
toast.success('Preference saved')
|
|
}}
|
|
className={cn(
|
|
'mt-2 block w-full max-w-xs rounded-xl border border-[#1e2130] bg-[#14161d] px-3 py-2',
|
|
'text-sm text-[#e2e5eb]',
|
|
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
|
)}
|
|
>
|
|
<option value="markdown">Markdown (.md)</option>
|
|
<option value="text">Plain Text (.txt)</option>
|
|
<option value="html">HTML (.html)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
{/* SSO Section (Task 11) */}
|
|
{isAccountOwner && (
|
|
<div className="bg-[#14161d] border border-[#1e2130] rounded-xl p-4 sm:p-6">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<ShieldCheck className="h-5 w-5 text-[#848b9b]" />
|
|
<h2 className="text-lg font-semibold text-[#e2e5eb]">Single Sign-On (SSO)</h2>
|
|
<span className="inline-flex items-center rounded-full bg-[rgba(34,211,238,0.10)] px-2.5 py-0.5 text-xs font-sans text-xs font-medium text-[#22d3ee]">
|
|
Enterprise
|
|
</span>
|
|
</div>
|
|
<p className="text-sm text-[#848b9b] mb-4">
|
|
SAML and OIDC single sign-on is available for enterprise plans. Contact us to enable SSO for
|
|
your organization.
|
|
</p>
|
|
<a
|
|
href="mailto:support@resolutionflow.com?subject=SSO%20Setup%20Request"
|
|
className={cn(
|
|
'inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium',
|
|
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-[#e2e5eb]',
|
|
'hover:border-[rgba(255,255,255,0.12)] transition-all'
|
|
)}
|
|
>
|
|
Contact Us
|
|
</a>
|
|
</div>
|
|
)}
|
|
|
|
{/* Danger Zone */}
|
|
<div className="rounded-xl border border-rose-500/20 p-4 sm:p-6">
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<AlertTriangle className="h-5 w-5 text-rose-500" />
|
|
<h2 className="text-lg font-semibold text-[#e2e5eb]">Danger Zone</h2>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{isAccountOwner ? (
|
|
<>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium text-[#e2e5eb]">Transfer Ownership</p>
|
|
<p className="text-xs text-[#848b9b]">Make another member the account owner</p>
|
|
</div>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => setShowTransferModal(true)}
|
|
className="border-amber-500/30 text-amber-400 hover:bg-amber-500/10"
|
|
>
|
|
Transfer
|
|
</Button>
|
|
</div>
|
|
<div className="flex items-center justify-between border-t border-[#1e2130] pt-3">
|
|
<div>
|
|
<p className="text-sm font-medium text-[#e2e5eb]">Delete Account</p>
|
|
<p className="text-xs text-[#848b9b]">Permanently delete your account and all data</p>
|
|
</div>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => setShowDeleteModal(true)}
|
|
>
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium text-[#e2e5eb]">Leave Account</p>
|
|
<p className="text-xs text-[#848b9b]">Leave this account and create a personal one</p>
|
|
</div>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => setShowLeaveModal(true)}
|
|
>
|
|
Leave
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Modals */}
|
|
{showTransferModal && (
|
|
<TransferOwnershipModal
|
|
members={members}
|
|
onClose={() => setShowTransferModal(false)}
|
|
onTransferred={() => { setShowTransferModal(false); loadData() }}
|
|
/>
|
|
)}
|
|
{showLeaveModal && account && (
|
|
<LeaveAccountModal
|
|
accountName={account.name}
|
|
onClose={() => setShowLeaveModal(false)}
|
|
/>
|
|
)}
|
|
{showDeleteModal && (
|
|
<DeleteAccountModal onClose={() => setShowDeleteModal(false)} />
|
|
)}
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
/** Small helper component for usage stat display */
|
|
function UsageStat({
|
|
label,
|
|
current,
|
|
max,
|
|
}: {
|
|
label: string
|
|
current: number
|
|
max: number | null
|
|
}) {
|
|
const isUnlimited = max === null
|
|
const percentage = isUnlimited ? 0 : Math.min((current / max) * 100, 100)
|
|
const isNearLimit = !isUnlimited && percentage >= 80
|
|
const isAtLimit = !isUnlimited && current >= max
|
|
|
|
return (
|
|
<div className="rounded-md border border-[#1e2130] bg-[#14161d] p-3">
|
|
<p className="text-xs font-medium text-[#848b9b]">{label}</p>
|
|
<p
|
|
className={cn(
|
|
'mt-1 text-lg font-semibold',
|
|
isAtLimit ? 'text-red-400' : isNearLimit ? 'text-yellow-400' : 'text-[#e2e5eb]'
|
|
)}
|
|
>
|
|
{current}
|
|
<span className="text-sm font-normal text-[#848b9b]">
|
|
{' '}/ {isUnlimited ? 'Unlimited' : max}
|
|
</span>
|
|
</p>
|
|
{!isUnlimited && (
|
|
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-accent">
|
|
<div
|
|
className={cn(
|
|
'h-full rounded-full transition-all',
|
|
isAtLimit ? 'bg-red-400' : isNearLimit ? 'bg-yellow-500' : 'bg-primary'
|
|
)}
|
|
style={{ width: `${percentage}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default AccountSettingsPage
|