fix: wizard grey screen, missing form fields, and implement settings page

- Fix Tailwind v4 modal overlay: bg-opacity-75 removed, use bg-gray-500/75
- Restructure AddMailAccountModal with flexbox + relative z-10
- Add missing required fields to form: forward_to, delivery_method,
  delete_after_forward; auto-sync email_address from username
- Fix DashboardLayout mobile overlay opacity (same Tailwind v4 issue)
- Make email_address/forward_to required in MailAccountCreate interface
- Add userApi.updateProfile() method
- Implement Settings page: profile, account info, security sections
- Update CHANGELOG.md and docs/TODO.md

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/d3a9e3bc-5637-45c5-b674-8ef436953614
This commit is contained in:
copilot-swe-agent[bot]
2026-03-25 09:32:56 +00:00
parent 94552a1ec7
commit 2e3b56098a
6 changed files with 323 additions and 56 deletions
+4
View File
@@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- Fixed wizard to create new mail accounts showing a big grey screen: `bg-opacity-75` was removed in Tailwind CSS v4; replaced with the `/75` opacity modifier syntax (`bg-gray-500/75`) in `AddMailAccountModal` and `DashboardLayout` mobile overlay. Restructured the modal from the deprecated `inline-block align-bottom` centering trick to a proper flexbox layout with `relative z-10` on the modal content.
- Fixed mail account creation always failing with a backend validation error: `email_address` and `forward_to` are required fields in the backend schema but were missing from the `AddMailAccountModal` form. Added both fields to the form — `email_address` is auto-synced from the username input, and `forward_to` (destination Gmail address) is a new explicit field pre-populated from the logged-in user's email. Also added `delivery_method` selector and `delete_after_forward` checkbox.
- Implemented the Settings page (was a placeholder showing "coming soon"): now includes a Profile section to update name and email via `PUT /users/me`, an Account Information section showing subscription tier/status and member-since date, and a Security section.
- Fixed `sqlalchemy.exc.DBAPIError` raised by asyncpg when inserting timezone-aware `datetime.now(timezone.utc)` values into timezone-naive `DateTime` (TIMESTAMP WITHOUT TIME ZONE) columns: changed all `DateTime` column definitions in `database_models.py` to `DateTime(timezone=True)` (TIMESTAMP WITH TIME ZONE) and replaced all `default=datetime.utcnow` callable references with `default=lambda: datetime.now(timezone.utc)` for consistent, timezone-aware timestamps throughout the ORM.
- Fixed `ProgrammingError` (`cached statement plan is invalid`) raised by the asyncpg dialect during startup: SQLAlchemy's asyncpg wrapper maintains an LRU prepared-statement cache per connection (default size 100). When `Base.metadata.create_all()` executes `CREATE TYPE … AS ENUM` DDL inside a transaction, PostgreSQL invalidates the cached plans for that connection. The next enum-type existence check then fails because the dialect tries to reuse the now-stale prepared statement. Fix: set `prepared_statement_cache_size=0` in `connect_args` on `create_async_engine` to disable the cache entirely, which is the documented SQLAlchemy recommendation for DDL-at-startup scenarios.
- Fixed `UndefinedTableError` on first boot: the lifespan startup event now calls `Base.metadata.create_all()` via the async engine before attempting to seed default settings, so all tables are created automatically when the database is empty (e.g., fresh PostgreSQL container with no Alembic migrations run yet).
@@ -28,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Upgraded `sqlalchemy` from `2.0.25` to `2.0.48` to fix `AssertionError: Class ... directly inherits TypingOnly but has additional attributes` on Python 3.14 (`__static_attributes__`, `__firstlineno__`)
### Added
- `userApi.updateProfile()` method in `frontend/src/lib/api.ts` for updating user profile via `PUT /users/me`
- **Backend URL logged at startup**: The Next.js server now logs the resolved `BACKEND_URL` (e.g. `[proxy] BACKEND_URL = http://backend:8000`) via `src/instrumentation.ts` when the server starts, making it easy to diagnose `ECONNREFUSED` proxy errors. The per-request error log now also includes the full target URL.
- **Dual-registry Docker deployment**: CI now builds separate backend and frontend images and pushes to both GHCR (`ghcr.io`) and private registry (`registry.cklnet.com`) using a matrix strategy
- **Database-backed configuration**: `AppSetting` model and `ConfigService` for hybrid config (DB-first, env-var fallback)
+3 -2
View File
@@ -209,9 +209,10 @@ because the API client layer is missing.
- [x] Dashboard with stats cards and processing runs table
- [x] Mail accounts list with CRUD operations
- [x] Settings page
- [x] `AddMailAccountModal` component (auto-detect, test connection)
- [x] `AddMailAccountModal` component (auto-detect, test connection, all required fields)
- [x] `DashboardLayout` with responsive sidebar
- [x] `AuthGuard` for protected routes
- [x] Fix wizard grey screen (Tailwind v4 `bg-opacity``/75` syntax, modal restructure)
### Not Started 📋
- [ ] End-to-end testing of frontend against backend API
@@ -307,7 +308,7 @@ because the API client layer is missing.
| Production Ready | 30% | 🔴 Needs Work |
| Observability | 10% | 🔴 Needs Work |
| Backend Features | 85% | 🟢 Near Complete |
| Frontend | 50% | 🟡 In Progress |
| Frontend | 65% | 🟢 Near Complete |
**Overall Repository Readiness**: 55% ⚠️
+203 -6
View File
@@ -1,19 +1,216 @@
'use client';
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AuthGuard } from '@/components/AuthGuard';
import { DashboardLayout } from '@/components/DashboardLayout';
import { userApi } from '@/lib/api';
import { useAuthStore } from '@/store/authStore';
import { CheckCircle, Loader2, User, Mail, Shield } from 'lucide-react';
export default function SettingsPage() {
return (
<AuthGuard>
<DashboardLayout>
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-gray-600">Settings page coming soon...</p>
</div>
</div>
<SettingsContent />
</DashboardLayout>
</AuthGuard>
);
}
function SettingsContent() {
const queryClient = useQueryClient();
const { user, setUser } = useAuthStore();
const [profileForm, setProfileForm] = useState({
full_name: user?.full_name || '',
email: user?.email || '',
});
const [profileSaved, setProfileSaved] = useState(false);
// Refresh user data from the server
const { data: currentUser } = useQuery({
queryKey: ['current-user'],
queryFn: userApi.getCurrentUser,
initialData: user ?? undefined,
});
// Sync form when server data arrives
useEffect(() => {
if (currentUser) {
setProfileForm({
full_name: currentUser.full_name || '',
email: currentUser.email || '',
});
}
}, [currentUser]);
const updateProfileMutation = useMutation({
mutationFn: (data: { full_name: string; email: string }) =>
userApi.updateProfile(data),
onSuccess: (updatedUser) => {
setUser(updatedUser);
queryClient.invalidateQueries({ queryKey: ['current-user'] });
setProfileSaved(true);
setTimeout(() => setProfileSaved(false), 3000);
},
});
const handleProfileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setProfileForm((prev) => ({ ...prev, [name]: value }));
};
const handleProfileSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await updateProfileMutation.mutateAsync({
full_name: profileForm.full_name,
email: profileForm.email,
});
} catch (error) {
const errorMessage =
error instanceof Error && 'response' in error
? (error as { response?: { data?: { detail?: string } } }).response?.data
?.detail
: null;
alert(errorMessage || 'Failed to update profile');
}
};
const displayUser = currentUser ?? user;
return (
<div className="space-y-8">
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
{/* Profile Section */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 flex items-center gap-2">
<User className="h-5 w-5 text-gray-500" />
<h2 className="text-lg font-semibold text-gray-900">Profile</h2>
</div>
<form onSubmit={handleProfileSubmit} className="px-6 py-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Full Name
</label>
<input
type="text"
name="full_name"
value={profileForm.full_name}
onChange={handleProfileChange}
className="w-full max-w-md px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Your full name"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email Address
</label>
<input
type="email"
name="email"
value={profileForm.email}
onChange={handleProfileChange}
className="w-full max-w-md px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="you@example.com"
/>
</div>
<div className="flex items-center gap-3 pt-2">
<button
type="submit"
disabled={updateProfileMutation.isPending}
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{updateProfileMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving
</>
) : (
'Save Changes'
)}
</button>
{profileSaved && (
<span className="flex items-center text-sm text-green-600">
<CheckCircle className="h-4 w-4 mr-1" />
Saved successfully
</span>
)}
</div>
</form>
</div>
{/* Account Information */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 flex items-center gap-2">
<Mail className="h-5 w-5 text-gray-500" />
<h2 className="text-lg font-semibold text-gray-900">Account Information</h2>
</div>
<div className="px-6 py-6 space-y-3">
<div className="flex items-center text-sm">
<span className="text-gray-500 w-40">Subscription tier:</span>
<span className="font-medium text-gray-900 capitalize">
{displayUser?.subscription_tier ?? '—'}
</span>
</div>
<div className="flex items-center text-sm">
<span className="text-gray-500 w-40">Subscription status:</span>
<span className="font-medium text-gray-900 capitalize">
{displayUser?.subscription_status ?? '—'}
</span>
</div>
<div className="flex items-center text-sm">
<span className="text-gray-500 w-40">Member since:</span>
<span className="text-gray-900">
{displayUser?.created_at
? new Date(displayUser.created_at).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
})
: '—'}
</span>
</div>
{displayUser?.oauth_provider && (
<div className="flex items-center text-sm">
<span className="text-gray-500 w-40">Linked account:</span>
<span className="font-medium text-gray-900 capitalize">
{displayUser.oauth_provider}
</span>
</div>
)}
{displayUser?.last_login_at && (
<div className="flex items-center text-sm">
<span className="text-gray-500 w-40">Last login:</span>
<span className="text-gray-900">
{new Date(displayUser.last_login_at).toLocaleString()}
</span>
</div>
)}
</div>
</div>
{/* Security Section */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 flex items-center gap-2">
<Shield className="h-5 w-5 text-gray-500" />
<h2 className="text-lg font-semibold text-gray-900">Security</h2>
</div>
<div className="px-6 py-6">
<p className="text-sm text-gray-600">
Password change and two-factor authentication settings are coming soon.
</p>
{displayUser?.oauth_provider === 'google' && (
<p className="mt-2 text-sm text-gray-500">
Your account is authenticated via Google OAuth password management is handled by Google.
</p>
)}
</div>
</div>
</div>
);
}
+105 -45
View File
@@ -3,6 +3,7 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { mailAccountsApi, MailAccount, MailAccountCreate } from '@/lib/api';
import { useAuthStore } from '@/store/authStore';
import { X, Loader2, CheckCircle, XCircle } from 'lucide-react';
import { ProviderWizard } from './ProviderWizard';
@@ -15,6 +16,7 @@ type WizardStep = 'provider' | 'form';
export function AddMailAccountModal({ account, onClose }: AddMailAccountModalProps) {
const queryClient = useQueryClient();
const { user } = useAuthStore();
const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle');
const [testMessage, setTestMessage] = useState('');
const [autoDetecting, setAutoDetecting] = useState(false);
@@ -22,14 +24,20 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
const [formData, setFormData] = useState<MailAccountCreate>({
name: account?.name || '',
protocol: account?.protocol || 'pop3',
email_address: account?.email_address || '',
protocol: account?.protocol || 'pop3_ssl',
host: account?.host || '',
port: account?.port || 995,
username: '',
password: '',
use_ssl: account?.use_ssl ?? true,
use_tls: account?.use_tls ?? false,
forward_to: account?.forward_to || user?.email || '',
delivery_method: account?.delivery_method || 'gmail_api',
is_enabled: account?.is_enabled ?? true,
check_interval_minutes: account?.check_interval_minutes || 5,
max_emails_per_check: account?.max_emails_per_check || 100,
max_emails_per_check: account?.max_emails_per_check || 50,
delete_after_forward: account?.delete_after_forward ?? true,
});
const createMutation = useMutation({
@@ -43,11 +51,21 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value, type } = e.target;
setFormData((prev) => ({
...prev,
[name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked :
type === 'number' ? Number(value) : value,
}));
const newValue =
type === 'checkbox'
? (e.target as HTMLInputElement).checked
: type === 'number'
? Number(value)
: value;
setFormData((prev) => {
const updated = { ...prev, [name]: newValue };
// Keep email_address in sync with username unless explicitly changed
if (name === 'username') {
updated.email_address = value;
}
return updated;
});
};
const handleProviderSelect = (config: { name: string; protocol: string; host: string; port: number; use_ssl: boolean }) => {
@@ -110,8 +128,8 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
setTestMessage('Connection successful!');
} catch (error) {
setTestStatus('error');
const errorMessage = error instanceof Error && 'response' in error
? (error as { response?: { data?: { detail?: string } } }).response?.data?.detail
const errorMessage = error instanceof Error && 'response' in error
? (error as { response?: { data?: { detail?: string } } }).response?.data?.detail
: null;
setTestMessage(errorMessage || 'Connection failed');
}
@@ -122,8 +140,8 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
try {
await createMutation.mutateAsync(formData);
} catch (error) {
const errorMessage = error instanceof Error && 'response' in error
? (error as { response?: { data?: { detail?: string } } }).response?.data?.detail
const errorMessage = error instanceof Error && 'response' in error
? (error as { response?: { data?: { detail?: string } } }).response?.data?.detail
: null;
alert(errorMessage || 'Failed to save account');
}
@@ -131,10 +149,10 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-screen items-center justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={onClose} />
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<div className="fixed inset-0 bg-gray-500/75 transition-opacity" onClick={onClose} />
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full">
<div className="relative z-10 bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all w-full sm:my-8 sm:max-w-2xl">
<form onSubmit={handleSubmit}>
<div className="bg-white px-6 pt-6 pb-4">
<div className="flex items-center justify-between mb-6">
@@ -270,21 +288,69 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
</div>
</div>
<div className="flex items-center">
<input
type="checkbox"
name="use_ssl"
id="use_ssl"
checked={formData.use_ssl}
onChange={handleChange}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="use_ssl" className="ml-2 block text-sm text-gray-700">
Use SSL/TLS
<div className="flex items-center gap-6">
<div className="flex items-center">
<input
type="checkbox"
name="use_ssl"
id="use_ssl"
checked={formData.use_ssl}
onChange={handleChange}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="use_ssl" className="ml-2 block text-sm text-gray-700">
Use SSL/TLS
</label>
</div>
<div className="flex items-center">
<input
type="checkbox"
name="delete_after_forward"
id="delete_after_forward"
checked={formData.delete_after_forward}
onChange={handleChange}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="delete_after_forward" className="ml-2 block text-sm text-gray-700">
Delete after forwarding
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Forward To (destination email)
</label>
<input
type="email"
name="forward_to"
value={formData.forward_to}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="you@gmail.com"
/>
<p className="mt-1 text-xs text-gray-500">
Fetched emails will be delivered to this address.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Delivery Method
</label>
<select
name="delivery_method"
value={formData.delivery_method}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="gmail_api">Gmail API (recommended)</option>
<option value="smtp">SMTP</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Check Interval (minutes)
@@ -296,31 +362,25 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
onChange={handleChange}
required
min="1"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Max Emails Per Check
</label>
<input
type="number"
name="max_emails_per_check"
value={formData.max_emails_per_check}
onChange={handleChange}
min="1"
max="1440"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-md p-3">
<p className="text-sm text-blue-800">
<strong>Delivery:</strong> Emails will be delivered to your Gmail account.
Configure your Gmail API credentials in Settings for direct injection (recommended),
or they will be forwarded via SMTP.
</p>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Max Emails Per Check
</label>
<input
type="number"
name="max_emails_per_check"
value={formData.max_emails_per_check}
onChange={handleChange}
min="1"
max="1000"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{testStatus !== 'idle' && (
+1 -1
View File
@@ -77,7 +77,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
{/* Mobile sidebar */}
{sidebarOpen && (
<div className="fixed inset-0 z-40 lg:hidden">
<div className="fixed inset-0 bg-gray-600 bg-opacity-75" onClick={() => setSidebarOpen(false)} />
<div className="fixed inset-0 bg-gray-600/75" onClick={() => setSidebarOpen(false)} />
<div className="fixed inset-y-0 left-0 flex w-64 flex-col bg-white">
<div className="flex items-center justify-between h-16 px-4 border-b border-gray-200">
<h1 className="text-xl font-bold text-gray-900">POP3 Forwarder</h1>
+7 -2
View File
@@ -79,7 +79,7 @@ export interface MailAccount {
export interface MailAccountCreate {
name: string;
email_address?: string;
email_address: string;
protocol: string;
host: string;
port: number;
@@ -87,7 +87,7 @@ export interface MailAccountCreate {
use_tls?: boolean;
username: string;
password: string;
forward_to?: string;
forward_to: string;
delivery_method?: string;
is_enabled?: boolean;
check_interval_minutes?: number;
@@ -176,6 +176,11 @@ export const userApi = {
const response = await api.get<User>("/users/me");
return response.data;
},
async updateProfile(data: { full_name?: string; email?: string }): Promise<User> {
const response = await api.put<User>("/users/me", data);
return response.data;
},
};
// ── Mail Accounts API ───────────────────────────────────────────────────