Harden website auth bootstrap and provider parity
This commit is contained in:
parent
b1764be306
commit
ce389a97a7
29 changed files with 979 additions and 136 deletions
|
|
@ -89,10 +89,13 @@ Current behavior:
|
|||
- shared SuperTokens auth frontend posture aligned with FamiliarOS and ScriptoriumAI
|
||||
- email/password login and registration
|
||||
- optional GitHub and Google OAuth flags
|
||||
- optional ORCID redirect posture retained client-side as a backend-gated extension point
|
||||
- optional ORCID now also exists as a bounded first-party auth-server custom-provider lane instead of remaining a client-only placeholder
|
||||
- deterministic local fallback mode when the backend is not configured
|
||||
- bounded auth-health probing that now distinguishes configured, reachable, and ready shared-core posture without mutating auth state
|
||||
- bounded frontend auth-env diagnostics that now warn when the browser lane still points at loopback, insecure `http`, or split backend/auth targets
|
||||
- auth-health runtime truth now also reports ORCID readiness beside GitHub and Google when that provider is configured
|
||||
- login and register now both expose the same provider-row continuation and redirect-recovery behavior, so provider sign-in posture is not narrower on registration than on login
|
||||
- the same-origin bundle renderer now emits `VITE_ORCID_OAUTH_ENABLED` from the authoritative server/provider manifest instead of hardcoding it off in the bundle lane
|
||||
|
||||
### Browser dashboard posture
|
||||
|
||||
|
|
@ -146,6 +149,10 @@ The login and register pages now also surface those shared-auth runtime warnings
|
|||
instead of silently behaving like production auth when the lane is still in
|
||||
local fallback or mixed deployment posture.
|
||||
|
||||
The public docs route now also carries an explicit browser-account-method guide
|
||||
so provider truth no longer depends on operators reaching only the auth form
|
||||
pages to see which shared-auth lanes are actually present in the current build.
|
||||
|
||||
The website package now also includes a first-party external runtime-readiness
|
||||
command under:
|
||||
|
||||
|
|
|
|||
|
|
@ -259,6 +259,35 @@ Additional follow-up after the protected website hardening continuation:
|
|||
- `scripts/run-hypertwist-gitnexus-status.sh` then reported the bounded
|
||||
mirror `Status: up-to-date`
|
||||
|
||||
Latest tool refresh on `2026-06-27`:
|
||||
|
||||
- the same bounded refactor loop stayed healthy through the shared-auth
|
||||
provider parity and public-manual continuation packet:
|
||||
- `scripts/run-hypertwist-sentrux-source-only.sh` passed again with:
|
||||
- `Quality: 6218`
|
||||
- all `7` rules passing
|
||||
- `scripts/run-hypertwist-gitnexus-analyze.sh` again fell back cleanly from
|
||||
the retained local CLI to `npx -y gitnexus@latest` on this Linux host,
|
||||
then re-indexed the bounded mirror successfully at:
|
||||
- `16,310` nodes
|
||||
- `38,379` edges
|
||||
- `672` clusters
|
||||
- `300` flows
|
||||
- completion time `91.4s`
|
||||
- `scripts/run-hypertwist-gitnexus-status.sh` then reported:
|
||||
- `Indexed commit: 15e3608`
|
||||
- `Current commit: 15e3608`
|
||||
- `Status: up-to-date`
|
||||
|
||||
Current interpretation after that refresh:
|
||||
|
||||
- the public/auth/manual widening did not introduce new structural debt
|
||||
- the repo-local `sentrux` lane remains stable as the day-to-day structural
|
||||
gate
|
||||
- the retained GitNexus local runtime is still not usable on this Linux host
|
||||
because of the native `LadybugDB` payload mismatch, but the HyperTwist-owned
|
||||
wrapper continues to fail over truthfully instead of masking that host fact
|
||||
|
||||
Latest protected packaged-proof continuation refresh on `2026-06-24`:
|
||||
|
||||
- `scripts/run-hypertwist-gitnexus-analyze.sh` again fell back cleanly from the
|
||||
|
|
@ -940,6 +969,44 @@ Latest website/operator follow-up later on `2026-06-24`:
|
|||
cleanup and deeper product/runtime quality review, not as unresolved
|
||||
toolchain setup debt
|
||||
|
||||
Latest refresh on `2026-06-27`:
|
||||
|
||||
- the next bounded website/auth refactor then split config-only shared-auth
|
||||
runtime facts away from heavy SuperTokens recipe imports, moved the route
|
||||
wrapper onto a lazy auth-only path, and kept sign-in or sign-up or provider
|
||||
actions on demand-loaded recipe modules instead of front-loading them into
|
||||
the ordinary public website surface
|
||||
- the production website build then proved that the remaining auth-core chunk
|
||||
dropped below the warning threshold:
|
||||
- `npm --prefix website run build`
|
||||
- `auth-core-vendor`: `391.18 kB`
|
||||
- the previous oversized Vite warning no longer appeared
|
||||
- the owned source-only structural gate also stayed clean and improved again:
|
||||
- `scripts/run-hypertwist-sentrux-source-only.sh`
|
||||
- `Quality: 6220`
|
||||
- all `7` rules pass
|
||||
- the HyperTwist-owned graph refresh again succeeded on the bounded mirror:
|
||||
- `scripts/run-hypertwist-gitnexus-analyze.sh`
|
||||
- retained local CLI again failed cleanly on this Linux host because of the
|
||||
cross-platform native payload mismatch, so the wrapper truthfully fell back
|
||||
to `npx -y gitnexus@latest`
|
||||
- fallback analyze run completed successfully in `93.2s`
|
||||
- bounded mirror result:
|
||||
- `16,303` nodes
|
||||
- `38,349` edges
|
||||
- `674` clusters
|
||||
- `300` flows
|
||||
- `scripts/run-hypertwist-gitnexus-status.sh` then reported:
|
||||
- `Indexed commit: 5272e18`
|
||||
- `Current commit: 5272e18`
|
||||
- `Status: up-to-date`
|
||||
- current truthful reading after this refresh:
|
||||
- HyperTwist’s “vanilla refactor” lane is no longer about tool adoption or
|
||||
a failing browser/public structural gate
|
||||
- the current higher-value follow-ups are product-quality review, bounded
|
||||
code ownership cleanup, and intentionally chosen runtime packets rather
|
||||
than emergency website/auth bundle repair
|
||||
|
||||
## Out of scope
|
||||
|
||||
This note does not:
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ Recommended server env posture:
|
|||
- `WEBSITE_BASE_PATH=/auth`
|
||||
- `COOKIE_SECURE=true`
|
||||
- `SERVE_STATIC_WEBSITE=true` when the auth server owns same-origin public delivery
|
||||
- optional `ORCID_CLIENT_ID` and `ORCID_CLIENT_SECRET` when ORCID browser sign-in is intentionally enabled
|
||||
- optional `ORCID_IS_SANDBOX=true` for sandbox-only ORCID rehearsal instead of the production ORCID authority
|
||||
- real `PADDLE_WEBHOOK_SECRET`
|
||||
- real `PADDLE_PRODUCT_PLAN_MAP` and/or `PADDLE_PRICE_PLAN_MAP`
|
||||
|
||||
|
|
@ -58,6 +60,16 @@ the readiness command still fails until real launch values are inserted.
|
|||
They now also carry the explicit static-serving control variables for the
|
||||
same-origin lane.
|
||||
|
||||
Current same-family follow-up on `2026-06-27`:
|
||||
|
||||
- HyperTwist now treats ORCID as a real bounded shared-auth provider lane
|
||||
rather than a client-only extension point
|
||||
- the same-origin bundle renderer now emits matching frontend plus server ORCID
|
||||
env values from the authoritative manifest whenever both ORCID credentials
|
||||
are present
|
||||
- runtime diagnostics now also warn when ORCID is only partially configured, so
|
||||
a half-wired provider cannot silently masquerade as launch-ready auth posture
|
||||
|
||||
That expectation is now proven two ways in the repo itself:
|
||||
|
||||
- the real `check-runtime-readiness` CLI is exercised against the checked-in production example files and must fail until placeholders are replaced
|
||||
|
|
@ -149,7 +161,7 @@ Before public launch, the current runtime and dashboard surfaces should show:
|
|||
- runtime mode: `public`
|
||||
- `public auth origin ready: yes`
|
||||
- no runtime config errors
|
||||
- no runtime config warnings that indicate loopback, insecure `http`, or partial OAuth setup
|
||||
- no runtime config warnings that indicate loopback, insecure `http`, or partial GitHub/Google/ORCID OAuth setup
|
||||
- dashboard launch-readiness issues cleared for download/check-out/source configuration
|
||||
- the deployed website root includes the first-party shell marker rather than
|
||||
the old rollout placeholder page
|
||||
|
|
|
|||
|
|
@ -926,6 +926,91 @@ Current audit note:
|
|||
- `scripts/run-hypertwist-gitnexus-status.sh`
|
||||
- bounded mirror `Status: up-to-date`
|
||||
|
||||
## Latest website bootstrap hardening follow-up (`2026-06-27`)
|
||||
|
||||
- the public website entrypoint now initializes shared SuperTokens posture from
|
||||
`website/src/main.tsx` before React root creation instead of waiting for a
|
||||
later auth-shell or platform-auth path to trigger first use
|
||||
- `website/index.html` now also carries a centered first-paint HyperTwist boot
|
||||
shell inside `#root`, so public and protected routes no longer begin from a
|
||||
blank document while the first bundle loads
|
||||
- focused bootstrap validation stayed green under:
|
||||
- `npm --prefix website test -- --run src/__tests__/main.bootstrap.test.tsx src/__tests__/App.bootstrap.test.tsx`
|
||||
- `2` test files passed
|
||||
- `5` tests passed
|
||||
|
||||
## Latest website auth bundle hardening follow-up (`2026-06-27`)
|
||||
|
||||
- the remaining public-website auth payload warning was then closed without
|
||||
widening product scope:
|
||||
- config-only auth runtime facts now live in
|
||||
`website/src/auth/supertokens-runtime.ts`
|
||||
- heavy SuperTokens client initialization remains in
|
||||
`website/src/auth/supertokens-client.ts`, but now loads its large recipe
|
||||
modules lazily instead of importing them into ordinary public routes
|
||||
- the auth-route wrapper moved behind
|
||||
`website/src/auth/SuperTokensRouteWrapper.tsx` and is now lazy-loaded only
|
||||
when a route actually needs the shared browser-auth shell
|
||||
- platform-auth login, register, provider-login, and logout flows now use
|
||||
lazy recipe imports instead of front-loading those modules into the main
|
||||
website application path
|
||||
- current production-build truth after that split:
|
||||
- `npm --prefix website run build`
|
||||
- no Vite chunk-size warning remained
|
||||
- `auth-core-vendor` dropped to `391.18 kB`
|
||||
- the route wrapper itself stayed tiny at `0.21 kB`
|
||||
- focused auth/bootstrap validation widened and stayed green under:
|
||||
- `npm --prefix website test -- --run src/__tests__/main.bootstrap.test.tsx src/__tests__/App.bootstrap.test.tsx src/__tests__/platform-auth.bootstrap.test.tsx src/__tests__/public-auth-pages.test.tsx`
|
||||
- `4` test files passed
|
||||
- `17` tests passed
|
||||
- the full same-family web/product gate then stayed green again under:
|
||||
- `scripts/run-hypertwist-web-surface-validation.sh`
|
||||
- focused website route/auth/release validation: `12` files, `66` tests
|
||||
passed
|
||||
- website/server validation: `10` files, `36` tests passed
|
||||
- `Content/Browser` verify/build passed
|
||||
- website and `Content/Browser` production audits stayed clean
|
||||
- the only retained audit residual remained the already-documented upstream
|
||||
`supertokens-node -> nodemailer` advisory in `website/server`
|
||||
|
||||
## Latest shared-auth provider parity follow-up (`2026-06-27`)
|
||||
|
||||
- the remaining HyperTwist shared-auth provider drift then closed in the same
|
||||
family without widening product scope:
|
||||
- the auth server now owns a bounded first-party ORCID custom-provider lane
|
||||
in `website/server/src/index.ts` instead of leaving ORCID as a UI-only
|
||||
placeholder
|
||||
- runtime config diagnostics now also cover partial ORCID configuration in
|
||||
`website/server/src/runtime-config.ts`
|
||||
- the same-origin bundle renderer now emits matching ORCID frontend/server
|
||||
env facts from the authoritative manifest instead of hardcoding
|
||||
`VITE_ORCID_OAUTH_ENABLED=false`
|
||||
- protected auth-health truth now reports ORCID readiness beside GitHub and
|
||||
Google
|
||||
- login and register now both share the same provider-row continuation and
|
||||
redirect-recovery behavior instead of leaving provider sign-in posture
|
||||
narrower on registration
|
||||
- the public docs route now also exposes the real shared browser-auth method
|
||||
lineup so provider truth is no longer stranded only on the auth entry
|
||||
pages
|
||||
- cross-repo source truth for this packet stayed disciplined:
|
||||
- FamiliarOS current website auth posture remains intentionally narrower
|
||||
(`email/password` plus `GitHub`)
|
||||
- ScriptoriumAI current auth server still carries the bounded ORCID custom
|
||||
provider pattern on the same `supertokens-node@24.0.2` family
|
||||
- HyperTwist now reuses that bounded provider approach in a first-party way
|
||||
while keeping desktop-first simulator truth unchanged
|
||||
- focused validation for the widened packet stayed green under:
|
||||
- `npm --prefix website test -- --run src/__tests__/public-auth-pages.test.tsx src/__tests__/public-marketing-pages.test.tsx src/__tests__/platform-auth.bootstrap.test.tsx`
|
||||
- `npm --prefix website/server test -- --run src/__tests__/runtime-config.test.ts src/__tests__/auth-health.test.ts`
|
||||
- `npm --prefix website run test -- --run scripts/render-same-origin-bundle-lib.test.mjs`
|
||||
- `npm --prefix website/server run type-check`
|
||||
- the same-family umbrella and structural gates also remained the target truth:
|
||||
- `scripts/run-hypertwist-web-surface-validation.sh`
|
||||
- `scripts/run-hypertwist-sentrux-source-only.sh`
|
||||
- `scripts/run-hypertwist-gitnexus-analyze.sh`
|
||||
- `scripts/run-hypertwist-gitnexus-status.sh`
|
||||
|
||||
## Latest native/public control-roster parity follow-up (`2026-06-25`)
|
||||
|
||||
- the same-family native/operator continuity lane then aligned the shipped
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -32,10 +32,113 @@
|
|||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@400;500;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(84, 203, 255, 0.16), transparent 40%),
|
||||
linear-gradient(180deg, #0b1020, #050814 68%);
|
||||
color: #f4f6f8;
|
||||
font-family: "IBM Plex Sans", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.hypertwist-initial-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 2rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hypertwist-initial-shell__frame {
|
||||
width: min(30rem, 100%);
|
||||
border: 1px solid rgba(84, 203, 255, 0.22);
|
||||
border-radius: 1.75rem;
|
||||
background: rgba(9, 14, 28, 0.9);
|
||||
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.4);
|
||||
padding: 2rem 1.5rem;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hypertwist-initial-shell__brand {
|
||||
width: 4.5rem;
|
||||
height: 4.5rem;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 0 22px rgba(84, 203, 255, 0.25));
|
||||
}
|
||||
|
||||
.hypertwist-initial-shell__eyebrow {
|
||||
margin: 0;
|
||||
color: #54cbff;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hypertwist-initial-shell__spinner {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 999px;
|
||||
border: 3px solid rgba(84, 203, 255, 0.18);
|
||||
border-top-color: #54cbff;
|
||||
border-right-color: #f7b267;
|
||||
animation: hypertwist-initial-shell-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.hypertwist-initial-shell__title {
|
||||
margin: 0;
|
||||
font-family: "Space Grotesk", "IBM Plex Sans", system-ui, sans-serif;
|
||||
font-size: clamp(1.55rem, 3vw, 2rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.hypertwist-initial-shell__body {
|
||||
margin: 0;
|
||||
max-width: 24rem;
|
||||
color: rgba(212, 225, 245, 0.86);
|
||||
font-size: 0.97rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@keyframes hypertwist-initial-shell-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<title>HyperTwist</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="root">
|
||||
<div class="hypertwist-initial-shell" role="status" aria-live="polite" aria-label="Loading HyperTwist">
|
||||
<div class="hypertwist-initial-shell__frame">
|
||||
<img
|
||||
src="/branding/hypertwist-3d-symbol.png"
|
||||
alt="HyperTwist"
|
||||
class="hypertwist-initial-shell__brand"
|
||||
/>
|
||||
<p class="hypertwist-initial-shell__eyebrow">Booting HyperTwist</p>
|
||||
<div class="hypertwist-initial-shell__spinner" aria-hidden="true"></div>
|
||||
<p class="hypertwist-initial-shell__title">Loading HyperTwist...</p>
|
||||
<p class="hypertwist-initial-shell__body">
|
||||
Preparing the public product story, shared auth posture, and desktop-first release lane.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
const BACKDROP_ID = 'hypertwist-auth-shell-backdrop';
|
||||
|
|
|
|||
|
|
@ -143,6 +143,9 @@ export function resolveBundleManifest(manifest = {}) {
|
|||
githubClientSecret: normalizeTrimmed(server.githubClientSecret || ''),
|
||||
googleClientId: normalizeTrimmed(server.googleClientId || ''),
|
||||
googleClientSecret: normalizeTrimmed(server.googleClientSecret || ''),
|
||||
orcidClientId: normalizeTrimmed(server.orcidClientId || ''),
|
||||
orcidClientSecret: normalizeTrimmed(server.orcidClientSecret || ''),
|
||||
orcidIsSandbox: normalizeBoolean(server.orcidIsSandbox, false),
|
||||
defaultPlan: normalizeTrimmed(server.defaultPlan || 'free'),
|
||||
defaultRole: normalizeTrimmed(server.defaultRole || 'operator'),
|
||||
healthTimeoutMs: normalizeTrimmed(server.healthTimeoutMs || '2000'),
|
||||
|
|
@ -170,7 +173,7 @@ export function buildFrontendEnvEntries(manifest = {}) {
|
|||
VITE_AUTH_API_TIMEOUT_MS: '8000',
|
||||
VITE_GITHUB_OAUTH_ENABLED: String(Boolean(resolved.server.githubClientId && resolved.server.githubClientSecret)),
|
||||
VITE_GOOGLE_OAUTH_ENABLED: String(Boolean(resolved.server.googleClientId && resolved.server.googleClientSecret)),
|
||||
VITE_ORCID_OAUTH_ENABLED: 'false',
|
||||
VITE_ORCID_OAUTH_ENABLED: String(Boolean(resolved.server.orcidClientId && resolved.server.orcidClientSecret)),
|
||||
VITE_PUBLIC_DOCS_URL: resolved.publicDocsUrl,
|
||||
VITE_RELEASE_NOTES_URL: resolved.releaseNotesUrl,
|
||||
VITE_SUPPORT_EMAIL: resolved.supportEmail,
|
||||
|
|
@ -204,6 +207,9 @@ export function buildServerEnvEntries(manifest = {}) {
|
|||
GITHUB_CLIENT_SECRET: resolved.server.githubClientSecret,
|
||||
GOOGLE_CLIENT_ID: resolved.server.googleClientId,
|
||||
GOOGLE_CLIENT_SECRET: resolved.server.googleClientSecret,
|
||||
ORCID_CLIENT_ID: resolved.server.orcidClientId,
|
||||
ORCID_CLIENT_SECRET: resolved.server.orcidClientSecret,
|
||||
ORCID_IS_SANDBOX: String(resolved.server.orcidIsSandbox),
|
||||
DEFAULT_PLAN: resolved.server.defaultPlan,
|
||||
DEFAULT_ROLE: resolved.server.defaultRole,
|
||||
SUPERTOKENS_HEALTH_TIMEOUT_MS: resolved.server.healthTimeoutMs,
|
||||
|
|
|
|||
|
|
@ -93,6 +93,19 @@ describe('buildFrontendEnvEntries', () => {
|
|||
expect(env.VITE_SUPERTOKENS_WEBSITE_DOMAIN).toBe('https://preview.hypertwist.app')
|
||||
expect(env.VITE_AUTH_API_BASE_URL).toBe('https://preview.hypertwist.app')
|
||||
})
|
||||
|
||||
it('enables ORCID in frontend env only when the server manifest carries a complete ORCID credential pair', () => {
|
||||
const env = buildFrontendEnvEntries({
|
||||
...createValidManifest(),
|
||||
server: {
|
||||
...createValidManifest().server,
|
||||
orcidClientId: 'orcid-client-id',
|
||||
orcidClientSecret: 'orcid-client-secret',
|
||||
},
|
||||
})
|
||||
|
||||
expect(env.VITE_ORCID_OAUTH_ENABLED).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildServerEnvEntries', () => {
|
||||
|
|
@ -115,6 +128,22 @@ describe('buildServerEnvEntries', () => {
|
|||
expect(env.API_DOMAIN).toBe('https://preview.hypertwist.app')
|
||||
expect(env.WEBSITE_DOMAIN).toBe('https://preview.hypertwist.app')
|
||||
})
|
||||
|
||||
it('carries ORCID server env through when the manifest declares the custom provider credentials', () => {
|
||||
const env = buildServerEnvEntries({
|
||||
...createValidManifest(),
|
||||
server: {
|
||||
...createValidManifest().server,
|
||||
orcidClientId: 'orcid-client-id',
|
||||
orcidClientSecret: 'orcid-client-secret',
|
||||
orcidIsSandbox: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(env.ORCID_CLIENT_ID).toBe('orcid-client-id')
|
||||
expect(env.ORCID_CLIENT_SECRET).toBe('orcid-client-secret')
|
||||
expect(env.ORCID_IS_SANDBOX).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildBundleValidationReport', () => {
|
||||
|
|
|
|||
|
|
@ -41,11 +41,13 @@ describe('getRuntimeConfigDiagnostics', () => {
|
|||
superTokensCoreUri: 'http://localhost:3567',
|
||||
cookieSecure: false,
|
||||
googleClientId: 'only-client-id',
|
||||
orcidClientId: 'only-orcid-client-id',
|
||||
})
|
||||
|
||||
expect(diagnostics.publicOriginReady).toBe(false)
|
||||
expect(diagnostics.errors).toContain('API_DOMAIN must not include a path; use API_BASE_PATH for route prefixing.')
|
||||
expect(diagnostics.errors).toContain('WEBSITE_DOMAIN must be an absolute URL origin.')
|
||||
expect(diagnostics.warnings).toContain('Google OAuth is only partially configured; set both client ID and client secret or neither.')
|
||||
expect(diagnostics.warnings).toContain('ORCID OAuth is only partially configured; set both client ID and client secret or neither.')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ import { createSessionLikeFromTestPayload, readSignedTestSessionFromRequest, typ
|
|||
|
||||
const Github = GithubProvider as unknown as (options: { clientId: string; clientSecret: string; scope?: string[] }) => ReturnType<typeof GithubProvider>
|
||||
const Google = GoogleProvider as unknown as (options: { clientId: string; clientSecret: string; scope?: string[] }) => ReturnType<typeof GoogleProvider>
|
||||
type ThirdPartyProviderList = NonNullable<
|
||||
NonNullable<NonNullable<Parameters<typeof ThirdParty.init>[0]>['signInAndUpFeature']>['providers']
|
||||
>
|
||||
|
||||
const PORT = Number(process.env.PORT || 3001)
|
||||
const SUPERTOKENS_CORE_URI = process.env.SUPERTOKENS_CORE_URI || 'http://localhost:3567'
|
||||
|
|
@ -35,6 +38,9 @@ const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || ''
|
|||
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || ''
|
||||
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || ''
|
||||
const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || ''
|
||||
const ORCID_CLIENT_ID = process.env.ORCID_CLIENT_ID || ''
|
||||
const ORCID_CLIENT_SECRET = process.env.ORCID_CLIENT_SECRET || ''
|
||||
const ORCID_IS_SANDBOX = String(process.env.ORCID_IS_SANDBOX || '').toLowerCase() === 'true'
|
||||
const DEFAULT_PLAN = normalizeBillingPlan(process.env.DEFAULT_PLAN || 'free')
|
||||
const DEFAULT_ROLE = normalizeBillingRole(process.env.DEFAULT_ROLE || 'operator')
|
||||
const SUPERTOKENS_HEALTH_TIMEOUT_MS = Number(process.env.SUPERTOKENS_HEALTH_TIMEOUT_MS || 2_000)
|
||||
|
|
@ -63,6 +69,8 @@ const runtimeConfigDiagnostics = getRuntimeConfigDiagnostics({
|
|||
githubClientSecret: GITHUB_CLIENT_SECRET,
|
||||
googleClientId: GOOGLE_CLIENT_ID,
|
||||
googleClientSecret: GOOGLE_CLIENT_SECRET,
|
||||
orcidClientId: ORCID_CLIENT_ID,
|
||||
orcidClientSecret: ORCID_CLIENT_SECRET,
|
||||
})
|
||||
|
||||
function normalizeBillingPlan(value: string): BillingPlan {
|
||||
|
|
@ -150,6 +158,64 @@ async function getRequestSession(req: SessionRequest, res: express.Response): Pr
|
|||
return (await Session.getSession(req, res, { sessionRequired: false })) ?? null
|
||||
}
|
||||
|
||||
function createOrcidProvider() {
|
||||
const orcidBase = ORCID_IS_SANDBOX ? 'https://sandbox.orcid.org' : 'https://orcid.org'
|
||||
|
||||
return {
|
||||
id: 'orcid',
|
||||
name: 'ORCID',
|
||||
get(redirectURI: string | undefined, authCodeFromRequest: string | undefined) {
|
||||
return {
|
||||
accessTokenAPI: {
|
||||
url: `${orcidBase}/oauth/token`,
|
||||
params: {
|
||||
client_id: ORCID_CLIENT_ID,
|
||||
client_secret: ORCID_CLIENT_SECRET,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: redirectURI || '',
|
||||
code: authCodeFromRequest || '',
|
||||
},
|
||||
},
|
||||
authorisationRedirect: {
|
||||
url: `${orcidBase}/oauth/authorize`,
|
||||
params: {
|
||||
client_id: ORCID_CLIENT_ID,
|
||||
response_type: 'code',
|
||||
scope: '/authenticate',
|
||||
redirect_uri: redirectURI || '',
|
||||
},
|
||||
},
|
||||
getUserInfo: async (tokenAPIResponse: {
|
||||
access_token?: string
|
||||
orcid?: string
|
||||
name?: string
|
||||
email?: string
|
||||
}) => {
|
||||
const orcidId = String(tokenAPIResponse.orcid || '').trim()
|
||||
const fallbackEmailLocalPart = orcidId.replace(/\W/g, '') || 'orcid-user'
|
||||
|
||||
return {
|
||||
thirdPartyUserId: orcidId,
|
||||
email: {
|
||||
id: tokenAPIResponse.email
|
||||
? String(tokenAPIResponse.email).trim().toLowerCase()
|
||||
: `${fallbackEmailLocalPart}@orcid.placeholder`,
|
||||
isVerified: true,
|
||||
},
|
||||
rawUserInfoFromProvider: {
|
||||
fromUserInfoAPI: {
|
||||
orcidId,
|
||||
name: String(tokenAPIResponse.name || '').trim(),
|
||||
},
|
||||
fromIdTokenPayload: {},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const recipeList = [
|
||||
EmailPassword.init({
|
||||
signUpFeature: {
|
||||
|
|
@ -181,7 +247,7 @@ const recipeList = [
|
|||
}),
|
||||
]
|
||||
|
||||
const thirdPartyProviders = []
|
||||
const thirdPartyProviders: Array<ReturnType<typeof Github> | ReturnType<typeof Google> | ReturnType<typeof createOrcidProvider>> = []
|
||||
if (GITHUB_CLIENT_ID && GITHUB_CLIENT_SECRET) {
|
||||
thirdPartyProviders.push(Github({
|
||||
clientId: GITHUB_CLIENT_ID,
|
||||
|
|
@ -196,11 +262,14 @@ if (GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET) {
|
|||
scope: ['openid', 'email', 'profile'],
|
||||
}))
|
||||
}
|
||||
if (ORCID_CLIENT_ID && ORCID_CLIENT_SECRET) {
|
||||
thirdPartyProviders.push(createOrcidProvider())
|
||||
}
|
||||
|
||||
if (thirdPartyProviders.length > 0) {
|
||||
recipeList.splice(1, 0, ThirdParty.init({
|
||||
signInAndUpFeature: {
|
||||
providers: thirdPartyProviders,
|
||||
providers: thirdPartyProviders as ThirdPartyProviderList,
|
||||
},
|
||||
override: {
|
||||
apis: (originalImplementation) => ({
|
||||
|
|
@ -353,6 +422,7 @@ app.get('/api/auth/health', async (_req, res) => {
|
|||
oauth: {
|
||||
github: Boolean(GITHUB_CLIENT_ID && GITHUB_CLIENT_SECRET),
|
||||
google: Boolean(GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET),
|
||||
orcid: Boolean(ORCID_CLIENT_ID && ORCID_CLIENT_SECRET),
|
||||
},
|
||||
},
|
||||
fallback: {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ interface RuntimeConfigInput {
|
|||
githubClientSecret?: string
|
||||
googleClientId?: string
|
||||
googleClientSecret?: string
|
||||
orcidClientId?: string
|
||||
orcidClientSecret?: string
|
||||
}
|
||||
|
||||
export interface RuntimeConfigDiagnostics {
|
||||
|
|
@ -65,6 +67,8 @@ export function getRuntimeConfigDiagnostics({
|
|||
githubClientSecret,
|
||||
googleClientId,
|
||||
googleClientSecret,
|
||||
orcidClientId,
|
||||
orcidClientSecret,
|
||||
}: RuntimeConfigInput): RuntimeConfigDiagnostics {
|
||||
const warnings: string[] = []
|
||||
const errors: string[] = []
|
||||
|
|
@ -131,6 +135,10 @@ export function getRuntimeConfigDiagnostics({
|
|||
warnings.push('Google OAuth is only partially configured; set both client ID and client secret or neither.')
|
||||
}
|
||||
|
||||
if (!hasCompleteCredentialPair(orcidClientId, orcidClientSecret) && (orcidClientId || orcidClientSecret)) {
|
||||
warnings.push('ORCID OAuth is only partially configured; set both client ID and client secret or neither.')
|
||||
}
|
||||
|
||||
const mode: RuntimeConfigDiagnostics['mode'] = allLoopback
|
||||
? 'local'
|
||||
: noLoopback
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
import { Suspense, lazy } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter, useLocation } from 'react-router-dom'
|
||||
import { SuperTokensWrapper } from 'supertokens-auth-react'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import { PlatformAuthProvider } from './auth/platform-auth'
|
||||
import { isSuperTokensConfigured } from './auth/supertokens-client'
|
||||
import { isSuperTokensConfigured } from './auth/supertokens-runtime'
|
||||
import { AppRouteTree } from './router/AppRouteTree'
|
||||
import { AuthShellBackdropSync } from './router/AuthShellBackdropSync'
|
||||
import { ROUTER_FUTURE_FLAGS } from './router/router-future'
|
||||
import './styles/global.css'
|
||||
|
||||
const LazySuperTokensRouteWrapper = lazy(async () => ({
|
||||
default: (await import('./auth/SuperTokensRouteWrapper')).SuperTokensRouteWrapper,
|
||||
}))
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
|
|
@ -49,7 +53,11 @@ function MaybeSuperTokensWrapper({ children }: { children: React.ReactNode }) {
|
|||
return <>{children}</>
|
||||
}
|
||||
|
||||
return <SuperTokensWrapper>{children}</SuperTokensWrapper>
|
||||
return (
|
||||
<Suspense fallback={<>{children}</>}>
|
||||
<LazySuperTokensRouteWrapper>{children}</LazySuperTokensRouteWrapper>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ vi.mock('../auth/platform-auth', () => ({
|
|||
usePlatformAuth: () => mockUsePlatformAuth(),
|
||||
}))
|
||||
|
||||
vi.mock('../auth/supertokens-client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../auth/supertokens-client')>()
|
||||
vi.mock('../auth/supertokens-runtime', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../auth/supertokens-runtime')>()
|
||||
return {
|
||||
...actual,
|
||||
isSuperTokensConfigured: () => mockIsSuperTokensConfigured(),
|
||||
|
|
@ -25,8 +25,8 @@ vi.mock('../auth/supertokens-client', async (importOriginal) => {
|
|||
}
|
||||
})
|
||||
|
||||
vi.mock('supertokens-auth-react', () => ({
|
||||
SuperTokensWrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
vi.mock('../auth/SuperTokensRouteWrapper', () => ({
|
||||
SuperTokensRouteWrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="supertokens-wrapper">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
|
|
|||
45
website/src/__tests__/main.bootstrap.test.tsx
Normal file
45
website/src/__tests__/main.bootstrap.test.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockEnsureSuperTokensInit = vi.fn()
|
||||
const mockRender = vi.fn()
|
||||
const mockCreateRoot = vi.fn((_: Element | DocumentFragment | null) => ({
|
||||
render: mockRender,
|
||||
}))
|
||||
|
||||
vi.mock('../auth/supertokens-client', () => ({
|
||||
ensureSuperTokensInit: () => mockEnsureSuperTokensInit(),
|
||||
}))
|
||||
|
||||
vi.mock('../App', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('react-dom/client', () => ({
|
||||
default: {
|
||||
createRoot: (element: Element | DocumentFragment | null) => mockCreateRoot(element),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('main bootstrap', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
mockEnsureSuperTokensInit.mockReset()
|
||||
mockCreateRoot.mockClear()
|
||||
mockRender.mockClear()
|
||||
document.body.innerHTML = '<div id="root"><div id="hypertwist-initial-shell">Boot</div></div>'
|
||||
})
|
||||
|
||||
it('initializes shared auth before creating the React root', async () => {
|
||||
await import('../main')
|
||||
await Promise.resolve()
|
||||
|
||||
const root = document.getElementById('root')
|
||||
|
||||
expect(mockEnsureSuperTokensInit).toHaveBeenCalledTimes(1)
|
||||
expect(mockCreateRoot).toHaveBeenCalledWith(root)
|
||||
expect(mockRender).toHaveBeenCalledTimes(1)
|
||||
expect(mockEnsureSuperTokensInit.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockCreateRoot.mock.invocationCallOrder[0],
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -14,22 +14,16 @@ vi.mock('../auth/auth-api', () => ({
|
|||
|
||||
vi.mock('../auth/supertokens-client', () => ({
|
||||
ensureSuperTokensInit: (...args: unknown[]) => mockEnsureSuperTokensInit(...args),
|
||||
signInWithSuperTokens: vi.fn(),
|
||||
signUpWithSuperTokens: vi.fn(),
|
||||
redirectToSuperTokensThirdPartyLogin: vi.fn(),
|
||||
signOutFromSuperTokens: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../auth/supertokens-runtime', () => ({
|
||||
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'
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ vi.mock('../auth/auth-api', () => ({
|
|||
getReleaseManifest: (...args: unknown[]) => mockGetReleaseManifest(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../auth/supertokens-client', () => ({
|
||||
vi.mock('../auth/supertokens-runtime', () => ({
|
||||
getSuperTokensAuthRuntimeValidation: () => mockGetSuperTokensAuthRuntimeValidation(),
|
||||
isGitHubOAuthEnabled: () => mockIsGitHubOAuthEnabled(),
|
||||
isGoogleOAuthEnabled: () => mockIsGoogleOAuthEnabled(),
|
||||
|
|
@ -317,6 +317,23 @@ describe('public auth and download pages', () => {
|
|||
expect(mockLogin).toHaveBeenCalledWith({ method: 'orcid', email: '' })
|
||||
})
|
||||
|
||||
it('shows the same enabled OAuth providers on the register page and routes them through the shared login handler', async () => {
|
||||
mockIsGoogleOAuthEnabled.mockReturnValue(true)
|
||||
mockIsGitHubOAuthEnabled.mockReturnValue(true)
|
||||
mockIsOrcidOAuthEnabled.mockReturnValue(true)
|
||||
mockLogin.mockResolvedValue({ ok: true })
|
||||
|
||||
renderAuthRoutes('/register')
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Continue with Google' }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Continue with GitHub' }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Continue with ORCID' }))
|
||||
|
||||
expect(mockLogin).toHaveBeenCalledWith({ method: 'google', email: '' })
|
||||
expect(mockLogin).toHaveBeenCalledWith({ method: 'github', email: '' })
|
||||
expect(mockLogin).toHaveBeenCalledWith({ method: 'orcid', email: '' })
|
||||
})
|
||||
|
||||
it('keeps configured public download targets behind the protected dashboard instead of exposing raw URLs', async () => {
|
||||
renderDownloadPage()
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ import { ROUTER_FUTURE_FLAGS } from '../router/router-future'
|
|||
|
||||
const mockGetReleaseManifest = vi.fn()
|
||||
const mockGetAuthHealth = vi.fn()
|
||||
const mockGetSuperTokensAuthRuntimeValidation = vi.fn()
|
||||
const mockIsGitHubOAuthEnabled = vi.fn(() => false)
|
||||
const mockIsGoogleOAuthEnabled = vi.fn(() => false)
|
||||
const mockIsOrcidOAuthEnabled = vi.fn(() => false)
|
||||
|
||||
vi.mock('../auth/platform-auth', () => ({
|
||||
usePlatformAuth: () => ({
|
||||
|
|
@ -18,6 +22,13 @@ vi.mock('../auth/auth-api', () => ({
|
|||
getReleaseManifest: (...args: unknown[]) => mockGetReleaseManifest(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../auth/supertokens-runtime', () => ({
|
||||
getSuperTokensAuthRuntimeValidation: () => mockGetSuperTokensAuthRuntimeValidation(),
|
||||
isGitHubOAuthEnabled: () => mockIsGitHubOAuthEnabled(),
|
||||
isGoogleOAuthEnabled: () => mockIsGoogleOAuthEnabled(),
|
||||
isOrcidOAuthEnabled: () => mockIsOrcidOAuthEnabled(),
|
||||
}))
|
||||
|
||||
vi.mock('../site-config', () => ({
|
||||
brandConfig: {
|
||||
brandName: 'HyperTwist',
|
||||
|
|
@ -140,6 +151,18 @@ describe('public marketing pages', () => {
|
|||
cleanup()
|
||||
mockGetAuthHealth.mockReset()
|
||||
mockGetReleaseManifest.mockReset()
|
||||
mockGetSuperTokensAuthRuntimeValidation.mockReset()
|
||||
mockIsGitHubOAuthEnabled.mockReset()
|
||||
mockIsGoogleOAuthEnabled.mockReset()
|
||||
mockIsOrcidOAuthEnabled.mockReset()
|
||||
mockGetSuperTokensAuthRuntimeValidation.mockReturnValue({
|
||||
ready: true,
|
||||
missing: [],
|
||||
warnings: [],
|
||||
})
|
||||
mockIsGitHubOAuthEnabled.mockReturnValue(true)
|
||||
mockIsGoogleOAuthEnabled.mockReturnValue(false)
|
||||
mockIsOrcidOAuthEnabled.mockReturnValue(true)
|
||||
})
|
||||
|
||||
it('routes public download access through the protected dashboard with the requested platform preserved', async () => {
|
||||
|
|
@ -1108,6 +1131,74 @@ describe('public marketing pages', () => {
|
|||
expect(screen.getByRole('link', { name: 'https://docs.hypertwist.app' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the current browser account method lineup on the public docs page', async () => {
|
||||
mockGetAuthHealth.mockResolvedValue({
|
||||
ok: true,
|
||||
service: 'hypertwist-auth-server',
|
||||
supertokens: {
|
||||
configured: true,
|
||||
reachable: true,
|
||||
ready: true,
|
||||
apiVersion: '5.4',
|
||||
error: null,
|
||||
oauth: {
|
||||
github: true,
|
||||
google: false,
|
||||
orcid: true,
|
||||
},
|
||||
},
|
||||
fallback: {
|
||||
enabled: true,
|
||||
active: false,
|
||||
reason: null,
|
||||
},
|
||||
billing: {
|
||||
statePath: '/var/lib/hypertwist/auth/hypertwist-billing-state.json',
|
||||
processedEventCount: 0,
|
||||
pricePlanMapConfigured: false,
|
||||
productPlanMapConfigured: false,
|
||||
webhookSecretConfigured: false,
|
||||
},
|
||||
runtime: {
|
||||
mode: 'mixed',
|
||||
public_origin_ready: true,
|
||||
cookie_secure: true,
|
||||
api_domain: 'https://hypertwist.app',
|
||||
website_domain: 'https://hypertwist.app',
|
||||
warnings: [],
|
||||
errors: [],
|
||||
},
|
||||
})
|
||||
mockGetReleaseManifest.mockResolvedValue({
|
||||
ok: true,
|
||||
manifest: {
|
||||
generated_at: '2026-06-22T12:00:00.000Z',
|
||||
support_email: 'hello@hypertwist.app',
|
||||
public_docs_url: 'https://docs.hypertwist.app',
|
||||
release_notes_url: 'https://notes.hypertwist.app',
|
||||
corresponding_source_url: 'https://hypertwist.app/open-source/source.zip',
|
||||
open_source_repo_url: 'https://github.com/hypertwist/hypertwist',
|
||||
viewer: {
|
||||
authenticated: false,
|
||||
canDownload: false,
|
||||
plan: null,
|
||||
role: null,
|
||||
accessStatus: null,
|
||||
},
|
||||
platforms: [],
|
||||
},
|
||||
})
|
||||
|
||||
renderWithProviders(<DocsPage />)
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Browser account access methods' })).toBeTruthy()
|
||||
expect(screen.getByText('Auth methods ready')).toBeTruthy()
|
||||
expect(screen.getByText('Email and password')).toBeTruthy()
|
||||
expect(screen.getByText('GitHub sign-in')).toBeTruthy()
|
||||
expect(screen.getByText('ORCID sign-in')).toBeTruthy()
|
||||
expect(screen.queryByText('Google sign-in')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the public operator manual on the docs page', async () => {
|
||||
mockGetAuthHealth.mockResolvedValue({
|
||||
ok: true,
|
||||
|
|
|
|||
6
website/src/auth/SuperTokensRouteWrapper.tsx
Normal file
6
website/src/auth/SuperTokensRouteWrapper.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { ReactNode } from 'react'
|
||||
import { SuperTokensWrapper } from 'supertokens-auth-react'
|
||||
|
||||
export function SuperTokensRouteWrapper({ children }: { children: ReactNode }) {
|
||||
return <SuperTokensWrapper>{children}</SuperTokensWrapper>
|
||||
}
|
||||
|
|
@ -68,6 +68,7 @@ export interface AuthHealthPayload {
|
|||
oauth?: {
|
||||
github: boolean
|
||||
google: boolean
|
||||
orcid?: boolean
|
||||
}
|
||||
}
|
||||
fallback: {
|
||||
|
|
|
|||
|
|
@ -8,17 +8,21 @@ import {
|
|||
type ReactNode,
|
||||
type SetStateAction,
|
||||
} from 'react'
|
||||
import { signIn as superTokensSignIn, signUp as superTokensSignUp } from 'supertokens-auth-react/recipe/emailpassword'
|
||||
import { redirectToThirdPartyLogin } from 'supertokens-auth-react/recipe/thirdparty'
|
||||
import { signOut as superTokensSignOut } from 'supertokens-auth-react/recipe/session'
|
||||
import {
|
||||
getCurrentUser,
|
||||
logoutCurrentUser,
|
||||
type ApiBootstrapUserPayload,
|
||||
type ReleaseManifestPayload,
|
||||
} from './auth-api'
|
||||
import { ensureSuperTokensInit, isSuperTokensConfigured } from './supertokens-client'
|
||||
import {
|
||||
ensureSuperTokensInit,
|
||||
redirectToSuperTokensThirdPartyLogin,
|
||||
signInWithSuperTokens,
|
||||
signOutFromSuperTokens,
|
||||
signUpWithSuperTokens,
|
||||
} from './supertokens-client'
|
||||
import { validateStrongPassword } from './password-policy'
|
||||
import { isSuperTokensConfigured } from './supertokens-runtime'
|
||||
|
||||
export type AuthMethod = 'supertokens' | 'email' | 'github' | 'google' | 'orcid'
|
||||
export type ColorMode = 'dark' | 'light'
|
||||
|
|
@ -421,7 +425,10 @@ export function PlatformAuthProvider({ children }: { children: ReactNode }) {
|
|||
releaseAuthoritySyncItems,
|
||||
async login(input) {
|
||||
if (input.method === 'github' || input.method === 'google' || input.method === 'orcid') {
|
||||
await redirectToThirdPartyLogin({ thirdPartyId: input.method })
|
||||
if (!superTokensConfigured) {
|
||||
return { ok: false, error: 'Shared browser auth is not fully configured yet.' }
|
||||
}
|
||||
await redirectToSuperTokensThirdPartyLogin(input.method)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
|
|
@ -437,8 +444,8 @@ export function PlatformAuthProvider({ children }: { children: ReactNode }) {
|
|||
return { ok: true }
|
||||
}
|
||||
|
||||
ensureSuperTokensInit()
|
||||
const response = await superTokensSignIn({
|
||||
await ensureSuperTokensInit()
|
||||
const response = await signInWithSuperTokens({
|
||||
formFields: [
|
||||
{ id: 'email', value: safeEmail },
|
||||
{ id: 'password', value: String(input.password || '') },
|
||||
|
|
@ -479,8 +486,8 @@ export function PlatformAuthProvider({ children }: { children: ReactNode }) {
|
|||
return { ok: true }
|
||||
}
|
||||
|
||||
ensureSuperTokensInit()
|
||||
const response = await superTokensSignUp({
|
||||
await ensureSuperTokensInit()
|
||||
const response = await signUpWithSuperTokens({
|
||||
formFields: [
|
||||
{ id: 'email', value: safeEmail },
|
||||
{ id: 'password', value: input.password },
|
||||
|
|
@ -509,8 +516,7 @@ export function PlatformAuthProvider({ children }: { children: ReactNode }) {
|
|||
if (!superTokensConfigured) {
|
||||
return
|
||||
}
|
||||
ensureSuperTokensInit()
|
||||
await redirectToThirdPartyLogin({ thirdPartyId: provider })
|
||||
await redirectToSuperTokensThirdPartyLogin(provider)
|
||||
},
|
||||
async logout() {
|
||||
clearSessionState(setUser, setReleaseAuthoritySyncItems)
|
||||
|
|
@ -523,7 +529,7 @@ export function PlatformAuthProvider({ children }: { children: ReactNode }) {
|
|||
// Keep local logout deterministic even if the API is unavailable.
|
||||
}
|
||||
try {
|
||||
await superTokensSignOut()
|
||||
await signOutFromSuperTokens()
|
||||
} catch {
|
||||
// Session revocation can already have happened server-side.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,17 @@
|
|||
import SuperTokens from 'supertokens-auth-react'
|
||||
import EmailPassword from 'supertokens-auth-react/recipe/emailpassword'
|
||||
import ThirdParty, { Github, Google } from 'supertokens-auth-react/recipe/thirdparty'
|
||||
import Session from 'supertokens-auth-react/recipe/session'
|
||||
import { getAuthRuntimeConfig, validateAuthRuntimeConfig } from './auth-env'
|
||||
import { normalizeNextPath } from './next-path'
|
||||
import { PASSWORD_POLICY_HINT, PASSWORD_POLICY_PLACEHOLDER, validateStrongPassword } from './password-policy'
|
||||
|
||||
const authRuntime = getAuthRuntimeConfig()
|
||||
const authRuntimeValidation = validateAuthRuntimeConfig()
|
||||
const SUPERTOKENS_API_DOMAIN = authRuntime.superTokensApiDomain
|
||||
const SUPERTOKENS_WEBSITE_DOMAIN = authRuntime.superTokensWebsiteDomain
|
||||
const SUPERTOKENS_API_BASE_PATH = authRuntime.superTokensApiBasePath
|
||||
const SUPERTOKENS_WEBSITE_BASE_PATH = authRuntime.superTokensWebsiteBasePath
|
||||
|
||||
const GITHUB_OAUTH_ENABLED = String((import.meta.env as Record<string, string>).VITE_GITHUB_OAUTH_ENABLED || '').toLowerCase() === 'true'
|
||||
const GOOGLE_OAUTH_ENABLED = String((import.meta.env as Record<string, string>).VITE_GOOGLE_OAUTH_ENABLED || '').toLowerCase() === 'true'
|
||||
const ORCID_OAUTH_ENABLED = String((import.meta.env as Record<string, string>).VITE_ORCID_OAUTH_ENABLED || '').toLowerCase() === 'true'
|
||||
import {
|
||||
isGitHubOAuthEnabled,
|
||||
isGoogleOAuthEnabled,
|
||||
isSuperTokensConfigured,
|
||||
SUPERTOKENS_API_BASE_PATH,
|
||||
SUPERTOKENS_API_DOMAIN,
|
||||
SUPERTOKENS_WEBSITE_BASE_PATH,
|
||||
SUPERTOKENS_WEBSITE_DOMAIN,
|
||||
} from './supertokens-runtime'
|
||||
|
||||
let initialized = false
|
||||
let initPromise: Promise<void> | null = null
|
||||
|
||||
function readRequestedPostAuthPath() {
|
||||
if (typeof window === 'undefined') return null
|
||||
|
|
@ -71,85 +65,129 @@ const SUPERTOKENS_BRAND_STYLE = `
|
|||
}
|
||||
`
|
||||
|
||||
export function isSuperTokensConfigured() {
|
||||
return Boolean(SUPERTOKENS_API_DOMAIN)
|
||||
type SuperTokensAuthResponse = {
|
||||
status?: string
|
||||
formFields?: Array<{ error?: string }>
|
||||
}
|
||||
|
||||
export function isGitHubOAuthEnabled() {
|
||||
return GITHUB_OAUTH_ENABLED
|
||||
type SuperTokensFormField = {
|
||||
id: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export function isGoogleOAuthEnabled() {
|
||||
return GOOGLE_OAUTH_ENABLED
|
||||
type SuperTokensFormInput = {
|
||||
formFields: SuperTokensFormField[]
|
||||
}
|
||||
|
||||
export function isOrcidOAuthEnabled() {
|
||||
return ORCID_OAUTH_ENABLED
|
||||
}
|
||||
type SuperTokensThirdPartyId = 'github' | 'google' | 'orcid'
|
||||
|
||||
export function getSuperTokensAuthRuntimeValidation() {
|
||||
return authRuntimeValidation
|
||||
}
|
||||
|
||||
export function ensureSuperTokensInit() {
|
||||
export async function ensureSuperTokensInit() {
|
||||
if (initialized || !isSuperTokensConfigured() || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const thirdPartyProviders = []
|
||||
if (GITHUB_OAUTH_ENABLED) thirdPartyProviders.push(Github.init())
|
||||
if (GOOGLE_OAUTH_ENABLED) thirdPartyProviders.push(Google.init())
|
||||
if (!initPromise) {
|
||||
initPromise = (async () => {
|
||||
const [
|
||||
{ default: SuperTokens },
|
||||
{ default: EmailPassword },
|
||||
thirdPartyModule,
|
||||
{ default: Session },
|
||||
] = await Promise.all([
|
||||
import('supertokens-auth-react'),
|
||||
import('supertokens-auth-react/recipe/emailpassword'),
|
||||
import('supertokens-auth-react/recipe/thirdparty'),
|
||||
import('supertokens-auth-react/recipe/session'),
|
||||
])
|
||||
|
||||
const recipeList: Parameters<typeof SuperTokens.init>[0]['recipeList'] = [
|
||||
EmailPassword.init({
|
||||
signInAndUpFeature: {
|
||||
signInForm: {
|
||||
formFields: [
|
||||
{ id: 'email', label: 'Email', placeholder: 'operator@hypertwist.app' },
|
||||
{ id: 'password', label: 'Password', placeholder: 'Your password' },
|
||||
],
|
||||
},
|
||||
signUpForm: {
|
||||
formFields: [
|
||||
{ id: 'email', label: 'Email', placeholder: 'operator@hypertwist.app' },
|
||||
{
|
||||
id: 'password',
|
||||
label: `Password (${PASSWORD_POLICY_HINT})`,
|
||||
placeholder: PASSWORD_POLICY_PLACEHOLDER,
|
||||
validate: async (value: unknown) => validateStrongPassword(String(value || '')),
|
||||
const { default: ThirdParty, Github, Google } = thirdPartyModule
|
||||
|
||||
const thirdPartyProviders = []
|
||||
if (isGitHubOAuthEnabled()) thirdPartyProviders.push(Github.init())
|
||||
if (isGoogleOAuthEnabled()) thirdPartyProviders.push(Google.init())
|
||||
|
||||
const recipeList: Parameters<typeof SuperTokens.init>[0]['recipeList'] = [
|
||||
EmailPassword.init({
|
||||
signInAndUpFeature: {
|
||||
signInForm: {
|
||||
formFields: [
|
||||
{ id: 'email', label: 'Email', placeholder: 'operator@hypertwist.app' },
|
||||
{ id: 'password', label: 'Password', placeholder: 'Your password' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
Session.init(),
|
||||
]
|
||||
signUpForm: {
|
||||
formFields: [
|
||||
{ id: 'email', label: 'Email', placeholder: 'operator@hypertwist.app' },
|
||||
{
|
||||
id: 'password',
|
||||
label: `Password (${PASSWORD_POLICY_HINT})`,
|
||||
placeholder: PASSWORD_POLICY_PLACEHOLDER,
|
||||
validate: async (value: unknown) => validateStrongPassword(String(value || '')),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
Session.init(),
|
||||
]
|
||||
|
||||
if (thirdPartyProviders.length > 0) {
|
||||
recipeList.splice(1, 0, ThirdParty.init({
|
||||
signInAndUpFeature: {
|
||||
providers: thirdPartyProviders,
|
||||
},
|
||||
}))
|
||||
if (thirdPartyProviders.length > 0) {
|
||||
recipeList.splice(1, 0, ThirdParty.init({
|
||||
signInAndUpFeature: {
|
||||
providers: thirdPartyProviders,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
SuperTokens.init({
|
||||
appInfo: {
|
||||
appName: 'HyperTwist',
|
||||
apiDomain: SUPERTOKENS_API_DOMAIN,
|
||||
websiteDomain: SUPERTOKENS_WEBSITE_DOMAIN || window.location.origin,
|
||||
apiBasePath: SUPERTOKENS_API_BASE_PATH,
|
||||
websiteBasePath: SUPERTOKENS_WEBSITE_BASE_PATH,
|
||||
},
|
||||
recipeList,
|
||||
style: SUPERTOKENS_BRAND_STYLE,
|
||||
getRedirectionURL: async (context) => {
|
||||
if (context.action === 'SUCCESS') {
|
||||
return readRequestedPostAuthPath() || '/app'
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
initialized = true
|
||||
})().finally(() => {
|
||||
if (!initialized) {
|
||||
initPromise = null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
SuperTokens.init({
|
||||
appInfo: {
|
||||
appName: 'HyperTwist',
|
||||
apiDomain: SUPERTOKENS_API_DOMAIN,
|
||||
websiteDomain: SUPERTOKENS_WEBSITE_DOMAIN || window.location.origin,
|
||||
apiBasePath: SUPERTOKENS_API_BASE_PATH,
|
||||
websiteBasePath: SUPERTOKENS_WEBSITE_BASE_PATH,
|
||||
},
|
||||
recipeList,
|
||||
style: SUPERTOKENS_BRAND_STYLE,
|
||||
getRedirectionURL: async (context) => {
|
||||
if (context.action === 'SUCCESS') {
|
||||
return readRequestedPostAuthPath() || '/app'
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
initialized = true
|
||||
await initPromise
|
||||
}
|
||||
|
||||
export async function signInWithSuperTokens(input: SuperTokensFormInput) {
|
||||
await ensureSuperTokensInit()
|
||||
const { signIn } = await import('supertokens-auth-react/recipe/emailpassword')
|
||||
return signIn(input) as Promise<SuperTokensAuthResponse>
|
||||
}
|
||||
|
||||
export async function signUpWithSuperTokens(input: SuperTokensFormInput) {
|
||||
await ensureSuperTokensInit()
|
||||
const { signUp } = await import('supertokens-auth-react/recipe/emailpassword')
|
||||
return signUp(input) as Promise<SuperTokensAuthResponse>
|
||||
}
|
||||
|
||||
export async function redirectToSuperTokensThirdPartyLogin(thirdPartyId: SuperTokensThirdPartyId) {
|
||||
await ensureSuperTokensInit()
|
||||
const { redirectToThirdPartyLogin } = await import('supertokens-auth-react/recipe/thirdparty')
|
||||
await redirectToThirdPartyLogin({ thirdPartyId })
|
||||
}
|
||||
|
||||
export async function signOutFromSuperTokens() {
|
||||
await ensureSuperTokensInit()
|
||||
const { signOut } = await import('supertokens-auth-react/recipe/session')
|
||||
await signOut()
|
||||
}
|
||||
|
|
|
|||
35
website/src/auth/supertokens-runtime.ts
Normal file
35
website/src/auth/supertokens-runtime.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { getAuthRuntimeConfig, validateAuthRuntimeConfig } from './auth-env'
|
||||
|
||||
const authRuntime = getAuthRuntimeConfig()
|
||||
const authRuntimeValidation = validateAuthRuntimeConfig()
|
||||
|
||||
const env = import.meta.env as Record<string, string | undefined>
|
||||
|
||||
export const SUPERTOKENS_API_DOMAIN = authRuntime.superTokensApiDomain
|
||||
export const SUPERTOKENS_WEBSITE_DOMAIN = authRuntime.superTokensWebsiteDomain
|
||||
export const SUPERTOKENS_API_BASE_PATH = authRuntime.superTokensApiBasePath
|
||||
export const SUPERTOKENS_WEBSITE_BASE_PATH = authRuntime.superTokensWebsiteBasePath
|
||||
|
||||
const GITHUB_OAUTH_ENABLED = String(env.VITE_GITHUB_OAUTH_ENABLED || '').toLowerCase() === 'true'
|
||||
const GOOGLE_OAUTH_ENABLED = String(env.VITE_GOOGLE_OAUTH_ENABLED || '').toLowerCase() === 'true'
|
||||
const ORCID_OAUTH_ENABLED = String(env.VITE_ORCID_OAUTH_ENABLED || '').toLowerCase() === 'true'
|
||||
|
||||
export function isSuperTokensConfigured() {
|
||||
return Boolean(SUPERTOKENS_API_DOMAIN)
|
||||
}
|
||||
|
||||
export function isGitHubOAuthEnabled() {
|
||||
return GITHUB_OAUTH_ENABLED
|
||||
}
|
||||
|
||||
export function isGoogleOAuthEnabled() {
|
||||
return GOOGLE_OAUTH_ENABLED
|
||||
}
|
||||
|
||||
export function isOrcidOAuthEnabled() {
|
||||
return ORCID_OAUTH_ENABLED
|
||||
}
|
||||
|
||||
export function getSuperTokensAuthRuntimeValidation() {
|
||||
return authRuntimeValidation
|
||||
}
|
||||
117
website/src/components/ui/BrowserAuthMethodsGuide.tsx
Normal file
117
website/src/components/ui/BrowserAuthMethodsGuide.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import {
|
||||
getSuperTokensAuthRuntimeValidation,
|
||||
isGitHubOAuthEnabled,
|
||||
isGoogleOAuthEnabled,
|
||||
isOrcidOAuthEnabled,
|
||||
} from '../../auth/supertokens-runtime'
|
||||
|
||||
type BrowserAuthMethodCard = {
|
||||
title: string
|
||||
description: string
|
||||
bullets: readonly string[]
|
||||
}
|
||||
|
||||
function buildBrowserAuthMethodCards(): readonly BrowserAuthMethodCard[] {
|
||||
const cards: BrowserAuthMethodCard[] = [
|
||||
{
|
||||
title: 'Email and password',
|
||||
description:
|
||||
'This is the default browser-account lane and remains the most predictable shared-auth path across preview, mixed, and production deployment postures.',
|
||||
bullets: [
|
||||
'Use it when you want the clearest route into the protected dashboard, protected downloads, and desktop-link pairing.',
|
||||
'It opens the same protected account and release surfaces as the optional provider buttons.',
|
||||
'It does not change the desktop-first simulator boundary or replace native pairing.',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
if (isGitHubOAuthEnabled()) {
|
||||
cards.push({
|
||||
title: 'GitHub sign-in',
|
||||
description:
|
||||
'GitHub is available in this build as an optional browser-account shortcut when the shared auth server has that provider configured.',
|
||||
bullets: [
|
||||
'Use it to enter the same protected dashboard and release lanes without creating a separate password first.',
|
||||
'It still relies on the same shared auth runtime, entitlement checks, and desktop-link handoff as the email/password lane.',
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
if (isGoogleOAuthEnabled()) {
|
||||
cards.push({
|
||||
title: 'Google sign-in',
|
||||
description:
|
||||
'Google is available in this build as an optional shared-auth provider for browser account continuity.',
|
||||
bullets: [
|
||||
'Use it when you want the same protected download, account, and rollout surfaces through a Google-backed sign-in flow.',
|
||||
'It does not widen browser ownership into simulator execution or change the desktop pairing boundary.',
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
if (isOrcidOAuthEnabled()) {
|
||||
cards.push({
|
||||
title: 'ORCID sign-in',
|
||||
description:
|
||||
'ORCID is available in this build as a first-party shared-auth provider rather than a UI-only placeholder, so research-oriented identity can route through the same protected operator lanes.',
|
||||
bullets: [
|
||||
'Use it when this deployment has ORCID configured and you want standards-backed browser sign-in continuity into the protected dashboard and download lanes.',
|
||||
'It remains a browser-account path only; it does not replace desktop-link pairing or the native simulator runtime.',
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
return cards
|
||||
}
|
||||
|
||||
export function BrowserAuthMethodsGuide() {
|
||||
const authRuntimeValidation = getSuperTokensAuthRuntimeValidation()
|
||||
const cards = buildBrowserAuthMethodCards()
|
||||
const diagnostics = [
|
||||
...authRuntimeValidation.missing.map((item) => `Missing auth env: ${item}`),
|
||||
...authRuntimeValidation.warnings,
|
||||
]
|
||||
|
||||
const statusLabel = authRuntimeValidation.ready
|
||||
? (diagnostics.length === 0 ? 'Auth methods ready' : 'Auth methods mixed')
|
||||
: 'Auth methods bounded'
|
||||
|
||||
const summary = authRuntimeValidation.ready
|
||||
? (
|
||||
diagnostics.length === 0
|
||||
? 'The shared browser-auth runtime is configured cleanly in this build, so the methods below all route into the same protected account, release, and desktop-pairing surfaces.'
|
||||
: 'The shared browser-auth runtime is present in this build, but remaining warnings should be cleared before this surface is treated as fully production-ready.'
|
||||
)
|
||||
: 'This build may still rely on bounded fallback posture until the missing shared-auth runtime values are configured, even though the method lineup below stays useful as operator guidance.'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<article className="callout">
|
||||
<p className={`status-pill${authRuntimeValidation.ready && diagnostics.length === 0 ? ' status-pill--success' : ' status-pill--info'}`}>
|
||||
{statusLabel}
|
||||
</p>
|
||||
<p>{summary}</p>
|
||||
{diagnostics.length > 0 ? (
|
||||
<ul className="list top-gap">
|
||||
{diagnostics.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</article>
|
||||
<div className="card-grid top-gap">
|
||||
{cards.map((card) => (
|
||||
<article key={card.title} className="card">
|
||||
<h3>{card.title}</h3>
|
||||
<p>{card.description}</p>
|
||||
<ul className="list top-gap">
|
||||
{card.bullets.map((bullet) => (
|
||||
<li key={bullet}>{bullet}</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,9 +1,16 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { ensureSuperTokensInit } from './auth/supertokens-client'
|
||||
import App from './App'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
async function bootstrap() {
|
||||
await ensureSuperTokensInit()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
}
|
||||
|
||||
void bootstrap()
|
||||
|
|
|
|||
|
|
@ -953,7 +953,7 @@ export function DashboardOverviewPage() {
|
|||
<li>Fallback enabled: {healthQuery.data.fallback.enabled ? 'yes' : 'no'}</li>
|
||||
<li>Fallback active: {healthQuery.data.fallback.active ? 'yes' : 'no'}</li>
|
||||
<li>Core API version: {healthQuery.data.supertokens.apiVersion || 'unavailable'}</li>
|
||||
<li>OAuth routes: GitHub {healthQuery.data.supertokens.oauth?.github ? 'on' : 'off'}, Google {healthQuery.data.supertokens.oauth?.google ? 'on' : 'off'}</li>
|
||||
<li>OAuth routes: GitHub {healthQuery.data.supertokens.oauth?.github ? 'on' : 'off'}, Google {healthQuery.data.supertokens.oauth?.google ? 'on' : 'off'}, ORCID {healthQuery.data.supertokens.oauth?.orcid ? 'on' : 'off'}</li>
|
||||
<li>Runtime mode: {healthQuery.data.runtime.mode}</li>
|
||||
<li>Cookie secure: {healthQuery.data.runtime.cookie_secure ? 'yes' : 'no'}</li>
|
||||
<li>Public auth origin ready: {healthQuery.data.runtime.public_origin_ready ? 'yes' : 'no'}</li>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
isGitHubOAuthEnabled,
|
||||
isGoogleOAuthEnabled,
|
||||
isOrcidOAuthEnabled,
|
||||
} from '../auth/supertokens-client'
|
||||
} from '../auth/supertokens-runtime'
|
||||
import { SiteMetadata } from '../components/seo/SiteMetadata'
|
||||
import { OperationalStatusCallout } from '../components/ui/OperationalStatusCallout'
|
||||
import { browserDesktopRealityCards, deliverySurfaceCards, operatorManualTracks } from '../site-data'
|
||||
|
|
@ -259,6 +259,7 @@ export function LoginPage() {
|
|||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [oauthLoading, setOauthLoading] = useState<'google' | 'github' | 'orcid' | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
|
|
@ -279,6 +280,23 @@ export function LoginPage() {
|
|||
navigate(nextPath, { replace: true })
|
||||
}
|
||||
|
||||
async function handleOAuth(provider: 'google' | 'github' | 'orcid') {
|
||||
setError('')
|
||||
setOauthLoading(provider)
|
||||
try {
|
||||
const result = await login({ method: provider, email: '' })
|
||||
if (result?.ok === false) {
|
||||
setError(result.error || 'Unable to continue with the selected identity provider.')
|
||||
setOauthLoading(null)
|
||||
return
|
||||
}
|
||||
setOauthLoading(null)
|
||||
} catch {
|
||||
setError('Unable to continue with the selected identity provider.')
|
||||
setOauthLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SiteMetadata
|
||||
|
|
@ -306,18 +324,18 @@ export function LoginPage() {
|
|||
</form>
|
||||
<div className="provider-row">
|
||||
{isGoogleOAuthEnabled() ? (
|
||||
<button className="button button--ghost button--full" type="button" onClick={() => void login({ method: 'google', email: '' })}>
|
||||
Continue with Google
|
||||
<button className="button button--ghost button--full" disabled={oauthLoading !== null} type="button" onClick={() => void handleOAuth('google')}>
|
||||
{oauthLoading === 'google' ? 'Redirecting to Google...' : 'Continue with Google'}
|
||||
</button>
|
||||
) : null}
|
||||
{isGitHubOAuthEnabled() ? (
|
||||
<button className="button button--ghost button--full" type="button" onClick={() => void login({ method: 'github', email: '' })}>
|
||||
Continue with GitHub
|
||||
<button className="button button--ghost button--full" disabled={oauthLoading !== null} type="button" onClick={() => void handleOAuth('github')}>
|
||||
{oauthLoading === 'github' ? 'Redirecting to GitHub...' : 'Continue with GitHub'}
|
||||
</button>
|
||||
) : null}
|
||||
{isOrcidOAuthEnabled() ? (
|
||||
<button className="button button--ghost button--full" type="button" onClick={() => void login({ method: 'orcid', email: '' })}>
|
||||
Continue with ORCID
|
||||
<button className="button button--ghost button--full" disabled={oauthLoading !== null} type="button" onClick={() => void handleOAuth('orcid')}>
|
||||
{oauthLoading === 'orcid' ? 'Redirecting to ORCID...' : 'Continue with ORCID'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -330,13 +348,14 @@ export function LoginPage() {
|
|||
|
||||
export function RegisterPage() {
|
||||
const navigate = useNavigate()
|
||||
const { register, isAuthenticated } = usePlatformAuth()
|
||||
const { login, register, isAuthenticated } = usePlatformAuth()
|
||||
const nextPath = useNextPath()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [oauthLoading, setOauthLoading] = useState<'google' | 'github' | 'orcid' | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
|
|
@ -357,6 +376,23 @@ export function RegisterPage() {
|
|||
navigate(nextPath, { replace: true })
|
||||
}
|
||||
|
||||
async function handleOAuth(provider: 'google' | 'github' | 'orcid') {
|
||||
setError('')
|
||||
setOauthLoading(provider)
|
||||
try {
|
||||
const result = await login({ method: provider, email: '' })
|
||||
if (result?.ok === false) {
|
||||
setError(result.error || 'Unable to continue with the selected identity provider.')
|
||||
setOauthLoading(null)
|
||||
return
|
||||
}
|
||||
setOauthLoading(null)
|
||||
} catch {
|
||||
setError('Unable to continue with the selected identity provider.')
|
||||
setOauthLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SiteMetadata
|
||||
|
|
@ -384,6 +420,23 @@ export function RegisterPage() {
|
|||
{isSubmitting ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="provider-row">
|
||||
{isGoogleOAuthEnabled() ? (
|
||||
<button className="button button--ghost button--full" disabled={oauthLoading !== null} type="button" onClick={() => void handleOAuth('google')}>
|
||||
{oauthLoading === 'google' ? 'Redirecting to Google...' : 'Continue with Google'}
|
||||
</button>
|
||||
) : null}
|
||||
{isGitHubOAuthEnabled() ? (
|
||||
<button className="button button--ghost button--full" disabled={oauthLoading !== null} type="button" onClick={() => void handleOAuth('github')}>
|
||||
{oauthLoading === 'github' ? 'Redirecting to GitHub...' : 'Continue with GitHub'}
|
||||
</button>
|
||||
) : null}
|
||||
{isOrcidOAuthEnabled() ? (
|
||||
<button className="button button--ghost button--full" disabled={oauthLoading !== null} type="button" onClick={() => void handleOAuth('orcid')}>
|
||||
{oauthLoading === 'orcid' ? 'Redirecting to ORCID...' : 'Continue with ORCID'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<AuthBrowserDesktopRealityPanel />
|
||||
<AuthAccessGuide nextPath={nextPath} />
|
||||
</AuthShell>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { ArrowRight, BookOpenText, Boxes, Download, ExternalLink, Landmark, Moni
|
|||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { MarketingShell } from '../components/layout/MarketingShell'
|
||||
import { SiteMetadata } from '../components/seo/SiteMetadata'
|
||||
import { BrowserAuthMethodsGuide } from '../components/ui/BrowserAuthMethodsGuide'
|
||||
import { PublicLaunchStatus } from '../components/ui/PublicLaunchStatus'
|
||||
import { ProductSurfaceMatrix } from '../components/ui/ProductSurfaceMatrix'
|
||||
import { brandConfig } from '../site-config'
|
||||
|
|
@ -871,6 +872,13 @@ export function DocsPage() {
|
|||
cards={operatorManualTracks}
|
||||
/>
|
||||
|
||||
<Section
|
||||
title="Browser account access methods"
|
||||
description="The public manual now makes the current shared-auth lineup explicit too, so operators do not have to infer provider truth only from the sign-in form buttons."
|
||||
>
|
||||
<BrowserAuthMethodsGuide />
|
||||
</Section>
|
||||
|
||||
<StepCardSection
|
||||
title="Recovery and degraded-state manual"
|
||||
description="This manual section explains how to move through auth or release-authority degradation without confusing fallback continuity for live production authority."
|
||||
|
|
|
|||
|
|
@ -824,6 +824,21 @@ export const resourceCollections = [
|
|||
] as const
|
||||
|
||||
export const changelogEntries = [
|
||||
{
|
||||
date: 'June 27, 2026',
|
||||
title: 'Shared browser auth now exposes real provider parity and public-manual account guidance',
|
||||
details: 'HyperTwist no longer leaves ORCID as a UI-only placeholder: the auth server now owns a bounded custom ORCID provider, same-origin bundle rendering emits matching ORCID env flags, auth-health reports ORCID readiness, login and register both recover cleanly around provider redirects, and the public docs route now shows the real browser-account method lineup instead of hiding it inside the form pages alone.',
|
||||
},
|
||||
{
|
||||
date: 'June 27, 2026',
|
||||
title: 'Shared browser auth now stays off the public first-paint path until it is actually needed',
|
||||
details: 'Config-only auth runtime facts now live separately from the heavy SuperTokens client modules, the route wrapper is lazy-loaded only on auth-sensitive paths, and sign-in or sign-up or provider actions now import their recipe code on demand. The website production build no longer emits the oversized auth-core warning: the remaining auth core chunk is now 391.18 kB instead of staying above the Vite warning threshold.',
|
||||
},
|
||||
{
|
||||
date: 'June 27, 2026',
|
||||
title: 'Website startup now boots with centered first-paint shell and early shared-auth init',
|
||||
details: 'The website entrypoint now initializes the shared SuperTokens posture before React root creation, and the public root document now ships a centered HyperTwist first-paint shell inside #root so public and protected routes no longer begin from a blank page while the first bundle loads.',
|
||||
},
|
||||
{
|
||||
date: 'June 27, 2026',
|
||||
title: 'The remaining legal public routes now carry the same release-decision guide too',
|
||||
|
|
@ -1011,6 +1026,10 @@ export const sourceAvailability = {
|
|||
}
|
||||
|
||||
export const supportFaqs = [
|
||||
{
|
||||
question: 'Which browser sign-in methods are actually supported?',
|
||||
answer: 'Email/password is the baseline shared-auth lane. GitHub, Google, and ORCID may also appear when the current deployment has those providers configured. Regardless of method, browser sign-in only opens the protected account, release, and desktop-pairing surfaces; it does not replace the native desktop simulator.',
|
||||
},
|
||||
{
|
||||
question: 'Is the simulator fully in the browser?',
|
||||
answer: 'No. The current shipping lane is desktop-first and Unreal-backed. The public website offers account, operator, support, and download access, while the optional full-browser simulator path remains spec-only, so the browser does not currently replace the package-validated native runtime.',
|
||||
|
|
|
|||
|
|
@ -7,8 +7,17 @@ export default defineConfig({
|
|||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('supertokens-auth-react/recipe/thirdparty')) {
|
||||
return 'auth-thirdparty-vendor'
|
||||
}
|
||||
if (id.includes('supertokens-auth-react/recipe/emailpassword')) {
|
||||
return 'auth-email-vendor'
|
||||
}
|
||||
if (id.includes('supertokens-auth-react/recipe/session')) {
|
||||
return 'auth-session-vendor'
|
||||
}
|
||||
if (id.includes('supertokens-auth-react')) {
|
||||
return 'auth-vendor'
|
||||
return 'auth-core-vendor'
|
||||
}
|
||||
if (id.includes('react-router-dom') || id.includes('@tanstack/react-query')) {
|
||||
return 'app-vendor'
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue