feat(settings): default landing page (plan Tier 6)

- New per-user 'default_landing_page' setting (tracker default / calendar /
  summary / spending), registered in the whitelist + defaults.
- Implemented as a post-login redirect (keeps /=Tracker so the Home link and
  direct nav are unaffected). Precedence: admin → deep-link they were bounced
  from (state.from) → default landing → /. Resolved by fetching settings in the
  login success path, with a safe / fallback so a fetch failure never blocks login.
- Settings: Select in the Regional section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-05 16:03:01 -05:00
parent 01030fe7d0
commit 7e5e012434
3 changed files with 46 additions and 4 deletions

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { toast } from 'sonner';
import { api } from '@/api';
import { errMessage } from '@/lib/utils';
@ -22,8 +22,13 @@ interface AuthModeInfo {
oidc_provider_name?: string;
}
const LANDING_ROUTES: Record<string, string> = {
tracker: '/', calendar: '/calendar', summary: '/summary', spending: '/spending',
};
export default function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const { setUser, refresh } = useAuth();
const [username, setUsername] = useState('');
@ -49,6 +54,24 @@ export default function LoginPage() {
const destFor = (user?: User | null) => (user?.is_default_admin ? '/admin' : '/');
// Where to send the user after signing in. Precedence: admin console →
// explicit deep-link they were bounced from → their default landing page → /.
const resolveDest = async (user?: User | null): Promise<string> => {
if (user?.is_default_admin) return '/admin';
const from = (location.state as { from?: { pathname?: string } } | null)?.from?.pathname;
if (from && from !== '/login') return from;
try {
const s = (await api.settings()) as Record<string, unknown>;
const landing = typeof s.default_landing_page === 'string' ? LANDING_ROUTES[s.default_landing_page] : undefined;
return landing || '/';
} catch {
return '/'; // never block login on a settings-fetch failure
}
};
const navigateAfterAuth = async (user?: User | null) => {
navigate(await resolveDest(user), { replace: true });
};
useEffect(() => {
api.authMode()
.then(raw => {
@ -77,7 +100,7 @@ export default function LoginPage() {
setShowPrivacy(true);
setShowChangePw(false);
} else {
navigate(destFor(user), { replace: true });
void navigateAfterAuth(user);
}
};
@ -148,7 +171,7 @@ export default function LoginPage() {
if (pendingUser?.first_login) {
setShowPrivacy(true);
} else {
navigate(destFor(pendingUser), { replace: true });
void navigateAfterAuth(pendingUser);
}
} catch (err) {
toast.error(errMessage(err, 'Failed to change password.'));
@ -164,7 +187,7 @@ export default function LoginPage() {
refresh();
setShowPrivacy(false);
navigate(destFor(pendingUser), { replace: true });
void navigateAfterAuth(pendingUser);
};
return (

View File

@ -315,6 +315,7 @@ interface SettingsState {
currency: string;
date_format: string;
week_start: string;
default_landing_page: string;
grace_period_days: number | string;
due_soon_days: number | string;
drift_threshold_pct: string;
@ -335,6 +336,7 @@ export default function SettingsPage() {
currency: 'USD',
date_format: 'MM/DD/YYYY',
week_start: '0',
default_landing_page: 'tracker',
grace_period_days: 5,
due_soon_days: 3,
drift_threshold_pct: '5',
@ -368,6 +370,7 @@ export default function SettingsPage() {
currency: s.currency,
date_format: s.date_format,
week_start: s.week_start,
default_landing_page: s.default_landing_page,
grace_period_days: s.grace_period_days,
due_soon_days: s.due_soon_days,
drift_threshold_pct: s.drift_threshold_pct,
@ -494,6 +497,20 @@ export default function SettingsPage() {
</SelectContent>
</Select>
</SettingRow>
<SettingRow label="Default landing page" description="Where you land right after signing in.">
<Select value={settings.default_landing_page} onValueChange={(v) => set('default_landing_page', v)}>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tracker">Tracker</SelectItem>
<SelectItem value="calendar">Calendar</SelectItem>
<SelectItem value="summary">Summary</SelectItem>
<SelectItem value="spending">Spending</SelectItem>
</SelectContent>
</Select>
</SettingRow>
</SectionCard>
{/* Tracker Layout */}

View File

@ -6,6 +6,7 @@ const USER_SETTING_KEYS = [
'currency',
'date_format',
'week_start',
'default_landing_page',
'grace_period_days',
'due_soon_days',
'notify_days_before',
@ -32,6 +33,7 @@ const USER_SETTING_KEYS = [
const USER_SETTING_DEFAULTS = {
week_start: '0',
default_landing_page: 'tracker',
due_soon_days: '3',
search_bars_collapsed: 'false',
bank_auto_categorize_merchants: 'true',