mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
feat(account-link): redirect-based connect handshake for self-hosted linking (#7494)
Links a self-hosted instance to a SaaS team over an ordinary redirect, and leaves the admin's browser holding a Stirling session at the same time. ## The problem A self-hosted server needs a device credential bound to a SaaS team, and the admin's Supabase JWT must never reach the instance backend. Three things ruled out the obvious approaches: - **A customer hostname can never be in Supabase's redirect allow-list**, so the sign-in cannot happen on the instance's own origin. That is why SSO and sign-up did not work for linking at all. - **A device credential identifies a server, not a person.** Every attended portal read (Usage, Billing, Documents, Infrastructure) goes through `getPortalSaasToken()` and needs a *user* session, so a credential-only link left all of them asking for a second sign-in. - **The previous design relayed a JWT** from the browser into the instance, which is the thing we wanted to avoid. That path is deleted here. ## The solution Redirect and nonce, modelled on desktop's `authService.loginWithSelfHostedOAuth`: mint a nonce, hand the browser off, accept only a callback carrying that nonce back. Desktop has the OS route the reply; self-hosted has no OS hop, so our own approval page performs it. That is the point — the human half happens on an origin we control. ``` instance SaaS admin's browser | POST connect/request | | | (name, callback, nonce, | | | claim-secret hash) | | |-------------------------->| | | <- requestId + authorizeUrl | | | GET /link?request=... | | |<-------------------------------| | | sign in (SSO works here), | | | see ACCOUNT + ORIGIN, approve | | |------------------------------->| | | 302 callback#nonce+session | | POST connect/claim | | | (requestId, claim secret)| | |-------------------------->| | | <- device credential | | ``` Four properties carry the safety, and each is stated in the code because each is easy to lose in a refactor: - **The redirect target is never caller-supplied.** Validated once at creation, then read back from the stored row, so nothing in the approval page's URL can steer the token elsewhere. - **Approval and minting are separate.** Approval records the team and hands out nothing usable; the credential is minted only on claim, authenticated by a secret that never entered a browser. - **A re-authentication cannot move a server between teams.** The team is pinned at creation from the credential only that instance holds, so an approver from another team gets `WRONG_TEAM` instead of a rebind. - **The approver has to confirm what they are binding.** The page shows the address and the signed-in account, with a way to switch, and a checkbox naming the address gates the approve button. The name the server reports is deliberately not shown: the requester picks it on an unauthenticated endpoint, and its honest value is the hostname already in the address. The session rides the URL fragment, so it stays out of access logs and `Referer`, and is stripped before anything awaits. The claim is row-locked, so one approval mints once. A request lives 30 minutes; a settled one is not offered again, since approving it fails server-side. Signing in mid-flow no longer loses the request. The id is kept on the SaaS origin and resumed after any sign-in, which is what makes creating an account work: the confirmation email opens a new tab, where the `next` parameter is gone. Reading it does not consume it — the request may be open in two tabs — and only a recorded decision retires it. The result lands as a modal over the portal the admin started from, and the portal re-reads its link status so the page behind agrees with the modal. Plaintext `http://` callbacks are accepted rather than refused, because many self-hosted instances legitimately run plain HTTP on a private network; the address carries a warning icon explaining the risk, derived server-side so a requester cannot suppress it. Hard-refusing `http://` to a public IP literal is a reasonable follow-up; a bare hostname can't be classified without a DNS lookup, so the warning stays the general mechanism. ## Configuration Four surfaces. Placeholders below, not values. **SaaS backend** | Setting | Needed | Why | |---|---|---| | `stirling.billing.account-link.enabled` | Yes, `true` | The connect controller and service are `@ConditionalOnProperty` with no default, so without it the endpoints do not exist. | | `system.frontendUrl` | Only when the approval page is not on the API's own origin | Where the approver is sent. Must include the app's base path if it is served under one, or the redirect misses `/link`. | **SaaS frontend** | Setting | Needed | Why | |---|---|---| | `VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY` | Yes | Its own sign-in. Must be the project the SaaS backend validates tokens against. | | `RUN_SUBPATH` | Only if served under a subpath | Moves the approval page to `<base>/<subpath>/link`, so `system.frontendUrl` has to agree. | **Self-hosted backend** | Setting | Needed | Why | |---|---|---| | `stirling.billing.account-link.enabled` | Yes, `true` | Defaults to `false`. | | `stirling.billing.account-link.saas-base-url` | Yes | Origin of the SaaS API it links to. Not the SaaS frontend. | | `system.frontendUrl` | Optional | Externally reachable base URL for the callback. Otherwise derived from the request's `Origin`, which is right for ordinary deployments and wrong behind a rewriting proxy. | **Self-hosted frontend** | Setting | Needed | Why | |---|---|---| | `VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY` | Yes | Accepts the session handed over in the callback fragment. | | `VITE_SAAS_API_URL` | For Usage and Billing | Attended reads go to the SaaS API with the admin's token. Absent, those surfaces stay on the mock. | | `VITE_INCLUDE_PORTAL` | Production builds | Dev builds include the portal automatically; without it there is no link UI and no callback route. | Two things worth stating because neither fails loudly: - **Both frontends must use the URL *and* key of the same Supabase project**, and the same one the SaaS backend validates against. A key from one project with a URL from another is accepted by the browser and rejected by Supabase, which surfaces much later as "session expired" on Usage rather than as an error at hand-over. - **The Supabase redirect allow-list must contain the SaaS app's `/auth/callback`**, since a confirmation email returns through it. Entries are matched exactly. - **`system.frontendUrl` is the existing setting for this**, not a new one, so each side reads its own value and there is nothing extra to configure. It also gates share links, so on a stack with storage and sharing already on, setting it here turns those on too. The self-hosted side deliberately does **not** configure where the approval page lives — SaaS answers that in the connect-request reply, being the only party that knows. Also here, because testing this needs two stacks side by side: `linked:staging` / `linked:dev` (which derive `system.frontendUrl` and `RUN_SUBPATH` themselves), the missing `frontend:staging:saas`, and a per-mode vite `cacheDir` — two dev servers in different modes otherwise re-optimise over one shared dep cache. ## How to test Automated and green: `task frontend:check:all` plus both backend modules. `ConnectRequestServiceTest` covers callback validation, the per-IP cap, single-use approval, claim outcomes, expiry, `WRONG_TEAM` and reauth confirming without minting; `ConnectServiceTest` covers callback-resolution precedence including a foreign-origin callback being discarded; `ConnectControllerTest` covers the authorize URL, including the forwarded-header path and only the first hop being trusted; `ConnectCallback.test.tsx` covers the fragment being stripped synchronously and malformed fragments refused; `LinkAccountModal.test.tsx` covers link and reauth hitting different endpoints. Manual walkthrough: 1. `task linked:staging` — added here; brings up a SaaS stack and a self-hosted instance pointed at it, on discovered ports, and prints the four addresses. 2. Open the link-account modal in the self-hosted portal and continue. Expect the SaaS approval page at `/link?request=<id>`. 3. Sign in as a team leader, or create an account and confirm the email. Either way you should come back to the approval page. 4. Tick the acknowledgement and approve. Expect the fragment gone from the address bar immediately, a result modal over the portal, the portal showing linked without a reload, and attended reads (Usage, Billing) working without a second sign-in. 5. Repeat, approving as a member of a different team. Expect a refusal, not a rebind. ## Outstanding - #7415 to be reworked against this design once this lands. - **No SaaS-side UI to disconnect a server.** `GET /account-link/instances` and `POST /account-link/instances/{id}/revoke` are already team-scoped and leader-gated, and the portal has a panel that uses them, but `portal-saas/components/settings/accountLinkSettings.tsx` exports `null` on the reasoning that "SaaS has no account-link concept". That held when linking was a self-hosted admin managing their own instance; here a leader approves a server they may not administer, and has no way to withdraw it. The seam to fill is that one file. Expected to land with the CTA work in #7415. --------- Co-authored-by: James Brunton <jbrunton96@gmail.com>
This commit is contained in:
co-authored by
James Brunton
parent
f7a2c626c9
commit
732ef18ae5
@@ -3240,7 +3240,7 @@ enterEmailConfirm = "To confirm deletion, please type your email address ({{emai
|
||||
guestDescription = "You are signed in as a guest. Consider upgrading your account above."
|
||||
label = "Overview"
|
||||
manageAccountPreferences = "Manage your account preferences"
|
||||
signedInAs = "Signed in as"
|
||||
signedInAs = "Account"
|
||||
title = "Account Settings"
|
||||
|
||||
[config.account.profilePicture]
|
||||
@@ -3351,6 +3351,39 @@ integration = "Integration Configuration"
|
||||
security = "Security Configuration"
|
||||
system = "System Configuration"
|
||||
|
||||
[connect]
|
||||
loading = "Checking this request."
|
||||
redirecting = "Returning you to your server."
|
||||
|
||||
[connect.confirm]
|
||||
acknowledge = "I recognise this address and want to connect it to my team"
|
||||
approve = "Connect server"
|
||||
deny = "Decline"
|
||||
lead = "A Stirling server is asking to connect to your team. Check the address below is yours before you approve."
|
||||
originLabel = "Address"
|
||||
signedInAs = "Signed in as"
|
||||
switchAccount = "Use a different account"
|
||||
title = "Connect this server?"
|
||||
unknownAccount = "an unknown account"
|
||||
|
||||
[connect.confirm.insecure]
|
||||
body = "This address does not use HTTPS, so your sign-in will be sent over an unencrypted connection. Only approve it on a network you trust."
|
||||
label = "Not an encrypted address"
|
||||
|
||||
[connect.declined]
|
||||
body = "Nothing was connected. You can close this page."
|
||||
title = "Request declined"
|
||||
|
||||
[connect.error]
|
||||
failed = "That did not go through. Only a team owner can connect a server."
|
||||
|
||||
[connect.meta]
|
||||
title = "Connect a server"
|
||||
|
||||
[connect.notFound]
|
||||
body = "This connection request is not valid. It may have expired, or already been used. Start another one from your server."
|
||||
title = "Request not valid"
|
||||
|
||||
[convert]
|
||||
autoRotate = "Auto Rotate"
|
||||
autoRotateDescription = "Automatically rotate images to better fit the PDF page"
|
||||
@@ -6487,6 +6520,34 @@ after = "to enable account linking against the hosted Stirling account. In dev y
|
||||
before = "Set"
|
||||
title = "SaaS login not configured"
|
||||
|
||||
[portal.accountLink.connect.callback]
|
||||
continue = "Continue"
|
||||
linkedNotSignedIn = "You are not signed in to Stirling in this browser, so usage and billing will ask you to sign in."
|
||||
modalTitle = "Connecting this server"
|
||||
retry = "Try again"
|
||||
signedInAnyway = "You are signed in to Stirling, so billing and usage will load. Only the server link is incomplete."
|
||||
working = "Finishing the connection."
|
||||
|
||||
[portal.accountLink.connect.callback.expired]
|
||||
body = "Connection requests are short lived. Start another one."
|
||||
title = "Request expired"
|
||||
|
||||
[portal.accountLink.connect.callback.linked]
|
||||
body = "This server is connected to your Stirling account."
|
||||
title = "Server connected"
|
||||
|
||||
[portal.accountLink.connect.callback.malformed]
|
||||
body = "This page was opened without a valid connection response. Start the connection from settings."
|
||||
title = "Could not read the response"
|
||||
|
||||
[portal.accountLink.connect.callback.rejected]
|
||||
body = "This request was declined or has already been used. Start another one if that was not intended."
|
||||
title = "Connection not completed"
|
||||
|
||||
[portal.accountLink.connect.callback.unfinished]
|
||||
body = "Stirling did not confirm the connection. This is usually temporary."
|
||||
title = "Not finished yet"
|
||||
|
||||
[portal.accountLink.gate]
|
||||
action = "Link account"
|
||||
description = "Link this org's Stirling account to use billable features."
|
||||
@@ -6520,17 +6581,24 @@ minutesAgo_other = "{{count}}m ago"
|
||||
never = "never"
|
||||
|
||||
[portal.accountLink.modal]
|
||||
linkSubtitle = "Sign in to the account this server should bill against."
|
||||
linkTitle = "Link your Stirling account"
|
||||
reauthSubtitle = "Your session expired — sign back in to your Stirling account. Your instance stays linked."
|
||||
cancel = "Cancel"
|
||||
continueLink = "Continue to Stirling"
|
||||
continueReauth = "Sign in again"
|
||||
linkSubtitle = "Connect this server to the Stirling account it should bill against."
|
||||
linkTitle = "Connect your Stirling account"
|
||||
noAuthorizeUrl = "Stirling did not return somewhere to continue. Try again in a moment."
|
||||
reauthSubtitle = "Your Stirling session expired. Sign in again to keep seeing usage and billing. This server stays connected either way."
|
||||
reauthTitle = "Sign in again"
|
||||
simulateSignIn = "Simulate sign-in (dev)"
|
||||
startFailed = "Could not reach Stirling to start the connection. Check this server's outbound network access, then try again."
|
||||
step1 = "We send you to stirling.com to sign in. Any sign-in method works there, including Google and single sign-on."
|
||||
step2 = "You check this server's address and approve it. A team owner has to do this the first time."
|
||||
step3 = "Stirling brings you straight back here and finishes up."
|
||||
|
||||
[portal.accountLink.modal.loginNotConfigured]
|
||||
after = "to enable in-app linking against the hosted Stirling account."
|
||||
after = "so this server can finish the connection when you come back."
|
||||
and = "and"
|
||||
before = "Set"
|
||||
title = "SaaS login not configured"
|
||||
title = "Stirling connection not configured"
|
||||
|
||||
[portal.accountLink.panel]
|
||||
instancesSub = "Every self-hosted instance registered to this org. Revoke a credential to immediately cut off its unattended access."
|
||||
|
||||
@@ -1,52 +1,24 @@
|
||||
import { TierProvider } from "@portal/contexts/TierContext";
|
||||
import { LinkProvider, useLink } from "@portal/contexts/LinkContext";
|
||||
import { LinkProvider } from "@portal/contexts/LinkContext";
|
||||
import { UIProvider, useUI } from "@portal/contexts/UIContext";
|
||||
import type { SupabaseLoginSession } from "@app/auth/ui/useSupabaseLogin";
|
||||
import { LinkAccountModal } from "@portal/components/account-link/LinkAccountModal";
|
||||
import {
|
||||
AccountLinkProvider,
|
||||
useAccountLinkContext,
|
||||
} from "@portal/contexts/AccountLinkContext";
|
||||
import { AccountLinkProvider } from "@portal/contexts/AccountLinkContext";
|
||||
import { ConnectCallbackHost } from "@portal/components/account-link/ConnectCallbackHost";
|
||||
import { PortalChrome } from "@portal/components/PortalChrome";
|
||||
|
||||
/**
|
||||
* The one and only account-link login modal. Mounted at the app root (never
|
||||
* nested in another overlay) and driven by UIContext, so any "Link account" CTA
|
||||
* — sidebar, billing prompt, feature gate, Settings panel — opens this exact
|
||||
* instance. Linking is finished by the shared {@link useAccountLinkContext}
|
||||
* orchestration.
|
||||
*/
|
||||
/** The one and only account-link modal. */
|
||||
function LinkModalHost() {
|
||||
const { linkModalOpen, linkModalMode, closeLinkModal } = useUI();
|
||||
const { markSaasSessionChanged } = useLink();
|
||||
const link = useAccountLinkContext();
|
||||
// "reauth" only refreshes the browser SaaS session for attended reads — the
|
||||
// sign-in already applied it to the Supabase client, so we just signal a
|
||||
// refetch. It must NOT call completeLink (that re-registers → duplicate row).
|
||||
const onLinked =
|
||||
linkModalMode === "reauth"
|
||||
? () => markSaasSessionChanged()
|
||||
: (session: SupabaseLoginSession) => link.completeLink(session);
|
||||
return (
|
||||
<LinkAccountModal
|
||||
open={linkModalOpen}
|
||||
mode={linkModalMode}
|
||||
onClose={closeLinkModal}
|
||||
onLinked={onLinked}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-hosted provider stack. The account-link layer (LinkProvider +
|
||||
* AccountLinkProvider + the login modal) wraps the shared chrome; the tier is
|
||||
* derived from the link/subscription state (see usePlanTier). TierProvider sits
|
||||
* inside LinkProvider because the self-hosted usePlanTier reads useLink.
|
||||
*
|
||||
* The SaaS build shadows this file to drop the account-link layer entirely — the
|
||||
* signed-in account IS the SaaS account, so there is nothing to link and the
|
||||
* tier comes from the wallet.
|
||||
*/
|
||||
/** Self-hosted provider stack. */
|
||||
export function PortalProviders() {
|
||||
return (
|
||||
<LinkProvider initialState="unlinked">
|
||||
@@ -55,6 +27,7 @@ export function PortalProviders() {
|
||||
<AccountLinkProvider>
|
||||
<PortalChrome />
|
||||
<LinkModalHost />
|
||||
<ConnectCallbackHost />
|
||||
</AccountLinkProvider>
|
||||
</UIProvider>
|
||||
</TierProvider>
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
fetchInstances,
|
||||
fetchLocalUsage,
|
||||
fetchStatus,
|
||||
linkInstance,
|
||||
revokeInstance,
|
||||
unlinkInstance,
|
||||
} from "@portal/api/link";
|
||||
@@ -55,21 +54,14 @@ describe("api/link — local backend (this instance)", () => {
|
||||
expect(status.linked).toBe(false);
|
||||
});
|
||||
|
||||
it("links this instance via the local endpoint, never returning a secret", async () => {
|
||||
const status = await linkInstance({
|
||||
supabaseJwt: "jwt_abc",
|
||||
name: "node-1",
|
||||
});
|
||||
expect(status.linked).toBe(true);
|
||||
expect(status.name).toBe("node-1");
|
||||
// Contract: the device secret is stored server-side, never sent to the portal.
|
||||
it("never exposes the device credential in a status read", async () => {
|
||||
// Contract: the device secret is stored server-side and the portal never sees it.
|
||||
const status = await fetchStatus();
|
||||
expect(status).not.toHaveProperty("deviceSecret");
|
||||
expect(status).not.toHaveProperty("deviceId");
|
||||
expect(await (await fetchStatus()).linked).toBe(true);
|
||||
});
|
||||
|
||||
it("unlinks this instance", async () => {
|
||||
await linkInstance({ supabaseJwt: "jwt_abc" });
|
||||
// unlink returns 204 (no body); the status is read back separately.
|
||||
await unlinkInstance();
|
||||
expect((await fetchStatus()).linked).toBe(false);
|
||||
@@ -84,18 +76,6 @@ describe("api/link — local backend (this instance)", () => {
|
||||
);
|
||||
expect(usage.totalUnsyncedUnits).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("forwards the SaaS JWT in the link body", async () => {
|
||||
let seenBody: unknown = null;
|
||||
server.events.on("request:start", async ({ request }) => {
|
||||
if (request.method === "POST" && request.url.endsWith("/link")) {
|
||||
seenBody = await request.clone().json();
|
||||
}
|
||||
});
|
||||
await linkInstance({ supabaseJwt: "jwt_xyz", name: "n" });
|
||||
expect(seenBody).toMatchObject({ supabaseJwt: "jwt_xyz" });
|
||||
server.events.removeAllListeners();
|
||||
});
|
||||
});
|
||||
|
||||
describe("api/link — SaaS backend (team-wide)", () => {
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import { apiClient } from "@portal/api/http";
|
||||
|
||||
/** Body for POST /api/v1/account-link/link — the SaaS JWT + optional name. */
|
||||
export interface LinkInstanceRequest {
|
||||
/** Admin's SaaS session JWT, obtained via the hosted-login popup. */
|
||||
supabaseJwt: string;
|
||||
/** Optional label for this instance. */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** Link status for this instance (GET /api/v1/account-link/status). */
|
||||
export interface LinkStatus {
|
||||
linked: boolean;
|
||||
@@ -15,12 +7,7 @@ export interface LinkStatus {
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locally-accrued usage not yet reported to SaaS (GET /api/v1/account-link/usage).
|
||||
* The portal adds this on top of the SaaS-synced spend so "current usage"
|
||||
* includes work done since the last daily sync. Per-category unsynced units for
|
||||
* the current period; all zero when metering is off or nothing is pending.
|
||||
*/
|
||||
/** Locally-accrued usage not yet reported to SaaS (GET /api/v1/account-link/usage). */
|
||||
export interface LocalUsage {
|
||||
/** ISO timestamp of the current period start; null when unknown (not yet synced). */
|
||||
periodStart: string | null;
|
||||
@@ -42,93 +29,88 @@ export interface LinkedInstanceRow {
|
||||
revoked: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Account-link client (combined-billing "Mode A"). Two distinct surfaces:
|
||||
*
|
||||
* THIS instance — apiClient.local (Spring admin bearer auto-attached):
|
||||
* - POST /api/v1/account-link/link — hand the local backend the admin's
|
||||
* SaaS JWT in the body. It registers
|
||||
* with SaaS + stores the device
|
||||
* secret SERVER-SIDE; the portal
|
||||
* NEVER receives or renders it.
|
||||
* - GET /api/v1/account-link/status — Linked / Not-linked for this
|
||||
* instance.
|
||||
* - POST /api/v1/account-link/unlink — drop this instance's link (local
|
||||
* backend best-effort tells SaaS).
|
||||
*
|
||||
* TEAM-WIDE management — apiClient.saas (admin's Supabase JWT auto-attached
|
||||
* from the in-app account-link login):
|
||||
* - GET /api/v1/account-link/instances — every linked instance
|
||||
* - POST /api/v1/account-link/instances/{id}/revoke
|
||||
*
|
||||
* The team-wide endpoints are served by the hosted SaaS Java backend (the
|
||||
* local backend has no such routes), so they go through apiClient.saas. In
|
||||
* Storybook/tests, wildcard MSW handlers match both the local and absolute
|
||||
* SaaS URLs.
|
||||
*/
|
||||
/** Account-link client (combined billing). */
|
||||
|
||||
const BASE = "/api/v1/account-link";
|
||||
|
||||
/**
|
||||
* Link THIS instance. The local backend takes the SaaS JWT, registers with
|
||||
* SaaS, and persists the device secret itself; the response carries only the
|
||||
* resulting link status. No secret is returned.
|
||||
*/
|
||||
export async function linkInstance(
|
||||
req: LinkInstanceRequest,
|
||||
): Promise<LinkStatus> {
|
||||
return apiClient.local.json<LinkStatus>(`${BASE}/link`, {
|
||||
method: "POST",
|
||||
body: req,
|
||||
});
|
||||
}
|
||||
|
||||
/** Linked / Not-linked for this instance. */
|
||||
export async function fetchStatus(): Promise<LinkStatus> {
|
||||
return apiClient.local.json<LinkStatus>(`${BASE}/status`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Locally-accrued usage not yet reported to SaaS — the portal adds this on top
|
||||
* of the SaaS-synced spend so "current usage" includes work done since the last
|
||||
* daily sync. Local-backend call; returns zeros when metering is off.
|
||||
* Locally-accrued usage not yet reported to SaaS — the portal adds this on top of the SaaS-synced spend so "current usage" includes work done since the last daily sync.
|
||||
*/
|
||||
export async function fetchLocalUsage(): Promise<LocalUsage> {
|
||||
return apiClient.local.json<LocalUsage>(`${BASE}/usage`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop this instance's link. The local backend best-effort tells SaaS to
|
||||
* revoke before clearing the credential locally, then returns 204 — there's no
|
||||
* body, so the caller sets the known unlinked status itself.
|
||||
*/
|
||||
/** Drop this instance's link. */
|
||||
export async function unlinkInstance(): Promise<void> {
|
||||
await apiClient.local.json<void>(`${BASE}/unlink`, { method: "POST" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Nudge the local backend to sync + refresh its cached entitlement now. Called
|
||||
* right after a checkout completes so the instance's request-time gate reflects
|
||||
* the new subscription immediately instead of waiting out its entitlement-cache
|
||||
* TTL. Best-effort — the caller swallows failures (metering off → 409, or the
|
||||
* local backend unreachable); the scheduled sync / TTL refresh is the backstop.
|
||||
*/
|
||||
/** Nudge the local backend to sync + refresh its cached entitlement now. */
|
||||
export async function triggerLocalSync(): Promise<void> {
|
||||
await apiClient.local.json<void>(`${BASE}/sync-now`, { method: "POST" });
|
||||
}
|
||||
|
||||
/** Where a browser-mediated connect handshake has got to. */
|
||||
export type ConnectPhase =
|
||||
| "NONE"
|
||||
| "PENDING"
|
||||
| "LINKED"
|
||||
| "EXPIRED"
|
||||
| "REJECTED"
|
||||
| "UNAVAILABLE";
|
||||
|
||||
export interface ConnectStatus {
|
||||
phase: ConnectPhase;
|
||||
/** Approval page to send the admin to. */
|
||||
authorizeUrl: string | null;
|
||||
secondsRemaining: number | null;
|
||||
teamId: number | null;
|
||||
}
|
||||
|
||||
const CONNECT = `${BASE}/connect`;
|
||||
|
||||
/** Open a handshake and get the approval URL to send the admin to. */
|
||||
export async function startConnect(
|
||||
name?: string,
|
||||
callbackUrl?: string,
|
||||
): Promise<ConnectStatus> {
|
||||
return apiClient.local.json<ConnectStatus>(`${CONNECT}/start`, {
|
||||
method: "POST",
|
||||
body: { name, callbackUrl },
|
||||
});
|
||||
}
|
||||
|
||||
/** Re-establish the SaaS session for a server that is already linked. */
|
||||
export async function startReauth(
|
||||
callbackUrl?: string,
|
||||
): Promise<ConnectStatus> {
|
||||
return apiClient.local.json<ConnectStatus>(`${CONNECT}/reauth`, {
|
||||
method: "POST",
|
||||
body: { callbackUrl },
|
||||
});
|
||||
}
|
||||
|
||||
/** Finish a handshake using the nonce the approval page put in the callback fragment. */
|
||||
export async function completeConnect(nonce: string): Promise<ConnectStatus> {
|
||||
return apiClient.local.json<ConnectStatus>(`${CONNECT}/complete`, {
|
||||
method: "POST",
|
||||
body: { nonce },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Every linked instance for the team — SaaS-direct call with the admin's
|
||||
* Supabase JWT (no longer takes an accessToken parameter; the saas client
|
||||
* resolves the live session itself).
|
||||
* Every linked instance for the team — SaaS-direct call with the admin's Supabase JWT (no longer takes an accessToken parameter; the saas client resolves the live session itself).
|
||||
*/
|
||||
export async function fetchInstances(): Promise<LinkedInstanceRow[]> {
|
||||
return apiClient.saas.json<LinkedInstanceRow[]>(`${BASE}/instances`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a linked instance — SaaS-direct call with the admin's Supabase JWT.
|
||||
*/
|
||||
/** Revoke a linked instance — SaaS-direct call with the admin's Supabase JWT. */
|
||||
export async function revokeInstance(instanceId: number): Promise<void> {
|
||||
await apiClient.saas.json<void>(`${BASE}/instances/${instanceId}/revoke`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -22,11 +22,12 @@ const key = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY;
|
||||
|
||||
export const isSaasSupabaseConfigured = Boolean(url && key);
|
||||
|
||||
/** OAuth providers the hosted SaaS login offers (mirrors the SaaS editor login). */
|
||||
export const SAAS_OAUTH_PROVIDERS = ["google", "github", "apple", "azure"];
|
||||
|
||||
/** sessionStorage marker set before an SSO redirect so the return can finish the link. */
|
||||
export const PENDING_LINK_KEY = "stirling-account-link-pending";
|
||||
/*
|
||||
* SAAS_OAUTH_PROVIDERS and PENDING_LINK_KEY are gone. They served an in-portal SSO sign-in that
|
||||
* could not work: the provider only redirects to allow-listed URLs, so a customer's origin was
|
||||
* never returned to and the admin was left on stirling.com. Provider choice now happens on our own
|
||||
* origin during the connect handshake, where the redirect can actually complete.
|
||||
*/
|
||||
|
||||
let configured = false;
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Modal } from "@app/ui";
|
||||
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
|
||||
import { withBasePath } from "@app/constants/app";
|
||||
import {
|
||||
completeConnect,
|
||||
startConnect,
|
||||
type ConnectPhase,
|
||||
} from "@portal/api/link";
|
||||
import { ensureSaasSupabase } from "@portal/auth/saasSupabase";
|
||||
import { useAccountLinkContext } from "@portal/contexts/AccountLinkContext";
|
||||
import {
|
||||
ConnectCallbackView,
|
||||
type ConnectCallbackState,
|
||||
} from "@portal/components/account-link/ConnectCallbackView";
|
||||
import "@portal/views/ConnectCallback.css";
|
||||
|
||||
/** What the callback route hands over, read from the URL fragment before stripping it. */
|
||||
export interface AccountLinkReturn {
|
||||
type: string | null;
|
||||
nonce: string | null;
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
}
|
||||
|
||||
interface LocationState {
|
||||
accountLinkReturn?: AccountLinkReturn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finishes the handshake and reports the outcome, over the portal the admin
|
||||
* started from.
|
||||
*
|
||||
* Mounted alongside the other portal-wide modal rather than being its own route:
|
||||
* the result is a step in a task, so the page behind it should still be there.
|
||||
*/
|
||||
export function ConnectCallbackHost() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { refresh } = useAccountLinkContext();
|
||||
const handover = (location.state as LocationState | null)?.accountLinkReturn;
|
||||
|
||||
const [state, setState] = useState<ConnectCallbackState | null>(null);
|
||||
const [sessionRestored, setSessionRestored] = useState(false);
|
||||
const nonceRef = useRef<string | null>(null);
|
||||
const startedRef = useRef(false);
|
||||
|
||||
const finish = useCallback(
|
||||
async (nonce: string) => {
|
||||
setState("working");
|
||||
try {
|
||||
const outcome = toViewState((await completeConnect(nonce)).phase);
|
||||
setState(outcome);
|
||||
// The portal read its status on mount, before this existed. Without this
|
||||
// the page behind the modal still says unlinked until a reload.
|
||||
if (outcome === "linked") await refresh();
|
||||
} catch {
|
||||
// Could not reach our own backend. The handshake is still open, so this
|
||||
// is worth another attempt rather than a restart.
|
||||
setState("retry");
|
||||
}
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!handover || startedRef.current) return;
|
||||
startedRef.current = true;
|
||||
|
||||
const { type, nonce, accessToken, refreshToken } = handover;
|
||||
if (type !== "link" || !nonce) {
|
||||
setState("malformed");
|
||||
return;
|
||||
}
|
||||
nonceRef.current = nonce;
|
||||
|
||||
void (async () => {
|
||||
if (accessToken && refreshToken) {
|
||||
try {
|
||||
const supabase = ensureSaasSupabase();
|
||||
// Logged, not swallowed: silently this resurfaces later as "session
|
||||
// expired" on the usage page, with nothing tying it back here.
|
||||
if (!supabase) {
|
||||
console.warn(
|
||||
"[account-link] no Supabase client: VITE_SUPABASE_URL / VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY are not set for this build",
|
||||
);
|
||||
} else {
|
||||
const { error } = await supabase.auth.setSession({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
if (error) {
|
||||
console.warn("[account-link] setSession failed:", error.message);
|
||||
} else {
|
||||
setSessionRestored(true);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[account-link] session hand-off threw:", e);
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
"[account-link] callback carried no tokens; the approval page had no session to pass",
|
||||
);
|
||||
}
|
||||
await finish(nonce);
|
||||
})();
|
||||
}, [handover, finish]);
|
||||
|
||||
/**
|
||||
* Retry means different things either side of a still-valid handshake: finish the one we have, or open a new one when it is past saving.
|
||||
*/
|
||||
const onRetry = useCallback(() => {
|
||||
if (state === "retry" && nonceRef.current) {
|
||||
void finish(nonceRef.current);
|
||||
return;
|
||||
}
|
||||
setState("working");
|
||||
// Same callback the modal sends. Without it the backend falls back to the bare
|
||||
// origin, which drops the app's base path and lands the return on nothing.
|
||||
void startConnect(
|
||||
window.location.hostname,
|
||||
new URL(
|
||||
withBasePath("/account-link/callback"),
|
||||
window.location.origin,
|
||||
).toString(),
|
||||
)
|
||||
.then((status) => {
|
||||
if (status.authorizeUrl) {
|
||||
window.location.assign(status.authorizeUrl);
|
||||
} else {
|
||||
setState("rejected");
|
||||
}
|
||||
})
|
||||
.catch(() => setState("retry"));
|
||||
}, [state, finish]);
|
||||
|
||||
// Drops the handover with it, so a back navigation does not reopen the result.
|
||||
const done = useCallback(() => {
|
||||
setState(null);
|
||||
navigate(PORTAL_BASENAME, { replace: true });
|
||||
}, [navigate]);
|
||||
|
||||
if (!state) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={done}
|
||||
width="md"
|
||||
title={t(
|
||||
"portal.accountLink.connect.callback.modalTitle",
|
||||
"Connecting this server",
|
||||
)}
|
||||
>
|
||||
<ConnectCallbackView
|
||||
state={state}
|
||||
sessionRestored={sessionRestored}
|
||||
onRetry={onRetry}
|
||||
onDone={done}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PENDING and UNAVAILABLE collapse into one "try again" state: both mean the handshake is intact but unfinished, which is the same thing to do about it.
|
||||
*/
|
||||
function toViewState(phase: ConnectPhase): ConnectCallbackState {
|
||||
switch (phase) {
|
||||
case "LINKED":
|
||||
return "linked";
|
||||
case "EXPIRED":
|
||||
return "expired";
|
||||
case "PENDING":
|
||||
case "UNAVAILABLE":
|
||||
return "retry";
|
||||
default:
|
||||
return "rejected";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, Button, Spinner } from "@app/ui";
|
||||
|
||||
/** Outcomes of returning from the approval page. */
|
||||
export type ConnectCallbackState =
|
||||
| "working"
|
||||
| "linked"
|
||||
| "retry"
|
||||
| "expired"
|
||||
| "rejected"
|
||||
| "malformed";
|
||||
|
||||
export interface ConnectCallbackViewProps {
|
||||
state: ConnectCallbackState;
|
||||
/** True once the SaaS session landed, regardless of how the link itself went. */
|
||||
sessionRestored: boolean;
|
||||
onRetry: () => void;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
/** Presentation for the account-link callback. */
|
||||
export function ConnectCallbackView({
|
||||
state,
|
||||
sessionRestored,
|
||||
onRetry,
|
||||
onDone,
|
||||
}: ConnectCallbackViewProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (state === "working") {
|
||||
return (
|
||||
<div className="portal-connect-callback">
|
||||
<Spinner size="md" />
|
||||
<p>
|
||||
{t(
|
||||
"portal.accountLink.connect.callback.working",
|
||||
"Finishing the connection.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "linked") {
|
||||
return (
|
||||
<div className="portal-connect-callback">
|
||||
<Banner
|
||||
tone="success"
|
||||
title={t(
|
||||
"portal.accountLink.connect.callback.linked.title",
|
||||
"Server connected",
|
||||
)}
|
||||
>
|
||||
{t(
|
||||
"portal.accountLink.connect.callback.linked.body",
|
||||
"This server is connected to your Stirling account.",
|
||||
)}
|
||||
</Banner>
|
||||
{/* The inverse of the failure note below: the link took but the sign-in did
|
||||
not, which otherwise only shows up later as "session expired" on a page
|
||||
that gives no hint the two are related. */}
|
||||
{sessionRestored ? null : (
|
||||
<p className="portal-connect-callback__note">
|
||||
{t(
|
||||
"portal.accountLink.connect.callback.linkedNotSignedIn",
|
||||
"You are not signed in to Stirling in this browser, so usage and billing will ask you to sign in.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<Button variant="primary" onClick={onDone}>
|
||||
{t("portal.accountLink.connect.callback.continue", "Continue")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { tone, title, body, retryable } = failure(state, t);
|
||||
return (
|
||||
<div className="portal-connect-callback">
|
||||
<Banner tone={tone} title={title}>
|
||||
{body}
|
||||
</Banner>
|
||||
{/* The SaaS sign-in and the server link are separate outcomes. Say so when
|
||||
one worked and the other did not, or the admin re-runs the whole thing
|
||||
to fix a problem that is already half solved. */}
|
||||
{sessionRestored ? (
|
||||
<p className="portal-connect-callback__note">
|
||||
{t(
|
||||
"portal.accountLink.connect.callback.signedInAnyway",
|
||||
"You are signed in to Stirling, so billing and usage will load. Only the server link is incomplete.",
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
<Button variant="primary" onClick={retryable ? onRetry : onDone}>
|
||||
{retryable
|
||||
? t("portal.accountLink.connect.callback.retry", "Try again")
|
||||
: t("portal.accountLink.connect.callback.continue", "Continue")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Translate = ReturnType<typeof useTranslation>["t"];
|
||||
|
||||
function failure(state: ConnectCallbackState, t: Translate) {
|
||||
switch (state) {
|
||||
case "expired":
|
||||
return {
|
||||
tone: "warning" as const,
|
||||
title: t(
|
||||
"portal.accountLink.connect.callback.expired.title",
|
||||
"Request expired",
|
||||
),
|
||||
body: t(
|
||||
"portal.accountLink.connect.callback.expired.body",
|
||||
"Connection requests are short lived. Start another one.",
|
||||
),
|
||||
retryable: true,
|
||||
};
|
||||
case "rejected":
|
||||
return {
|
||||
tone: "warning" as const,
|
||||
title: t(
|
||||
"portal.accountLink.connect.callback.rejected.title",
|
||||
"Connection not completed",
|
||||
),
|
||||
body: t(
|
||||
"portal.accountLink.connect.callback.rejected.body",
|
||||
"This request was declined or has already been used. Start another one if that was not intended.",
|
||||
),
|
||||
retryable: true,
|
||||
};
|
||||
case "malformed":
|
||||
return {
|
||||
tone: "danger" as const,
|
||||
title: t(
|
||||
"portal.accountLink.connect.callback.malformed.title",
|
||||
"Could not read the response",
|
||||
),
|
||||
body: t(
|
||||
"portal.accountLink.connect.callback.malformed.body",
|
||||
"This page was opened without a valid connection response. Start the connection from settings.",
|
||||
),
|
||||
retryable: false,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
tone: "warning" as const,
|
||||
// Not "retry.*": that key is the button label, and TOML cannot hold a
|
||||
// value and a table under the same name.
|
||||
title: t(
|
||||
"portal.accountLink.connect.callback.unfinished.title",
|
||||
"Not finished yet",
|
||||
),
|
||||
body: t(
|
||||
"portal.accountLink.connect.callback.unfinished.body",
|
||||
"Stirling did not confirm the connection. This is usually temporary.",
|
||||
),
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,9 @@ const base: UseAccountLink = {
|
||||
status: { linked: false, name: null },
|
||||
phase: "idle",
|
||||
error: null,
|
||||
completeLink: async () => {},
|
||||
|
||||
unlink: async () => {},
|
||||
refresh: async () => {},
|
||||
};
|
||||
|
||||
const meta: Meta<typeof LinkAccountCard> = {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/* Connect-account modal. Imported by the component rather than relying on the
|
||||
account-link view's stylesheet: this modal is mounted at the app root, so it
|
||||
renders on pages that never import that view. */
|
||||
|
||||
.portal-link__modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.portal-link__steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.portal-link__modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -8,16 +8,19 @@ const meta: Meta<typeof LinkAccountModal> = {
|
||||
args: {
|
||||
open: true,
|
||||
onClose: () => {},
|
||||
onLinked: async () => {},
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof LinkAccountModal>;
|
||||
|
||||
/** Default "link" mode — sign in to register this instance against a Stirling account. */
|
||||
/**
|
||||
* "link" mode — explains the trip to Stirling and starts the handshake. There is no
|
||||
* sign-in form: a sign-in started on a self-hosted origin cannot complete, because
|
||||
* the provider will not redirect back to a hostname it does not know.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** "reauth" mode — an already-linked instance's session expired and needs a fresh sign-in. */
|
||||
/** "reauth" mode — the server stays linked; only the browser session is renewed. */
|
||||
export const Reauth: Story = {
|
||||
args: { mode: "reauth" },
|
||||
};
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, waitFor } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
/** The modal every "link account" CTA in the portal opens. */
|
||||
const { startConnect, startReauth } = vi.hoisted(() => ({
|
||||
startConnect: vi.fn(),
|
||||
startReauth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@portal/api/link", () => ({ startConnect, startReauth }));
|
||||
vi.mock("@portal/auth/saasSupabase", () => ({
|
||||
isSaasSupabaseConfigured: true,
|
||||
}));
|
||||
|
||||
import { LinkAccountModal } from "@portal/components/account-link/LinkAccountModal";
|
||||
|
||||
const AUTHORIZE = "http://localhost:5174/link?request=req-1";
|
||||
|
||||
function renderModal(mode?: "link" | "reauth") {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<LinkAccountModal open onClose={() => {}} mode={mode} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Clicks the primary action (the secondary one is Cancel). */
|
||||
function clickContinue(getAllByRole: (role: string) => HTMLElement[]) {
|
||||
const buttons = getAllByRole("button");
|
||||
act(() => buttons[buttons.length - 1].click());
|
||||
}
|
||||
|
||||
describe("LinkAccountModal", () => {
|
||||
let assign: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
startConnect.mockResolvedValue({
|
||||
phase: "PENDING",
|
||||
authorizeUrl: AUTHORIZE,
|
||||
secondsRemaining: 900,
|
||||
teamId: null,
|
||||
});
|
||||
startReauth.mockResolvedValue({
|
||||
phase: "PENDING",
|
||||
authorizeUrl: AUTHORIZE,
|
||||
secondsRemaining: 900,
|
||||
teamId: null,
|
||||
});
|
||||
assign = vi.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: {
|
||||
origin: "http://localhost:5173",
|
||||
hostname: "localhost",
|
||||
assign,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("offers no sign-in form, because a sign-in started here cannot complete", () => {
|
||||
const { container } = renderModal();
|
||||
|
||||
// The provider buttons this modal used to carry sent the admin to Stirling and
|
||||
// abandoned them there. Nothing should collect credentials on this origin.
|
||||
expect(container.querySelector("input[type=password]")).toBeNull();
|
||||
expect(container.querySelector("input[type=email]")).toBeNull();
|
||||
});
|
||||
|
||||
it("starts a link handshake and hands the browser to Stirling", async () => {
|
||||
const { getAllByRole } = renderModal();
|
||||
|
||||
clickContinue(getAllByRole);
|
||||
|
||||
await waitFor(() => expect(startConnect).toHaveBeenCalled());
|
||||
// Callback built from this page's own origin, which the backend then checks
|
||||
// against the request's Origin header.
|
||||
expect(startConnect).toHaveBeenCalledWith(
|
||||
"localhost",
|
||||
"http://localhost:5173/account-link/callback",
|
||||
);
|
||||
await waitFor(() => expect(assign).toHaveBeenCalledWith(AUTHORIZE));
|
||||
expect(startReauth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the reauth endpoint when only the session needs renewing", async () => {
|
||||
const { getAllByRole } = renderModal("reauth");
|
||||
|
||||
clickContinue(getAllByRole);
|
||||
|
||||
// A different endpoint on purpose: reauth presents the device credential so
|
||||
// Stirling pins the handshake to the team that already owns this server.
|
||||
await waitFor(() =>
|
||||
expect(startReauth).toHaveBeenCalledWith(
|
||||
"http://localhost:5173/account-link/callback",
|
||||
),
|
||||
);
|
||||
expect(startConnect).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(assign).toHaveBeenCalledWith(AUTHORIZE));
|
||||
});
|
||||
|
||||
it("stays put and explains itself when the handshake cannot start", async () => {
|
||||
startConnect.mockRejectedValue(new Error("offline"));
|
||||
|
||||
const { getAllByRole } = renderModal();
|
||||
clickContinue(getAllByRole);
|
||||
|
||||
await waitFor(() => expect(startConnect).toHaveBeenCalled());
|
||||
expect(assign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not navigate when there is nothing to navigate to", async () => {
|
||||
// Already linked: the backend reports status without an authorize URL.
|
||||
startConnect.mockResolvedValue({
|
||||
phase: "LINKED",
|
||||
authorizeUrl: null,
|
||||
secondsRemaining: null,
|
||||
teamId: 7,
|
||||
});
|
||||
|
||||
const { getAllByRole } = renderModal();
|
||||
clickContinue(getAllByRole);
|
||||
|
||||
await waitFor(() => expect(startConnect).toHaveBeenCalled());
|
||||
expect(assign).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,63 +1,60 @@
|
||||
import { useEffect } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, Button, Modal } from "@app/ui";
|
||||
import SupabaseLoginForm from "@app/auth/ui/SupabaseLoginForm";
|
||||
import {
|
||||
useSupabaseLogin,
|
||||
type SupabaseLoginSession,
|
||||
} from "@app/auth/ui/useSupabaseLogin";
|
||||
import "@app/auth/ui/auth-theme.css";
|
||||
import {
|
||||
ensureSaasSupabase,
|
||||
isSaasSupabaseConfigured,
|
||||
PENDING_LINK_KEY,
|
||||
SAAS_OAUTH_PROVIDERS,
|
||||
} from "@portal/auth/saasSupabase";
|
||||
import { withBasePath } from "@app/constants/app";
|
||||
import { startConnect, startReauth } from "@portal/api/link";
|
||||
import { isSaasSupabaseConfigured } from "@portal/auth/saasSupabase";
|
||||
import "@portal/components/account-link/LinkAccountModal.css";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* "link" registers this instance against the signed-in account; "reauth" only
|
||||
* refreshes an expired SaaS session (the instance is already linked). The mode
|
||||
* is persisted across the OAuth redirect so the SSO-return handler doesn't
|
||||
* re-register on a reauth.
|
||||
* "link" connects this server to a team for the first time; "reauth" only re-establishes the browser's Stirling session for a server that is already linked.
|
||||
*/
|
||||
mode?: "link" | "reauth";
|
||||
/** Called with the SaaS session after a successful sign-in. */
|
||||
onLinked: (session: SupabaseLoginSession) => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-app account-link login. Signs the admin in to their Stirling (SaaS) account
|
||||
* via the shared Supabase login (SSO + email/password), then hands the resulting
|
||||
* session to the caller to register this instance. No popup; the device secret
|
||||
* never reaches the browser. SSO redirects away and is finished by useAccountLink
|
||||
* on return.
|
||||
*/
|
||||
export function LinkAccountModal({
|
||||
open,
|
||||
onClose,
|
||||
mode = "link",
|
||||
onLinked,
|
||||
}: Props) {
|
||||
/** Sends the admin off to Stirling to connect this server. */
|
||||
export function LinkAccountModal({ open, onClose, mode = "link" }: Props) {
|
||||
const { t } = useTranslation();
|
||||
useEffect(() => {
|
||||
if (open) ensureSaasSupabase();
|
||||
}, [open]);
|
||||
|
||||
const reauth = mode === "reauth";
|
||||
const login = useSupabaseLogin({
|
||||
providers: SAAS_OAUTH_PROVIDERS,
|
||||
// Return to the current page after SSO; the SSO-return handler in
|
||||
// useAccountLink reads the persisted mode so it links vs. only refreshes.
|
||||
redirectTo: window.location.href,
|
||||
onBeforeOAuth: () => sessionStorage.setItem(PENDING_LINK_KEY, mode),
|
||||
onSuccess: async (session) => {
|
||||
await onLinked(session);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const begin = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const callbackUrl = new URL(
|
||||
withBasePath("/account-link/callback"),
|
||||
window.location.origin,
|
||||
).toString();
|
||||
const status = reauth
|
||||
? await startReauth(callbackUrl)
|
||||
: await startConnect(window.location.hostname, callbackUrl);
|
||||
if (status.authorizeUrl) {
|
||||
window.location.assign(status.authorizeUrl);
|
||||
return;
|
||||
}
|
||||
// Already linked, or a handshake we cannot act on. Nothing to navigate to.
|
||||
setError(
|
||||
t(
|
||||
"portal.accountLink.modal.noAuthorizeUrl",
|
||||
"Stirling did not return somewhere to continue. Try again in a moment.",
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
setError(
|
||||
t(
|
||||
"portal.accountLink.modal.startFailed",
|
||||
"Could not reach Stirling to start the connection. Check this server's outbound network access, then try again.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [reauth, t]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -69,30 +66,49 @@ export function LinkAccountModal({
|
||||
? t("portal.accountLink.modal.reauthTitle", "Sign in again")
|
||||
: t(
|
||||
"portal.accountLink.modal.linkTitle",
|
||||
"Link your Stirling account",
|
||||
"Connect your Stirling account",
|
||||
)
|
||||
}
|
||||
subtitle={
|
||||
reauth
|
||||
? t(
|
||||
"portal.accountLink.modal.reauthSubtitle",
|
||||
"Your session expired — sign back in to your Stirling account. Your instance stays linked.",
|
||||
"Your Stirling session expired. Sign in again to keep seeing usage and billing. This server stays connected either way.",
|
||||
)
|
||||
: t(
|
||||
"portal.accountLink.modal.linkSubtitle",
|
||||
"Sign in to the account this server should bill against.",
|
||||
"Connect this server to the Stirling account it should bill against.",
|
||||
)
|
||||
}
|
||||
>
|
||||
{isSaasSupabaseConfigured ? (
|
||||
<SupabaseLoginForm state={login} />
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<div className="portal-link__modal-body">
|
||||
<ol className="portal-link__steps">
|
||||
<li>
|
||||
{t(
|
||||
"portal.accountLink.modal.step1",
|
||||
"We send you to stirling.com to sign in. Any sign-in method works there, including Google and single sign-on.",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
"portal.accountLink.modal.step2",
|
||||
"You check this server's address and approve it. A team owner has to do this the first time.",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
"portal.accountLink.modal.step3",
|
||||
"Stirling brings you straight back here and finishes up.",
|
||||
)}
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
{!isSaasSupabaseConfigured && (
|
||||
<Banner
|
||||
tone="neutral"
|
||||
tone="warning"
|
||||
title={t(
|
||||
"portal.accountLink.modal.loginNotConfigured.title",
|
||||
"SaaS login not configured",
|
||||
"Stirling connection not configured",
|
||||
)}
|
||||
>
|
||||
{t("portal.accountLink.modal.loginNotConfigured.before", "Set")}{" "}
|
||||
@@ -101,25 +117,27 @@ export function LinkAccountModal({
|
||||
<code>VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY</code>{" "}
|
||||
{t(
|
||||
"portal.accountLink.modal.loginNotConfigured.after",
|
||||
"to enable in-app linking against the hosted Stirling account.",
|
||||
"so this server can finish the connection when you come back.",
|
||||
)}
|
||||
</Banner>
|
||||
{import.meta.env.DEV && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={async () => {
|
||||
await onLinked({ access_token: "dev-stub-jwt" });
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
"portal.accountLink.modal.simulateSignIn",
|
||||
"Simulate sign-in (dev)",
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
)}
|
||||
|
||||
{error && <Banner tone="danger">{error}</Banner>}
|
||||
|
||||
<div className="portal-link__modal-actions">
|
||||
<Button variant="secondary" disabled={busy} onClick={onClose}>
|
||||
{t("portal.accountLink.modal.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" loading={busy} onClick={() => void begin()}>
|
||||
{reauth
|
||||
? t("portal.accountLink.modal.continueReauth", "Sign in again")
|
||||
: t(
|
||||
"portal.accountLink.modal.continueLink",
|
||||
"Continue to Stirling",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,15 +5,7 @@ import {
|
||||
} from "@portal/hooks/useAccountLink";
|
||||
|
||||
/**
|
||||
* Single app-wide {@link useAccountLink} instance. The link flow is orchestrated
|
||||
* in exactly one place so that:
|
||||
* - status is fetched once on mount (not per consumer), and
|
||||
* - the SSO-return effect fires once — two instances would both call
|
||||
* {@link UseAccountLink.completeLink} on return and re-register the device
|
||||
* credential, leaving a duplicate linked_instance row.
|
||||
*
|
||||
* Consumers (the top-level link modal host, the Settings account-link panel,
|
||||
* the link card) read this shared instance instead of calling the hook again.
|
||||
* Single app-wide {@link useAccountLink} instance, so status is fetched once on mount rather than per consumer.
|
||||
*/
|
||||
const AccountLinkContext = createContext<UseAccountLink | null>(null);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "react";
|
||||
|
||||
/**
|
||||
* The "linked" dimension of the account-link surface (combined-billing "Mode A"),
|
||||
* The "linked" dimension of the account-link surface (combined billing),
|
||||
* a sibling to TierContext. It answers one question the rest of the portal asks:
|
||||
* has this self-hosted org linked its SaaS account, and if so, is it on the free
|
||||
* grant or actively subscribed?
|
||||
@@ -59,13 +59,6 @@ interface LinkContextValue {
|
||||
isLinked: boolean;
|
||||
/** Convenience for `LINK_INFO[linkState].unlocked` — billable features usable. */
|
||||
featuresUnlocked: boolean;
|
||||
/**
|
||||
* Bumps whenever the browser's SaaS session changes (e.g. a re-sign-in after
|
||||
* expiry). Attended SaaS reads (the wallet) key off this to refetch with the
|
||||
* fresh token without re-establishing the instance link.
|
||||
*/
|
||||
saasSessionNonce: number;
|
||||
markSaasSessionChanged: () => void;
|
||||
}
|
||||
|
||||
const LinkContext = createContext<LinkContextValue | null>(null);
|
||||
@@ -78,11 +71,6 @@ export function LinkProvider({
|
||||
initialState?: LinkState;
|
||||
}) {
|
||||
const [linkState, setLinkState] = useState<LinkState>(initialState);
|
||||
const [saasSessionNonce, setSaasSessionNonce] = useState(0);
|
||||
const markSaasSessionChanged = useCallback(
|
||||
() => setSaasSessionNonce((n) => n + 1),
|
||||
[],
|
||||
);
|
||||
const value = useMemo<LinkContextValue>(() => {
|
||||
const unlocked = LINK_INFO[linkState].unlocked;
|
||||
return {
|
||||
@@ -90,10 +78,8 @@ export function LinkProvider({
|
||||
setLinkState,
|
||||
isLinked: linkState !== "unlinked",
|
||||
featuresUnlocked: unlocked,
|
||||
saasSessionNonce,
|
||||
markSaasSessionChanged,
|
||||
};
|
||||
}, [linkState, saasSessionNonce, markSaasSessionChanged]);
|
||||
}, [linkState]);
|
||||
return <LinkContext.Provider value={value}>{children}</LinkContext.Provider>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, waitFor } from "@testing-library/react";
|
||||
import { LinkProvider } from "@portal/contexts/LinkContext";
|
||||
|
||||
/**
|
||||
* The SSO-return path is mode-aware: a "reauth" return must only refresh the
|
||||
* session, NOT re-register the instance (re-registering mints a duplicate device
|
||||
* credential). This is the exact regression that slipped through once, so it gets
|
||||
* a dedicated guard.
|
||||
*/
|
||||
const { linkInstance, fetchStatus, unlinkInstance, getSession } = vi.hoisted(
|
||||
() => ({
|
||||
linkInstance: vi.fn(),
|
||||
fetchStatus: vi.fn(),
|
||||
unlinkInstance: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@portal/api/link", () => ({
|
||||
linkInstance,
|
||||
fetchStatus,
|
||||
unlinkInstance,
|
||||
}));
|
||||
vi.mock("@portal/auth/saasSupabase", () => ({
|
||||
PENDING_LINK_KEY: "stirling_pending_link",
|
||||
isSaasSupabaseConfigured: true,
|
||||
SAAS_OAUTH_PROVIDERS: [],
|
||||
ensureSaasSupabase: () => ({ auth: { getSession } }),
|
||||
}));
|
||||
|
||||
import { useAccountLink } from "@portal/hooks/useAccountLink";
|
||||
import { PENDING_LINK_KEY } from "@portal/auth/saasSupabase";
|
||||
|
||||
function Probe() {
|
||||
useAccountLink();
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderHook = () =>
|
||||
render(
|
||||
<LinkProvider initialState="linked-free">
|
||||
<Probe />
|
||||
</LinkProvider>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
linkInstance.mockReset().mockResolvedValue({ linked: true, name: null });
|
||||
fetchStatus.mockReset().mockResolvedValue({ linked: true, name: null });
|
||||
unlinkInstance.mockReset();
|
||||
getSession.mockReset().mockResolvedValue({
|
||||
data: { session: { access_token: "tok" } },
|
||||
});
|
||||
sessionStorage.clear();
|
||||
});
|
||||
afterEach(() => sessionStorage.clear());
|
||||
|
||||
describe("useAccountLink — SSO return", () => {
|
||||
it("reauth mode refreshes the session WITHOUT re-registering", async () => {
|
||||
sessionStorage.setItem(PENDING_LINK_KEY, "reauth");
|
||||
renderHook();
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(linkInstance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("link mode registers the instance with the returned token", async () => {
|
||||
sessionStorage.setItem(PENDING_LINK_KEY, "link");
|
||||
renderHook();
|
||||
await waitFor(() => expect(linkInstance).toHaveBeenCalledTimes(1));
|
||||
expect(linkInstance.mock.calls[0][0].supabaseJwt).toBe("tok");
|
||||
});
|
||||
});
|
||||
@@ -1,33 +1,9 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { SupabaseLoginSession } from "@app/auth/ui/useSupabaseLogin";
|
||||
import {
|
||||
ensureSaasSupabase,
|
||||
isSaasSupabaseConfigured,
|
||||
PENDING_LINK_KEY,
|
||||
} from "@portal/auth/saasSupabase";
|
||||
import {
|
||||
fetchStatus,
|
||||
linkInstance,
|
||||
unlinkInstance,
|
||||
type LinkStatus,
|
||||
} from "@portal/api/link";
|
||||
import { useApplyLinkFacts, useLink } from "@portal/contexts/LinkContext";
|
||||
import { isSaasSupabaseConfigured } from "@portal/auth/saasSupabase";
|
||||
import { fetchStatus, unlinkInstance, type LinkStatus } from "@portal/api/link";
|
||||
import { useApplyLinkFacts } from "@portal/contexts/LinkContext";
|
||||
|
||||
/**
|
||||
* Orchestrates the account-link flow for THIS instance:
|
||||
*
|
||||
* 1. The admin signs in to their Stirling account IN-APP (LinkAccountModal →
|
||||
* shared Supabase login), minting a short-term SaaS JWT.
|
||||
* 2. {@link completeLink} POSTs that JWT to the LOCAL backend (api/link.ts),
|
||||
* which registers with SaaS and stores the device secret server-side.
|
||||
* 3. The resulting Linked / Not-linked status is read back.
|
||||
*
|
||||
* Email/password resolves inline (the modal calls completeLink). SSO redirects
|
||||
* the browser to the provider and back; the returned session is finished here on
|
||||
* mount (see the pending-link effect). The device secret is never received or
|
||||
* rendered. Subscription state is resolved separately from the wallet, so a fresh
|
||||
* link marks the org linked-free.
|
||||
*/
|
||||
/** Reads and clears THIS instance's link status. */
|
||||
|
||||
export type LinkPhase = "idle" | "linking" | "error";
|
||||
|
||||
@@ -38,84 +14,32 @@ export interface UseAccountLink {
|
||||
status: LinkStatus | null;
|
||||
phase: LinkPhase;
|
||||
error: string | null;
|
||||
/** Finish linking THIS instance with a SaaS session minted by the login modal. */
|
||||
completeLink: (session: SupabaseLoginSession, name?: string) => Promise<void>;
|
||||
/** Unlink this instance. */
|
||||
unlink: () => Promise<void>;
|
||||
/** Re-read the status, for when something outside this hook changed it. */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useAccountLink(): UseAccountLink {
|
||||
const applyLinkFacts = useApplyLinkFacts();
|
||||
const { markSaasSessionChanged } = useLink();
|
||||
const [status, setStatus] = useState<LinkStatus | null>(null);
|
||||
const [phase, setPhase] = useState<LinkPhase>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const completeLink = useCallback(
|
||||
async (session: SupabaseLoginSession, name?: string) => {
|
||||
setPhase("linking");
|
||||
setError(null);
|
||||
try {
|
||||
const next = await linkInstance({
|
||||
supabaseJwt: session.access_token,
|
||||
name,
|
||||
});
|
||||
setStatus(next);
|
||||
setPhase("idle");
|
||||
if (next.linked) applyLinkFacts(true, false);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setPhase("error");
|
||||
}
|
||||
},
|
||||
[applyLinkFacts],
|
||||
);
|
||||
|
||||
// Read the current link status on mount.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void fetchStatus()
|
||||
.then((s) => {
|
||||
if (!cancelled) {
|
||||
setStatus(s);
|
||||
// A linked instance is at least linked-free; subscription comes from the wallet.
|
||||
if (s.linked) applyLinkFacts(true, false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Status endpoint absent (flag off) / unreachable → leave status null,
|
||||
// which renders as "Not linked". Don't surface an error or leak an
|
||||
// unhandled rejection for the expected flag-off case.
|
||||
if (!cancelled) setStatus({ linked: false, name: null });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const s = await fetchStatus();
|
||||
setStatus(s);
|
||||
// A linked instance is at least linked-free; subscription comes from the wallet.
|
||||
if (s.linked) applyLinkFacts(true, false);
|
||||
} catch {
|
||||
setStatus({ linked: false, name: null });
|
||||
}
|
||||
}, [applyLinkFacts]);
|
||||
|
||||
// SSO return: an SSO sign-in we kicked off has redirected back and the SaaS
|
||||
// session is now in the shared Supabase client. The pending marker carries the
|
||||
// mode: "reauth" only refreshes attended reads (the instance is already linked
|
||||
// — re-registering would mint a duplicate credential); anything else links.
|
||||
useEffect(() => {
|
||||
const supabase = ensureSaasSupabase();
|
||||
const pending = sessionStorage.getItem(PENDING_LINK_KEY);
|
||||
if (!supabase || pending === null) return;
|
||||
let cancelled = false;
|
||||
void supabase.auth.getSession().then(({ data }) => {
|
||||
sessionStorage.removeItem(PENDING_LINK_KEY);
|
||||
const token = data.session?.access_token;
|
||||
if (!token || cancelled) return;
|
||||
if (pending === "reauth") {
|
||||
markSaasSessionChanged();
|
||||
} else {
|
||||
void completeLink({ access_token: token });
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [completeLink, markSaasSessionChanged]);
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const unlink = useCallback(async () => {
|
||||
setPhase("linking");
|
||||
@@ -136,7 +60,7 @@ export function useAccountLink(): UseAccountLink {
|
||||
status,
|
||||
phase,
|
||||
error,
|
||||
completeLink,
|
||||
unlink,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import type { LinkInstanceRequest } from "@portal/api/link";
|
||||
import {
|
||||
getLocalStatus,
|
||||
getLocalUsage,
|
||||
@@ -12,14 +11,13 @@ import {
|
||||
/**
|
||||
* Account-link MSW handlers. Two surfaces:
|
||||
*
|
||||
* - LOCAL backend (this instance): link / status / unlink. `link` mutates the
|
||||
* in-memory store and flips local status so the surface behaves like a real
|
||||
* backend within a session. The device secret stays server-side — never
|
||||
* returned over the wire, matching the real contract.
|
||||
* - LOCAL backend (this instance): the connect handshake, status and unlink.
|
||||
* `connect/complete` mutates the in-memory store and flips local status so the
|
||||
* surface behaves like a real backend within a session. The device secret stays
|
||||
* server-side — never returned over the wire, matching the real contract.
|
||||
* - SaaS backend (team-wide): instances / revoke.
|
||||
*
|
||||
* Mirrors the real AccountLinkController paths so MSW can be dropped with no code
|
||||
* change.
|
||||
* Mirrors the real controller paths so MSW can be dropped with no code change.
|
||||
*/
|
||||
export const linkHandlers = [
|
||||
http.get("/api/v1/account-link/status", async () => {
|
||||
@@ -27,15 +25,38 @@ export const linkHandlers = [
|
||||
return HttpResponse.json(getLocalStatus());
|
||||
}),
|
||||
|
||||
http.post("/api/v1/account-link/link", async ({ request }) => {
|
||||
// Opening a handshake hands back where to send the admin. The real backend gets
|
||||
// that URL from SaaS rather than composing it, so the mock returns one too.
|
||||
http.post("*/api/v1/account-link/connect/start", async () => {
|
||||
await delay(120);
|
||||
let name: string | undefined;
|
||||
try {
|
||||
name = ((await request.json()) as LinkInstanceRequest)?.name;
|
||||
} catch {
|
||||
// empty body — name stays undefined
|
||||
}
|
||||
return HttpResponse.json(linkLocal(name), { status: 201 });
|
||||
return HttpResponse.json({
|
||||
phase: "PENDING",
|
||||
authorizeUrl: "https://app.stirling.test/link?request=mock-request",
|
||||
secondsRemaining: 900,
|
||||
teamId: null,
|
||||
});
|
||||
}),
|
||||
|
||||
http.post("*/api/v1/account-link/connect/reauth", async () => {
|
||||
await delay(120);
|
||||
return HttpResponse.json({
|
||||
phase: "PENDING",
|
||||
authorizeUrl: "https://app.stirling.test/link?request=mock-reauth",
|
||||
secondsRemaining: 900,
|
||||
teamId: null,
|
||||
});
|
||||
}),
|
||||
|
||||
// The callback's completion step. Flips the store to linked, as a real claim would.
|
||||
http.post("*/api/v1/account-link/connect/complete", async () => {
|
||||
await delay(120);
|
||||
linkLocal("mock-server");
|
||||
return HttpResponse.json({
|
||||
phase: "LINKED",
|
||||
authorizeUrl: null,
|
||||
secondsRemaining: null,
|
||||
teamId: 7,
|
||||
});
|
||||
}),
|
||||
|
||||
http.get("/api/v1/account-link/usage", async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Account-link fixtures. Types live in api/link.ts (the backend contract);
|
||||
* this module only builds fake data for Storybook and tests.
|
||||
*
|
||||
* "Mode A" combined billing: a self-hosted instance links the org's SaaS account
|
||||
* Combined billing: a self-hosted instance links the org's SaaS account
|
||||
* so its unattended calls bill against the org wallet. Two surfaces:
|
||||
*
|
||||
* - THIS instance: the local backend (`POST /api/v1/account-link/link`,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/* Account-link callback. A transient page the admin passes through, so it is
|
||||
centred and says one thing rather than trying to be a settings screen. */
|
||||
|
||||
.portal-connect-callback {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
max-width: 30rem;
|
||||
margin: 4rem auto;
|
||||
padding: 0 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.portal-connect-callback > * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* The button is the one thing that should not stretch to the banner's width. */
|
||||
.portal-connect-callback button {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.portal-connect-callback p {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.portal-connect-callback__note {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
/**
|
||||
* The callback handles a live session token in a URL fragment, so the behaviour worth pinning is what it does with it: strip it immediately, refuse anything it cannot verify, and keep the two outcomes (SaaS sign-in, server link) independent of each other.
|
||||
*/
|
||||
const { completeConnect, startConnect, setSession, refresh } = vi.hoisted(
|
||||
() => ({
|
||||
completeConnect: vi.fn(),
|
||||
startConnect: vi.fn(),
|
||||
setSession: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@portal/api/link", () => ({ completeConnect, startConnect }));
|
||||
vi.mock("@portal/auth/saasSupabase", () => ({
|
||||
ensureSaasSupabase: () => ({ auth: { setSession } }),
|
||||
}));
|
||||
vi.mock("@portal/contexts/AccountLinkContext", () => ({
|
||||
useAccountLinkContext: () => ({ refresh }),
|
||||
}));
|
||||
|
||||
import ConnectCallback from "@portal/views/ConnectCallback";
|
||||
import { ConnectCallbackHost } from "@portal/components/account-link/ConnectCallbackHost";
|
||||
|
||||
const NONCE = "the-nonce";
|
||||
|
||||
function landOn(fragment: string) {
|
||||
window.history.replaceState(null, "", `/account-link/callback${fragment}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Route and host together: the route reads the fragment, the portal renders the
|
||||
* outcome. Exercising them apart would test the hand-off rather than the flow.
|
||||
*/
|
||||
function renderFlow() {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<MemoryRouter initialEntries={["/account-link/callback"]}>
|
||||
<ConnectCallbackHost />
|
||||
<Routes>
|
||||
<Route path="/account-link/callback" element={<ConnectCallback />} />
|
||||
<Route path="/processor" element={<div data-testid="portal" />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("account-link callback", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
completeConnect.mockResolvedValue({
|
||||
phase: "LINKED",
|
||||
authorizeUrl: null,
|
||||
secondsRemaining: null,
|
||||
teamId: 7,
|
||||
});
|
||||
setSession.mockResolvedValue({ error: null });
|
||||
});
|
||||
|
||||
it("removes the token-bearing fragment from the URL", async () => {
|
||||
landOn(`#type=link&nonce=${NONCE}&access_token=at&refresh_token=rt`);
|
||||
|
||||
renderFlow();
|
||||
|
||||
// Synchronous, before any await: the fragment must not survive long enough
|
||||
// to be read from the address bar or land in a history entry.
|
||||
expect(window.location.hash).toBe("");
|
||||
await waitFor(() => expect(completeConnect).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("lands on the portal rather than leaving the result on a bare page", async () => {
|
||||
landOn(`#type=link&nonce=${NONCE}&access_token=at&refresh_token=rt`);
|
||||
|
||||
const { getByTestId } = renderFlow();
|
||||
|
||||
await waitFor(() => expect(getByTestId("portal")).toBeTruthy());
|
||||
});
|
||||
|
||||
it("re-reads the link status, so the page behind agrees with the modal", async () => {
|
||||
landOn(`#type=link&nonce=${NONCE}`);
|
||||
|
||||
renderFlow();
|
||||
|
||||
await waitFor(() => expect(refresh).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("deposits the session and then finishes the link with the nonce", async () => {
|
||||
landOn(`#type=link&nonce=${NONCE}&access_token=at&refresh_token=rt`);
|
||||
|
||||
renderFlow();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(setSession).toHaveBeenCalledWith({
|
||||
access_token: "at",
|
||||
refresh_token: "rt",
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(completeConnect).toHaveBeenCalledWith(NONCE));
|
||||
});
|
||||
|
||||
it("finishes the link even when the session hand-off fails", async () => {
|
||||
landOn(`#type=link&nonce=${NONCE}&access_token=at&refresh_token=rt`);
|
||||
setSession.mockRejectedValue(new Error("nope"));
|
||||
|
||||
renderFlow();
|
||||
|
||||
// The two outcomes are independent: a failed sign-in must not strand the
|
||||
// server unlinked.
|
||||
await waitFor(() => expect(completeConnect).toHaveBeenCalledWith(NONCE));
|
||||
});
|
||||
|
||||
it("links without a session when the fragment carries no tokens", async () => {
|
||||
landOn(`#type=link&nonce=${NONCE}`);
|
||||
|
||||
renderFlow();
|
||||
|
||||
await waitFor(() => expect(completeConnect).toHaveBeenCalledWith(NONCE));
|
||||
expect(setSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a fragment with no nonce", async () => {
|
||||
landOn("#type=link&access_token=at&refresh_token=rt");
|
||||
|
||||
renderFlow();
|
||||
|
||||
await waitFor(() => expect(window.location.hash).toBe(""));
|
||||
expect(completeConnect).not.toHaveBeenCalled();
|
||||
expect(setSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a fragment that is not a link response", async () => {
|
||||
landOn(`#type=something-else&nonce=${NONCE}&access_token=at`);
|
||||
|
||||
renderFlow();
|
||||
|
||||
await waitFor(() => expect(window.location.hash).toBe(""));
|
||||
expect(completeConnect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a bare page load", async () => {
|
||||
landOn("");
|
||||
|
||||
renderFlow();
|
||||
|
||||
expect(completeConnect).not.toHaveBeenCalled();
|
||||
expect(setSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers a retry rather than a restart while the handshake is still open", async () => {
|
||||
landOn(`#type=link&nonce=${NONCE}`);
|
||||
completeConnect.mockResolvedValue({
|
||||
phase: "UNAVAILABLE",
|
||||
authorizeUrl: null,
|
||||
secondsRemaining: null,
|
||||
teamId: null,
|
||||
});
|
||||
|
||||
const { getAllByRole } = renderFlow();
|
||||
|
||||
await waitFor(() => expect(completeConnect).toHaveBeenCalledTimes(1));
|
||||
// Last button, not the only one: the modal shell contributes a close button.
|
||||
const buttons = getAllByRole("button");
|
||||
act(() => buttons[buttons.length - 1].click());
|
||||
|
||||
// Retries the existing handshake; starting a new one would waste the
|
||||
// approval a human just gave.
|
||||
await waitFor(() => expect(completeConnect).toHaveBeenCalledTimes(2));
|
||||
expect(startConnect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
|
||||
import type { AccountLinkReturn } from "@portal/components/account-link/ConnectCallbackHost";
|
||||
|
||||
/**
|
||||
* Return leg of the account-link handshake. Stirling redirects here with the
|
||||
* admin's session in the URL fragment.
|
||||
*
|
||||
* This route only reads the fragment and hands it to the portal, which owns the
|
||||
* rest. Rendering the outcome here would put it on an empty page; the portal is
|
||||
* where the admin started, so that is where the result belongs.
|
||||
*/
|
||||
export default function ConnectCallback() {
|
||||
const navigate = useNavigate();
|
||||
const startedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (startedRef.current) return;
|
||||
startedRef.current = true;
|
||||
|
||||
const params = new URLSearchParams(window.location.hash.replace(/^#/, ""));
|
||||
// Before anything else: the fragment carries a live session token.
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${window.location.search}`,
|
||||
);
|
||||
|
||||
const accountLinkReturn: AccountLinkReturn = {
|
||||
type: params.get("type"),
|
||||
nonce: params.get("nonce"),
|
||||
accessToken: params.get("access_token"),
|
||||
refreshToken: params.get("refresh_token"),
|
||||
};
|
||||
// Router state, not the URL: the tokens are live and must not be re-shareable.
|
||||
navigate(PORTAL_BASENAME, { replace: true, state: { accountLinkReturn } });
|
||||
}, [navigate]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -3,13 +3,6 @@ import type { ReactElement } from "react";
|
||||
import { Route } from "react-router-dom";
|
||||
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
|
||||
|
||||
// The portal ships as a lazy chunk of the editor. It's included in dev (so it's
|
||||
// always available to work on) and in production builds made with
|
||||
// VITE_INCLUDE_PORTAL=true (set by -PbuildWithPortal in the JAR, and by the deploy
|
||||
// GHA when the portal or AI layers change). Vite replaces the env with a literal at
|
||||
// build time, so when it's off the dynamic import below is tree-shaken out and the
|
||||
// portal chunk isn't emitted. PortalApp stays module-level so it isn't recreated on
|
||||
// each render.
|
||||
const includePortal =
|
||||
import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV;
|
||||
|
||||
@@ -21,18 +14,28 @@ const PortalApp = includePortal
|
||||
: null;
|
||||
|
||||
/**
|
||||
* The portal mounts as an admin-only route-set at PORTAL_BASENAME (/processor/*).
|
||||
* Access is gated inside PortalApp (its own AuthProvider + AuthGate, plus server
|
||||
* enforcement), so this just wires the lazy route into the editor's router when
|
||||
* the portal is included in this build.
|
||||
* Return leg of the account-link handshake, which Stirling redirects to with the admin's session in the URL fragment.
|
||||
*/
|
||||
const ConnectCallback = includePortal
|
||||
? lazy(async () => {
|
||||
const m = await import("@portal/views/ConnectCallback");
|
||||
return { default: m.default };
|
||||
})
|
||||
: null;
|
||||
|
||||
/** The portal mounts as an admin-only route-set at PORTAL_BASENAME (/processor/*). */
|
||||
export function getAdminRouteExtensions(): ReactElement[] {
|
||||
if (!PortalApp) return [];
|
||||
if (!PortalApp || !ConnectCallback) return [];
|
||||
return [
|
||||
<Route
|
||||
key="portal"
|
||||
path={`${PORTAL_BASENAME}/*`}
|
||||
element={<PortalApp />}
|
||||
/>,
|
||||
<Route
|
||||
key="account-link-callback"
|
||||
path="/account-link/callback"
|
||||
element={<ConnectCallback />}
|
||||
/>,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -11,10 +11,12 @@ import { LoadingFallback } from "@app/components/shared/LoadingFallback";
|
||||
import OnboardingTour from "@app/components/onboarding/OnboardingTour";
|
||||
import Landing from "@app/routes/Landing";
|
||||
import Login from "@app/routes/Login";
|
||||
import { ResumePendingConnect } from "@app/routes/ResumePendingConnect";
|
||||
import Signup from "@app/routes/Signup";
|
||||
import AuthCallback from "@app/routes/AuthCallback";
|
||||
import ResetPassword from "@app/routes/ResetPassword";
|
||||
import OAuthConsent from "@app/routes/OAuthConsent";
|
||||
import ConnectApprove from "@app/routes/ConnectApprove";
|
||||
import ShareLinkPage from "@app/routes/ShareLinkPage";
|
||||
import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions";
|
||||
import OnboardingBootstrap from "@app/components/OnboardingBootstrap";
|
||||
@@ -110,12 +112,17 @@ export default function App() {
|
||||
>
|
||||
<AppLayout>
|
||||
<NonAuthBootstraps />
|
||||
<ResumePendingConnect />
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
<Route path="/auth/reset" element={<ResetPassword />} />
|
||||
<Route path="/oauth/consent" element={<OAuthConsent />} />
|
||||
{/* Human half of the self-hosted account-link handshake. It
|
||||
lives on this origin because a customer hostname can
|
||||
never be in the provider's redirect allow-list. */}
|
||||
<Route path="/link" element={<ConnectApprove />} />
|
||||
{/* Shared-file links. Team invites are NOT routed here: on
|
||||
SaaS they are accepted in-app via the Supabase team
|
||||
invitation banner, not the Spring password-based
|
||||
|
||||
@@ -4,6 +4,7 @@ import { resolveLandingPath } from "@app/utils/loginLanding";
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { withBasePath } from "@app/constants/app";
|
||||
import { readPendingConnect } from "@app/routes/pendingConnect";
|
||||
import { AuthShell } from "@app/auth/ui/AuthShell";
|
||||
import ErrorMessage from "@app/auth/ui/ErrorMessage";
|
||||
import { Spinner } from "@app/ui/Spinner";
|
||||
@@ -133,10 +134,20 @@ export default function AuthCallback() {
|
||||
// URL can't bounce the user off-origin after sign-in.
|
||||
// No explicit destination: land team leads on the processor and everyone
|
||||
// else on the editor.
|
||||
// Explicit `next` first, so a sign-in started for another reason is not
|
||||
// hijacked by a remembered connect request.
|
||||
const explicitNext = url.searchParams.get("next");
|
||||
const pendingConnect = readPendingConnect();
|
||||
const destination =
|
||||
next.startsWith("/") && !next.startsWith("//")
|
||||
? next
|
||||
: await resolveLandingPath();
|
||||
explicitNext &&
|
||||
explicitNext.startsWith("/") &&
|
||||
!explicitNext.startsWith("//")
|
||||
? explicitNext
|
||||
: pendingConnect
|
||||
? `/link?request=${encodeURIComponent(pendingConnect)}`
|
||||
: next.startsWith("/") && !next.startsWith("//")
|
||||
? next
|
||||
: await resolveLandingPath();
|
||||
console.log("[Auth Callback Debug] Redirecting to:", destination);
|
||||
|
||||
setTimeout(() => navigate(destination, { replace: true }), 1500);
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useTranslation } from "@app/hooks/useTranslation";
|
||||
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
|
||||
import { withBasePath } from "@app/constants/app";
|
||||
import {
|
||||
clearPendingConnect,
|
||||
rememberPendingConnect,
|
||||
} from "@app/routes/pendingConnect";
|
||||
import loginHeader from "@app/assets/brand/modern-logo/LoginLightModeHeader.svg";
|
||||
import AuthLayout from "@app/routes/authShared/AuthLayout";
|
||||
import {
|
||||
ConnectApproveView,
|
||||
type ApprovePhase,
|
||||
type PendingConnect,
|
||||
} from "@app/routes/ConnectApproveView";
|
||||
import "@app/routes/authShared/saas-auth.css";
|
||||
import "@app/routes/connect.css";
|
||||
|
||||
interface ApproveResponse {
|
||||
callbackUrl: string;
|
||||
nonce: string;
|
||||
}
|
||||
|
||||
/** Wider than the view renders: only PENDING is still actionable. */
|
||||
interface ConnectLookup extends PendingConnect {
|
||||
status: "PENDING" | "APPROVED" | "DENIED" | "CONSUMED";
|
||||
}
|
||||
|
||||
/** Approve a self-hosted server's request to connect to a team. */
|
||||
export default function ConnectApprove() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { session, user, loading, signOut } = useAuth();
|
||||
const [params] = useSearchParams();
|
||||
const requestId = params.get("request");
|
||||
|
||||
const [phase, setPhase] = useState<ApprovePhase>("loading");
|
||||
const [pending, setPending] = useState<PendingConnect | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const lookedUpRef = useRef(false);
|
||||
|
||||
useDocumentMeta({ title: t("connect.meta.title", "Connect a server") });
|
||||
|
||||
// On arrival, not only when signed out: an approver who is already signed in can
|
||||
// still be sent away to re-authenticate, and needs the same way back.
|
||||
useEffect(() => {
|
||||
if (requestId) rememberPendingConnect(requestId);
|
||||
}, [requestId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || session) return;
|
||||
// No basename: every consumer of `next` reaches it through navigate(), which
|
||||
// applies the basename itself, so carrying it here yields /app/app/link.
|
||||
const next = `/link${requestId ? `?request=${encodeURIComponent(requestId)}` : ""}`;
|
||||
navigate(`/login?next=${encodeURIComponent(next)}`, { replace: true });
|
||||
}, [loading, session, requestId, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || !session || lookedUpRef.current) return;
|
||||
lookedUpRef.current = true;
|
||||
if (!requestId) {
|
||||
setPhase("notFound");
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await apiClient.get<ConnectLookup>(
|
||||
`/api/v1/account-link/connect/${encodeURIComponent(requestId)}`,
|
||||
);
|
||||
// Approving a settled request fails server-side, so offering the form again
|
||||
// would only produce a dead end.
|
||||
if (res.data.status !== "PENDING") {
|
||||
clearPendingConnect();
|
||||
setPhase(res.data.status === "DENIED" ? "declined" : "notFound");
|
||||
return;
|
||||
}
|
||||
setPending(res.data);
|
||||
setPhase("confirm");
|
||||
} catch {
|
||||
clearPendingConnect();
|
||||
setPhase("notFound");
|
||||
}
|
||||
})();
|
||||
}, [loading, session, requestId]);
|
||||
|
||||
const onDecide = useCallback(
|
||||
async (approve: boolean) => {
|
||||
if (!requestId) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const path = `/api/v1/account-link/connect/${encodeURIComponent(requestId)}`;
|
||||
try {
|
||||
if (!approve) {
|
||||
await apiClient.post(`${path}/deny`);
|
||||
clearPendingConnect();
|
||||
setPhase("declined");
|
||||
return;
|
||||
}
|
||||
const res = await apiClient.post<ApproveResponse>(`${path}/approve`);
|
||||
clearPendingConnect();
|
||||
setPhase("redirecting");
|
||||
window.location.replace(returnUrl(res.data, session));
|
||||
} catch {
|
||||
setError(
|
||||
t(
|
||||
"connect.error.failed",
|
||||
"That did not go through. Only a team owner can connect a server.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[requestId, session, t],
|
||||
);
|
||||
|
||||
/**
|
||||
* Sign out, then let the signed-out effect above send them to login with the request preserved.
|
||||
*/
|
||||
const onSwitchAccount = useCallback(() => {
|
||||
void signOut();
|
||||
}, [signOut]);
|
||||
|
||||
if (loading || !session) return null;
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
{/* Same header as the sibling auth pages: an admin arriving from another
|
||||
screen should be able to tell at a glance they are on our site and not
|
||||
somewhere that merely looks like it. */}
|
||||
<div className="auth-logo-block">
|
||||
<img
|
||||
src={loginHeader}
|
||||
alt="Stirling PDF"
|
||||
className="auth-logo-header auth-logo-header--light"
|
||||
/>
|
||||
<img
|
||||
src={withBasePath("/modern-logo/LoginDarkModeHeader.svg")}
|
||||
alt="Stirling PDF"
|
||||
className="auth-logo-header auth-logo-header--dark"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ConnectApproveView
|
||||
phase={phase}
|
||||
pending={pending}
|
||||
signedInEmail={user?.email ?? null}
|
||||
busy={busy}
|
||||
error={error}
|
||||
onDecide={(approve) => void onDecide(approve)}
|
||||
onSwitchAccount={onSwitchAccount}
|
||||
/>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
/** The callback with the session appended as a fragment. */
|
||||
function returnUrl(
|
||||
approval: ApproveResponse,
|
||||
session: { access_token?: string; refresh_token?: string } | null,
|
||||
): string {
|
||||
const fragment = new URLSearchParams({ type: "link", nonce: approval.nonce });
|
||||
if (session?.access_token && session?.refresh_token) {
|
||||
fragment.set("access_token", session.access_token);
|
||||
fragment.set("refresh_token", session.refresh_token);
|
||||
}
|
||||
return `${approval.callbackUrl}#${fragment.toString()}`;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "@app/hooks/useTranslation";
|
||||
import { Banner, Button, Checkbox, Spinner } from "@app/ui";
|
||||
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
|
||||
export type ApprovePhase =
|
||||
| "loading"
|
||||
| "confirm"
|
||||
| "redirecting"
|
||||
| "declined"
|
||||
| "notFound";
|
||||
|
||||
/** What the approver is being asked to connect. */
|
||||
export interface PendingConnect {
|
||||
requestId: string;
|
||||
callbackOrigin: string;
|
||||
insecureTransport: boolean;
|
||||
}
|
||||
|
||||
export interface ConnectApproveViewProps {
|
||||
phase: ApprovePhase;
|
||||
pending: PendingConnect | null;
|
||||
/** Email of the account the server would be connected to. */
|
||||
signedInEmail: string | null;
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
onDecide: (approve: boolean) => void;
|
||||
/** Sign out and come back here, keeping the request so it survives the detour. */
|
||||
onSwitchAccount: () => void;
|
||||
}
|
||||
|
||||
/** Presentation for the connect approval page. */
|
||||
export function ConnectApproveView({
|
||||
phase,
|
||||
pending,
|
||||
signedInEmail,
|
||||
busy,
|
||||
error,
|
||||
onDecide,
|
||||
onSwitchAccount,
|
||||
}: ConnectApproveViewProps) {
|
||||
const { t } = useTranslation();
|
||||
// Gates the primary action: anyone can create a request, so the approver reading
|
||||
// the address is the only thing between one and a linked team.
|
||||
const [acknowledged, setAcknowledged] = useState(false);
|
||||
|
||||
if (phase === "loading" || phase === "redirecting") {
|
||||
return (
|
||||
<div className="saas-connect">
|
||||
<Spinner size="md" />
|
||||
<p className="saas-connect__lead">
|
||||
{phase === "redirecting"
|
||||
? t("connect.redirecting", "Returning you to your server.")
|
||||
: t("connect.loading", "Checking this request.")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "notFound") {
|
||||
return (
|
||||
<div className="saas-connect">
|
||||
<Banner
|
||||
tone="danger"
|
||||
title={t("connect.notFound.title", "Request not valid")}
|
||||
>
|
||||
{t(
|
||||
"connect.notFound.body",
|
||||
"This connection request is not valid. It may have expired, or already been used. Start another one from your server.",
|
||||
)}
|
||||
</Banner>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "declined") {
|
||||
return (
|
||||
<div className="saas-connect">
|
||||
<Banner
|
||||
tone="warning"
|
||||
title={t("connect.declined.title", "Request declined")}
|
||||
>
|
||||
{t(
|
||||
"connect.declined.body",
|
||||
"Nothing was connected. You can close this page.",
|
||||
)}
|
||||
</Banner>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="saas-connect">
|
||||
<h1 className="saas-connect__title">
|
||||
{t("connect.confirm.title", "Connect this server?")}
|
||||
</h1>
|
||||
<p className="saas-connect__lead">
|
||||
{t(
|
||||
"connect.confirm.lead",
|
||||
"A Stirling server is asking to connect to your team. Check the address below is yours before you approve.",
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* One panel, because the account and the address are two halves of the same
|
||||
decision: right server, wrong account is still wrong. */}
|
||||
<dl className="saas-connect__facts">
|
||||
<dt>{t("connect.confirm.signedInAs", "Account")}</dt>
|
||||
<dd>
|
||||
{signedInEmail ??
|
||||
t("connect.confirm.unknownAccount", "an unknown account")}
|
||||
<button
|
||||
type="button"
|
||||
className="saas-connect__switch"
|
||||
disabled={busy}
|
||||
onClick={onSwitchAccount}
|
||||
>
|
||||
{t("connect.confirm.switchAccount", "Use a different account")}
|
||||
</button>
|
||||
</dd>
|
||||
{/* The reported name is deliberately not shown. The requester chooses it on an
|
||||
unauthenticated endpoint, so it is the field an attacker would set to look
|
||||
familiar, and its honest value is the hostname already in the address. It
|
||||
still labels the server in the linked-instances list, after the decision. */}
|
||||
<dt className="saas-connect__origin-label">
|
||||
{t("connect.confirm.originLabel", "Address")}
|
||||
{pending?.insecureTransport ? (
|
||||
<Tooltip
|
||||
position="top"
|
||||
content={t(
|
||||
"connect.confirm.insecure.body",
|
||||
"This address does not use HTTPS, so your sign-in will be sent over an unencrypted connection. Only approve it on a network you trust.",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="saas-connect__insecure"
|
||||
tabIndex={0}
|
||||
role="img"
|
||||
aria-label={t(
|
||||
"connect.confirm.insecure.label",
|
||||
"Not an encrypted address",
|
||||
)}
|
||||
>
|
||||
<LocalIcon icon="warning-rounded" width="1rem" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</dt>
|
||||
<dd className="saas-connect__origin">{pending?.callbackOrigin}</dd>
|
||||
</dl>
|
||||
|
||||
{error ? <Banner tone="danger">{error}</Banner> : null}
|
||||
|
||||
<Checkbox
|
||||
checked={acknowledged}
|
||||
disabled={busy}
|
||||
onChange={(e) => setAcknowledged(e.currentTarget.checked)}
|
||||
label={t(
|
||||
"connect.confirm.acknowledge",
|
||||
"I recognise this address and want to connect it to my team",
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="saas-connect__actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={busy}
|
||||
onClick={() => onDecide(false)}
|
||||
>
|
||||
{t("connect.confirm.deny", "Decline")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy || !acknowledged}
|
||||
onClick={() => onDecide(true)}
|
||||
>
|
||||
{t("connect.confirm.approve", "Connect server")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { readPendingConnect } from "@app/routes/pendingConnect";
|
||||
|
||||
/**
|
||||
* Sends a newly signed-in visitor back to the approval page they were pulled away
|
||||
* from.
|
||||
*
|
||||
* Mounted app-wide, not only in the auth callback: a confirmation email can land the
|
||||
* visitor anywhere in the app with a session, and only the ones below resolve the
|
||||
* request themselves.
|
||||
*/
|
||||
export function ResumePendingConnect() {
|
||||
const { session, loading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const handled = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || !session || handled.current) return;
|
||||
if (
|
||||
location.pathname === "/link" ||
|
||||
location.pathname === "/auth/callback"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
handled.current = true;
|
||||
const requestId = readPendingConnect();
|
||||
if (requestId) {
|
||||
navigate(`/link?request=${encodeURIComponent(requestId)}`, {
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
}, [loading, session, location.pathname, navigate]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/* Connect-approval page. The origin is the thing the approver has to actually
|
||||
read, so it gets the visual weight and everything else stays quiet. */
|
||||
|
||||
.saas-connect {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.saas-connect__title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.saas-connect__lead {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
/* Sits inside the facts panel rather than beside the email: at this width a
|
||||
right-aligned action wraps onto its own line and reads as a third field. */
|
||||
.saas-connect__switch {
|
||||
display: block;
|
||||
margin-top: 0.125rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--c-accent-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.saas-connect__switch:hover:not(:disabled) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.saas-connect__switch:disabled {
|
||||
color: var(--c-text-muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.saas-connect__facts {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.375rem 1rem;
|
||||
margin: 0;
|
||||
padding: 0.875rem;
|
||||
background: var(--c-surface-sunken);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.saas-connect__facts dt {
|
||||
margin: 0;
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.saas-connect__facts dd {
|
||||
margin: 0;
|
||||
color: var(--c-text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Monospaced so a lookalike hostname is harder to skim past. */
|
||||
.saas-connect__origin {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.saas-connect__origin-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.saas-connect__insecure {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
color: var(--c-warning);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.saas-connect__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Remembers that the visitor arrived wanting to connect a server, so a sign-in
|
||||
* detour can return them to the approval page.
|
||||
*
|
||||
* localStorage, not sessionStorage: the confirmation email opens a new tab, and
|
||||
* sessionStorage is per-tab — empty exactly when it is needed.
|
||||
*
|
||||
* Only the request id, which is already in the URL and carries no secret. This
|
||||
* decides where the approver lands, never whether the link happens.
|
||||
*
|
||||
* Reading does not consume it: the request may be open in another tab, or the page
|
||||
* closed and reopened, or the reader mounted twice. Only a recorded decision, or a
|
||||
* request that is settled or gone, retires it.
|
||||
*/
|
||||
const KEY = "stirling-pending-connect";
|
||||
|
||||
/** Matches the server's request lifetime, so a stale intent cannot hijack a later sign-in. */
|
||||
const TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
interface Stored {
|
||||
requestId: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
export function rememberPendingConnect(requestId: string): void {
|
||||
try {
|
||||
const value: Stored = { requestId, at: Date.now() };
|
||||
window.localStorage.setItem(KEY, JSON.stringify(value));
|
||||
} catch {
|
||||
// Private browsing or a full quota; nothing to fall back to.
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops the intent without reading it, once it has been acted on. */
|
||||
export function clearPendingConnect(): void {
|
||||
try {
|
||||
window.localStorage.removeItem(KEY);
|
||||
} catch {
|
||||
// Unwritable store; nothing to remove.
|
||||
}
|
||||
}
|
||||
|
||||
/** The pending request, or null when absent or expired. Leaves it in place. */
|
||||
export function readPendingConnect(): string | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(KEY);
|
||||
if (!raw) return null;
|
||||
const value = JSON.parse(raw) as Stored;
|
||||
if (typeof value?.requestId !== "string" || typeof value?.at !== "number") {
|
||||
clearPendingConnect();
|
||||
return null;
|
||||
}
|
||||
if (Date.now() - value.at > TTL_MS) {
|
||||
clearPendingConnect();
|
||||
return null;
|
||||
}
|
||||
return value.requestId;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -259,6 +259,16 @@ export default defineConfig(async ({ mode, command }) => {
|
||||
};
|
||||
|
||||
return {
|
||||
// Per-mode: the default is one shared node_modules/.vite, so two dev servers in
|
||||
// different modes re-optimize over each other and the browser 504s on a stale dep
|
||||
// hash. Anchored to frontend/ because a relative path resolves against the vite
|
||||
// root (editor/) and would create a second node_modules there.
|
||||
cacheDir: resolve(
|
||||
import.meta.dirname,
|
||||
"..",
|
||||
"node_modules",
|
||||
`.vite-${effectiveMode}`,
|
||||
),
|
||||
define: {
|
||||
__DEV_WORKTREE_LABEL__: JSON.stringify(devWorktreeLabel),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user