diff --git a/README.md b/README.md index 4f4c586..728e930 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,8 @@ The hosted dashboard supports the delivered protocol-1 management surface: Backend authorization remains authoritative. Client-side role and status checks only shape the interface and never replace backend guards. -Hosted cookie authentication requires a browser that supports the backend's CHIPS `Partitioned` cookies and the Web Locks API. The PWA refuses unsafe refresh behavior when Web Locks are unavailable. This is the current supported hosted-auth contract; CHIPS and Web Locks are not universal across browsers. The backend must use exactly `CORS_ORIGIN=https://app.minepanel.xyz` for the hosted deployment. Same-origin deployment remains the broadest compatibility option. -GitHub OAuth is optional when/if advertised by backend capability and is not a Phase 1.5 completion blocker. The PKCE authorization-code fallback is conditional future compatibility work only if full hosted cross-origin browser compatibility remains a product requirement; it is not implemented today. +Hosted cookie authentication requires a secure browser context with Web Locks and a browser that supports the backend's CHIPS `Partitioned` cookies. The PWA refuses unsafe refresh behavior when Web Locks are unavailable; it relies on the backend's capability advertisement and cannot independently prove HttpOnly partitioned-cookie behavior for every backend origin. This is the current supported hosted-auth contract; CHIPS and Web Locks are not universal across browsers. The backend must use exactly `CORS_ORIGIN=https://app.minepanel.xyz` for the hosted deployment. Same-origin deployment remains the broadest compatibility option. See the public [`/compatibility`](https://app.minepanel.xyz/compatibility) guide for the browser and hosted-auth boundary. +GitHub OAuth is optional future identity work and is not a Phase 1.5 completion blocker. No token-based hosted-auth fallback is implemented for unsupported browser environments. Google sign-in for accounts with enabled TOTP is not supported by the current backend contract: provider login returns `TwoFactorAuthenticationRequired` without a pre-auth challenge. Use password sign-in for those accounts until the backend supplies a constrained provider 2FA continuation. diff --git a/roadmap.json b/roadmap.json index 4b3594f..869fca9 100644 --- a/roadmap.json +++ b/roadmap.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-08-27", + "updatedAt": "2026-09-02", "phases": [ { "id": "1", @@ -45,7 +45,7 @@ "label": "Phase 1.5", "name": "Hosted Authentication", "status": "completed", - "description": "The supported protocol-1 hosted-auth contract is complete: CHIPS partitioned HttpOnly cookies + Web Locks for hosted sessions, with local auth, Google auth, TOTP, account security, and first-admin setup. Optional GitHub OAuth and conditional PKCE compatibility work remain separate future concerns; CHIPS/Web Locks are not universal across browsers.", + "description": "The supported protocol-1 hosted-auth contract is complete: CHIPS partitioned HttpOnly cookies + Web Locks for hosted sessions, with local auth, Google auth, TOTP, account security, and first-admin setup. CHIPS/Web Locks are not universal across browsers.", "progress": 100, "progressLabel": "100% complete", "items": [ @@ -76,14 +76,6 @@ { "text": "First-admin setup", "done": true - }, - { - "text": "[Optional] GitHub OAuth when/if advertised by backend capability", - "done": false - }, - { - "text": "[Conditional] PKCE authorization-code fallback if full hosted-browser compatibility remains required", - "done": false } ] }, @@ -156,6 +148,21 @@ "done": false } ] + }, + { + "id": "future-identity-authentication", + "label": "Future Identity / Authentication", + "name": "Optional Follow-ons", + "description": "Optional identity integrations that remain outside the completed hosted-authentication scope.", + "status": "future", + "progress": 0, + "progressLabel": null, + "items": [ + { + "text": "[Optional] GitHub OAuth identity integration", + "done": false + } + ] } ], "scope": "scope: MinePanel hosted dashboard (minepanel-pwa). This file is the PWA roadmap; it does not redefine the global backend project roadmap." diff --git a/src/app/panel-route.spec.tsx b/src/app/panel-route.spec.tsx index ea9a7c1..c620b9b 100644 --- a/src/app/panel-route.spec.tsx +++ b/src/app/panel-route.spec.tsx @@ -29,6 +29,7 @@ let root: Root | null = null; let container: HTMLDivElement | null = null; beforeEach(async () => { + Object.defineProperty(globalThis, 'isSecureContext', { configurable: true, value: true }); Object.defineProperty(navigator, 'locks', { configurable: true, value: { @@ -57,6 +58,7 @@ afterEach(async () => { await db.clear('instances'); db.close(); vi.unstubAllGlobals(); + Object.defineProperty(globalThis, 'isSecureContext', { configurable: true, value: undefined }); Object.defineProperty(navigator, 'locks', { configurable: true, value: undefined }); }); @@ -377,4 +379,12 @@ describe('panel hard remount (Finding 2 regression)', () => { expect(container?.textContent).not.toContain('AdminA'); expect(await waitForText('UserB')).toBe(true); }); +}); + +describe('public compatibility route', () => { + it('renders without a saved or connected panel', async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + await mountApplicationRouter(queryClient, '/compatibility'); + expect(await waitForText('MinePanel compatibility')).toBe(true); + }); }); \ No newline at end of file diff --git a/src/app/router.tsx b/src/app/router.tsx index e58a6e5..f885d9d 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -2,6 +2,7 @@ import { createBrowserRouter, Navigate } from 'react-router'; import { AppFrame } from '@/components/app-frame'; import { PanelRoute } from '@/components/panel-route'; import { AddPanelPage } from '@/pages/add-panel-page'; +import { CompatibilityPage } from '@/pages/compatibility-page'; import { HomePage } from '@/pages/home-page'; import { AccountPage } from '@/pages/panel/account-page'; import { AdminUserDetailPage } from '@/pages/panel/admin/user-detail-page'; @@ -19,6 +20,7 @@ export const router = createBrowserRouter([ Component: AppFrame, children: [ { index: true, Component: HomePage }, + { path: 'compatibility', Component: CompatibilityPage }, { path: 'add', Component: AddPanelPage }, ], }, diff --git a/src/auth/panel-session-context.tsx b/src/auth/panel-session-context.tsx index 61cab8a..19c5476 100644 --- a/src/auth/panel-session-context.tsx +++ b/src/auth/panel-session-context.tsx @@ -4,6 +4,22 @@ import type { PanelInfo } from '@/api/types'; import type { InstanceIdentity } from '@/api/query-keys'; import type { SessionController } from './session-controller'; +export type BrowserIncompatibilityReason = + | 'insecure-context' + | 'web-locks-unavailable'; + +export type PanelIncompatibilityReason = + | 'unsupported-protocol' + | 'partitioned-auth-not-advertised'; + +export type SessionProblem = + | { kind: 'offline' } + | { kind: 'expired' } + | { kind: 'incompatible'; subject: 'browser'; reason: BrowserIncompatibilityReason } + | { kind: 'incompatible'; subject: 'panel'; reason: PanelIncompatibilityReason } + | { kind: 'hosted-origin-forbidden' } + | { kind: 'session-restore-failed' }; + export type ShellSession = | { kind: 'loading' } | { kind: 'anonymous' } @@ -12,7 +28,7 @@ export type ShellSession = | { kind: 'password-change-required' } | { kind: 'account-pending' } | { kind: 'account-banned' } - | { kind: 'error'; reason: 'offline' | 'incompatible' | 'expired' }; + | { kind: 'error'; problem: SessionProblem }; export type PanelSessionValue = { panel: InstanceIdentity | null; diff --git a/src/auth/session-controller.spec.ts b/src/auth/session-controller.spec.ts index b5b979e..853e434 100644 --- a/src/auth/session-controller.spec.ts +++ b/src/auth/session-controller.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { BackendClient } from '@/api/backend-client'; import type { AuthProfile, PanelInfo } from '@/api/types'; -import { BackendApiError } from '@/api/errors'; +import { BackendApiError, BackendClientError } from '@/api/errors'; import type { ShellSession } from './panel-session-context'; import { resetRefreshBrokerForTests } from './refresh-broker'; import { SessionController } from './session-controller'; @@ -10,11 +10,12 @@ const info: PanelInfo = { name: 'MinePanel', version: '1', api: { protocolVersio const profile: AuthProfile = { id: 'user-a', username: 'alex', role: 'USER' }; const installLocks = (): void => { + Object.defineProperty(globalThis, 'isSecureContext', { configurable: true, value: true }); Object.defineProperty(navigator, 'locks', { configurable: true, value: { request: async (_name: string, _options: { mode: 'exclusive' }, callback: () => Promise) => callback() } }); }; - afterEach(() => { resetRefreshBrokerForTests(); + Object.defineProperty(globalThis, 'isSecureContext', { configurable: true, value: undefined }); Object.defineProperty(navigator, 'locks', { configurable: true, value: undefined }); }); @@ -68,11 +69,61 @@ describe('SessionController', () => { }); it('requires a compatible browser lock before hosted-cookie authentication', async () => { + Object.defineProperty(globalThis, 'isSecureContext', { configurable: true, value: true }); + const client = { origin: 'https://panel.example', getInfo: vi.fn().mockResolvedValue(info), getProfile: vi.fn() }; + const { controller, states } = controllerFor(client); + await controller.start(); + expect(client.getProfile).not.toHaveBeenCalled(); + expect(states.at(-1)).toEqual({ kind: 'error', problem: { kind: 'incompatible', subject: 'browser', reason: 'web-locks-unavailable' } }); + }); + + it('blocks an insecure hosted context before restoring a session', async () => { + installLocks(); + Object.defineProperty(globalThis, 'isSecureContext', { configurable: true, value: false }); const client = { origin: 'https://panel.example', getInfo: vi.fn().mockResolvedValue(info), getProfile: vi.fn() }; const { controller, states } = controllerFor(client); await controller.start(); expect(client.getProfile).not.toHaveBeenCalled(); - expect(states.at(-1)).toEqual({ kind: 'error', reason: 'incompatible' }); + expect(states.at(-1)).toEqual({ kind: 'error', problem: { kind: 'incompatible', subject: 'browser', reason: 'insecure-context' } }); + }); + + it('distinguishes unsupported panel protocol and hosted-auth advertisement', async () => { + installLocks(); + const protocolInfo = { ...info, api: { protocolVersion: 2 } }; + const client = { origin: 'https://panel.example', getInfo: vi.fn().mockResolvedValue(protocolInfo), getProfile: vi.fn() }; + const { controller, states } = controllerFor(client); + await controller.start(); + expect(states.at(-1)).toEqual({ kind: 'error', problem: { kind: 'incompatible', subject: 'panel', reason: 'unsupported-protocol' } }); + + client.getInfo.mockResolvedValue({ ...info, capabilities: { ...info.capabilities, auth: { ...info.capabilities.auth, partitionedCookies: false } } }); + await controller.start(); + expect(states.at(-1)).toEqual({ kind: 'error', problem: { kind: 'incompatible', subject: 'panel', reason: 'partitioned-auth-not-advertised' } }); + expect(client.getProfile).not.toHaveBeenCalled(); + }); + + it('keeps unavailable panels separate from compatibility failures', async () => { + installLocks(); + const client = { origin: 'https://panel.example', getInfo: vi.fn().mockRejectedValue(new BackendClientError('unavailable', 'offline')), getProfile: vi.fn() }; + const { controller, states } = controllerFor(client); + await controller.start(); + expect(states.at(-1)).toEqual({ kind: 'error', problem: { kind: 'offline' } }); + }); + it('retries with fresh panel compatibility information', async () => { + installLocks(); + const client = { + origin: 'https://panel.example', + getInfo: vi.fn() + .mockResolvedValueOnce({ ...info, api: { protocolVersion: 2 } }) + .mockResolvedValueOnce(info), + getProfile: vi.fn().mockResolvedValue(profile), + }; + const { controller, states } = controllerFor(client); + await controller.start(); + expect(states.at(-1)).toEqual({ kind: 'error', problem: { kind: 'incompatible', subject: 'panel', reason: 'unsupported-protocol' } }); + controller.retryRestore(); + await flush(); + expect(client.getInfo).toHaveBeenCalledTimes(2); + expect(states.at(-1)).toEqual({ kind: 'authenticated', profile: { id: 'user-a', username: 'alex', role: 'USER' } }); }); }); @@ -168,7 +219,7 @@ describe('expired-session retry (BLOCKER-3 regression)', () => { controller.retryRestore(); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(states.at(-1)).toEqual({ kind: 'error', reason: 'expired' }); + expect(states.at(-1)).toEqual({ kind: 'error', problem: { kind: 'expired' } }); // The retry action must now resolve to anonymous instead of looping. controller.retryRestore(); @@ -193,7 +244,7 @@ describe('terminal refresh boundary (Finding 1 regression)', () => { // Simulate the client coordinator discovering a terminally invalid // refresh session on an arbitrary authenticated endpoint. controller.handleTerminalRefresh(); - expect(states.at(-1)).toEqual({ kind: 'error', reason: 'expired' }); + expect(states.at(-1)).toEqual({ kind: 'error', problem: { kind: 'expired' } }); expect(onBoundary).toHaveBeenCalledOnce(); // Idempotent: repeated terminal notifications must not re-run boundary. @@ -230,12 +281,12 @@ describe('session authority race (MP-BASELINE-SESSION-001)', () => { mockableClient.getProfile = vi.fn(() => deferred.promise); void controller.restoreProfile(); controller.handleTerminalRefresh(); - expect(states.at(-1)).toEqual({ kind: 'error', reason: 'expired' }); + expect(states.at(-1)).toEqual({ kind: 'error', problem: { kind: 'expired' } }); deferred.resolve(profile); await flush(); await flush(); - expect(states.at(-1)).toEqual({ kind: 'error', reason: 'expired' }); + expect(states.at(-1)).toEqual({ kind: 'error', problem: { kind: 'expired' } }); }); it('whole-session clear cannot be overwritten by a stale profile restore', async () => { diff --git a/src/auth/session-controller.ts b/src/auth/session-controller.ts index fdba53f..4a9d5ad 100644 --- a/src/auth/session-controller.ts +++ b/src/auth/session-controller.ts @@ -2,7 +2,12 @@ import type { BackendClient, PanelInfo } from '@/api/backend-client'; import { BackendApiError, BackendClientError } from '@/api/errors'; import type { AuthProfile, LoginInput, PublicUser, RegisterInput } from '@/api/types'; import { hasRefreshLockSupport, refreshWithBroker } from './refresh-broker'; -import type { ShellSession } from './panel-session-context'; +import type { + BrowserIncompatibilityReason, + PanelIncompatibilityReason, + SessionProblem, + ShellSession, +} from './panel-session-context'; export type SessionControllerOptions = { client: BackendClient; @@ -30,8 +35,22 @@ const profileForShell = (profile: AuthProfile): Extract - info.api.protocolVersion === 1 && info.capabilities.auth.partitionedCookies; +const browserIncompatibility = (): BrowserIncompatibilityReason | null => { + if (globalThis.isSecureContext !== true) return 'insecure-context'; + if (!hasRefreshLockSupport()) return 'web-locks-unavailable'; + return null; +}; + +const panelIncompatibility = (info: PanelInfo): PanelIncompatibilityReason | null => { + if (info.api.protocolVersion !== 1) return 'unsupported-protocol'; + if (!info.capabilities.auth.partitionedCookies) return 'partitioned-auth-not-advertised'; + return null; +}; + +const incompatibleProblem = (reason: BrowserIncompatibilityReason | PanelIncompatibilityReason): SessionProblem => + reason === 'insecure-context' || reason === 'web-locks-unavailable' + ? { kind: 'incompatible', subject: 'browser', reason } + : { kind: 'incompatible', subject: 'panel', reason }; const isApiCode = (error: unknown, code: string): boolean => error instanceof BackendApiError && error.code === code; @@ -81,7 +100,7 @@ export class SessionController { this.invalidateOperations(); this.hadAuthenticatedSession = false; this.options.onBoundary(); - this.setState({ kind: 'error', reason: 'expired' }); + this.setState({ kind: 'error', problem: { kind: 'expired' } }); } } @@ -102,6 +121,7 @@ export class SessionController { async start(): Promise { this.disposed = false; + const wasAuthenticated = this.state.kind === 'authenticated'; const operation = ++this.operation; this.setState({ kind: 'loading' }); try { @@ -109,8 +129,13 @@ export class SessionController { if (!this.isCurrent(operation)) return; this.info = info; this.options.onInfo(info, null); - if (!isCapabilityCompatible(info) || !hasRefreshLockSupport()) { - this.setState({ kind: 'error', reason: 'incompatible' }); + const issue = browserIncompatibility() ?? panelIncompatibility(info); + if (issue !== null) { + if (wasAuthenticated) { + this.hadAuthenticatedSession = false; + this.options.onBoundary(); + } + this.setState({ kind: 'error', problem: incompatibleProblem(issue) }); return; } await this.restore(operation); @@ -118,7 +143,10 @@ export class SessionController { if (!this.isCurrent(operation)) return; this.info = null; this.options.onInfo(null, error); - this.setState({ kind: 'error', reason: isUnavailable(error) ? 'offline' : 'incompatible' }); + this.setState({ + kind: 'error', + problem: isUnavailable(error) ? { kind: 'offline' } : { kind: 'session-restore-failed' }, + }); } } @@ -255,7 +283,7 @@ export class SessionController { const wasAuthenticated = this.hadAuthenticatedSession; this.hadAuthenticatedSession = false; this.options.onBoundary(); - this.setState(wasAuthenticated ? { kind: 'error', reason: 'expired' } : { kind: 'anonymous' }); + this.setState(wasAuthenticated ? { kind: 'error', problem: { kind: 'expired' } } : { kind: 'anonymous' }); return; } this.mapTerminalError(error, true); @@ -281,26 +309,33 @@ export class SessionController { this.setState({ kind: 'account-banned' }); return; } - if (isApiCode(error, 'CsrfOriginForbidden') || error instanceof BackendClientError && error.kind === 'invalid-response') { + if (isApiCode(error, 'CsrfOriginForbidden')) { + this.invalidateOperations(); + this.options.onBoundary(); + this.setState({ kind: 'error', problem: { kind: 'hosted-origin-forbidden' } }); + return; + } + if (error instanceof BackendClientError && error.kind === 'invalid-response') { this.invalidateOperations(); this.options.onBoundary(); - this.setState({ kind: 'error', reason: 'incompatible' }); + this.setState({ kind: 'error', problem: { kind: 'session-restore-failed' } }); return; } if (restoring && (isUnavailable(error) || error instanceof BackendApiError && error.status >= 500)) { - this.setState({ kind: 'error', reason: 'offline' }); + this.setState({ kind: 'error', problem: { kind: 'offline' } }); return; } if (restoring) { this.invalidateOperations(); this.options.onBoundary(); - this.setState({ kind: 'error', reason: 'incompatible' }); + this.setState({ kind: 'error', problem: { kind: 'session-restore-failed' } }); } } private ensureCompatible(): void { - if (this.info === null || !isCapabilityCompatible(this.info) || !hasRefreshLockSupport()) { - this.setState({ kind: 'error', reason: 'incompatible' }); + const issue = this.info === null ? null : browserIncompatibility() ?? panelIncompatibility(this.info); + if (issue !== null) { + this.setState({ kind: 'error', problem: incompatibleProblem(issue) }); throw new Error('This panel cannot safely use hosted cookie authentication in this browser.'); } } diff --git a/src/components/panel-shell.spec.tsx b/src/components/panel-shell.spec.tsx new file mode 100644 index 0000000..80d71cd --- /dev/null +++ b/src/components/panel-shell.spec.tsx @@ -0,0 +1,108 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { MemoryRouter, Route, Routes } from 'react-router'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { PanelSessionValue } from '@/auth/panel-session-context'; +import { PanelSessionContext } from '@/auth/panel-session-context'; +import { CompatibilityPage } from '@/pages/compatibility-page'; +import { PanelShell } from './panel-shell'; + +const actEnvironment = globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }; +let root: Root | null = null; +let container: HTMLDivElement | null = null; + +const mount = async (state: PanelSessionValue['state']): Promise => { + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + const value: PanelSessionValue = { + panel: { id: 'panel-a', origin: 'https://panel.example.com' }, + client: null, + info: null, + infoError: null, + state, + signOut: vi.fn().mockResolvedValue(undefined), + retryRestore: vi.fn(), + notifyProfileChanged: vi.fn(), + }; + await act(async () => { + root?.render( + + + + } /> + } /> + + + , + ); + }); +}; + +afterEach(async () => { + await act(async () => { + root?.unmount(); + }); + root = null; + container?.remove(); + container = null; + actEnvironment.IS_REACT_ACT_ENVIRONMENT = false; +}); + +describe('PanelShell compatibility guidance', () => { + it('renders browser-specific copy and navigates to public compatibility guidance', async () => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + await mount({ kind: 'error', problem: { kind: 'incompatible', subject: 'browser', reason: 'web-locks-unavailable' } }); + + expect(container?.querySelector('h1')?.textContent).toBe('Browser not supported'); + expect(container?.textContent).toContain('cross-tab locking'); + expect(container?.textContent).toContain('Learn more'); + expect(container?.textContent).toContain('Retry'); + await act(async () => { + container?.querySelector('a[href="/compatibility"]')?.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(container?.querySelector('h1')?.textContent).toBe('MinePanel compatibility'); + }); + + it('uses protocol-specific wording for backend incompatibility', async () => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + await mount({ kind: 'error', problem: { kind: 'incompatible', subject: 'panel', reason: 'unsupported-protocol' } }); + + expect(container?.querySelector('h1')?.textContent).toBe('Panel not compatible'); + expect(container?.textContent).toContain('protocol version'); + expect(container?.textContent).toContain('compatible backend version'); + expect(container?.textContent).not.toContain('partitioned-cookie'); + expect(container?.textContent).not.toContain('Browser not supported'); + }); + + it('uses partitioned-auth-specific wording for backend incompatibility', async () => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + await mount({ kind: 'error', problem: { kind: 'incompatible', subject: 'panel', reason: 'partitioned-auth-not-advertised' } }); + + expect(container?.querySelector('h1')?.textContent).toBe('Panel not compatible'); + expect(container?.textContent).toContain('partitioned-cookie hosted authentication capability'); + expect(container?.textContent).toContain('Update or verify the backend deployment'); + expect(container?.textContent).not.toContain('protocol version'); + }); +}); + +describe('CompatibilityPage', () => { + it('renders without a saved panel session', async () => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + await act(async () => { + root?.render( + + + , + ); + }); + + expect(container.querySelector('h1')?.textContent).toBe('MinePanel compatibility'); + expect(container.textContent).toContain('HttpOnly'); + expect(container.textContent).toContain('Web Locks'); + }); +}); diff --git a/src/components/panel-shell.tsx b/src/components/panel-shell.tsx index e0b5ceb..a8457b0 100644 --- a/src/components/panel-shell.tsx +++ b/src/components/panel-shell.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'; import { Link, matchPath, NavLink, Outlet, useLocation } from 'react-router'; import { getProbeErrorMessage } from '@/api/errors'; -import { usePanelSession } from '@/auth/panel-session-context'; +import { usePanelSession, type SessionProblem } from '@/auth/panel-session-context'; import { PasswordChangePage } from '@/pages/panel/password-change-page'; import { SignInPage } from '@/pages/panel/sign-in-page'; import { TwoFactorPage } from '@/pages/panel/two-factor-page'; @@ -34,9 +34,43 @@ const NavItem = ({ to, onNavigate, children }: { to: string; onNavigate?: () => ); const BlockedScreen = ({ title, children, onReturn }: { title: string; children: ReactNode; onReturn: () => void }): React.JSX.Element => ( -

