Harden website auth bootstrap and dashboard coverage
This commit is contained in:
parent
e9546f2a95
commit
5f0f487b61
10 changed files with 364 additions and 7 deletions
|
|
@ -148,6 +148,12 @@ The repo bootstrap CI now also validates the website lane directly through:
|
|||
- `website` frontend install, type-check, test, and build
|
||||
- `website/server` install, type-check, and test
|
||||
|
||||
The frontend behavior coverage now also explicitly pins:
|
||||
|
||||
- login redirect preservation for pathname, query, and hash deep links
|
||||
- browser auth-bootstrap normalization when the account payload reports email/fallback posture
|
||||
- dashboard launch-readiness visibility plus generated desktop-link verify URL behavior
|
||||
|
||||
This is browser-based user access for the operator/account surface.
|
||||
|
||||
It is **not** a claim that the simulator itself is now browser-owned.
|
||||
|
|
|
|||
|
|
@ -264,7 +264,7 @@ repo.
|
|||
| Feature | Status | Primary authority | Notes |
|
||||
|---|---|---|---|
|
||||
| Public `hypertwist.app` marketing shell | Implemented now | first-party `website/` app + feature registry/roadmap authority | HyperTwist now has a dedicated first-party public web surface for homepage, about, resources, pricing, download, support, and legal routes. This lane is separate from the embedded Unreal browser runtime under `Content/Browser/` and does not claim browser-simulator parity. The same package now also carries a first-party external runtime-readiness verifier so deploy-time env and live health posture can be checked outside the dashboard, plus separated local-versus-production env templates whose placeholder values are intentionally rejected until real launch config is in place, and bootstrap CI now validates both the frontend and auth-server website commands directly. |
|
||||
| Browser-based operator/account dashboard | Implemented now | first-party `website/` app + shared auth/dashboard packet | A protected browser dashboard is now live for operator access, account state, download posture, browser-access boundary explanation, notices review, and bounded billing/entitlement status. It reuses the shared SuperTokens auth posture proven in FamiliarOS and ScriptoriumAI while remaining HyperTwist-specific in product content and boundary claims, the current auth-health surface now truthfully distinguishes configured versus reachable or ready shared-core posture while exposing fallback-active reason instead of hardcoding readiness, and the same dashboard now also surfaces launch-readiness truth for download URLs, checkout links, source/notices URLs, billing-secret/map configuration, and local-versus-public runtime deployment posture. |
|
||||
| Browser-based operator/account dashboard | Implemented now | first-party `website/` app + shared auth/dashboard packet | A protected browser dashboard is now live for operator access, account state, download posture, browser-access boundary explanation, notices review, and bounded billing/entitlement status. It reuses the shared SuperTokens auth posture proven in FamiliarOS and ScriptoriumAI while remaining HyperTwist-specific in product content and boundary claims, the current auth-health surface now truthfully distinguishes configured versus reachable or ready shared-core posture while exposing fallback-active reason instead of hardcoding readiness, and the same dashboard now also surfaces launch-readiness truth for download URLs, checkout links, source/notices URLs, billing-secret/map configuration, and local-versus-public runtime deployment posture. Focused frontend coverage now also protects deep-link login redirect preservation, fallback/email auth-bootstrap normalization, and desktop-link verify-url/dashboard readiness behavior. |
|
||||
| Desktop download posture and browser-to-desktop pairing | Implemented now | first-party `website/` app + `website/server` desktop-link endpoints | Public download targets, dashboard-side release posture, and short-lived desktop-link token generation/verification are now first-party owned. The current server posture now enforces exact website-origin matching, bounded per-user issuance, one-time token consumption, and billing-backed plan/download entitlement resolution with focused `website/server` tests green on `2026-06-22`, and the verify handshake now returns the same resolved download-entitlement posture the dashboard sees instead of only identity plus plan/role. The public `/download` page now keeps raw download URLs behind the protected dashboard instead of exposing them directly. Actual release URLs remain deployment configuration rather than hardcoded product truth. |
|
||||
| Paddle-ready pricing and billing webhook seam | Implemented now | first-party `website/` app + `website/server` billing endpoint | The public pricing surface now exists with plan structure, checkout-link configuration seams, and the same `/api/billing/paddle/webhook` endpoint family used by the broader product website lane. The current server now verifies `Paddle-Signature` against `PADDLE_WEBHOOK_SECRET` using the documented raw-body HMAC flow, persists a bounded first-party billing state file, and applies verified Paddle events into account/download entitlement state that the browser dashboard consumes, with focused `website/server` tests green on `2026-06-22`. Production checkout URLs, secret management, and broader operator/admin billing workflows remain deployment/application tasks, not shipped-code omissions. |
|
||||
| Public open-source notices and corresponding-source surface | Implemented now | first-party `website/` app + `HYPERTWIST_MPL_DISTRIBUTION_PLACEMENT_CHECKLIST_2026-05-25.md` | HyperTwist now has a stable public `Open Source Notices` route linked from pricing, download, and footer surfaces, satisfying the requirement that public distribution surfaces expose notice and corresponding-source guidance when shipped builds contain `MPL`-covered material. The exact public corresponding-source URL still must be configured before external launch. |
|
||||
|
|
|
|||
|
|
@ -218,7 +218,10 @@ Current consolidated milestone snapshot:
|
|||
`/api/auth/health` posture before public launch, along with separate local
|
||||
versus production example env families whose `replace-me` scaffolding is now
|
||||
explicitly rejected by that verifier, and the bootstrap CI lane now also runs
|
||||
the website/frontend plus website/server validation commands directly,
|
||||
the website/frontend plus website/server validation commands directly, while
|
||||
focused frontend coverage now also pins deep-link login redirects, fallback
|
||||
auth-bootstrap normalization, and dashboard launch-readiness plus desktop-link
|
||||
verify-url behavior,
|
||||
persists a bounded first-party billing-state file, applies verified Paddle
|
||||
events into account/download entitlement state, and surfaces that resolved
|
||||
billing/download posture back through `/api/auth/me`, the protected browser
|
||||
|
|
|
|||
|
|
@ -86,3 +86,9 @@ The repo bootstrap CI now also validates this lane through:
|
|||
|
||||
- frontend `npm ci`, `npm run type-check`, `npm test`, and `npm run build`
|
||||
- auth-server `npm ci`, `npm run type-check`, and `npm test`
|
||||
|
||||
The focused frontend test coverage now also pins:
|
||||
|
||||
- route-guard redirect preservation for pathname, query, and hash deep links
|
||||
- auth-bootstrap normalization when fallback/email sessions are re-hydrated
|
||||
- dashboard launch-readiness plus desktop-link verify-url behavior
|
||||
|
|
|
|||
130
website/src/__tests__/DashboardOverviewPage.test.tsx
Normal file
130
website/src/__tests__/DashboardOverviewPage.test.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
const mockUsePlatformAuth = vi.fn()
|
||||
const mockCreateDesktopLinkToken = vi.fn()
|
||||
const mockGetAuthHealth = vi.fn()
|
||||
const mockBuildAuthApiBaseUrls = vi.fn(() => ['https://hypertwist.app'])
|
||||
|
||||
vi.mock('../auth/platform-auth', () => ({
|
||||
usePlatformAuth: () => mockUsePlatformAuth(),
|
||||
}))
|
||||
|
||||
vi.mock('../auth/auth-api', () => ({
|
||||
buildAuthApiBaseUrls: () => mockBuildAuthApiBaseUrls(),
|
||||
createDesktopLinkToken: (...args: unknown[]) => mockCreateDesktopLinkToken(...args),
|
||||
getAuthHealth: (...args: unknown[]) => mockGetAuthHealth(...args),
|
||||
}))
|
||||
|
||||
import { DashboardOverviewPage } from '../pages/app-pages'
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<DashboardOverviewPage />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('DashboardOverviewPage', () => {
|
||||
beforeEach(() => {
|
||||
mockUsePlatformAuth.mockReset()
|
||||
mockCreateDesktopLinkToken.mockReset()
|
||||
mockGetAuthHealth.mockReset()
|
||||
mockBuildAuthApiBaseUrls.mockReset()
|
||||
mockBuildAuthApiBaseUrls.mockReturnValue(['https://hypertwist.app'])
|
||||
|
||||
mockUsePlatformAuth.mockReturnValue({
|
||||
user: {
|
||||
id: 'operator-1',
|
||||
name: 'Operator',
|
||||
email: 'operator@hypertwist.app',
|
||||
authMethod: 'email',
|
||||
plan: 'operator',
|
||||
role: 'operator',
|
||||
canDownload: true,
|
||||
billing: {
|
||||
source: 'local-fallback',
|
||||
accessStatus: 'trial',
|
||||
canDownload: true,
|
||||
lastEventType: 'transaction.completed',
|
||||
},
|
||||
},
|
||||
superTokensConfigured: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces launch-readiness issues and the generated desktop-link verify URL', async () => {
|
||||
mockGetAuthHealth.mockResolvedValue({
|
||||
ok: true,
|
||||
service: 'hypertwist-auth-server',
|
||||
supertokens: {
|
||||
configured: true,
|
||||
reachable: false,
|
||||
ready: false,
|
||||
apiVersion: null,
|
||||
oauth: {
|
||||
github: false,
|
||||
google: false,
|
||||
},
|
||||
},
|
||||
fallback: {
|
||||
enabled: true,
|
||||
active: true,
|
||||
reason: 'not_ready',
|
||||
},
|
||||
billing: {
|
||||
statePath: '/tmp/hypertwist-billing.json',
|
||||
processedEventCount: 0,
|
||||
pricePlanMapConfigured: false,
|
||||
productPlanMapConfigured: false,
|
||||
webhookSecretConfigured: false,
|
||||
},
|
||||
runtime: {
|
||||
mode: 'mixed',
|
||||
public_origin_ready: false,
|
||||
cookie_secure: false,
|
||||
api_domain: 'http://localhost:3001',
|
||||
website_domain: 'http://localhost:4273',
|
||||
warnings: ['COOKIE_SECURE disabled'],
|
||||
errors: ['API_DOMAIN still points at localhost'],
|
||||
},
|
||||
})
|
||||
|
||||
mockCreateDesktopLinkToken.mockResolvedValue({
|
||||
ok: true,
|
||||
token: 'desktop-token-123',
|
||||
expires_at: '2026-06-22T12:00:00.000Z',
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Checking auth server health/i)).toBeNull()
|
||||
})
|
||||
|
||||
expect(screen.getByText(/This session is currently using local fallback posture/i)).toBeTruthy()
|
||||
expect(screen.getByText(/Shared auth core is not fully ready right now/i)).toBeTruthy()
|
||||
expect(screen.getByText(/Public launch is not fully configured yet:/i)).toBeTruthy()
|
||||
expect(screen.getByText(/Paddle webhook secret missing/i)).toBeTruthy()
|
||||
expect(screen.getByText(/Public auth runtime still uses local or mixed deployment posture/i)).toBeTruthy()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /generate desktop-link token/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText((content) => content === 'desktop-token-123')).toBeTruthy()
|
||||
})
|
||||
|
||||
expect(screen.getByText(/https:\/\/hypertwist\.app\/api\/auth\/desktop-link\/verify\?token=desktop-token-123/i)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
163
website/src/__tests__/platform-auth.bootstrap.test.tsx
Normal file
163
website/src/__tests__/platform-auth.bootstrap.test.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const mockGetCurrentUser = vi.fn()
|
||||
const mockLogoutCurrentUser = vi.fn()
|
||||
const mockEnsureSuperTokensInit = vi.fn()
|
||||
const mockIsSuperTokensConfigured = vi.fn(() => true)
|
||||
|
||||
vi.mock('../auth/auth-api', () => ({
|
||||
getCurrentUser: (...args: unknown[]) => mockGetCurrentUser(...args),
|
||||
logoutCurrentUser: (...args: unknown[]) => mockLogoutCurrentUser(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../auth/supertokens-client', () => ({
|
||||
ensureSuperTokensInit: (...args: unknown[]) => mockEnsureSuperTokensInit(...args),
|
||||
isSuperTokensConfigured: () => mockIsSuperTokensConfigured(),
|
||||
}))
|
||||
|
||||
vi.mock('supertokens-auth-react/recipe/emailpassword', () => ({
|
||||
signIn: vi.fn(),
|
||||
signUp: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('supertokens-auth-react/recipe/thirdparty', () => ({
|
||||
redirectToThirdPartyLogin: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('supertokens-auth-react/recipe/session', () => ({
|
||||
signOut: vi.fn(),
|
||||
}))
|
||||
|
||||
import { PlatformAuthProvider, usePlatformAuth } from '../auth/platform-auth'
|
||||
|
||||
const AUTH_STORAGE_KEY = 'hypertwist.platform.user.v1'
|
||||
|
||||
function AuthProbe() {
|
||||
const { user, isLoading } = usePlatformAuth()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="loading">{String(isLoading)}</div>
|
||||
<div data-testid="user-id">{user?.id ?? 'none'}</div>
|
||||
<div data-testid="auth-method">{user?.authMethod ?? 'none'}</div>
|
||||
<div data-testid="plan">{user?.plan ?? 'none'}</div>
|
||||
<div data-testid="role">{user?.role ?? 'none'}</div>
|
||||
<div data-testid="can-download">{String(user?.canDownload ?? false)}</div>
|
||||
<div data-testid="billing-source">{user?.billing?.source ?? 'none'}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderWithProvider(children: ReactNode) {
|
||||
return render(<PlatformAuthProvider>{children}</PlatformAuthProvider>)
|
||||
}
|
||||
|
||||
function createJsonResponse(status: number, body: unknown) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
}
|
||||
}
|
||||
|
||||
function seedStoredUser(user: Record<string, unknown>) {
|
||||
window.localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(user))
|
||||
}
|
||||
|
||||
describe('PlatformAuthProvider bootstrap', () => {
|
||||
beforeEach(() => {
|
||||
cleanup()
|
||||
window.localStorage.clear()
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
mockGetCurrentUser.mockReset()
|
||||
mockLogoutCurrentUser.mockReset()
|
||||
mockEnsureSuperTokensInit.mockReset()
|
||||
mockIsSuperTokensConfigured.mockReset()
|
||||
mockIsSuperTokensConfigured.mockReturnValue(true)
|
||||
})
|
||||
|
||||
it('preserves email fallback auth method when bootstrap mode is email_fallback', async () => {
|
||||
seedStoredUser({
|
||||
id: 'fallback-1',
|
||||
email: 'founder@hypertwist.app',
|
||||
name: 'Founder',
|
||||
authMethod: 'email',
|
||||
plan: 'free',
|
||||
role: 'operator',
|
||||
canDownload: false,
|
||||
billing: {
|
||||
source: 'local-fallback',
|
||||
accessStatus: 'local-fallback',
|
||||
canDownload: false,
|
||||
},
|
||||
})
|
||||
|
||||
mockGetCurrentUser.mockResolvedValue(
|
||||
createJsonResponse(200, {
|
||||
mode: 'email_fallback',
|
||||
user: {
|
||||
id: 'fallback-1',
|
||||
email: 'founder@hypertwist.app',
|
||||
name: 'Founder',
|
||||
plan: 'operator',
|
||||
role: 'operator',
|
||||
can_download: true,
|
||||
billing: {
|
||||
source: 'local-fallback',
|
||||
access_status: 'local-fallback',
|
||||
can_download: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
renderWithProvider(<AuthProbe />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('plan').textContent).toBe('operator')
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('auth-method').textContent).toBe('email')
|
||||
expect(screen.getByTestId('can-download').textContent).toBe('true')
|
||||
expect(screen.getByTestId('billing-source').textContent).toBe('local-fallback')
|
||||
expect(mockEnsureSuperTokensInit).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(JSON.parse(window.localStorage.getItem(AUTH_STORAGE_KEY) || '{}')).toMatchObject({
|
||||
authMethod: 'email',
|
||||
plan: 'operator',
|
||||
canDownload: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a stored local fallback session when bootstrap cannot reach the account API', async () => {
|
||||
mockIsSuperTokensConfigured.mockReturnValue(false)
|
||||
seedStoredUser({
|
||||
id: 'local-1',
|
||||
email: 'operator@hypertwist.app',
|
||||
name: 'Operator',
|
||||
authMethod: 'email',
|
||||
plan: 'free',
|
||||
role: 'operator',
|
||||
canDownload: false,
|
||||
billing: {
|
||||
source: 'local-fallback',
|
||||
accessStatus: 'local-fallback',
|
||||
canDownload: false,
|
||||
},
|
||||
})
|
||||
|
||||
mockGetCurrentUser.mockRejectedValue(new Error('offline'))
|
||||
|
||||
renderWithProvider(<AuthProbe />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('loading').textContent).toBe('false')
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('auth-method').textContent).toBe('email')
|
||||
expect(screen.getByTestId('billing-source').textContent).toBe('local-fallback')
|
||||
expect(mockEnsureSuperTokensInit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
14
website/src/__tests__/route-guard.test.ts
Normal file
14
website/src/__tests__/route-guard.test.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { buildLoginRedirectTarget } from '../auth/route-guard'
|
||||
|
||||
describe('route guard helpers', () => {
|
||||
it('builds a login redirect target with encoded pathname, search, and hash', () => {
|
||||
const target = buildLoginRedirectTarget({
|
||||
pathname: '/app/downloads',
|
||||
search: '?platform=windows',
|
||||
hash: '#desktop-link',
|
||||
})
|
||||
|
||||
expect(target).toBe('/login?next=%2Fapp%2Fdownloads%3Fplatform%3Dwindows%23desktop-link')
|
||||
})
|
||||
})
|
||||
|
|
@ -94,6 +94,36 @@ function normalizePlan(value: unknown): PlatformUser['plan'] {
|
|||
return 'free'
|
||||
}
|
||||
|
||||
function normalizeAuthMethod(value: unknown): AuthMethod | undefined {
|
||||
const method = String(value || '').trim().toLowerCase()
|
||||
if (
|
||||
method === 'supertokens' ||
|
||||
method === 'email' ||
|
||||
method === 'github' ||
|
||||
method === 'google' ||
|
||||
method === 'orcid'
|
||||
) {
|
||||
return method
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function deriveBootstrapAuthMethod(payload: ApiBootstrapUserPayload, fallbackMethod: AuthMethod): AuthMethod {
|
||||
const explicitMethod = normalizeAuthMethod(payload.user?.auth_method)
|
||||
if (explicitMethod) {
|
||||
return explicitMethod
|
||||
}
|
||||
|
||||
const mode = String(payload.mode || '').trim().toLowerCase()
|
||||
if (mode === 'supertokens') return 'supertokens'
|
||||
if (mode === 'github' || mode === 'google' || mode === 'orcid') return mode
|
||||
if (mode === 'email' || mode === 'email_fallback' || mode === 'local_fallback' || mode === 'local-fallback') {
|
||||
return 'email'
|
||||
}
|
||||
|
||||
return fallbackMethod
|
||||
}
|
||||
|
||||
function normalizeRole(value: unknown): PlatformRole | undefined {
|
||||
const role = String(value || '').trim().toLowerCase()
|
||||
if (role === 'viewer' || role === 'operator' || role === 'reviewer' || role === 'admin') {
|
||||
|
|
@ -146,7 +176,7 @@ function normalizeApiUser(input: ApiBootstrapUserPayload['user'] | undefined, fa
|
|||
id,
|
||||
email,
|
||||
name: String(input.name || '').trim() || deriveNameFromEmail(email),
|
||||
authMethod: String(input.auth_method || '').trim() === 'supertokens' ? 'supertokens' : fallbackMethod,
|
||||
authMethod: normalizeAuthMethod(input.auth_method) || fallbackMethod,
|
||||
plan: normalizePlan(input.plan),
|
||||
role,
|
||||
isAdmin: input.is_admin === true || role === 'admin',
|
||||
|
|
@ -226,7 +256,7 @@ export function PlatformAuthProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
|
||||
const payload = await response.json() as ApiBootstrapUserPayload
|
||||
const nextUser = normalizeApiUser(payload.user, 'supertokens')
|
||||
const nextUser = normalizeApiUser(payload.user, deriveBootstrapAuthMethod(payload, 'supertokens'))
|
||||
if (!cancelled) {
|
||||
setUser(nextUser)
|
||||
writeStoredUser(nextUser)
|
||||
|
|
@ -292,7 +322,7 @@ export function PlatformAuthProvider({ children }: { children: ReactNode }) {
|
|||
return { ok: false, error: 'Signed in, but account bootstrap failed.' }
|
||||
}
|
||||
const payload = await bootstrapResponse.json() as ApiBootstrapUserPayload
|
||||
const nextUser = normalizeApiUser(payload.user, 'supertokens')
|
||||
const nextUser = normalizeApiUser(payload.user, deriveBootstrapAuthMethod(payload, 'supertokens'))
|
||||
setUser(nextUser)
|
||||
writeStoredUser(nextUser)
|
||||
} catch {
|
||||
|
|
@ -336,7 +366,7 @@ export function PlatformAuthProvider({ children }: { children: ReactNode }) {
|
|||
return { ok: false, error: 'Account created, but bootstrap failed.' }
|
||||
}
|
||||
const payload = await bootstrapResponse.json() as ApiBootstrapUserPayload
|
||||
const nextUser = normalizeApiUser(payload.user, 'supertokens')
|
||||
const nextUser = normalizeApiUser(payload.user, deriveBootstrapAuthMethod(payload, 'supertokens'))
|
||||
setUser(nextUser)
|
||||
writeStoredUser(nextUser)
|
||||
} catch {
|
||||
|
|
|
|||
4
website/src/auth/route-guard.ts
Normal file
4
website/src/auth/route-guard.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export function buildLoginRedirectTarget(location: Pick<Location, 'pathname' | 'search' | 'hash'>) {
|
||||
const next = `${location.pathname}${location.search}${location.hash}`
|
||||
return `/login?next=${encodeURIComponent(next)}`
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { Navigate, Outlet, useLocation } from 'react-router-dom'
|
||||
import { usePlatformAuth } from '../../auth/platform-auth'
|
||||
import { buildLoginRedirectTarget } from '../../auth/route-guard'
|
||||
import { GeneralPageLoader } from '../ui/Skeletons'
|
||||
|
||||
export function ProtectedRoute() {
|
||||
|
|
@ -11,7 +12,7 @@ export function ProtectedRoute() {
|
|||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to={`/login?next=${encodeURIComponent(location.pathname + location.search)}`} replace />
|
||||
return <Navigate to={buildLoginRedirectTarget(location)} replace />
|
||||
}
|
||||
|
||||
return <Outlet />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue