mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 06:41:46 +00:00
merge: resolve conflicts with dev, fix 7 Snyk security issues
- Resolve translation conflicts (keep both journey + OAuth scope keys) - Resolve migrations.ts (dev OAuth migrations + journey migrations) - Fix hono directory traversal, response splitting, input validation (CVE-2026-39407/08/09/10) - Fix @hono/node-server directory traversal (CVE-2026-39406) - Fix nodemailer CRLF injection (upgrade to 8.0.5)
This commit is contained in:
@@ -321,7 +321,7 @@ describe('AdminPage', () => {
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
||||
|
||||
expect(screen.queryByRole('button', { name: /mcp tokens/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /mcp access/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows MCP Tokens tab button when MCP addon is enabled', async () => {
|
||||
@@ -337,7 +337,7 @@ describe('AdminPage', () => {
|
||||
render(<AdminPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /mcp tokens/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /mcp access/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -646,9 +646,9 @@ describe('AdminPage', () => {
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
||||
render(<AdminPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /mcp tokens/i })).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /mcp access/i })).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /mcp tokens/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /mcp access/i }));
|
||||
|
||||
expect(screen.getByTestId('mcp-tokens-panel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -18,6 +18,12 @@ beforeEach(() => {
|
||||
seedStore(usePermissionsStore, {
|
||||
level: 'owner',
|
||||
} as any);
|
||||
// Intercept CurrencyWidget's external fetch so it resolves before teardown
|
||||
server.use(
|
||||
http.get('https://api.exchangerate-api.com/v4/latest/:currency', () => {
|
||||
return HttpResponse.json({ rates: { USD: 1.08, EUR: 1, CHF: 0.97 } });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('DashboardPage', () => {
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
// FE-PAGE-OAUTH-001 to FE-PAGE-OAUTH-012
|
||||
import { render, screen, waitFor } from '../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { resetAllStores, seedStore } from '../../tests/helpers/store';
|
||||
import { buildUser } from '../../tests/helpers/factories';
|
||||
import OAuthAuthorizePage from './OAuthAuthorizePage';
|
||||
|
||||
// Default OAuth query params
|
||||
const DEFAULT_SEARCH = '?client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A4000%2Fcallback&scope=trips%3Aread&state=abc&code_challenge=challenge&code_challenge_method=S256';
|
||||
|
||||
function setSearchParams(search: string) {
|
||||
window.history.pushState({}, '', '/oauth/authorize' + search);
|
||||
}
|
||||
|
||||
const VALIDATE_OK = {
|
||||
valid: true,
|
||||
client: { name: 'Test App', allowed_scopes: ['trips:read'] },
|
||||
scopes: ['trips:read'],
|
||||
consentRequired: true,
|
||||
loginRequired: false,
|
||||
scopeSelectable: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
setSearchParams(DEFAULT_SEARCH);
|
||||
server.resetHandlers();
|
||||
// Default: authenticated user
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, isLoading: false });
|
||||
// Default validate: consent required
|
||||
server.use(
|
||||
http.get('/api/oauth/authorize/validate', () => HttpResponse.json(VALIDATE_OK)),
|
||||
http.post('/api/oauth/authorize', () =>
|
||||
HttpResponse.json({ redirect: 'http://localhost:4000/callback?code=abc' })
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.history.pushState({}, '', '/');
|
||||
});
|
||||
|
||||
describe('OAuthAuthorizePage', () => {
|
||||
it('FE-PAGE-OAUTH-001: shows loading spinner initially', () => {
|
||||
server.use(
|
||||
http.get('/api/oauth/authorize/validate', async () => {
|
||||
await new Promise(() => {}); // never resolves
|
||||
return HttpResponse.json(VALIDATE_OK);
|
||||
})
|
||||
);
|
||||
render(<OAuthAuthorizePage />);
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PAGE-OAUTH-002: shows error state when validation fails', async () => {
|
||||
server.use(
|
||||
http.get('/api/oauth/authorize/validate', () =>
|
||||
HttpResponse.json({
|
||||
valid: false,
|
||||
error: 'invalid_client',
|
||||
error_description: 'Unknown client ID',
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<OAuthAuthorizePage />);
|
||||
await screen.findByText('Authorization Error');
|
||||
expect(screen.getByText('Unknown client ID')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PAGE-OAUTH-003: shows error state on network error', async () => {
|
||||
server.use(
|
||||
http.get('/api/oauth/authorize/validate', () =>
|
||||
HttpResponse.json({ error: 'server error' }, { status: 500 })
|
||||
)
|
||||
);
|
||||
render(<OAuthAuthorizePage />);
|
||||
await screen.findByText('Authorization Error');
|
||||
expect(screen.getByText(/Failed to validate/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PAGE-OAUTH-004: shows login_required state', async () => {
|
||||
server.use(
|
||||
http.get('/api/oauth/authorize/validate', () =>
|
||||
HttpResponse.json({ ...VALIDATE_OK, loginRequired: true, consentRequired: true })
|
||||
)
|
||||
);
|
||||
render(<OAuthAuthorizePage />);
|
||||
await screen.findByText('Sign in to continue');
|
||||
expect(screen.getByText('Sign in to TREK')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PAGE-OAUTH-005: shows client name in login_required state', async () => {
|
||||
server.use(
|
||||
http.get('/api/oauth/authorize/validate', () =>
|
||||
HttpResponse.json({ ...VALIDATE_OK, loginRequired: true })
|
||||
)
|
||||
);
|
||||
render(<OAuthAuthorizePage />);
|
||||
await screen.findByText('Sign in to continue');
|
||||
expect(screen.getByText(/Test App/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PAGE-OAUTH-006: shows consent form with client name and scope list', async () => {
|
||||
render(<OAuthAuthorizePage />);
|
||||
await screen.findByText('Test App');
|
||||
expect(screen.getByText('Authorization Request')).toBeInTheDocument();
|
||||
expect(screen.getByText('Approve Access')).toBeInTheDocument();
|
||||
expect(screen.getByText('Deny')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PAGE-OAUTH-007: auto-approves when consentRequired is false', async () => {
|
||||
let authorizeCalled = false;
|
||||
server.use(
|
||||
http.get('/api/oauth/authorize/validate', () =>
|
||||
HttpResponse.json({ ...VALIDATE_OK, consentRequired: false })
|
||||
),
|
||||
http.post('/api/oauth/authorize', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
authorizeCalled = true;
|
||||
expect(body.approved).toBe(true);
|
||||
return HttpResponse.json({ redirect: 'http://localhost:4000/callback?code=xyz' });
|
||||
})
|
||||
);
|
||||
render(<OAuthAuthorizePage />);
|
||||
// Shows auto-approving spinner
|
||||
await waitFor(() => {
|
||||
expect(authorizeCalled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PAGE-OAUTH-008: clicking Deny sends approved=false to authorize', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: Record<string, unknown> = {};
|
||||
server.use(
|
||||
http.post('/api/oauth/authorize', async ({ request }) => {
|
||||
body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ redirect: 'http://localhost:4000/callback?error=access_denied' });
|
||||
})
|
||||
);
|
||||
render(<OAuthAuthorizePage />);
|
||||
await screen.findByText('Deny');
|
||||
await user.click(screen.getByText('Deny'));
|
||||
await waitFor(() => {
|
||||
expect(body.approved).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PAGE-OAUTH-009: clicking Approve sends approved=true with selected scopes', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: Record<string, unknown> = {};
|
||||
server.use(
|
||||
http.post('/api/oauth/authorize', async ({ request }) => {
|
||||
body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ redirect: 'http://localhost:4000/callback?code=ok' });
|
||||
})
|
||||
);
|
||||
render(<OAuthAuthorizePage />);
|
||||
await screen.findByText('Approve Access');
|
||||
await user.click(screen.getByText('Approve Access'));
|
||||
await waitFor(() => {
|
||||
expect(body.approved).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PAGE-OAUTH-010: shows error when authorize call fails', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/oauth/authorize', () =>
|
||||
HttpResponse.json({ error: 'server error' }, { status: 500 })
|
||||
)
|
||||
);
|
||||
render(<OAuthAuthorizePage />);
|
||||
await screen.findByText('Approve Access');
|
||||
await user.click(screen.getByText('Approve Access'));
|
||||
await screen.findByText('Authorization Error');
|
||||
expect(screen.getByText(/Authorization failed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PAGE-OAUTH-011: scopeSelectable=true renders checkboxes for scopes', async () => {
|
||||
server.use(
|
||||
http.get('/api/oauth/authorize/validate', () =>
|
||||
HttpResponse.json({ ...VALIDATE_OK, scopeSelectable: true, scopes: ['trips:read', 'places:read'] })
|
||||
)
|
||||
);
|
||||
render(<OAuthAuthorizePage />);
|
||||
await screen.findByText('Choose which permissions to grant');
|
||||
expect(screen.getAllByRole('checkbox').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-PAGE-OAUTH-012: scopeSelectable=false renders read-only scope list', async () => {
|
||||
render(<OAuthAuthorizePage />);
|
||||
await screen.findByText('Permissions requested');
|
||||
// No checkboxes in read-only mode
|
||||
expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,356 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { oauthApi } from '../api/client'
|
||||
import { SCOPE_GROUPS } from '../api/oauthScopes'
|
||||
import { Lock, ShieldCheck, AlertTriangle, Loader2, LogIn } from 'lucide-react'
|
||||
import { useTranslation } from '../i18n'
|
||||
|
||||
interface ValidateResult {
|
||||
valid: boolean
|
||||
error?: string
|
||||
error_description?: string
|
||||
client?: { name: string; allowed_scopes: string[] }
|
||||
scopes?: string[]
|
||||
consentRequired?: boolean
|
||||
loginRequired?: boolean
|
||||
scopeSelectable?: boolean
|
||||
}
|
||||
|
||||
type PageState = 'loading' | 'login_required' | 'consent' | 'auto_approving' | 'error' | 'done'
|
||||
|
||||
export default function OAuthAuthorizePage(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const { isAuthenticated, isLoading: authLoading, loadUser } = useAuthStore()
|
||||
const [pageState, setPageState] = useState<PageState>('loading')
|
||||
const [validation, setValidation] = useState<ValidateResult | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
||||
const [selectedScopes, setSelectedScopes] = useState<string[]>([])
|
||||
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const clientId = params.get('client_id') || ''
|
||||
const redirectUri = params.get('redirect_uri') || ''
|
||||
const scope = params.get('scope') || ''
|
||||
const state = params.get('state') || ''
|
||||
const codeChallenge = params.get('code_challenge') || ''
|
||||
const ccMethod = params.get('code_challenge_method') || ''
|
||||
|
||||
// Load auth state once, then validate
|
||||
useEffect(() => {
|
||||
loadUser({ silent: true }).catch(() => {})
|
||||
}, [loadUser])
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return
|
||||
validateRequest()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [authLoading, isAuthenticated])
|
||||
|
||||
async function validateRequest() {
|
||||
setPageState('loading')
|
||||
try {
|
||||
const result = await oauthApi.validate({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope,
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: ccMethod,
|
||||
response_type: 'code',
|
||||
})
|
||||
setValidation(result)
|
||||
|
||||
if (!result.valid) {
|
||||
setPageState('error')
|
||||
setErrorMsg(result.error_description || result.error || 'Invalid authorization request')
|
||||
return
|
||||
}
|
||||
|
||||
if (result.loginRequired) {
|
||||
setPageState('login_required')
|
||||
return
|
||||
}
|
||||
|
||||
if (!result.consentRequired) {
|
||||
// Consent already on record — auto-approve silently with the full validated scope
|
||||
setPageState('auto_approving')
|
||||
await submitConsent(true, result.scopes ?? [])
|
||||
return
|
||||
}
|
||||
|
||||
// Pre-select all scopes the client is requesting — user can deselect
|
||||
setSelectedScopes(result.scopes ?? [])
|
||||
setPageState('consent')
|
||||
} catch (err: unknown) {
|
||||
setPageState('error')
|
||||
setErrorMsg('Failed to validate authorization request. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
async function submitConsent(approved: boolean, scopes: string[] = selectedScopes) {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await oauthApi.authorize({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
// When approving, send only the scopes the user selected; deny uses original scope
|
||||
scope: approved ? scopes.join(' ') : scope,
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: ccMethod,
|
||||
approved,
|
||||
})
|
||||
setPageState('done')
|
||||
window.location.href = result.redirect
|
||||
} catch {
|
||||
setPageState('error')
|
||||
setErrorMsg('Authorization failed. Please try again.')
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleScope(s: string) {
|
||||
setSelectedScopes(prev =>
|
||||
prev.includes(s) ? prev.filter(x => x !== s) : [...prev, s]
|
||||
)
|
||||
}
|
||||
|
||||
function toggleGroup(groupScopes: string[], allSelected: boolean) {
|
||||
setSelectedScopes(prev =>
|
||||
allSelected
|
||||
? prev.filter(s => !groupScopes.includes(s))
|
||||
: [...new Set([...prev, ...groupScopes])]
|
||||
)
|
||||
}
|
||||
|
||||
function handleLoginRedirect() {
|
||||
const next = '/oauth/authorize?' + params.toString()
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(next)
|
||||
}
|
||||
|
||||
// Group requested scopes by their translated group name
|
||||
const scopesByGroup = React.useMemo(() => {
|
||||
const requested = validation?.scopes || []
|
||||
const groups: Record<string, string[]> = {}
|
||||
for (const s of requested) {
|
||||
const keys = SCOPE_GROUPS[s]
|
||||
const group = keys ? t(keys.groupKey) : 'Other'
|
||||
if (!groups[group]) groups[group] = []
|
||||
groups[group].push(s)
|
||||
}
|
||||
return groups
|
||||
}, [validation, t])
|
||||
|
||||
// ---- Render states ----
|
||||
|
||||
if (pageState === 'loading' || pageState === 'auto_approving') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-primary)' }}>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{pageState === 'auto_approving' ? 'Authorizing…' : 'Loading…'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (pageState === 'error') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
||||
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-4 text-center" style={{ background: 'var(--bg-card)' }}>
|
||||
<AlertTriangle className="w-10 h-10 mx-auto text-red-500" />
|
||||
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Authorization Error</h1>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{errorMsg}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (pageState === 'login_required') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
||||
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-5" style={{ background: 'var(--bg-card)' }}>
|
||||
<div className="text-center space-y-2">
|
||||
<Lock className="w-10 h-10 mx-auto" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
||||
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Sign in to continue</h1>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<strong>{validation?.client?.name || clientId}</strong> wants access to your TREK account. Please sign in first.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLoginRedirect}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-white"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||
<LogIn className="w-4 h-4" />
|
||||
Sign in to TREK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// pageState === 'consent'
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
||||
<div className="w-full max-w-2xl rounded-xl shadow-lg overflow-hidden flex flex-col sm:flex-row" style={{ background: 'var(--bg-card)' }}>
|
||||
|
||||
{/* Left panel — app identity + actions */}
|
||||
<div className="sm:w-64 sm:flex-shrink-0 flex flex-col px-8 py-8 sm:border-r" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<ShieldCheck className="w-6 h-6" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide mb-1" style={{ color: 'var(--text-tertiary)' }}>Authorization Request</p>
|
||||
<h1 className="text-lg font-semibold leading-snug" style={{ color: 'var(--text-primary)' }}>
|
||||
{validation?.client?.name || clientId}
|
||||
</h1>
|
||||
<p className="text-sm mt-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
This application is requesting access to your TREK account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-2">
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Only grant access to applications you trust. Your data stays on your server.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => submitConsent(true)}
|
||||
disabled={submitting || (validation?.scopeSelectable === true && selectedScopes.length === 0)}
|
||||
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium text-white disabled:opacity-60 transition-opacity"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||
{submitting
|
||||
? 'Authorizing…'
|
||||
: validation?.scopeSelectable && selectedScopes.length === 0
|
||||
? 'Select at least one scope'
|
||||
: validation?.scopeSelectable
|
||||
? `Approve (${selectedScopes.length} scope${selectedScopes.length !== 1 ? 's' : ''})`
|
||||
: 'Approve Access'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => submitConsent(false)}
|
||||
disabled={submitting}
|
||||
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium border transition-colors hover:bg-slate-50 dark:hover:bg-slate-800 disabled:opacity-60"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel — selectable scopes */}
|
||||
<div className="flex-1 px-6 py-8 overflow-y-auto max-h-[80vh] sm:max-h-[600px]">
|
||||
<div className="space-y-6">
|
||||
{Object.keys(scopesByGroup).length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide mb-4" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{validation?.scopeSelectable ? 'Choose which permissions to grant' : 'Permissions requested'}
|
||||
</p>
|
||||
|
||||
{validation?.scopeSelectable ? (
|
||||
/* DCR client — user selects which scopes to grant */
|
||||
<div className="space-y-3">
|
||||
{Object.entries(scopesByGroup).map(([group, groupScopes]) => {
|
||||
const allGroupSelected = groupScopes.every(s => selectedScopes.includes(s))
|
||||
const someGroupSelected = groupScopes.some(s => selectedScopes.includes(s))
|
||||
return (
|
||||
<div key={group} className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
<label className="flex items-center gap-2.5 px-3 py-2 cursor-pointer" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allGroupSelected}
|
||||
ref={el => { if (el) el.indeterminate = someGroupSelected && !allGroupSelected }}
|
||||
onChange={() => toggleGroup(groupScopes, allGroupSelected)}
|
||||
className="rounded flex-shrink-0"
|
||||
/>
|
||||
<span className="text-xs font-semibold" style={{ color: 'var(--text-secondary)' }}>{group}</span>
|
||||
<span className="ml-auto text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{groupScopes.filter(s => selectedScopes.includes(s)).length}/{groupScopes.length}
|
||||
</span>
|
||||
</label>
|
||||
<div className="divide-y" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
{groupScopes.map(s => {
|
||||
const keys = SCOPE_GROUPS[s]
|
||||
return (
|
||||
<label
|
||||
key={s}
|
||||
className="flex items-start gap-2.5 px-3 py-2 cursor-pointer transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedScopes.includes(s)}
|
||||
onChange={() => toggleScope(s)}
|
||||
className="mt-0.5 rounded flex-shrink-0"
|
||||
/>
|
||||
<span className="mt-0.5 text-base leading-none flex-shrink-0">
|
||||
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{keys ? t(keys.labelKey) : s}</p>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{keys ? t(keys.descriptionKey) : ''}</p>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* Settings-created client — scopes are fixed, show read-only */
|
||||
<div className="space-y-5">
|
||||
{Object.entries(scopesByGroup).map(([group, groupScopes]) => (
|
||||
<div key={group}>
|
||||
<p className="text-xs font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{group}</p>
|
||||
<div className="space-y-1.5">
|
||||
{groupScopes.map(s => {
|
||||
const keys = SCOPE_GROUPS[s]
|
||||
return (
|
||||
<div key={s} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<span className="mt-0.5 text-base leading-none flex-shrink-0">
|
||||
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{keys ? t(keys.labelKey) : s}</p>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{keys ? t(keys.descriptionKey) : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Always-available tools — granted regardless of scopes */}
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide mb-3" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Always included
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{[
|
||||
{ name: 'list_trips', desc: 'List your trips so the AI can discover trip IDs' },
|
||||
{ name: 'get_trip_summary', desc: 'Read a trip overview needed to use any other tool' },
|
||||
].map(({ name, desc }) => (
|
||||
<div key={name} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<span className="mt-0.5 text-base leading-none flex-shrink-0">👁️</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium font-mono" style={{ color: 'var(--text-primary)' }}>{name}</p>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -65,8 +65,12 @@ vi.mock('../components/Planner/PlacesSidebar', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const capturedPlaceInspectorProps: { current: Record<string, any> } = { current: {} };
|
||||
vi.mock('../components/Planner/PlaceInspector', () => ({
|
||||
default: () => null,
|
||||
default: (props: Record<string, any>) => {
|
||||
capturedPlaceInspectorProps.current = props;
|
||||
return React.createElement('div', { 'data-testid': 'place-inspector' });
|
||||
},
|
||||
}));
|
||||
|
||||
const capturedDayDetailPanelProps: { current: Record<string, any> } = { current: {} };
|
||||
@@ -232,6 +236,7 @@ beforeEach(() => {
|
||||
capturedTripFormModalProps.current = {};
|
||||
capturedTripMembersModalProps.current = {};
|
||||
capturedFileManagerProps.current = {};
|
||||
capturedPlaceInspectorProps.current = {};
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
|
||||
});
|
||||
|
||||
@@ -1334,6 +1339,166 @@ describe('TripPlannerPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-PLANNER-046: Invalid session tab resets to plan', () => {
|
||||
it('resets activeTab to "plan" when saved tab is no longer in TRIP_TABS', async () => {
|
||||
// Save a tab id that requires the "memories" addon (disabled by default)
|
||||
sessionStorage.setItem('trip-tab-42', 'memories');
|
||||
seedTripStore({ id: 42 });
|
||||
|
||||
renderPlannerPage(42);
|
||||
|
||||
// The useEffect should detect the invalid tab and reset it
|
||||
await waitFor(() => {
|
||||
expect(sessionStorage.getItem('trip-tab-42')).toBe('plan');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-PLANNER-047: Desktop PlaceInspector onEdit with selectedAssignment', () => {
|
||||
it('calls onEdit on desktop PlaceInspector with selectedAssignmentId to cover if-branch', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const place = buildPlace({ id: 1, trip_id: 42, lat: 48.8566, lng: 2.3522 });
|
||||
const assignment = buildAssignment({ id: 10, day_id: 99, place, order_index: 0 });
|
||||
|
||||
mockPlaceSelectionState.selectedPlaceId = place.id;
|
||||
mockPlaceSelectionState.selectedAssignmentId = assignment.id;
|
||||
|
||||
seedTripStore({ id: 42 });
|
||||
seedStore(useTripStore, {
|
||||
places: [place],
|
||||
assignments: { '99': [assignment] },
|
||||
} as any);
|
||||
|
||||
renderPlannerPage(42);
|
||||
act(() => { vi.runAllTimers(); });
|
||||
vi.useRealTimers();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('place-inspector')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// onEdit with selectedAssignmentId set — covers lines 795-798 (if branch)
|
||||
await act(async () => {
|
||||
capturedPlaceInspectorProps.current.onEdit?.();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-PLANNER-048: Mobile PlaceInspector portal renders when isMobile is true', () => {
|
||||
it('renders PlaceInspector in mobile portal and covers mobile callbacks', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Simulate mobile viewport
|
||||
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 375 });
|
||||
|
||||
const place = buildPlace({ id: 1, trip_id: 42, lat: 48.8566, lng: 2.3522 });
|
||||
|
||||
mockPlaceSelectionState.selectedPlaceId = place.id;
|
||||
|
||||
seedTripStore({ id: 42 });
|
||||
seedStore(useTripStore, { places: [place] } as any);
|
||||
|
||||
renderPlannerPage(42);
|
||||
act(() => { vi.runAllTimers(); });
|
||||
vi.useRealTimers();
|
||||
|
||||
// Mobile portal renders the PlaceInspector (lines 830-879)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('place-inspector')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// onEdit without assignment — covers else branch at line 799
|
||||
await act(async () => {
|
||||
capturedPlaceInspectorProps.current.onEdit?.();
|
||||
});
|
||||
|
||||
// onClose — covers mobile onClose lambda
|
||||
await act(async () => {
|
||||
capturedPlaceInspectorProps.current.onClose?.();
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1024 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-PLANNER-049: Mobile sidebar left panel opens via Plan button', () => {
|
||||
it('clicking the mobile Plan button opens the left sidebar portal (lines 882-893)', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 375 });
|
||||
|
||||
seedTripStore({ id: 42 });
|
||||
|
||||
renderPlannerPage(42);
|
||||
act(() => { vi.runAllTimers(); });
|
||||
vi.useRealTimers();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The mobile portal buttons are rendered to document.body.
|
||||
// The "Plan" tab button has title="Plan"; the mobile portal button does not.
|
||||
const mobilePlanBtn = Array.from(document.body.querySelectorAll('button')).find(
|
||||
b => b.textContent === 'Plan' && !b.getAttribute('title'),
|
||||
);
|
||||
|
||||
if (mobilePlanBtn) {
|
||||
await act(async () => { fireEvent.click(mobilePlanBtn); });
|
||||
|
||||
// Mobile sidebar portal renders DayPlanSidebar — now two instances
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('day-plan-sidebar').length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
// Close the mobile sidebar via the X button inside the portal header
|
||||
const closeButtons = Array.from(document.body.querySelectorAll('button')).filter(
|
||||
b => !b.textContent || b.textContent.trim() === '',
|
||||
);
|
||||
if (closeButtons.length > 0) {
|
||||
await act(async () => { fireEvent.click(closeButtons[0]); });
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1024 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-PLANNER-050: Mobile sidebar right panel opens via Places button', () => {
|
||||
it('clicking the mobile Places button opens the right sidebar portal (lines 894)', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 375 });
|
||||
|
||||
seedTripStore({ id: 42 });
|
||||
|
||||
renderPlannerPage(42);
|
||||
act(() => { vi.runAllTimers(); });
|
||||
vi.useRealTimers();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('places-sidebar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// "Places" tab doesn't exist; the mobile portal "Places" button has no title
|
||||
const mobilePlacesBtn = Array.from(document.body.querySelectorAll('button')).find(
|
||||
b => b.textContent === 'Places' && !b.getAttribute('title'),
|
||||
);
|
||||
|
||||
if (mobilePlacesBtn) {
|
||||
await act(async () => { fireEvent.click(mobilePlacesBtn); });
|
||||
|
||||
// PlacesSidebar renders in mobile sidebar portal
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('places-sidebar').length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1024 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-PLANNER-037: onExpandedDaysChange covers mapPlaces hidden logic', () => {
|
||||
it('calls onExpandedDaysChange to trigger mapPlaces hidden set computation', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
Reference in New Issue
Block a user