{title}

{children}
+
+

{title}

+
{children}
+ +
); +const CompatibilityScreen = ({ + problem, + onRetry, +}: { + problem: Extract; + onRetry: () => void; +}): React.JSX.Element => { + const copy = problem.subject === 'browser' && problem.reason === 'insecure-context' + ? 'MinePanel hosted authentication requires a secure HTTPS browser context.' + : problem.subject === 'browser' + ? 'This browser does not provide the cross-tab locking MinePanel uses to keep session refreshes safe.' + : problem.reason === 'unsupported-protocol' + ? 'This panel reports a MinePanel protocol version that this dashboard does not support. Update the panel to a compatible backend version.' + : 'This panel does not advertise the partitioned-cookie hosted authentication capability required by the hosted dashboard. Update or verify the backend deployment.'; + + return ( +
+
+

{problem.subject === 'browser' ? 'Browser not supported' : 'Panel not compatible'}

+ {copy} +
+ Learn more + + Back to panels +
+
+
+ ); +}; + export const PanelShell = ({ children, panelLabel }: PanelShellProps): React.JSX.Element => { const { panel, state, signOut, retryRestore, infoError } = usePanelSession(); const [drawerOpen, setDrawerOpen] = useState(false); @@ -58,9 +92,18 @@ export const PanelShell = ({ children, panelLabel }: PanelShellProps): React.JSX if (state.kind === 'account-pending') return
void signOut()}>Your account is awaiting administrator approval. Return to sign in after an administrator approves it.
; if (state.kind === 'account-banned') return
void signOut()}>This account cannot access the panel. Contact the panel administrator if you need help.
; if (state.kind === 'error') { - const incompatible = state.reason === 'incompatible'; - const expired = state.reason === 'expired'; - return

{incompatible ? 'Panel compatibility issue' : expired ? 'Session expired' : 'Panel unavailable'}

{incompatible ? 'This panel or browser cannot safely use MinePanel hosted-cookie authentication. Check protocol, CHIPS, Web Locks, and exact panel origin configuration.' : expired ? 'Your browser session is no longer valid. Sign in again.' : getProbeErrorMessage(infoError)}
; + if (state.problem.kind === 'incompatible') { + return ; + } + const expired = state.problem.kind === 'expired'; + const message = state.problem.kind === 'offline' + ? getProbeErrorMessage(infoError) + : expired + ? 'Your browser session is no longer valid. Sign in again.' + : state.problem.kind === 'hosted-origin-forbidden' + ? 'This panel rejected the hosted dashboard origin. Verify its public HTTPS and CORS configuration.' + : 'This saved panel did not return a compatible MinePanel response.'; + return

{expired ? 'Session expired' : 'Panel unavailable'}

{message}
; } const admin = state.profile.role === 'ADMIN'; diff --git a/src/pages/compatibility-page.tsx b/src/pages/compatibility-page.tsx new file mode 100644 index 0000000..4e21eb1 --- /dev/null +++ b/src/pages/compatibility-page.tsx @@ -0,0 +1,68 @@ +import { Link } from 'react-router'; + +export const CompatibilityPage = (): React.JSX.Element => ( +
+
+

[ Hosted browser requirements ]

+

MinePanel compatibility

+

+ MinePanel is a hosted PWA that connects your browser directly to a self-hosted panel. When + those services are on different sites, the browser must provide secure cross-site session features. +

+
+ +
+

How hosted sign-in stays safe

+

+ Sign-in and session cookies are HttpOnly, so application + JavaScript cannot read session tokens. The backend marks hosted cookies as{' '} + Secure; SameSite=None; Partitioned. The browser isolates + those cookies to the MinePanel top-level site, allowing the dashboard to connect directly without + relying on globally shared third-party cookies. +

+
+ +
+

Why Web Locks are required

+

+ Multiple tabs can try to refresh the same rotating session at once. MinePanel uses the browser's + Web Locks feature to coordinate those refreshes and prevent tabs from racing the same session authority. +

+
+ +
+

What to use

+
    +
  • An up-to-date stable release of a major browser.
  • +
  • The hosted PWA loaded over HTTPS.
  • +
  • A browser-trusted public HTTPS origin for the panel.
  • +
  • An updated MinePanel backend when its compatibility check fails.
  • +
+

+ MinePanel cannot currently guarantee support for old browsers without these web-platform features, + arbitrary embedded WebViews, browser-untrusted HTTP endpoints, or arbitrary private and LAN origins + from the hosted app. The PWA can verify the backend's advertised protocol and hosted-auth capability, + but it cannot independently prove an HttpOnly partitioned-cookie exchange for every backend origin. +

+
+ +
+

Privacy and security

+

+ MinePanel intentionally keeps normal web session tokens in HttpOnly cookies instead of exposing them + to application JavaScript. There is currently no alternate token-based hosted-auth fallback for an + unsupported browser environment. +

+
+ + +
+);