fix(dev): Vite-API-Proxy, Auth, Stripe-Mails und Backend-Erweiterungen

- Client: API-Basis-URL (joinApiUrl, /v1-Falle), Vite strictPort + Proxy 127.0.0.1, Nicht-JSON-Fehler

- Server: /api-404 ohne Wildcard-Bug, SPA-Fallback, Auth-Middleware, Cron, Mailer, Crypto

- Routen: OAuth-State, Email/Stripe/Analytics; client/.env.example

Made-with: Cursor
This commit is contained in:
2026-04-03 00:23:01 +02:00
parent 61008b63bb
commit ecae89a79d
33 changed files with 1663 additions and 550 deletions

View File

@@ -60,12 +60,74 @@ import { PrivacySecurity } from '@/components/PrivacySecurity'
type TabType = 'profile' | 'accounts' | 'vip' | 'ai-control' | 'name-labels' | 'subscription' | 'privacy' | 'referrals'
const HEX_COLOR = /^#([0-9A-Fa-f]{6})$/
function validateLabelImport(
imported: unknown,
existing: CompanyLabel[]
): { labels: CompanyLabel[]; errors: string[] } {
const errors: string[] = []
if (!Array.isArray(imported)) {
return { labels: [], errors: ['File must contain a JSON array'] }
}
const seen = new Set<string>()
const labels: CompanyLabel[] = []
imported.forEach((row, i) => {
const rowNum = i + 1
if (!row || typeof row !== 'object') {
errors.push(`Row ${rowNum}: invalid object`)
return
}
const r = row as Record<string, unknown>
const name = typeof r.name === 'string' ? r.name.trim() : ''
if (!name) {
errors.push(`Row ${rowNum}: name is required`)
return
}
if (name.length > 50) {
errors.push(`Row ${rowNum}: name must be at most 50 characters`)
return
}
if (r.color != null && r.color !== '') {
if (typeof r.color !== 'string' || !HEX_COLOR.test(r.color)) {
errors.push(`Row ${rowNum}: color must be a valid #RRGGBB hex`)
return
}
}
const key = name.toLowerCase()
if (seen.has(key)) {
errors.push(`Row ${rowNum}: duplicate name "${name}" in import`)
return
}
seen.add(key)
if (existing.some((e) => e.name.trim().toLowerCase() === key)) {
errors.push(`Row ${rowNum}: name "${name}" already exists`)
return
}
labels.push({
id: typeof r.id === 'string' && r.id ? r.id : `label_import_${Date.now()}_${i}`,
name,
condition: typeof r.condition === 'string' ? r.condition : '',
enabled: r.enabled !== false,
category: typeof r.category === 'string' ? r.category : 'promotions',
})
})
if (existing.length + labels.length > 100) {
return {
labels: [],
errors: [`Cannot exceed 100 labels total (have ${existing.length}, importing ${labels.length})`],
}
}
return { labels, errors }
}
interface EmailAccount {
id: string
email: string
provider: 'gmail' | 'outlook' | 'imap'
provider: 'gmail' | 'outlook' | 'imap' | 'demo'
connected: boolean
lastSync?: string
isDemo?: boolean
}
interface VIPSender {
@@ -76,10 +138,35 @@ interface VIPSender {
interface Subscription {
status: string
plan: string
planDisplayName?: string
isFreeTier?: boolean
currentPeriodEnd?: string
cancelAtPeriodEnd?: boolean
}
function subscriptionTitle(sub: Subscription | null): string {
if (!sub) return ''
if (sub.planDisplayName) return sub.planDisplayName
if (sub.plan === 'free' || sub.isFreeTier) return 'Free plan'
if (sub.plan) return sub.plan.charAt(0).toUpperCase() + sub.plan.slice(1)
return 'Subscription'
}
function subscriptionBadge(sub: Subscription | null): {
label: string
variant: 'success' | 'warning' | 'secondary'
} {
if (!sub) return { label: '', variant: 'secondary' }
if (sub.isFreeTier) return { label: 'Free plan', variant: 'secondary' }
if (sub.status === 'active') return { label: 'Active', variant: 'success' }
const s = (sub.status || '').toLowerCase()
if (s === 'trialing' || s === 'trial') return { label: 'Trial', variant: 'warning' }
return {
label: sub.status ? sub.status.charAt(0).toUpperCase() + sub.status.slice(1) : 'Inactive',
variant: 'warning',
}
}
export function Settings() {
const { user } = useAuth()
const navigate = useNavigate()
@@ -130,6 +217,7 @@ export function Settings() {
})
const [categories, setCategories] = useState<CategoryInfo[]>([])
const [companyLabels, setCompanyLabels] = useState<CompanyLabel[]>([])
const [labelImportErrors, setLabelImportErrors] = useState<string[]>([])
const [isAdmin, setIsAdmin] = useState(false)
const [nameLabels, setNameLabels] = useState<NameLabel[]>([])
const [editingNameLabel, setEditingNameLabel] = useState<NameLabel | null>(null)
@@ -174,11 +262,24 @@ export function Settings() {
}
}, [user])
// Refetch subscription when opening this tab (fixes JWT timing vs initial loadData)
useEffect(() => {
if (activeTab !== 'subscription' || !user?.$id) return
let cancelled = false
;(async () => {
const res = await api.getSubscriptionStatus()
if (!cancelled && res.data) setSubscription(res.data)
})()
return () => {
cancelled = true
}
}, [activeTab, user?.$id])
const loadReferralData = async () => {
if (!user?.$id) return
setLoadingReferral(true)
try {
const res = await api.getReferralCode(user.$id)
const res = await api.getReferralCode()
if (res.data) setReferralData(res.data)
} catch (err) {
console.error('Failed to load referral data:', err)
@@ -194,24 +295,33 @@ export function Settings() {
try {
const [accountsRes, subsRes, prefsRes, aiControlRes, companyLabelsRes, meRes] = await Promise.all([
api.getEmailAccounts(user.$id),
api.getSubscriptionStatus(user.$id),
api.getUserPreferences(user.$id),
api.getAIControlSettings(user.$id),
api.getCompanyLabels(user.$id),
user?.email ? api.getMe(user.email) : Promise.resolve({ data: { isAdmin: false } }),
api.getEmailAccounts(),
api.getSubscriptionStatus(),
api.getUserPreferences(),
api.getAIControlSettings(),
api.getCompanyLabels(),
user?.$id ? api.getMe() : Promise.resolve({ data: { isAdmin: false } }),
])
if (accountsRes.data) setAccounts(accountsRes.data)
if (subsRes.data) setSubscription(subsRes.data)
if (meRes.data?.isAdmin) {
setIsAdmin(true)
const nameLabelsRes = await api.getNameLabels(user.$id, user.email)
const nameLabelsRes = await api.getNameLabels()
if (nameLabelsRes.data) setNameLabels(nameLabelsRes.data)
} else {
setIsAdmin(false)
}
if (prefsRes.data?.vipSenders) setVipSenders(prefsRes.data.vipSenders)
const pdata = prefsRes.data as {
profile?: { displayName?: string; timezone?: string; notificationPrefs?: { language?: string } }
} | undefined
if (pdata?.profile) {
const prof = pdata.profile
if (prof.displayName != null && prof.displayName !== '') setName(prof.displayName)
if (prof.timezone) setTimezone(prof.timezone)
if (prof.notificationPrefs?.language) setLanguage(String(prof.notificationPrefs.language))
}
if (aiControlRes.data) {
// Merge cleanup defaults if not present
const raw = aiControlRes.data
@@ -297,7 +407,7 @@ export function Settings() {
if (!user?.$id) return
setSaving(true)
try {
await api.saveAIControlSettings(user.$id, {
await api.saveAIControlSettings({
enabledCategories: aiControlSettings.enabledCategories,
categoryActions: aiControlSettings.categoryActions,
autoDetectCompanies: aiControlSettings.autoDetectCompanies,
@@ -326,24 +436,25 @@ export function Settings() {
// Load cleanup status
const loadCleanupStatus = async () => {
if (!user?.$id) return
const aid = accounts.find((a) => a.provider !== 'demo')?.id
if (!aid) return
try {
const res = await api.getCleanupStatus(user.$id)
const res = await api.getCleanupStatus(aid)
if (res.data) setCleanupStatus(res.data)
} catch {
// Silently fail if endpoint doesn't exist yet
console.debug('Cleanup status endpoint not available')
}
}
// Load cleanup preview
const loadCleanupPreview = async () => {
if (!user?.$id || !aiControlSettings.cleanup?.enabled) return
if (!aiControlSettings.cleanup?.enabled) return
const aid = accounts.find((a) => a.provider !== 'demo')?.id
if (!aid) return
try {
const res = await api.getCleanupPreview(user.$id)
if (res.data?.preview) setCleanupPreview(res.data.preview)
const res = await api.getCleanupPreview(aid)
if (res.data?.messages) setCleanupPreview(res.data.messages)
} catch {
// Silently fail if endpoint doesn't exist yet
console.debug('Cleanup preview endpoint not available')
}
}
@@ -356,14 +467,14 @@ export function Settings() {
loadCleanupPreview()
}
}
}, [activeTab, controlPanelTab, aiControlSettings.cleanup?.enabled, aiControlSettings.cleanup?.safety.dryRun])
}, [activeTab, controlPanelTab, aiControlSettings.cleanup?.enabled, aiControlSettings.cleanup?.safety.dryRun, accounts])
// Run cleanup now
const handleRunCleanup = async () => {
if (!user?.$id) return
setRunningCleanup(true)
try {
const res = await api.runCleanup(user.$id)
const res = await api.runCleanup()
if (res.data) {
showMessage('success', `Cleanup completed: ${res.data.emailsProcessed.readItems + res.data.emailsProcessed.promotions} emails processed`)
await loadCleanupStatus()
@@ -432,8 +543,15 @@ export function Settings() {
if (!user?.$id) return
setSaving(true)
try {
// TODO: Save profile data to backend
// await api.updateUserProfile(user.$id, { name, language, timezone })
const res = await api.updateProfile({
displayName: name,
timezone,
notificationPrefs: { language },
})
if (res.error) {
showMessage('error', res.error.message || 'Failed to save profile')
return
}
savedProfileRef.current = { name, language, timezone }
setHasProfileChanges(false)
showMessage('success', 'Profile saved successfully!')
@@ -472,7 +590,7 @@ export function Settings() {
setConnectingProvider(provider)
try {
const res = await api.getOAuthUrl(provider, user.$id)
const res = await api.getOAuthUrl(provider)
if (res.data?.url) {
window.location.href = res.data.url
}
@@ -486,7 +604,7 @@ export function Settings() {
if (!user?.$id) return
try {
await api.disconnectEmailAccount(accountId, user.$id)
await api.disconnectEmailAccount(accountId)
setAccounts(accounts.filter(a => a.id !== accountId))
showMessage('success', 'Account disconnected')
} catch {
@@ -498,7 +616,7 @@ export function Settings() {
e.preventDefault()
if (!user?.$id || !imapForm.email.trim() || !imapForm.password) return
setImapConnecting(true)
const res = await api.connectImapAccount(user.$id, {
const res = await api.connectImapAccount({
email: imapForm.email.trim(),
password: imapForm.password,
imapHost: imapForm.imapHost || undefined,
@@ -511,7 +629,7 @@ export function Settings() {
setImapConnecting(false)
return
}
const list = await api.getEmailAccounts(user.$id)
const list = await api.getEmailAccounts()
setAccounts(list.data ?? [])
setShowImapForm(false)
setImapForm({ email: '', password: '', imapHost: 'imap.porkbun.com', imapPort: 993, imapSecure: true })
@@ -541,7 +659,7 @@ export function Settings() {
setSaving(true)
try {
await api.saveUserPreferences(user.$id, { vipSenders })
await api.saveUserPreferences({ vipSenders })
showMessage('success', 'VIP list saved!')
} catch {
showMessage('error', 'Failed to save')
@@ -554,7 +672,7 @@ export function Settings() {
if (!user?.$id) return
try {
const res = await api.createPortalSession(user.$id)
const res = await api.createPortalSession()
if (res.data?.url) {
window.location.href = res.data.url
}
@@ -567,7 +685,7 @@ export function Settings() {
if (!user?.$id) return
try {
const res = await api.createSubscriptionCheckout(plan, user.$id, user.email)
const res = await api.createSubscriptionCheckout(plan, user.email)
if (res.data?.url) {
window.location.href = res.data.url
}
@@ -1721,6 +1839,7 @@ export function Settings() {
variant="secondary"
size="sm"
onClick={() => {
setLabelImportErrors([])
const input = document.createElement('input')
input.type = 'file'
input.accept = 'application/json'
@@ -1730,12 +1849,18 @@ export function Settings() {
try {
const text = await file.text()
const imported = JSON.parse(text)
if (Array.isArray(imported)) {
// TODO: Validate and import labels
showMessage('success', `Imported ${imported.length} labels`)
const { labels, errors } = validateLabelImport(imported, companyLabels)
if (errors.length > 0) {
setLabelImportErrors(errors)
showMessage('error', 'Fix import errors before saving')
return
}
setLabelImportErrors([])
setCompanyLabels([...companyLabels, ...labels])
showMessage('success', `Imported ${labels.length} labels`)
} catch {
showMessage('error', 'Invalid JSON file')
setLabelImportErrors([])
}
}
input.click()
@@ -1755,6 +1880,16 @@ export function Settings() {
Add Label
</Button>
</div>
{labelImportErrors.length > 0 && (
<div className="mt-3 w-full rounded-md border border-red-200 dark:border-red-900 bg-red-50 dark:bg-red-950/40 p-3 text-sm text-red-800 dark:text-red-200">
<p className="font-medium mb-1">Import issues</p>
<ul className="list-disc pl-5 space-y-0.5">
{labelImportErrors.map((err, i) => (
<li key={i}>{err}</li>
))}
</ul>
</div>
)}
</div>
{/* Auto-Detection Toggle */}
@@ -1853,7 +1988,7 @@ export function Settings() {
onClick={async () => {
if (!user?.$id || !label.id) return
try {
await api.saveCompanyLabel(user.$id, { ...label, enabled: !label.enabled })
await api.saveCompanyLabel({ ...label, enabled: !label.enabled })
setCompanyLabels(companyLabels.map(l => l.id === label.id ? { ...l, enabled: !l.enabled } : l))
showMessage('success', 'Label updated!')
} catch {
@@ -1874,7 +2009,7 @@ export function Settings() {
if (!user?.$id || !label.id) return
if (!confirm('Are you sure you want to delete this label?')) return
try {
await api.deleteCompanyLabel(user.$id, label.id)
await api.deleteCompanyLabel(label.id)
setCompanyLabels(companyLabels.filter(l => l.id !== label.id))
showMessage('success', 'Label deleted!')
} catch {
@@ -2163,7 +2298,7 @@ export function Settings() {
return
}
try {
const saved = await api.saveCompanyLabel(user.$id, editingLabel)
const saved = await api.saveCompanyLabel(editingLabel)
if (saved.data) {
if (editingLabel.id) {
setCompanyLabels(companyLabels.map(l => l.id === editingLabel.id ? (saved.data || l) : l))
@@ -2235,7 +2370,7 @@ export function Settings() {
onClick={async () => {
if (!user?.$id || !label.id) return
try {
await api.saveNameLabel(user.$id, user.email, { ...label, enabled: !label.enabled })
await api.saveNameLabel({ ...label, enabled: !label.enabled })
setNameLabels(nameLabels.map(l => l.id === label.id ? { ...l, enabled: !l.enabled } : l))
showMessage('success', 'Label updated!')
} catch {
@@ -2256,7 +2391,7 @@ export function Settings() {
if (!user?.$id || !label.id) return
if (!confirm('Delete this name label?')) return
try {
await api.deleteNameLabel(user.$id, user.email, label.id)
await api.deleteNameLabel(label.id)
setNameLabels(nameLabels.filter(l => l.id !== label.id))
showMessage('success', 'Label deleted!')
} catch {
@@ -2383,7 +2518,7 @@ export function Settings() {
return
}
try {
const saved = await api.saveNameLabel(user.$id, user.email, editingNameLabel)
const saved = await api.saveNameLabel(editingNameLabel)
if (saved.data) {
if (editingNameLabel.id) {
setNameLabels(nameLabels.map(l => l.id === editingNameLabel.id ? (saved.data || l) : l))
@@ -2466,7 +2601,7 @@ export function Settings() {
onDisconnect={async (accountId) => {
if (!user?.$id) return
try {
const result = await api.disconnectEmailAccount(accountId, user.$id)
const result = await api.disconnectEmailAccount(accountId)
if (result.data) {
setAccounts(accounts.filter(a => a.id !== accountId))
showMessage('success', 'Account disconnected')
@@ -2479,7 +2614,7 @@ export function Settings() {
if (!user?.$id) return
if (!confirm('Are you absolutely sure? This cannot be undone.')) return
try {
const result = await api.deleteAccount(user.$id)
const result = await api.deleteAccount()
if (result.data) {
showMessage('success', 'Account deleted. Redirecting...')
setTimeout(() => {
@@ -2503,30 +2638,63 @@ export function Settings() {
<CardDescription>Manage your MailFlow subscription</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between p-4 bg-gradient-to-r from-primary-50 to-accent-50 dark:from-primary-900/30 dark:to-accent-900/30 rounded-xl border border-primary-100 dark:border-primary-800">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-xl bg-white dark:bg-slate-800 shadow-sm flex items-center justify-center">
<Crown className="w-7 h-7 text-primary-500 dark:text-primary-400" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-bold text-lg text-slate-900 dark:text-slate-100">{subscription?.plan || 'Trial'}</h3>
<Badge variant={subscription?.status === 'active' ? 'success' : 'warning'}>
{subscription?.status === 'active' ? 'Active' : 'Trial'}
</Badge>
</div>
{subscription?.currentPeriodEnd && (
<p className="text-sm text-slate-500 dark:text-slate-400">
Next billing: {new Date(subscription.currentPeriodEnd).toLocaleDateString('en-US')}
</p>
)}
</div>
{loading ? (
<p className="text-sm text-slate-500 dark:text-slate-400 py-6">
Loading subscription
</p>
) : !subscription ? (
<div className="space-y-3 py-2">
<p className="text-sm text-slate-600 dark:text-slate-400">
Subscription status could not be loaded. Make sure you are signed in and the API is running.
</p>
<Button
variant="outline"
size="sm"
onClick={async () => {
const r = await api.getSubscriptionStatus()
if (r.data) {
setSubscription(r.data)
showMessage('success', 'Subscription loaded')
} else {
showMessage('error', r.error?.message || 'Failed to load subscription')
}
}}
>
Retry
</Button>
</div>
<Button onClick={handleManageSubscription}>
<ExternalLink className="w-4 h-4 mr-2" />
Manage
</Button>
</div>
) : (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 p-4 bg-gradient-to-r from-primary-50 to-accent-50 dark:from-primary-900/30 dark:to-accent-900/30 rounded-xl border border-primary-100 dark:border-primary-800">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-xl bg-white dark:bg-slate-800 shadow-sm flex items-center justify-center shrink-0">
<Crown className="w-7 h-7 text-primary-500 dark:text-primary-400" />
</div>
<div>
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-bold text-lg text-slate-900 dark:text-slate-100">
{subscriptionTitle(subscription)}
</h3>
{(() => {
const b = subscriptionBadge(subscription)
return b.label ? (
<Badge variant={b.variant}>{b.label}</Badge>
) : null
})()}
</div>
{subscription.currentPeriodEnd && (
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
Next billing:{' '}
{new Date(subscription.currentPeriodEnd).toLocaleDateString('en-US')}
</p>
)}
</div>
</div>
<Button className="shrink-0" onClick={handleManageSubscription}>
<ExternalLink className="w-4 h-4 mr-2" />
Manage
</Button>
</div>
)}
</CardContent>
</Card>
@@ -2556,8 +2724,8 @@ export function Settings() {
<span className="text-slate-500 dark:text-slate-400">/month</span>
</div>
<ul className="space-y-2 mb-6">
{plan.features.map((feature) => (
<li key={feature} className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400">
{plan.features.map((feature, fi) => (
<li key={`${plan.id}-${fi}`} className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400">
<Check className="w-4 h-4 text-green-500 dark:text-green-400" />
{feature}
</li>