mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Merge branch 'main' into metadata_writer
This commit is contained in:
+62
-3
@@ -40,12 +40,15 @@ tasks:
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
|
||||
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}'
|
||||
# Set by dev:linked. Inline rather than in `env:` so an empty value emits nothing
|
||||
# and cannot blank the committed default.
|
||||
ACCOUNT_LINK_SAAS_BASE_URL: '{{.ACCOUNT_LINK_SAAS_BASE_URL | default ""}}'
|
||||
env:
|
||||
SERVER_PORT: '{{.PORT}}'
|
||||
cmds:
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .ACCOUNT_LINK_SAAS_BASE_URL}}STIRLING_BILLING_ACCOUNT_LINK_ENABLED=true STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL={{.ACCOUNT_LINK_SAAS_BASE_URL}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||
platforms: [windows]
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .ACCOUNT_LINK_SAAS_BASE_URL}}STIRLING_BILLING_ACCOUNT_LINK_ENABLED=true STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL={{.ACCOUNT_LINK_SAAS_BASE_URL}} {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
platforms: [linux, darwin]
|
||||
|
||||
dev:bundled:
|
||||
@@ -84,6 +87,8 @@ tasks:
|
||||
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
||||
APP_BASE_URL: '{{.APP_BASE_URL}}'
|
||||
BASE_PATH: '{{.BASE_PATH}}'
|
||||
|
||||
staging:saas:
|
||||
desc: "Start SaaS backend against the shared v3 staging project"
|
||||
@@ -95,10 +100,47 @@ tasks:
|
||||
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
||||
APP_BASE_URL: '{{.APP_BASE_URL}}'
|
||||
BASE_PATH: '{{.BASE_PATH}}'
|
||||
|
||||
dev:linked:
|
||||
desc: "Self-hosted backend linked to a locally running SaaS backend (see task linked:*)"
|
||||
ignore_error: true
|
||||
vars:
|
||||
PORT: '{{.PORT | default "8080"}}'
|
||||
SAAS_BASE_URL: '{{.SAAS_BASE_URL | default "http://localhost:8081"}}'
|
||||
cmds:
|
||||
- 'echo ">> self-hosted :{{.PORT}} linking to SaaS at {{.SAAS_BASE_URL}}"'
|
||||
# The two backends run different STIRLING_FLAVOURs, which are different Gradle
|
||||
# project graphs sharing one build/ tree. Waiting avoids overlapping builds; it
|
||||
# does not make the sharing safe, so avoid rebuilding one while the other runs.
|
||||
- cmd: |
|
||||
n=0
|
||||
while [ "$n" -lt 150 ]; do
|
||||
if curl -s -m 2 "{{.SAAS_BASE_URL}}" >/dev/null 2>&1; then
|
||||
echo ">> SaaS backend is up, starting self-hosted"
|
||||
break
|
||||
fi
|
||||
n=$((n + 1))
|
||||
{{if eq OS "windows"}}powershell -NoProfile -Command "Start-Sleep -Seconds 2"{{else}}sleep 2{{end}}
|
||||
done
|
||||
if [ "$n" -ge 150 ]; then
|
||||
echo ">> SaaS backend never answered; starting anyway"
|
||||
fi
|
||||
- task: dev:proprietary
|
||||
vars:
|
||||
PORT: '{{.PORT}}'
|
||||
ACCOUNT_LINK_SAAS_BASE_URL: '{{.SAAS_BASE_URL}}'
|
||||
|
||||
_run:saas:
|
||||
internal: true
|
||||
dotenv: ['app/.env.saas.local', 'app/.env.saas']
|
||||
# The frontend files are here only for RUN_SUBPATH, which the authorize URL needs.
|
||||
# Last, because dotenv is set-if-absent: app/* still decides everything else.
|
||||
dotenv:
|
||||
- 'app/.env.saas.local'
|
||||
- 'app/.env.saas'
|
||||
- 'frontend/editor/.env.saas.local'
|
||||
- 'frontend/editor/.env.saas'
|
||||
ignore_error: true
|
||||
vars:
|
||||
PORT: '{{.PORT | default "8080"}}'
|
||||
@@ -111,12 +153,29 @@ tasks:
|
||||
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
|
||||
# Empty is the same as unset: the property defaults to empty and is blank-checked.
|
||||
APP_BASE_URL: '{{.APP_BASE_URL | default ""}}'
|
||||
# Relocates configs/pipeline/logs, for a second backend in the same directory.
|
||||
# Empty is the same as unset: the reader blank-checks it.
|
||||
BASE_PATH: '{{.BASE_PATH | default ""}}'
|
||||
env:
|
||||
SERVER_PORT: '{{.PORT}}'
|
||||
STIRLING_FLAVOR: saas
|
||||
STIRLING_BASE_PATH: '{{.BASE_PATH}}'
|
||||
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
||||
# Appends RUN_SUBPATH: the approval page is at <base>/link, so a subpath build
|
||||
# serves it at <base>/app/link. An explicit value still wins.
|
||||
SYSTEM_FRONTENDURL:
|
||||
sh: |
|
||||
if [ -n "${SYSTEM_FRONTENDURL:-}" ]; then
|
||||
echo "${SYSTEM_FRONTENDURL}"
|
||||
elif [ -n "{{.APP_BASE_URL}}" ] && [ -n "${RUN_SUBPATH:-}" ]; then
|
||||
echo "{{.APP_BASE_URL}}/${RUN_SUBPATH}"
|
||||
else
|
||||
echo "{{.APP_BASE_URL}}"
|
||||
fi
|
||||
cmds:
|
||||
# PROFILE_ARGS is empty when PROFILES=none, i.e. the bare `saas` profile
|
||||
# against SAAS_DB_* (production).
|
||||
|
||||
+13
-3
@@ -121,17 +121,17 @@ tasks:
|
||||
sh: |
|
||||
case "${SAAS_ENV:-dev}" in
|
||||
staging) ref="${SAAS_STAGING_PROJECT_REF:?set it in app/.env.saas.local}" ;;
|
||||
*) ref="${SAAS_DEV_PROJECT_REF:?set it in app/.env.saas.local, or run task staging:saas}" ;;
|
||||
*) ref="${SAAS_DEV_PROJECT_REF:?set it in app/.env.saas.local, or pass SAAS_ENV=staging}" ;;
|
||||
esac
|
||||
echo "https://${ref}.supabase.co"
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY:
|
||||
sh: |
|
||||
case "${SAAS_ENV:-dev}" in
|
||||
staging) echo "${SAAS_STAGING_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;;
|
||||
*) echo "${SAAS_DEV_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;;
|
||||
*) echo "${SAAS_DEV_PUBLISHABLE_KEY:?set it in app/.env.saas.local, or pass SAAS_ENV=staging}" ;;
|
||||
esac
|
||||
cmds:
|
||||
- 'echo ">> frontend Supabase target: $VITE_SUPABASE_URL"'
|
||||
- 'echo ">> frontend {{.SAAS_ENV}}: Supabase $VITE_SUPABASE_URL, backend $BACKEND_URL"'
|
||||
- npx vite editor --mode saas --port {{.PORT}}{{if .OPEN}} --open{{end}}
|
||||
|
||||
dev:
|
||||
@@ -173,6 +173,16 @@ tasks:
|
||||
OPEN: '{{.OPEN}}'
|
||||
SAAS_ENV: '{{.SAAS_ENV}}'
|
||||
|
||||
staging:saas:
|
||||
desc: "Start frontend dev server against the shared v3 staging project"
|
||||
cmds:
|
||||
- task: dev:saas
|
||||
vars:
|
||||
SAAS_ENV: staging
|
||||
PORT: '{{.PORT}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
OPEN: '{{.OPEN}}'
|
||||
|
||||
dev:desktop:
|
||||
desc: "Start frontend dev server in desktop mode"
|
||||
deps:
|
||||
|
||||
@@ -121,6 +121,92 @@ tasks:
|
||||
cmds:
|
||||
- task: dev:_all
|
||||
|
||||
# No engine: linking never calls it.
|
||||
linked:staging:
|
||||
desc: "SaaS on the shared v3 project + a self-hosted instance linked to it"
|
||||
cmds:
|
||||
- task: linked:_all
|
||||
vars: { SAAS_ENV: staging }
|
||||
|
||||
linked:dev:
|
||||
desc: "SaaS on the current PR's preview branch + a self-hosted instance linked to it"
|
||||
cmds:
|
||||
- task: linked:_all
|
||||
vars: { SAAS_ENV: dev }
|
||||
|
||||
linked:_all:
|
||||
internal: true
|
||||
vars:
|
||||
SAAS_ENV: '{{.SAAS_ENV | default "staging"}}'
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8081 5174 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8081 5174 8080 5173{{end}}'
|
||||
SAAS_BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
SAAS_FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
APP_BACKEND_PORT: '{{index (splitList "\n" .PORTS) 2}}'
|
||||
APP_FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 3}}'
|
||||
deps:
|
||||
# APP_BASE_URL is the SaaS *frontend*: the approval page is served by vite, not
|
||||
# by the API. BASE_PATH moves this backend's configs/pipeline aside so it does not
|
||||
# race the self-hosted one, which keeps ./configs and its existing database.
|
||||
- task: 'backend:{{.SAAS_ENV}}:saas'
|
||||
vars:
|
||||
PORT: '{{.SAAS_BACKEND_PORT}}'
|
||||
APP_BASE_URL: 'http://localhost:{{.SAAS_FRONTEND_PORT}}'
|
||||
BASE_PATH: 'tmp/linked-saas'
|
||||
- task: frontend:dev:saas
|
||||
vars:
|
||||
PORT: '{{.SAAS_FRONTEND_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.SAAS_BACKEND_PORT}}'
|
||||
SAAS_ENV: '{{.SAAS_ENV}}'
|
||||
- task: backend:dev:linked
|
||||
vars:
|
||||
PORT: '{{.APP_BACKEND_PORT}}'
|
||||
SAAS_BASE_URL: 'http://localhost:{{.SAAS_BACKEND_PORT}}'
|
||||
- task: frontend:dev:proprietary
|
||||
vars:
|
||||
PORT: '{{.APP_FRONTEND_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.APP_BACKEND_PORT}}'
|
||||
OPEN: "true"
|
||||
- task: linked:_ready
|
||||
vars:
|
||||
SAAS_BACKEND_PORT: '{{.SAAS_BACKEND_PORT}}'
|
||||
SAAS_FRONTEND_PORT: '{{.SAAS_FRONTEND_PORT}}'
|
||||
APP_BACKEND_PORT: '{{.APP_BACKEND_PORT}}'
|
||||
APP_FRONTEND_PORT: '{{.APP_FRONTEND_PORT}}'
|
||||
|
||||
# Waits for all four to answer, then prints where they landed.
|
||||
linked:_ready:
|
||||
internal: true
|
||||
cmds:
|
||||
- cmd: |
|
||||
n=0
|
||||
ok=0
|
||||
while [ "$n" -lt 150 ]; do
|
||||
ok=1
|
||||
for u in "http://localhost:{{.SAAS_BACKEND_PORT}}" \
|
||||
"http://localhost:{{.SAAS_FRONTEND_PORT}}" \
|
||||
"http://localhost:{{.APP_BACKEND_PORT}}" \
|
||||
"http://localhost:{{.APP_FRONTEND_PORT}}"; do
|
||||
# Not -o /dev/null: Windows curl.exe treats it as a real path and exits 23.
|
||||
curl -s -m 2 "$u" >/dev/null 2>&1 || ok=0
|
||||
done
|
||||
if [ "$ok" = 1 ]; then break; fi
|
||||
n=$((n + 1))
|
||||
# `sleep` is a binary, not a builtin, and Windows has none.
|
||||
{{if eq OS "windows"}}powershell -NoProfile -Command "Start-Sleep -Seconds 2"{{else}}sleep 2{{end}}
|
||||
done
|
||||
echo ""
|
||||
if [ "$ok" = 1 ]; then
|
||||
echo ">> all four answering"
|
||||
else
|
||||
echo ">> still waiting on one or more after 5 minutes; addresses below anyway"
|
||||
fi
|
||||
echo ">> self-hosted UI http://localhost:{{.APP_FRONTEND_PORT}}/processor"
|
||||
echo ">> self-hosted api http://localhost:{{.APP_BACKEND_PORT}}"
|
||||
echo ">> saas UI http://localhost:{{.SAAS_FRONTEND_PORT}}"
|
||||
echo ">> saas api http://localhost:{{.SAAS_BACKEND_PORT}}"
|
||||
echo ""
|
||||
|
||||
dev:_all:
|
||||
internal: true
|
||||
vars:
|
||||
|
||||
@@ -186,7 +186,7 @@ system:
|
||||
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion
|
||||
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). WARNING: leaving this empty falls back to allowing ALL origins (with credentials), it does NOT disable CORS. Set explicit origins to lock it down.
|
||||
backendUrl: "" # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
|
||||
frontendUrl: "" # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
|
||||
frontendUrl: "" # Base URL of the web app, as a browser reaches it (e.g. 'https://app.example.com', or 'https://example.com/app' if served under a base path). Optional - if not set, will use backendUrl. Used for any link handed to a browser: invite emails, share links, mobile QR codes, and the account-link handshake.
|
||||
enableMobileScanner: true # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured.
|
||||
enableMobileSignature: true # Enable drawing signatures on a phone via QR code from the Sign tool. Requires frontendUrl to be configured.
|
||||
mobileScannerSettings:
|
||||
|
||||
+139
-67
@@ -24,22 +24,6 @@ import tools.jackson.databind.node.ObjectNode;
|
||||
/**
|
||||
* Outbound calls from a self-hosted instance to its linked SaaS backend (combined-billing "Mode
|
||||
* A").
|
||||
*
|
||||
* <p>Calls:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #register} — relays the admin's short-lived Supabase JWT to {@code POST
|
||||
* /api/v1/account-link/register}; the SaaS side mints + returns a device credential.
|
||||
* <li>{@link #fetchEntitlement} — authenticates with the stored device credential against {@code
|
||||
* GET /api/v1/instance/entitlement}; what the local gate consults.
|
||||
* <li>{@link #reportUsage} — daily usage sync ({@code POST /api/v1/instance/sync}); reports
|
||||
* cumulative units and returns the refreshed entitlement.
|
||||
* <li>{@link #revokeSelf} — self-revokes the credential on local unlink ({@code POST
|
||||
* /api/v1/instance/revoke-self}).
|
||||
* </ul>
|
||||
*
|
||||
* <p>Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern; see
|
||||
* {@code AiEngineClient}); base URL + client are injectable so tests can stub SaaS.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -72,13 +56,7 @@ public class AccountLinkClient {
|
||||
this.httpClient = httpClient;
|
||||
}
|
||||
|
||||
/** The device credential a successful {@link #register} returns. */
|
||||
public record RegisterResult(String deviceId, String deviceSecret, Long teamId) {}
|
||||
|
||||
/**
|
||||
* A non-2xx reply from the SaaS account-link API. Carries the upstream status so the caller can
|
||||
* map auth failures (401/403) through rather than masking everything as a 502.
|
||||
*/
|
||||
/** A non-2xx reply from the SaaS account-link API. */
|
||||
public static class UpstreamException extends IOException {
|
||||
private final int status;
|
||||
|
||||
@@ -92,11 +70,7 @@ public class AccountLinkClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authoritative deny (401/403) — the device credential is revoked or invalid. Unlike a
|
||||
* transport/server failure (which returns {@code null} and fails open), the cache must BLOCK on
|
||||
* this. Unchecked so it propagates through {@link #fetchEntitlement}'s transport try/catch.
|
||||
*/
|
||||
/** Authoritative deny (401/403) — the device credential is revoked or invalid. */
|
||||
public static final class RevokedException extends RuntimeException {
|
||||
private final int status;
|
||||
|
||||
@@ -110,46 +84,142 @@ public class AccountLinkClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** What the SaaS side hands back when it records a connect handshake. */
|
||||
public record ConnectRequestResult(
|
||||
String requestId, int expiresInSeconds, String authorizeUrl) {}
|
||||
|
||||
public enum ConnectClaimOutcome {
|
||||
/** Approved and collected; the credential fields are populated. */
|
||||
GRANTED,
|
||||
/** A re-authentication was approved. */
|
||||
CONFIRMED,
|
||||
/** No human decision yet. */
|
||||
PENDING,
|
||||
/** Declined, expired or already used. */
|
||||
REJECTED,
|
||||
/** SaaS unreachable or erroring. */
|
||||
UNAVAILABLE
|
||||
}
|
||||
|
||||
public record ConnectClaimResult(
|
||||
ConnectClaimOutcome outcome, String deviceId, String deviceSecret, Long teamId) {
|
||||
static ConnectClaimResult of(ConnectClaimOutcome outcome) {
|
||||
return new ConnectClaimResult(outcome, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens a connect handshake. */
|
||||
public ConnectRequestResult connectRequest(
|
||||
String name, String callbackUrl, String nonce, String claimSecret) throws IOException {
|
||||
return connectRequest(name, callbackUrl, nonce, claimSecret, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relays the admin Supabase JWT to the SaaS register endpoint and returns the minted
|
||||
* credential.
|
||||
*
|
||||
* @throws IOException on transport failure or a non-2xx response (caller surfaces to the
|
||||
* admin).
|
||||
* As {@link #connectRequest}, but presenting an existing device credential so the SaaS side
|
||||
* treats this as a re-authentication and pins the handshake to the team we already belong to.
|
||||
*/
|
||||
public RegisterResult register(String supabaseJwt, String instanceName) throws IOException {
|
||||
String body =
|
||||
instanceName == null || instanceName.isBlank()
|
||||
? "{}"
|
||||
: "{\"name\":" + mapper.writeValueAsString(instanceName) + "}";
|
||||
HttpRequest request =
|
||||
public ConnectRequestResult connectRequest(
|
||||
String name,
|
||||
String callbackUrl,
|
||||
String nonce,
|
||||
String claimSecret,
|
||||
DeviceCredential credential)
|
||||
throws IOException {
|
||||
ObjectNode root = mapper.createObjectNode();
|
||||
if (name != null && !name.isBlank()) {
|
||||
root.put("name", name);
|
||||
}
|
||||
root.put("callbackUrl", callbackUrl);
|
||||
root.put("nonce", nonce);
|
||||
root.put("claimSecret", claimSecret);
|
||||
|
||||
HttpRequest.Builder builder =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(uri("/api/v1/account-link/register"))
|
||||
.header("Authorization", "Bearer " + supabaseJwt)
|
||||
.uri(uri("/api/v1/account-link/connect/request"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.timeout(timeout())
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(root)));
|
||||
if (credential != null) {
|
||||
builder.header(HEADER_DEVICE_ID, credential.getDeviceId())
|
||||
.header(HEADER_DEVICE_SECRET, credential.getDeviceSecret());
|
||||
}
|
||||
|
||||
HttpResponse<String> response = send(request);
|
||||
HttpResponse<String> response = send(builder.build());
|
||||
if (response.statusCode() / 100 != 2) {
|
||||
throw new UpstreamException(response.statusCode(), response.body());
|
||||
}
|
||||
JsonNode root = mapper.readTree(response.body());
|
||||
String deviceId = text(root, "deviceId");
|
||||
String deviceSecret = text(root, "deviceSecret");
|
||||
if (deviceId == null || deviceSecret == null) {
|
||||
throw new IOException("SaaS register response missing deviceId/deviceSecret");
|
||||
JsonNode body = mapper.readTree(response.body());
|
||||
String requestId = text(body, "requestId");
|
||||
if (requestId == null) {
|
||||
throw new IOException("SaaS connect response missing requestId");
|
||||
}
|
||||
String authorizeUrl = text(body, "authorizeUrl");
|
||||
if (authorizeUrl == null || !isAbsoluteHttpUrl(authorizeUrl)) {
|
||||
throw new IOException("SaaS connect response carried no usable authorizeUrl");
|
||||
}
|
||||
return new ConnectRequestResult(requestId, body.path("expiresIn").asInt(0), authorizeUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the device credential for an approved handshake, proving possession of the claim
|
||||
* secret.
|
||||
*/
|
||||
public ConnectClaimResult connectClaim(String requestId, String claimSecret) {
|
||||
HttpResponse<String> response;
|
||||
try {
|
||||
ObjectNode root = mapper.createObjectNode();
|
||||
root.put("requestId", requestId);
|
||||
root.put("claimSecret", claimSecret);
|
||||
HttpRequest request =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(uri("/api/v1/account-link/connect/claim"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.timeout(timeout())
|
||||
.POST(
|
||||
HttpRequest.BodyPublishers.ofString(
|
||||
mapper.writeValueAsString(root)))
|
||||
.build();
|
||||
response = send(request);
|
||||
} catch (Exception e) {
|
||||
log.debug("Connect claim failed (transport): {}", e.getMessage());
|
||||
return ConnectClaimResult.of(ConnectClaimOutcome.UNAVAILABLE);
|
||||
}
|
||||
int status = response.statusCode();
|
||||
if (status == 202) {
|
||||
return ConnectClaimResult.of(ConnectClaimOutcome.PENDING);
|
||||
}
|
||||
if (status >= 500 && status <= 599) {
|
||||
return ConnectClaimResult.of(ConnectClaimOutcome.UNAVAILABLE);
|
||||
}
|
||||
if (status < 200 || status > 299) {
|
||||
return ConnectClaimResult.of(ConnectClaimOutcome.REJECTED);
|
||||
}
|
||||
try {
|
||||
JsonNode body = mapper.readTree(response.body());
|
||||
Long teamId = body.hasNonNull("teamId") ? body.get("teamId").asLong() : null;
|
||||
// A re-authentication says so explicitly and carries no credential, so an absent
|
||||
// credential is only an error when we were expecting one.
|
||||
if ("confirmed".equals(text(body, "status"))) {
|
||||
return new ConnectClaimResult(ConnectClaimOutcome.CONFIRMED, null, null, teamId);
|
||||
}
|
||||
String deviceId = text(body, "deviceId");
|
||||
String deviceSecret = text(body, "deviceSecret");
|
||||
if (deviceId == null || deviceSecret == null) {
|
||||
log.warn("Connect claim succeeded but the reply carried no credential");
|
||||
return ConnectClaimResult.of(ConnectClaimOutcome.REJECTED);
|
||||
}
|
||||
return new ConnectClaimResult(
|
||||
ConnectClaimOutcome.GRANTED, deviceId, deviceSecret, teamId);
|
||||
} catch (RuntimeException e) {
|
||||
log.debug("Connect claim parse failed: {}", e.getMessage());
|
||||
return ConnectClaimResult.of(ConnectClaimOutcome.REJECTED);
|
||||
}
|
||||
Long teamId = root.hasNonNull("teamId") ? root.get("teamId").asLong() : null;
|
||||
return new RegisterResult(deviceId, deviceSecret, teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes this instance's own credential on the SaaS side, authenticated by that credential.
|
||||
* Best-effort: returns {@code false} if SaaS is unreachable or rejects, so the caller (local
|
||||
* unlink) can still clear locally and log the orphan for follow-up. Idempotent on SaaS.
|
||||
*/
|
||||
public boolean revokeSelf(String deviceId, String deviceSecret) {
|
||||
try {
|
||||
@@ -174,17 +244,7 @@ public class AccountLinkClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current entitlement using the stored device credential. Three outcomes:
|
||||
*
|
||||
* <ul>
|
||||
* <li>2xx → the parsed snapshot.
|
||||
* <li>401/403 → {@link RevokedException} (authoritative deny — revoked/invalid credential);
|
||||
* the caller must BLOCK, not fail open.
|
||||
* <li>transport failure, other non-2xx (e.g. 5xx), or a malformed body → {@code null}
|
||||
* ("unknown" — the caller fails open).
|
||||
* </ul>
|
||||
*/
|
||||
/** Fetches the current entitlement using the stored device credential. */
|
||||
public InstanceEntitlement fetchEntitlement(String deviceId, String deviceSecret) {
|
||||
HttpResponse<String> response;
|
||||
try {
|
||||
@@ -224,9 +284,6 @@ public class AccountLinkClient {
|
||||
/**
|
||||
* Reports the period's cumulative per-category units to {@code POST /api/v1/instance/sync} and
|
||||
* returns the fresh entitlement in the same reply — one round-trip both reports and refreshes.
|
||||
* SaaS bills the delta against its last-seen cumulative, so resending the same totals is
|
||||
* idempotent. Same three outcomes as {@link #fetchEntitlement}; on {@code null} the caller must
|
||||
* not advance its last-synced markers so the usage retries next sync.
|
||||
*/
|
||||
public InstanceEntitlement reportUsage(
|
||||
String deviceId,
|
||||
@@ -360,4 +417,19 @@ public class AccountLinkClient {
|
||||
private static String text(JsonNode node, String field) {
|
||||
return node.hasNonNull(field) ? node.get(field).asText() : null;
|
||||
}
|
||||
|
||||
/** Absolute http(s) with a host. */
|
||||
static boolean isAbsoluteHttpUrl(String candidate) {
|
||||
try {
|
||||
URI uri = URI.create(candidate.strip());
|
||||
String scheme = uri.getScheme();
|
||||
return uri.isAbsolute()
|
||||
&& scheme != null
|
||||
&& ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))
|
||||
&& uri.getHost() != null
|
||||
&& !uri.getHost().isBlank();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+88
-44
@@ -16,21 +16,11 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Same-origin account-link surface on the self-hosted instance (combined-billing "Mode A").
|
||||
*
|
||||
* <p>The portal (served from this same origin, admin authenticated by the existing self-hosted
|
||||
* security chain) calls these. {@code POST /link} relays the admin's Supabase JWT to the SaaS
|
||||
* backend, which mints + returns a device credential we store locally. {@code GET /status} backs
|
||||
* the portal's link card; {@code GET /usage} exposes locally-accrued unsynced usage the portal adds
|
||||
* to SaaS-synced spend; {@code POST /sync-now} forces an immediate usage sync (ops "reconcile now"
|
||||
* / test aid).
|
||||
*
|
||||
* <p>Admin-only, {@code @Profile("!saas")}, gated behind {@code
|
||||
* stirling.billing.account-link.enabled} — off → bean absent → 404.
|
||||
*/
|
||||
/** Same-origin account-link surface on the self-hosted instance (combined billing). */
|
||||
@Slf4j
|
||||
@Hidden
|
||||
@RestController
|
||||
@@ -41,51 +31,110 @@ import lombok.extern.slf4j.Slf4j;
|
||||
public class AccountLinkController {
|
||||
|
||||
private final AccountLinkService service;
|
||||
private final ConnectService connectService;
|
||||
private final LocalUsageService localUsageService;
|
||||
// Present only when metering is on (its own flag); absent → /sync-now reports 409.
|
||||
private final ObjectProvider<UsageSyncService> syncServiceProvider;
|
||||
|
||||
public AccountLinkController(
|
||||
AccountLinkService service,
|
||||
ConnectService connectService,
|
||||
LocalUsageService localUsageService,
|
||||
ObjectProvider<UsageSyncService> syncServiceProvider) {
|
||||
this.service = service;
|
||||
this.connectService = connectService;
|
||||
this.localUsageService = localUsageService;
|
||||
this.syncServiceProvider = syncServiceProvider;
|
||||
}
|
||||
|
||||
/** {@code supabaseJwt} is the admin's short-lived token the portal already holds. */
|
||||
public record LinkRequest(String supabaseJwt, String name) {}
|
||||
/** {@code callbackUrl} is the portal telling us where its own callback route lives. */
|
||||
public record ConnectStartRequest(String name, String callbackUrl) {}
|
||||
|
||||
@PostMapping("/link")
|
||||
public ResponseEntity<?> link(@RequestBody LinkRequest req) {
|
||||
if (req == null || req.supabaseJwt() == null || req.supabaseJwt().isBlank()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(java.util.Map.of("error", "supabaseJwt is required"));
|
||||
}
|
||||
/** {@code nonce} comes from the callback fragment the approval page redirected to. */
|
||||
public record ConnectCompleteRequest(String nonce) {}
|
||||
|
||||
/**
|
||||
* Opens a browser-mediated link handshake and returns the approval URL to send the admin to.
|
||||
*/
|
||||
@PostMapping("/connect/start")
|
||||
public ResponseEntity<?> connectStart(
|
||||
@RequestBody(required = false) ConnectStartRequest req, HttpServletRequest http) {
|
||||
try {
|
||||
return ResponseEntity.ok(service.link(req.supabaseJwt(), req.name()));
|
||||
return ResponseEntity.ok(
|
||||
connectService.start(req != null ? req.name() : null, callbackHint(req, http)));
|
||||
} catch (AccountLinkClient.UpstreamException e) {
|
||||
// Auth failures are the admin's token, not a gateway fault: surface 401/403 as-is so
|
||||
// the portal can prompt a re-sign-in. Anything else upstream → 502. Don't echo the
|
||||
// raw upstream body back to the browser.
|
||||
HttpStatus status =
|
||||
e.status() == HttpStatus.UNAUTHORIZED.value()
|
||||
|| e.status() == HttpStatus.FORBIDDEN.value()
|
||||
? HttpStatus.valueOf(e.status())
|
||||
: HttpStatus.BAD_GATEWAY;
|
||||
log.warn("Account-link register rejected upstream: HTTP {}", e.status());
|
||||
return ResponseEntity.status(status).body(java.util.Map.of("error", "LINK_FAILED"));
|
||||
} catch (IOException e) {
|
||||
// Don't echo e.getMessage() to the browser: a DNS/connection/TLS failure can carry the
|
||||
// configured SaaS host/IP. Log it server-side; return the same opaque body the
|
||||
// UpstreamException branch does.
|
||||
log.warn("Account-link failed (transport): {}", e.getMessage());
|
||||
log.warn("Account-link connect rejected upstream: HTTP {}", e.status());
|
||||
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
|
||||
.body(java.util.Map.of("error", "LINK_FAILED"));
|
||||
.body(java.util.Map.of("error", "CONNECT_FAILED"));
|
||||
} catch (IOException e) {
|
||||
// Same reasoning as /link: a transport message can carry the configured SaaS host.
|
||||
log.warn("Account-link connect failed (transport): {}", e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
|
||||
.body(java.util.Map.of("error", "CONNECT_FAILED"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-establishes the admin's SaaS session for a server that is already linked. */
|
||||
@PostMapping("/connect/reauth")
|
||||
public ResponseEntity<?> connectReauth(
|
||||
@RequestBody(required = false) ConnectStartRequest req, HttpServletRequest http) {
|
||||
try {
|
||||
return ResponseEntity.ok(connectService.startReauth(callbackHint(req, http)));
|
||||
} catch (AccountLinkClient.UpstreamException e) {
|
||||
log.warn("Account-link reauth rejected upstream: HTTP {}", e.status());
|
||||
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
|
||||
.body(java.util.Map.of("error", "CONNECT_FAILED"));
|
||||
} catch (IOException e) {
|
||||
log.warn("Account-link reauth failed: {}", e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
|
||||
.body(java.util.Map.of("error", "CONNECT_FAILED"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Called by the callback page with the nonce it found in the fragment. */
|
||||
@PostMapping("/connect/complete")
|
||||
public ResponseEntity<ConnectService.ConnectStatus> connectComplete(
|
||||
@RequestBody(required = false) ConnectCompleteRequest req) {
|
||||
return ResponseEntity.ok(connectService.complete(req != null ? req.nonce() : null));
|
||||
}
|
||||
|
||||
/** Everything we know about where the admin's browser is, for the callback. */
|
||||
private static ConnectService.CallbackHint callbackHint(
|
||||
ConnectStartRequest req, HttpServletRequest http) {
|
||||
return new ConnectService.CallbackHint(
|
||||
req != null ? req.callbackUrl() : null, http.getHeader("Origin"), baseUrlOf(http));
|
||||
}
|
||||
|
||||
/**
|
||||
* This instance's base URL as the browser reached it, including any context path so a subpath
|
||||
* deployment builds a callback that actually resolves.
|
||||
*/
|
||||
private static String baseUrlOf(HttpServletRequest request) {
|
||||
String forwardedProto = firstHop(request.getHeader("X-Forwarded-Proto"));
|
||||
String forwardedHost = firstHop(request.getHeader("X-Forwarded-Host"));
|
||||
String scheme = forwardedProto != null ? forwardedProto : request.getScheme();
|
||||
String hostPort;
|
||||
if (forwardedHost != null) {
|
||||
hostPort = forwardedHost;
|
||||
} else {
|
||||
int port = request.getServerPort();
|
||||
boolean defaultPort =
|
||||
("http".equals(scheme) && port == 80)
|
||||
|| ("https".equals(scheme) && port == 443);
|
||||
hostPort = defaultPort ? request.getServerName() : request.getServerName() + ":" + port;
|
||||
}
|
||||
String context = request.getContextPath() == null ? "" : request.getContextPath();
|
||||
return scheme + "://" + hostPort + context;
|
||||
}
|
||||
|
||||
private static String firstHop(String headerValue) {
|
||||
if (headerValue == null || headerValue.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String first = headerValue.split(",")[0].strip();
|
||||
return first.isEmpty() ? null : first;
|
||||
}
|
||||
|
||||
@GetMapping("/status")
|
||||
public ResponseEntity<AccountLinkService.LinkStatus> status() {
|
||||
return ResponseEntity.ok(service.status());
|
||||
@@ -106,12 +155,7 @@ public class AccountLinkController {
|
||||
return ResponseEntity.ok(localUsageService.currentPeriodUnsynced());
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces an immediate usage sync to SaaS — the same work the daily scheduler does. An admin
|
||||
* "reconcile now" action (and a test aid so you don't wait on the scheduler). Idempotent:
|
||||
* re-reports the current cumulative, so a repeat trigger bills nothing. {@code 204} once run;
|
||||
* {@code 409} when metering is off (the sync bean is absent).
|
||||
*/
|
||||
/** Forces an immediate usage sync to SaaS — the same work the daily scheduler does. */
|
||||
@PostMapping("/sync-now")
|
||||
public ResponseEntity<Void> syncNow() {
|
||||
UsageSyncService sync = syncServiceProvider.getIfAvailable();
|
||||
|
||||
+8
-27
@@ -8,29 +8,17 @@ import org.springframework.stereotype.Component;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Self-hosted side of combined-billing "Mode A" (connected self-hosted).
|
||||
*
|
||||
* <p>Binds the {@code stirling.billing.account-link.*} keys. {@link #enabled} mirrors the same flag
|
||||
* the gated beans test with {@code @ConditionalOnProperty}; it is kept here only so non-conditional
|
||||
* code (e.g. the gate's flag-off short-circuit, exposed status) can read it. The whole feature is
|
||||
* <b>off by default</b> and <b>dark</b> — when off nothing gates and the link endpoints 404.
|
||||
*/
|
||||
/** Self-hosted side of combined billing: this instance bills through a linked SaaS team. */
|
||||
@Getter
|
||||
@Setter
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "stirling.billing.account-link")
|
||||
public class AccountLinkProperties {
|
||||
|
||||
/** Master switch. When {@code false} (default) the feature is fully inert. */
|
||||
/** Master switch. */
|
||||
private boolean enabled = false;
|
||||
|
||||
/**
|
||||
* Base URL of the SaaS backend this instance links to (register + entitlement live there).
|
||||
*
|
||||
* <p>STUB: defaults to the public cloud host; an operator overrides it for staging. There is no
|
||||
* existing SaaS-base-url property in the self-hosted profile, so this is introduced here.
|
||||
*/
|
||||
/** Base URL of the SaaS backend this instance links to (register + entitlement live there). */
|
||||
private String saasBaseUrl = "https://stirling.com/app";
|
||||
|
||||
/** Cached entitlement is reused for this long before a refresh is attempted. */
|
||||
@@ -39,20 +27,18 @@ public class AccountLinkProperties {
|
||||
/** Connect/read timeout for the outbound SaaS calls. */
|
||||
private int requestTimeoutSeconds = 10;
|
||||
|
||||
/** Phase 2 usage metering + daily sync. Keyed under {@code …account-link.metering.*}. */
|
||||
/** Phase 2 usage metering + daily sync. */
|
||||
private final Metering metering = new Metering();
|
||||
|
||||
/**
|
||||
* Dedicated billing switch, <b>separate</b> from {@link #enabled} so the link plumbing can be
|
||||
* enabled (e.g. to test linking) without ever turning on real usage metering, reporting, or cap
|
||||
* enforcement. Both default off; metering requires the master flag too. This is the production
|
||||
* safety key — flipping it on is what actually bills linked instances.
|
||||
* Separate from {@link #enabled} so linking can be exercised without billing anything. Both
|
||||
* default off, and metering needs the master flag as well.
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Metering {
|
||||
|
||||
/** Turns on usage metering, the daily sync, and cap enforcement. Default off. */
|
||||
/** Turns on usage metering, the daily sync, and cap enforcement. */
|
||||
private boolean enabled = false;
|
||||
|
||||
/**
|
||||
@@ -65,12 +51,7 @@ public class AccountLinkProperties {
|
||||
*/
|
||||
private int graceDays = 3;
|
||||
|
||||
/**
|
||||
* Dedup window for identical input sets. A re-run of the same inputs within this window is
|
||||
* treated as workflow chaining and not re-charged; the same inputs run again after it are
|
||||
* billed afresh. Mirrors the cloud's {@code payg.lineage.workflow-window} so the same op
|
||||
* costs the same on the instance and in the cloud.
|
||||
*/
|
||||
/** Dedup window for identical input sets. */
|
||||
private Duration workflowWindow = Duration.ofMinutes(5);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-24
@@ -1,6 +1,5 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
@@ -9,13 +8,7 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Linking orchestrator (self-hosted side of combined-billing "Mode A").
|
||||
*
|
||||
* <p>{@link #link} is the same-origin action the portal triggers: it relays the admin's Supabase
|
||||
* JWT to the SaaS register endpoint, then persists the returned device credential secure-at-rest.
|
||||
* The credential — not the JWT — authenticates all later unattended entitlement calls.
|
||||
*/
|
||||
/** Linking orchestrator (self-hosted side of combined billing). */
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("!saas")
|
||||
@@ -38,24 +31,9 @@ public class AccountLinkService {
|
||||
/** Status of this instance's link, for the portal's "Account link" card. */
|
||||
public record LinkStatus(boolean linked, String deviceId, Long teamId, String linkedAt) {}
|
||||
|
||||
/**
|
||||
* Registers this instance with the SaaS team behind {@code supabaseJwt} and stores the
|
||||
* credential.
|
||||
*
|
||||
* @throws IOException if the SaaS register call fails (surfaced to the admin as a link error).
|
||||
*/
|
||||
public LinkStatus link(String supabaseJwt, String instanceName) throws IOException {
|
||||
AccountLinkClient.RegisterResult result = client.register(supabaseJwt, instanceName);
|
||||
credentialStore.save(result.deviceId(), result.deviceSecret(), result.teamId());
|
||||
entitlementCache.invalidate();
|
||||
log.info("Account-link: instance linked to team {}", result.teamId());
|
||||
return status();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlinks this instance — best-effort tells SaaS to revoke first (so the row gets {@code
|
||||
* revoked_at} set), then clears locally regardless. If SaaS is unreachable the local clear
|
||||
* still proceeds (admin's intent must win); the orphan row can be revoked from the portal.
|
||||
* revoked_at} set), then clears locally regardless.
|
||||
*/
|
||||
public void unlink() {
|
||||
credentialStore
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Singleton row holding this instance's daily-sync bookkeeping (combined-billing "Mode A").
|
||||
* Singleton row holding this instance's daily-sync bookkeeping (combined billing).
|
||||
*
|
||||
* <p>{@link #lastSyncSeq} is reserved (incremented + persisted) <em>before</em> each report so it
|
||||
* is strictly monotonic across restarts and partial failures — SaaS dedups replays by comparing it,
|
||||
|
||||
+1
-1
@@ -2,5 +2,5 @@ package stirling.software.proprietary.accountlink;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/** Persistence for the singleton {@link AccountLinkSyncState} (combined-billing "Mode A"). */
|
||||
/** Persistence for the singleton {@link AccountLinkSyncState} (combined billing). */
|
||||
public interface AccountLinkSyncStateRepository extends JpaRepository<AccountLinkSyncState, Long> {}
|
||||
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Base64;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/** Browser-mediated account linking, instance side. */
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("!saas")
|
||||
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||
public class ConnectService {
|
||||
|
||||
/** Frontend route that consumes the callback fragment. */
|
||||
static final String CALLBACK_PATH = "/account-link/callback";
|
||||
|
||||
private static final int SECRET_BYTES = 32;
|
||||
|
||||
private final AccountLinkClient client;
|
||||
private final ConnectStateRepository stateRepo;
|
||||
private final DeviceCredentialStore credentialStore;
|
||||
private final EntitlementCache entitlementCache;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final SecureRandom random = new SecureRandom();
|
||||
|
||||
public ConnectService(
|
||||
AccountLinkClient client,
|
||||
ConnectStateRepository stateRepo,
|
||||
DeviceCredentialStore credentialStore,
|
||||
EntitlementCache entitlementCache,
|
||||
ApplicationProperties applicationProperties) {
|
||||
this.client = client;
|
||||
this.stateRepo = stateRepo;
|
||||
this.credentialStore = credentialStore;
|
||||
this.entitlementCache = entitlementCache;
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
public enum Phase {
|
||||
/** Nothing in flight and not linked. */
|
||||
NONE,
|
||||
/** A handshake is open, waiting for a leader to approve it on the SaaS site. */
|
||||
PENDING,
|
||||
/** Linked. */
|
||||
LINKED,
|
||||
/** The handshake outlived its window; start a new one. */
|
||||
EXPIRED,
|
||||
/** Declined or already used; start a new one. */
|
||||
REJECTED,
|
||||
/** SaaS could not be reached; the handshake is still valid and can be retried. */
|
||||
UNAVAILABLE
|
||||
}
|
||||
|
||||
/** What the portal renders. */
|
||||
public record ConnectStatus(
|
||||
Phase phase, String authorizeUrl, Long secondsRemaining, Long teamId) {
|
||||
static ConnectStatus of(Phase phase) {
|
||||
return new ConnectStatus(phase, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything we know about where the admin's browser actually is, in decreasing authority. */
|
||||
public record CallbackHint(
|
||||
String requestedCallbackUrl, String browserOrigin, String derivedBaseUrl) {}
|
||||
|
||||
/** Opens a handshake and returns where to send the admin. */
|
||||
@Transactional
|
||||
public ConnectStatus start(String name, CallbackHint hint) throws IOException {
|
||||
if (credentialStore.isLinked()) {
|
||||
return status();
|
||||
}
|
||||
return open(name, hint, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a handshake that only re-establishes the admin's browser session, for an instance that
|
||||
* is already linked.
|
||||
*/
|
||||
@Transactional
|
||||
public ConnectStatus startReauth(CallbackHint hint) throws IOException {
|
||||
DeviceCredential credential =
|
||||
credentialStore
|
||||
.get()
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IOException(
|
||||
"This server is not linked, so there is no session"
|
||||
+ " to re-establish"));
|
||||
return open(credential.getDeviceId(), hint, credential);
|
||||
}
|
||||
|
||||
private ConnectStatus open(String name, CallbackHint hint, DeviceCredential credential)
|
||||
throws IOException {
|
||||
String callbackUrl = resolveCallbackUrl(hint);
|
||||
if (callbackUrl == null) {
|
||||
throw new IOException(
|
||||
"Cannot determine where to send the admin back to; set system.frontendUrl");
|
||||
}
|
||||
String nonce = randomSecret();
|
||||
String claimSecret = randomSecret();
|
||||
|
||||
AccountLinkClient.ConnectRequestResult created =
|
||||
client.connectRequest(name, callbackUrl, nonce, claimSecret, credential);
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
ConnectState state = new ConnectState();
|
||||
state.setId(ConnectState.SINGLETON_ID);
|
||||
state.setRequestId(created.requestId());
|
||||
state.setNonce(nonce);
|
||||
state.setClaimSecret(claimSecret);
|
||||
state.setCallbackUrl(callbackUrl);
|
||||
state.setAuthorizeUrl(created.authorizeUrl());
|
||||
state.setCreatedAt(now);
|
||||
state.setExpiresAt(
|
||||
now.plusSeconds(created.expiresInSeconds() > 0 ? created.expiresInSeconds() : 900));
|
||||
stateRepo.save(state);
|
||||
|
||||
log.info("Account-link connect: handshake {} opened", created.requestId());
|
||||
return pendingStatus(state, now);
|
||||
}
|
||||
|
||||
/** Finishes a handshake from the callback the approval page redirected to. */
|
||||
@Transactional
|
||||
public ConnectStatus complete(String nonce) {
|
||||
Optional<ConnectState> found = stateRepo.findById(ConnectState.SINGLETON_ID);
|
||||
if (found.isEmpty()) {
|
||||
// Already finished (a double-submitted callback) or never started.
|
||||
return status();
|
||||
}
|
||||
ConnectState state = found.get();
|
||||
if (state.isExpired(LocalDateTime.now())) {
|
||||
stateRepo.delete(state);
|
||||
return ConnectStatus.of(Phase.EXPIRED);
|
||||
}
|
||||
if (nonce == null || !nonceMatches(nonce, state.getNonce())) {
|
||||
log.warn(
|
||||
"Account-link connect: callback for handshake {} had a bad nonce",
|
||||
state.getRequestId());
|
||||
return ConnectStatus.of(Phase.REJECTED);
|
||||
}
|
||||
|
||||
AccountLinkClient.ConnectClaimResult claim =
|
||||
client.connectClaim(state.getRequestId(), state.getClaimSecret());
|
||||
return switch (claim.outcome()) {
|
||||
case GRANTED -> {
|
||||
credentialStore.save(claim.deviceId(), claim.deviceSecret(), claim.teamId());
|
||||
entitlementCache.invalidate();
|
||||
stateRepo.delete(state);
|
||||
log.info("Account-link connect: linked to team {}", claim.teamId());
|
||||
yield new ConnectStatus(Phase.LINKED, null, null, claim.teamId());
|
||||
}
|
||||
case CONFIRMED -> {
|
||||
stateRepo.delete(state);
|
||||
log.info(
|
||||
"Account-link connect: session re-established for team {}", claim.teamId());
|
||||
yield new ConnectStatus(Phase.LINKED, null, null, claim.teamId());
|
||||
}
|
||||
case PENDING ->
|
||||
// The admin reached the callback before the approval committed. The row stays,
|
||||
// so a retry finishes it.
|
||||
ConnectStatus.of(Phase.PENDING);
|
||||
case REJECTED -> {
|
||||
stateRepo.delete(state);
|
||||
yield ConnectStatus.of(Phase.REJECTED);
|
||||
}
|
||||
case UNAVAILABLE -> ConnectStatus.of(Phase.UNAVAILABLE);
|
||||
};
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public ConnectStatus status() {
|
||||
Optional<DeviceCredential> credential = credentialStore.get();
|
||||
if (credential.isPresent()) {
|
||||
return new ConnectStatus(Phase.LINKED, null, null, credential.get().getTeamId());
|
||||
}
|
||||
Optional<ConnectState> state = stateRepo.findById(ConnectState.SINGLETON_ID);
|
||||
if (state.isEmpty()) {
|
||||
return ConnectStatus.of(Phase.NONE);
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (state.get().isExpired(now)) {
|
||||
return ConnectStatus.of(Phase.EXPIRED);
|
||||
}
|
||||
return pendingStatus(state.get(), now);
|
||||
}
|
||||
|
||||
private static ConnectStatus pendingStatus(ConnectState state, LocalDateTime now) {
|
||||
long remaining = Duration.between(now, state.getExpiresAt()).toSeconds();
|
||||
return new ConnectStatus(
|
||||
Phase.PENDING, state.getAuthorizeUrl(), Math.max(remaining, 0), null);
|
||||
}
|
||||
|
||||
/** Decides the callback, preferring knowledge over inference. */
|
||||
String resolveCallbackUrl(CallbackHint hint) {
|
||||
String configured = applicationProperties.getSystem().getFrontendUrl();
|
||||
if (configured != null && !configured.isBlank()) {
|
||||
return trimTrailingSlash(configured.strip()) + CALLBACK_PATH;
|
||||
}
|
||||
String browserOrigin = originOf(hint.browserOrigin());
|
||||
if (browserOrigin != null) {
|
||||
String requested = hint.requestedCallbackUrl();
|
||||
if (requested != null && browserOrigin.equals(originOf(requested))) {
|
||||
return requested.strip();
|
||||
}
|
||||
return browserOrigin + CALLBACK_PATH;
|
||||
}
|
||||
return hint.derivedBaseUrl() == null || hint.derivedBaseUrl().isBlank()
|
||||
? null
|
||||
: trimTrailingSlash(hint.derivedBaseUrl().strip()) + CALLBACK_PATH;
|
||||
}
|
||||
|
||||
/** Scheme, host and port of an absolute http(s) URL; null if it is not one. */
|
||||
private static String originOf(String candidate) {
|
||||
if (candidate == null || candidate.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
URI uri;
|
||||
try {
|
||||
uri = new URI(candidate.strip());
|
||||
} catch (URISyntaxException e) {
|
||||
return null;
|
||||
}
|
||||
if (uri.getScheme() == null || uri.getHost() == null) {
|
||||
return null;
|
||||
}
|
||||
String scheme = uri.getScheme().toLowerCase(Locale.ROOT);
|
||||
if (!"http".equals(scheme) && !"https".equals(scheme)) {
|
||||
return null;
|
||||
}
|
||||
int port = uri.getPort();
|
||||
boolean defaultPort =
|
||||
port == -1
|
||||
|| ("http".equals(scheme) && port == 80)
|
||||
|| ("https".equals(scheme) && port == 443);
|
||||
return defaultPort
|
||||
? scheme + "://" + uri.getHost()
|
||||
: scheme + "://" + uri.getHost() + ":" + port;
|
||||
}
|
||||
|
||||
private static String trimTrailingSlash(String value) {
|
||||
return value.replaceAll("/+$", "");
|
||||
}
|
||||
|
||||
private String randomSecret() {
|
||||
byte[] buf = new byte[SECRET_BYTES];
|
||||
random.nextBytes(buf);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
|
||||
}
|
||||
|
||||
/** Constant-time so a caller cannot probe the nonce a character at a time. */
|
||||
private static boolean nonceMatches(String candidate, String expected) {
|
||||
if (expected == null) {
|
||||
return false;
|
||||
}
|
||||
return MessageDigest.isEqual(
|
||||
candidate.getBytes(StandardCharsets.UTF_8),
|
||||
expected.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/** The one in-flight "connect this server" handshake, instance side. */
|
||||
@Entity
|
||||
@Table(name = "account_link_connect_state")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class ConnectState implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public static final Long SINGLETON_ID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "id")
|
||||
private Long id = SINGLETON_ID;
|
||||
|
||||
/** Opaque handle the SaaS side gave us; identifies the handshake on both sides. */
|
||||
@Column(name = "request_id", nullable = false, length = 64)
|
||||
private String requestId;
|
||||
|
||||
/** Correlator we minted. */
|
||||
@Column(name = "nonce", nullable = false, length = 128)
|
||||
private String nonce;
|
||||
|
||||
/** Secret we minted and sent to SaaS server to server. */
|
||||
@Column(name = "claim_secret", nullable = false, length = 128)
|
||||
private String claimSecret;
|
||||
|
||||
/** Where we asked the approval page to send the admin back to. */
|
||||
@Column(name = "callback_url", nullable = false, length = 2048)
|
||||
private String callbackUrl;
|
||||
|
||||
/** The approval URL handed to the browser, so a reload can offer it again. */
|
||||
@Column(name = "authorize_url", nullable = false, length = 2048)
|
||||
private String authorizeUrl;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private LocalDateTime expiresAt;
|
||||
|
||||
public boolean isExpired(LocalDateTime now) {
|
||||
return expiresAt != null && expiresAt.isBefore(now);
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/** Data access for the singleton {@link ConnectState} row. */
|
||||
public interface ConnectStateRepository extends JpaRepository<ConnectState, Long> {}
|
||||
+2
-2
@@ -13,8 +13,8 @@ import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* The device credential this self-hosted instance received when it linked a SaaS account
|
||||
* (combined-billing "Mode A"). Singleton — one instance links to exactly one SaaS team.
|
||||
* The device credential this self-hosted instance received when it linked a SaaS account (combined
|
||||
* billing). Singleton — one instance links to exactly one SaaS team.
|
||||
*
|
||||
* <p>Unlike the SaaS side (which stores only a hash), the instance must keep the plaintext {@code
|
||||
* deviceSecret} so it can present it on every unattended entitlement call. It lives in the local
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Decides whether a request may proceed under combined-billing "Mode A" on a self-hosted instance.
|
||||
* Decides whether a request may proceed under combined billing on a self-hosted instance.
|
||||
*
|
||||
* <p>Rules (in order):
|
||||
*
|
||||
|
||||
+2
-2
@@ -39,8 +39,8 @@ import stirling.software.proprietary.policy.controller.PolicyRunRoutes;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
|
||||
/**
|
||||
* Request-time gate + meter for combined-billing "Mode A". {@code preHandle} blocks billable (API /
|
||||
* AI / automation) work when the instance is unlinked or over its limit; manual tools pass through.
|
||||
* Request-time gate + meter for combined billing. {@code preHandle} blocks billable (API / AI /
|
||||
* automation) work when the instance is unlinked or over its limit; manual tools pass through.
|
||||
* {@code afterCompletion} meters a successful billable op into the per-period cumulative counter.
|
||||
*
|
||||
* <p>Blocking responds {@code 402} with a machine-readable body the FE maps to a "link to activate"
|
||||
|
||||
+5
-5
@@ -16,11 +16,11 @@ import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* The last time the instance metered a given input set this period — the local equivalent of the
|
||||
* cloud's lineage join (combined-billing "Mode A"). The meter dedups on a rolling <b>workflow
|
||||
* window</b>: an identical input set re-submitted within the window (see {@link
|
||||
* AccountLinkProperties.Metering}) is treated as workflow chaining and not re-charged, while the
|
||||
* same inputs run again after the window are billed afresh — matching the cloud's 5-minute open-job
|
||||
* window so the same operation costs the same on the instance and in the cloud.
|
||||
* cloud's lineage join (combined billing). The meter dedups on a rolling <b>workflow window</b>: an
|
||||
* identical input set re-submitted within the window (see {@link AccountLinkProperties.Metering})
|
||||
* is treated as workflow chaining and not re-charged, while the same inputs run again after the
|
||||
* window are billed afresh — matching the cloud's 5-minute open-job window so the same operation
|
||||
* costs the same on the instance and in the cloud.
|
||||
*
|
||||
* <p>{@code lastMeteredAt} is refreshed on every sighting (the window slides, as recording a cloud
|
||||
* artifact touches its job). One row per {@code (period, signature)}; the unique constraint also
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/** Persistence for the per-period metered input-set signatures (combined-billing "Mode A"). */
|
||||
/** Persistence for the per-period metered input-set signatures (combined billing). */
|
||||
public interface MeteredInputSignatureRepository
|
||||
extends JpaRepository<MeteredInputSignature, Long> {
|
||||
|
||||
|
||||
+4
-4
@@ -17,10 +17,10 @@ import lombok.NoArgsConstructor;
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
|
||||
/**
|
||||
* Durable per-(billing period, category) cumulative usage counter for combined-billing "Mode A".
|
||||
* Each successful billable op increments its row; the daily sync reports the cumulative totals and
|
||||
* SaaS bills the delta since the last sync. The cumulative model is idempotent (a resend bills
|
||||
* nothing) and tamper-evident (a counter that drops is a signal). One row per {@code (period_start,
|
||||
* Durable per-(billing period, category) cumulative usage counter for combined billing. Each
|
||||
* successful billable op increments its row; the daily sync reports the cumulative totals and SaaS
|
||||
* bills the delta since the last sync. The cumulative model is idempotent (a resend bills nothing)
|
||||
* and tamper-evident (a counter that drops is a signal). One row per {@code (period_start,
|
||||
* category)}, auto-created by Hibernate; only the flag-gated {@link UsageMeterService} writes it.
|
||||
*/
|
||||
@Entity
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/** Persistence for the per-period/per-category usage counters (combined-billing "Mode A"). */
|
||||
/** Persistence for the per-period/per-category usage counters (combined billing). */
|
||||
public interface UsageCounterRepository extends JpaRepository<UsageCounter, Long> {
|
||||
|
||||
/**
|
||||
|
||||
+2
-2
@@ -18,8 +18,8 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
|
||||
/**
|
||||
* Daily usage sender for combined-billing "Mode A". Reports each period's cumulative per-category
|
||||
* usage to SaaS, which bills the delta against its own last-seen totals.
|
||||
* Daily usage sender for combined billing. Reports each period's cumulative per-category usage to
|
||||
* SaaS, which bills the delta against its own last-seen totals.
|
||||
*
|
||||
* <p>Resilience: the sync seq is persisted before the report so it never regresses across
|
||||
* restarts/failures; a transport failure leaves the {@code lastSyncedUnits} markers untouched so
|
||||
|
||||
+3
-3
@@ -11,9 +11,9 @@ import java.util.HexFormat;
|
||||
|
||||
/**
|
||||
* SHA-256 content fingerprint shared by the SaaS charge path and the linked self-hosted instance's
|
||||
* meter (combined-billing "Mode A"), so both derive an <em>identical</em> signature for the same
|
||||
* bytes — the basis for lineage dedup. Pure, no Spring: fixed 64 KiB buffer (allocation independent
|
||||
* of file size), hardware-accelerated by the JVM where available.
|
||||
* meter (combined billing), so both derive an <em>identical</em> signature for the same bytes — the
|
||||
* basis for lineage dedup. Pure, no Spring: fixed 64 KiB buffer (allocation independent of file
|
||||
* size), hardware-accelerated by the JVM where available.
|
||||
*
|
||||
* <p>Lives in {@code :proprietary} (not {@code :common}) so it stays out of the community core
|
||||
* build yet is reachable from {@code :saas} (which depends on {@code :proprietary}).
|
||||
|
||||
+67
-27
@@ -21,9 +21,9 @@ import org.mockito.ArgumentCaptor;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Stubs the {@link HttpClient} so the SaaS endpoint is never actually called. Confirms register
|
||||
* relays the JWT and parses the credential, and that entitlement parsing + the fail-open (null on
|
||||
* unreachable) behaviour hold.
|
||||
* Stubs the {@link HttpClient} so the SaaS endpoint is never actually called. Confirms the connect
|
||||
* handshake refuses an authorize URL it would not navigate to and carries no user token, and that
|
||||
* entitlement parsing + the fail-open (null on unreachable) behaviour hold.
|
||||
*/
|
||||
class AccountLinkClientTest {
|
||||
|
||||
@@ -48,39 +48,79 @@ class AccountLinkClientTest {
|
||||
return resp;
|
||||
}
|
||||
|
||||
// register() is gone with the JWT relay, and with it the two tests that asserted this client
|
||||
// sends an Authorization: Bearer header. Nothing here carries a user token any more.
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void registerRelaysJwtAndParsesCredential() throws Exception {
|
||||
// Build the stub response first: nesting response() inside when() trips Mockito's
|
||||
// unfinished-stubbing check (inner when() runs mid outer when()).
|
||||
void connectRequestRefusesAnAuthorizeUrlItWouldNotNavigateTo() throws Exception {
|
||||
// The reply drives a browser navigation, so a non-absolute or non-http(s) value must fail
|
||||
// loudly here rather than reach the admin.
|
||||
HttpResponse<String> resp =
|
||||
response(201, "{\"deviceId\":\"dev-1\",\"deviceSecret\":\"sec-1\",\"teamId\":42}");
|
||||
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
|
||||
when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class)))
|
||||
.thenReturn(resp);
|
||||
response(201, "{\"requestId\":\"req-1\",\"authorizeUrl\":\"/link?request=req-1\"}");
|
||||
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
|
||||
|
||||
AccountLinkClient.RegisterResult result = client.register("jwt-token", "My Server");
|
||||
|
||||
assertEquals("dev-1", result.deviceId());
|
||||
assertEquals("sec-1", result.deviceSecret());
|
||||
assertEquals(42L, result.teamId());
|
||||
|
||||
HttpRequest sent = captor.getValue();
|
||||
assertEquals("Bearer jwt-token", sent.headers().firstValue("Authorization").orElse(null));
|
||||
assertEquals(
|
||||
"https://saas.example.com/api/v1/account-link/register", sent.uri().toString());
|
||||
assertThrows(
|
||||
java.io.IOException.class,
|
||||
() -> client.connectRequest("n", "https://pdf.example.com/cb", "nonce", "secret"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void registerThrowsUpstreamExceptionWithStatusOnNon2xx() throws Exception {
|
||||
HttpResponse<String> resp = response(401, "{\"error\":\"unauthorized\"}");
|
||||
void connectRequestParsesTheAuthorizeUrlItIsGiven() throws Exception {
|
||||
HttpResponse<String> resp =
|
||||
response(
|
||||
201,
|
||||
"{\"requestId\":\"req-1\",\"expiresIn\":900,"
|
||||
+ "\"authorizeUrl\":\"https://app.example.com/link?request=req-1\"}");
|
||||
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
|
||||
when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class)))
|
||||
.thenReturn(resp);
|
||||
|
||||
AccountLinkClient.ConnectRequestResult result =
|
||||
client.connectRequest("n", "https://pdf.example.com/cb", "nonce", "secret");
|
||||
|
||||
assertEquals("req-1", result.requestId());
|
||||
assertEquals("https://app.example.com/link?request=req-1", result.authorizeUrl());
|
||||
// No user token on this call, by design.
|
||||
assertEquals(null, captor.getValue().headers().firstValue("Authorization").orElse(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void connectClaimGrantsTheCredentialOnSuccess() throws Exception {
|
||||
HttpResponse<String> resp =
|
||||
response(200, "{\"deviceId\":\"dev-1\",\"deviceSecret\":\"sec-1\",\"teamId\":7}");
|
||||
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
|
||||
AccountLinkClient.UpstreamException ex =
|
||||
assertThrows(
|
||||
AccountLinkClient.UpstreamException.class,
|
||||
() -> client.register("jwt", null));
|
||||
assertEquals(401, ex.status());
|
||||
|
||||
AccountLinkClient.ConnectClaimResult result = client.connectClaim("req-1", "secret");
|
||||
|
||||
assertEquals(AccountLinkClient.ConnectClaimOutcome.GRANTED, result.outcome());
|
||||
assertEquals("dev-1", result.deviceId());
|
||||
assertEquals("sec-1", result.deviceSecret());
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void connectClaimMapsTheStatusItIsGiven() throws Exception {
|
||||
// The whole point of these four: a claim consumes the request server-side, so
|
||||
// reading 200 as anything but success loses the credential irrecoverably.
|
||||
assertEquals(AccountLinkClient.ConnectClaimOutcome.PENDING, claimOutcome(202, "{}"));
|
||||
assertEquals(AccountLinkClient.ConnectClaimOutcome.UNAVAILABLE, claimOutcome(503, "{}"));
|
||||
assertEquals(AccountLinkClient.ConnectClaimOutcome.REJECTED, claimOutcome(400, "{}"));
|
||||
assertEquals(
|
||||
AccountLinkClient.ConnectClaimOutcome.CONFIRMED,
|
||||
claimOutcome(200, "{\"status\":\"confirmed\",\"teamId\":7}"));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private AccountLinkClient.ConnectClaimOutcome claimOutcome(int status, String body)
|
||||
throws Exception {
|
||||
// Built before the when(), not inside it: response() stubs a mock of its own, and
|
||||
// Mockito cannot have that happen mid-stubbing.
|
||||
HttpResponse<String> resp = response(status, body);
|
||||
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
|
||||
return client.connectClaim("req-1", "secret").outcome();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+40
-33
@@ -1,6 +1,7 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -14,16 +15,15 @@ import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import stirling.software.proprietary.accountlink.AccountLinkController.LinkRequest;
|
||||
|
||||
/**
|
||||
* The local (self-hosted) account-link controller's error mapping: an upstream auth rejection
|
||||
* surfaces as 401/403 (so the portal can prompt a re-sign-in) while other upstream / transport
|
||||
* faults are a 502.
|
||||
* The local (self-hosted) account-link controller's error mapping. Every upstream or transport
|
||||
* failure is a 502, and the response body never echoes the exception, because a DNS or TLS message
|
||||
* can carry the configured SaaS host.
|
||||
*/
|
||||
class AccountLinkControllerTest {
|
||||
|
||||
private AccountLinkService service;
|
||||
private ConnectService connectService;
|
||||
private UsageSyncService syncService;
|
||||
private ObjectProvider<UsageSyncService> syncProvider;
|
||||
private AccountLinkController controller;
|
||||
@@ -32,47 +32,54 @@ class AccountLinkControllerTest {
|
||||
@SuppressWarnings("unchecked")
|
||||
void setUp() {
|
||||
service = mock(AccountLinkService.class);
|
||||
connectService = mock(ConnectService.class);
|
||||
syncService = mock(UsageSyncService.class);
|
||||
syncProvider = mock(ObjectProvider.class);
|
||||
controller =
|
||||
new AccountLinkController(service, mock(LocalUsageService.class), syncProvider);
|
||||
new AccountLinkController(
|
||||
service, connectService, mock(LocalUsageService.class), syncProvider);
|
||||
}
|
||||
|
||||
@Test
|
||||
void link_missingJwt_returns400() {
|
||||
ResponseEntity<?> resp = controller.link(new LinkRequest(" ", null));
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
// These asserted POST /link's error mapping, which distinguished 401/403 so the portal could
|
||||
// prompt a re-sign-in. That endpoint is gone with the JWT relay, and the distinction went with
|
||||
// it: connect/start carries no user token, so an upstream refusal is never the admin's session
|
||||
// and everything non-transport is a plain gateway failure.
|
||||
|
||||
@Test
|
||||
void link_upstreamUnauthorized_maps401() throws Exception {
|
||||
when(service.link("jwt", null))
|
||||
.thenThrow(new AccountLinkClient.UpstreamException(401, "bad token"));
|
||||
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void link_upstreamForbidden_maps403() throws Exception {
|
||||
when(service.link("jwt", null))
|
||||
.thenThrow(new AccountLinkClient.UpstreamException(403, "forbidden"));
|
||||
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void link_upstreamServerError_maps502() throws Exception {
|
||||
when(service.link("jwt", null))
|
||||
void connectStart_upstreamFailure_maps502() throws Exception {
|
||||
when(connectService.start(any(), any()))
|
||||
.thenThrow(new AccountLinkClient.UpstreamException(500, "boom"));
|
||||
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
|
||||
|
||||
ResponseEntity<?> resp = controller.connectStart(null, request());
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void link_transportFailure_maps502() throws Exception {
|
||||
when(service.link("jwt", null)).thenThrow(new IOException("connection refused"));
|
||||
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
|
||||
void connectStart_transportFailure_maps502WithoutLeakingTheHost() throws Exception {
|
||||
when(connectService.start(any(), any()))
|
||||
.thenThrow(new IOException("connection refused to saas.internal:8081"));
|
||||
|
||||
ResponseEntity<?> resp = controller.connectStart(null, request());
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
|
||||
// The body must not echo the exception: a DNS/TLS message can carry the configured SaaS
|
||||
// host.
|
||||
assertThat(String.valueOf(resp.getBody())).doesNotContain("saas.internal");
|
||||
}
|
||||
|
||||
@Test
|
||||
void connectReauth_onAnUnlinkedServer_maps502() throws Exception {
|
||||
when(connectService.startReauth(any())).thenThrow(new IOException("not linked"));
|
||||
|
||||
ResponseEntity<?> resp = controller.connectReauth(null, request());
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
|
||||
}
|
||||
|
||||
/** Minimal request: the controller only reads Origin and the forwarded/host details from it. */
|
||||
private static jakarta.servlet.http.HttpServletRequest request() {
|
||||
return new org.springframework.mock.web.MockHttpServletRequest();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+6
-16
@@ -3,12 +3,10 @@ package stirling.software.proprietary.accountlink;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -30,33 +28,25 @@ class AccountLinkServiceTest {
|
||||
service = new AccountLinkService(client, store, cache);
|
||||
}
|
||||
|
||||
// The two link() tests here are gone with the JWT relay. Storing a credential and invalidating
|
||||
// the entitlement cache is now ConnectService's job and is covered by ConnectServiceTest; what
|
||||
// remains in this service is status and unlink.
|
||||
|
||||
@Test
|
||||
void link_storesCredentialAndInvalidatesCache() throws IOException {
|
||||
when(client.register("jwt", "name"))
|
||||
.thenReturn(new AccountLinkClient.RegisterResult("dev-1", "sec-1", 7L));
|
||||
void status_linkedFromTheStoredCredential() {
|
||||
DeviceCredential stored = new DeviceCredential();
|
||||
stored.setDeviceId("dev-1");
|
||||
stored.setTeamId(7L);
|
||||
stored.setLinkedAt(LocalDateTime.now());
|
||||
when(store.get()).thenReturn(Optional.of(stored));
|
||||
|
||||
AccountLinkService.LinkStatus status = service.link("jwt", "name");
|
||||
AccountLinkService.LinkStatus status = service.status();
|
||||
|
||||
verify(store).save("dev-1", "sec-1", 7L);
|
||||
verify(cache).invalidate();
|
||||
assertTrue(status.linked());
|
||||
assertEquals("dev-1", status.deviceId());
|
||||
assertEquals(7L, status.teamId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void link_propagatesRegisterFailure() throws IOException {
|
||||
when(client.register(any(), any())).thenThrow(new IOException("boom"));
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
IOException.class, () -> service.link("jwt", null));
|
||||
verify(cache, org.mockito.Mockito.never()).invalidate();
|
||||
}
|
||||
|
||||
@Test
|
||||
void status_unlinkedWhenNoCredential() {
|
||||
when(store.get()).thenReturn(Optional.empty());
|
||||
|
||||
+408
@@ -0,0 +1,408 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.accountlink.AccountLinkClient.ConnectClaimOutcome;
|
||||
import stirling.software.proprietary.accountlink.AccountLinkClient.ConnectClaimResult;
|
||||
import stirling.software.proprietary.accountlink.AccountLinkClient.ConnectRequestResult;
|
||||
import stirling.software.proprietary.accountlink.ConnectService.Phase;
|
||||
|
||||
/** Unit tests for the instance half of the connect handshake. */
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ConnectServiceTest {
|
||||
|
||||
private static final String NONCE = "the-nonce";
|
||||
private static final String CLAIM_SECRET = "the-claim-secret";
|
||||
private static final String AUTHORIZE_URL = "https://app.example.com/link?request=req-1";
|
||||
|
||||
@Mock private AccountLinkClient client;
|
||||
@Mock private ConnectStateRepository stateRepo;
|
||||
@Mock private DeviceCredentialStore credentialStore;
|
||||
@Mock private EntitlementCache entitlementCache;
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private ConnectService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
service =
|
||||
new ConnectService(
|
||||
client,
|
||||
stateRepo,
|
||||
credentialStore,
|
||||
entitlementCache,
|
||||
applicationProperties);
|
||||
}
|
||||
|
||||
private void configureFrontendUrl(String url) {
|
||||
applicationProperties.getSystem().setFrontendUrl(url);
|
||||
}
|
||||
|
||||
@Test
|
||||
void start_advertisesTheConfiguredFrontendUrlInPreferenceToTheRequest() throws Exception {
|
||||
configureFrontendUrl("https://pdf.example.com/");
|
||||
stubCreate();
|
||||
|
||||
service.start("prod-1", fromRequest("http://10.0.0.5:8080"));
|
||||
|
||||
verify(client)
|
||||
.connectRequest(
|
||||
anyString(),
|
||||
// Trailing slash trimmed, and the request's own view ignored.
|
||||
org.mockito.ArgumentMatchers.eq(
|
||||
"https://pdf.example.com" + ConnectService.CALLBACK_PATH),
|
||||
anyString(),
|
||||
anyString(),
|
||||
// A first link carries no credential; that is what makes it a first link.
|
||||
org.mockito.ArgumentMatchers.isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void start_fallsBackToTheAddressTheRequestArrivedOn() throws Exception {
|
||||
stubCreate();
|
||||
|
||||
service.start(null, fromRequest("https://pdf.internal:8443/stirling"));
|
||||
|
||||
ArgumentCaptor<String> callback = ArgumentCaptor.forClass(String.class);
|
||||
verify(client).connectRequest(any(), callback.capture(), anyString(), anyString(), any());
|
||||
// Context path preserved, so a subpath deployment gets a callback that resolves.
|
||||
assertThat(callback.getValue())
|
||||
.isEqualTo("https://pdf.internal:8443/stirling" + ConnectService.CALLBACK_PATH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void start_withNoAddressAtAllFailsRatherThanGuessing() {
|
||||
assertThat(catchIo(() -> service.start(null, fromRequest(null))))
|
||||
.hasMessageContaining("system.frontendUrl");
|
||||
verifyNoInteractions(client);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveCallback_honoursThePortalsOwnCallbackWhenTheBrowserOriginAgrees() {
|
||||
// The frontend is the only party that knows its router's base path.
|
||||
String requested = "http://localhost:5173/app/account-link/callback";
|
||||
|
||||
assertThat(
|
||||
service.resolveCallbackUrl(
|
||||
new ConnectService.CallbackHint(
|
||||
requested,
|
||||
"http://localhost:5173",
|
||||
"http://localhost:8080")))
|
||||
.isEqualTo(requested);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveCallback_ignoresACallbackFromADifferentOrigin() {
|
||||
assertThat(
|
||||
service.resolveCallbackUrl(
|
||||
new ConnectService.CallbackHint(
|
||||
"https://evil.example.com/steal",
|
||||
"http://localhost:5173",
|
||||
"http://localhost:8080")))
|
||||
.isEqualTo("http://localhost:5173" + ConnectService.CALLBACK_PATH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveCallback_prefersTheBrowserOriginOverTheApiRequest() {
|
||||
// The whole point: :5173 is where the admin is, :8080 is where the call landed.
|
||||
assertThat(
|
||||
service.resolveCallbackUrl(
|
||||
new ConnectService.CallbackHint(
|
||||
null, "http://localhost:5173", "http://localhost:8080")))
|
||||
.isEqualTo("http://localhost:5173" + ConnectService.CALLBACK_PATH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveCallback_letsConfigurationBeatEverything() {
|
||||
configureFrontendUrl("https://pdf.example.com/");
|
||||
|
||||
assertThat(
|
||||
service.resolveCallbackUrl(
|
||||
new ConnectService.CallbackHint(
|
||||
"http://localhost:5173/account-link/callback",
|
||||
"http://localhost:5173",
|
||||
"http://localhost:8080")))
|
||||
.isEqualTo("https://pdf.example.com" + ConnectService.CALLBACK_PATH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveCallback_ignoresAnUnusableOriginHeader() {
|
||||
// "null" is what a browser sends for an opaque origin; it must not become a callback.
|
||||
assertThat(
|
||||
service.resolveCallbackUrl(
|
||||
new ConnectService.CallbackHint(
|
||||
null, "null", "http://localhost:8080")))
|
||||
.isEqualTo("http://localhost:8080" + ConnectService.CALLBACK_PATH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void start_sendsTheAdminWhereverSaaSSaidToSendThem() throws Exception {
|
||||
stubCreate();
|
||||
|
||||
ConnectService.ConnectStatus status =
|
||||
service.start(null, fromRequest("https://pdf.example.com"));
|
||||
|
||||
assertThat(status.phase()).isEqualTo(Phase.PENDING);
|
||||
// Not composed here: only the SaaS side knows where its approval page lives, so an
|
||||
// instance configuring that could only get it wrong.
|
||||
assertThat(status.authorizeUrl()).isEqualTo(AUTHORIZE_URL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void start_keepsTheNonceAndClaimSecretItSent() throws Exception {
|
||||
stubCreate();
|
||||
|
||||
service.start(null, fromRequest("https://pdf.example.com"));
|
||||
|
||||
ArgumentCaptor<String> nonce = ArgumentCaptor.forClass(String.class);
|
||||
ArgumentCaptor<String> secret = ArgumentCaptor.forClass(String.class);
|
||||
verify(client).connectRequest(any(), anyString(), nonce.capture(), secret.capture(), any());
|
||||
|
||||
ArgumentCaptor<ConnectState> saved = ArgumentCaptor.forClass(ConnectState.class);
|
||||
verify(stateRepo).save(saved.capture());
|
||||
assertThat(saved.getValue().getNonce()).isEqualTo(nonce.getValue());
|
||||
assertThat(saved.getValue().getClaimSecret()).isEqualTo(secret.getValue());
|
||||
// Two independent secrets, not one value used twice.
|
||||
assertThat(nonce.getValue()).isNotEqualTo(secret.getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void start_whenAlreadyLinkedDoesNothing() throws Exception {
|
||||
when(credentialStore.isLinked()).thenReturn(true);
|
||||
when(credentialStore.get()).thenReturn(Optional.of(credential(7L)));
|
||||
|
||||
ConnectService.ConnectStatus status =
|
||||
service.start(null, fromRequest("https://pdf.example.com"));
|
||||
|
||||
assertThat(status.phase()).isEqualTo(Phase.LINKED);
|
||||
verifyNoInteractions(client);
|
||||
verify(stateRepo, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_withTheRightNonceStoresTheCredentialAndClearsTheHandshake() {
|
||||
ConnectState state = openHandshake();
|
||||
when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state));
|
||||
when(client.connectClaim("req-1", CLAIM_SECRET))
|
||||
.thenReturn(new ConnectClaimResult(ConnectClaimOutcome.GRANTED, "dev", "sec", 7L));
|
||||
|
||||
ConnectService.ConnectStatus status = service.complete(NONCE);
|
||||
|
||||
assertThat(status.phase()).isEqualTo(Phase.LINKED);
|
||||
assertThat(status.teamId()).isEqualTo(7L);
|
||||
verify(credentialStore).save("dev", "sec", 7L);
|
||||
verify(entitlementCache).invalidate();
|
||||
verify(stateRepo).delete(state);
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_withAWrongNonceClaimsNothingAndLeavesTheHandshakeAlone() {
|
||||
ConnectState state = openHandshake();
|
||||
when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state));
|
||||
|
||||
ConnectService.ConnectStatus status = service.complete("not-the-nonce");
|
||||
|
||||
assertThat(status.phase()).isEqualTo(Phase.REJECTED);
|
||||
// The important half: an unverified caller cannot cancel a legitimate handshake.
|
||||
verify(stateRepo, never()).delete(any());
|
||||
verifyNoInteractions(credentialStore);
|
||||
verify(client, never()).connectClaim(anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_withNoNonceAtAllIsRejected() {
|
||||
when(stateRepo.findById(ConnectState.SINGLETON_ID))
|
||||
.thenReturn(Optional.of(openHandshake()));
|
||||
|
||||
assertThat(service.complete(null).phase()).isEqualTo(Phase.REJECTED);
|
||||
verify(client, never()).connectClaim(anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_whenSaaSHasNotCommittedTheApprovalKeepsTheHandshake() {
|
||||
when(stateRepo.findById(ConnectState.SINGLETON_ID))
|
||||
.thenReturn(Optional.of(openHandshake()));
|
||||
when(client.connectClaim(anyString(), anyString()))
|
||||
.thenReturn(ConnectClaimResult.of(ConnectClaimOutcome.PENDING));
|
||||
|
||||
assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.PENDING);
|
||||
verify(stateRepo, never()).delete(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_whenSaaSIsUnreachableKeepsTheHandshakeForARetry() {
|
||||
when(stateRepo.findById(ConnectState.SINGLETON_ID))
|
||||
.thenReturn(Optional.of(openHandshake()));
|
||||
when(client.connectClaim(anyString(), anyString()))
|
||||
.thenReturn(ConnectClaimResult.of(ConnectClaimOutcome.UNAVAILABLE));
|
||||
|
||||
assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.UNAVAILABLE);
|
||||
verify(stateRepo, never()).delete(any());
|
||||
verifyNoInteractions(credentialStore);
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_whenDeclinedClearsTheHandshake() {
|
||||
ConnectState state = openHandshake();
|
||||
when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state));
|
||||
when(client.connectClaim(anyString(), anyString()))
|
||||
.thenReturn(ConnectClaimResult.of(ConnectClaimOutcome.REJECTED));
|
||||
|
||||
assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.REJECTED);
|
||||
verify(stateRepo).delete(state);
|
||||
verifyNoInteractions(credentialStore);
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_onAnExpiredHandshakeClearsItWithoutClaiming() {
|
||||
ConnectState state = openHandshake();
|
||||
state.setExpiresAt(LocalDateTime.now().minusSeconds(1));
|
||||
when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state));
|
||||
|
||||
assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.EXPIRED);
|
||||
verify(stateRepo).delete(state);
|
||||
verify(client, never()).connectClaim(anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void startReauth_presentsTheCredentialSoSaaSCanPinTheTeam() throws Exception {
|
||||
when(credentialStore.get()).thenReturn(Optional.of(credential(7L)));
|
||||
when(client.connectRequest(any(), anyString(), anyString(), anyString(), any()))
|
||||
.thenReturn(new ConnectRequestResult("req-1", 900, AUTHORIZE_URL));
|
||||
|
||||
service.startReauth(fromRequest("https://pdf.example.com"));
|
||||
|
||||
// Sending the credential is what makes the pinning trustworthy: the team comes from
|
||||
// something only this instance holds.
|
||||
verify(client)
|
||||
.connectRequest(
|
||||
any(),
|
||||
anyString(),
|
||||
anyString(),
|
||||
anyString(),
|
||||
org.mockito.ArgumentMatchers.argThat(
|
||||
c -> c != null && "dev".equals(c.getDeviceId())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void startReauth_onAnUnlinkedServerFails() {
|
||||
assertThat(catchIo(() -> service.startReauth(fromRequest("https://pdf.example.com"))))
|
||||
.hasMessageContaining("not linked");
|
||||
verifyNoInteractions(client);
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_onAConfirmedReauthKeepsTheExistingCredential() {
|
||||
ConnectState state = openHandshake();
|
||||
when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state));
|
||||
when(client.connectClaim(anyString(), anyString()))
|
||||
.thenReturn(new ConnectClaimResult(ConnectClaimOutcome.CONFIRMED, null, null, 7L));
|
||||
|
||||
ConnectService.ConnectStatus status = service.complete(NONCE);
|
||||
|
||||
assertThat(status.phase()).isEqualTo(Phase.LINKED);
|
||||
assertThat(status.teamId()).isEqualTo(7L);
|
||||
// Nothing to store: a second credential would orphan the one we already hold.
|
||||
verify(credentialStore, never()).save(anyString(), anyString(), any());
|
||||
verify(stateRepo).delete(state);
|
||||
}
|
||||
|
||||
@Test
|
||||
void status_reportsNothingInFlightWhenThereIsNoHandshakeOrCredential() {
|
||||
assertThat(service.status().phase()).isEqualTo(Phase.NONE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void status_reportsAnExpiredHandshakeRatherThanOfferingAStaleLink() {
|
||||
ConnectState state = openHandshake();
|
||||
state.setExpiresAt(LocalDateTime.now().minusSeconds(1));
|
||||
when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state));
|
||||
|
||||
ConnectService.ConnectStatus status = service.status();
|
||||
|
||||
assertThat(status.phase()).isEqualTo(Phase.EXPIRED);
|
||||
assertThat(status.authorizeUrl()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void status_countsDownWhileAHandshakeIsOpen() {
|
||||
when(stateRepo.findById(ConnectState.SINGLETON_ID))
|
||||
.thenReturn(Optional.of(openHandshake()));
|
||||
|
||||
ConnectService.ConnectStatus status = service.status();
|
||||
|
||||
assertThat(status.phase()).isEqualTo(Phase.PENDING);
|
||||
assertThat(status.secondsRemaining()).isPositive();
|
||||
assertThat(status.authorizeUrl()).isEqualTo("https://app.example.com/link?request=req-1");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/** A start with nothing but the reconstructed request URL, as a headless caller would send. */
|
||||
private static ConnectService.CallbackHint fromRequest(String derivedBaseUrl) {
|
||||
return new ConnectService.CallbackHint(null, null, derivedBaseUrl);
|
||||
}
|
||||
|
||||
private void stubCreate() throws Exception {
|
||||
// The five-argument overload: a first link passes a null credential rather than none.
|
||||
when(client.connectRequest(any(), anyString(), anyString(), anyString(), any()))
|
||||
.thenReturn(new ConnectRequestResult("req-1", 900, AUTHORIZE_URL));
|
||||
}
|
||||
|
||||
private static ConnectState openHandshake() {
|
||||
ConnectState state = new ConnectState();
|
||||
state.setId(ConnectState.SINGLETON_ID);
|
||||
state.setRequestId("req-1");
|
||||
state.setNonce(NONCE);
|
||||
state.setClaimSecret(CLAIM_SECRET);
|
||||
state.setCallbackUrl("https://pdf.example.com/account-link/callback");
|
||||
state.setAuthorizeUrl("https://app.example.com/link?request=req-1");
|
||||
state.setCreatedAt(LocalDateTime.now());
|
||||
state.setExpiresAt(LocalDateTime.now().plusMinutes(10));
|
||||
return state;
|
||||
}
|
||||
|
||||
private static DeviceCredential credential(Long teamId) {
|
||||
DeviceCredential credential = new DeviceCredential();
|
||||
credential.setDeviceId("dev");
|
||||
credential.setDeviceSecret("sec");
|
||||
credential.setTeamId(teamId);
|
||||
credential.setLinkedAt(LocalDateTime.now());
|
||||
return credential;
|
||||
}
|
||||
|
||||
/** Runs a throwing call and returns the exception, so the assertion reads in one line. */
|
||||
private static Throwable catchIo(ThrowingCall call) {
|
||||
try {
|
||||
call.run();
|
||||
throw new AssertionError("expected the call to fail");
|
||||
} catch (Exception e) {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
|
||||
private interface ThrowingCall {
|
||||
void run() throws Exception;
|
||||
}
|
||||
}
|
||||
+7
-85
@@ -4,14 +4,12 @@ import java.util.List;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@@ -19,25 +17,9 @@ import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.enumeration.TeamRole;
|
||||
import stirling.software.proprietary.model.TeamMembership;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
|
||||
import stirling.software.saas.util.AuthenticationUtils;
|
||||
import stirling.software.saas.accountlink.LeaderTeamResolver.LeaderTeam;
|
||||
|
||||
/**
|
||||
* Account-link registration surface (combined-billing "Mode A").
|
||||
*
|
||||
* <p>A self-hosted instance's local backend calls {@code POST /register} with the admin's
|
||||
* short-lived Supabase JWT (validated by the existing {@code SupabaseSecurityConfig} chain — no new
|
||||
* auth here). We resolve the caller's team, mint a device credential bound to it, and return the
|
||||
* secret exactly once. Ongoing entitlement reads authenticate with that device credential, not this
|
||||
* JWT.
|
||||
*
|
||||
* <p>Whole surface gated behind {@code stirling.billing.account-link.enabled}: off → beans absent →
|
||||
* 404. Leader-only, and the team is always derived from the caller (never the request body).
|
||||
*/
|
||||
/** Team-wide management of linked instances (combined billing). */
|
||||
@Slf4j
|
||||
@Hidden
|
||||
@RestController
|
||||
@@ -47,25 +29,13 @@ import stirling.software.saas.util.AuthenticationUtils;
|
||||
public class AccountLinkController {
|
||||
|
||||
private final AccountLinkService service;
|
||||
private final TeamMembershipRepository memberRepo;
|
||||
private final UserRepository userRepository;
|
||||
private final LeaderTeamResolver leaderTeams;
|
||||
|
||||
public AccountLinkController(
|
||||
AccountLinkService service,
|
||||
TeamMembershipRepository memberRepo,
|
||||
UserRepository userRepository) {
|
||||
public AccountLinkController(AccountLinkService service, LeaderTeamResolver leaderTeams) {
|
||||
this.service = service;
|
||||
this.memberRepo = memberRepo;
|
||||
this.userRepository = userRepository;
|
||||
this.leaderTeams = leaderTeams;
|
||||
}
|
||||
|
||||
/** Optional display name for the instance (hostname / label). */
|
||||
public record RegisterRequest(String name) {}
|
||||
|
||||
/** {@code deviceSecret} is plaintext and returned exactly once — the caller must store it. */
|
||||
public record RegisterResponse(
|
||||
Long instanceId, Long teamId, String deviceId, String deviceSecret, String name) {}
|
||||
|
||||
public record InstanceRow(
|
||||
Long instanceId,
|
||||
String deviceId,
|
||||
@@ -74,31 +44,10 @@ public class AccountLinkController {
|
||||
String lastSeenAt,
|
||||
boolean revoked) {}
|
||||
|
||||
@PostMapping("/register")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<RegisterResponse> register(
|
||||
@RequestBody(required = false) RegisterRequest req, Authentication auth) {
|
||||
LeaderTeam lt = resolveLeaderTeam(auth);
|
||||
if (lt.error() != null) {
|
||||
return ResponseEntity.status(lt.error()).build();
|
||||
}
|
||||
String name = req != null ? req.name() : null;
|
||||
AccountLinkService.RegisteredInstance reg =
|
||||
service.register(lt.teamId(), lt.userId(), name);
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(
|
||||
new RegisterResponse(
|
||||
reg.instanceId(),
|
||||
lt.teamId(),
|
||||
reg.deviceId(),
|
||||
reg.deviceSecret(),
|
||||
reg.name()));
|
||||
}
|
||||
|
||||
@GetMapping("/instances")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<List<InstanceRow>> list(Authentication auth) {
|
||||
LeaderTeam lt = resolveLeaderTeam(auth);
|
||||
LeaderTeam lt = leaderTeams.resolve(auth);
|
||||
if (lt.error() != null) {
|
||||
return ResponseEntity.status(lt.error()).build();
|
||||
}
|
||||
@@ -124,38 +73,11 @@ public class AccountLinkController {
|
||||
@PostMapping("/instances/{instanceId}/revoke")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<Void> revoke(@PathVariable Long instanceId, Authentication auth) {
|
||||
LeaderTeam lt = resolveLeaderTeam(auth);
|
||||
LeaderTeam lt = leaderTeams.resolve(auth);
|
||||
if (lt.error() != null) {
|
||||
return ResponseEntity.status(lt.error()).build();
|
||||
}
|
||||
boolean ok = service.revoke(lt.teamId(), instanceId);
|
||||
return ok ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Helpers — team always derived from the caller; instance linking is a leader (billing) action.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolved caller team, or an {@code error} status to return (teamId/userId null when error).
|
||||
*/
|
||||
private record LeaderTeam(Long teamId, Long userId, HttpStatus error) {}
|
||||
|
||||
private LeaderTeam resolveLeaderTeam(Authentication auth) {
|
||||
User user;
|
||||
try {
|
||||
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
||||
} catch (SecurityException e) {
|
||||
return new LeaderTeam(null, null, HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
|
||||
if (rows.isEmpty()) {
|
||||
return new LeaderTeam(null, null, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
TeamMembership m = rows.getFirst();
|
||||
if (m.getRole() != TeamRole.LEADER) {
|
||||
return new LeaderTeam(null, null, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
return new LeaderTeam(m.getTeam().getId(), user.getId(), null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,16 +18,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Account-link instance registration + lifecycle (combined-billing "Mode A").
|
||||
*
|
||||
* <p>Mints a {@code device_id} (public) + {@code device_secret} (high-entropy, returned once) bound
|
||||
* to a team, persisting only the SHA-256 hash of the secret. The instance authenticates its
|
||||
* unattended entitlement reads with that credential.
|
||||
*
|
||||
* <p>Gated behind {@code stirling.billing.account-link.enabled}: when off the bean is absent, so
|
||||
* {@link AccountLinkController} (which depends on it) drops out too and its endpoints 404.
|
||||
*/
|
||||
/** Account-link instance registration + lifecycle (combined billing). */
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@@ -80,10 +71,7 @@ public class AccountLinkService {
|
||||
return repo.findByTeamIdOrderByCreatedAtDesc(teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes an instance iff it belongs to {@code teamId}. Returns false if not found or owned by
|
||||
* a different team (so a caller can never revoke another team's instance). Idempotent.
|
||||
*/
|
||||
/** Revokes an instance iff it belongs to {@code teamId}. */
|
||||
@Transactional
|
||||
public boolean revoke(Long teamId, Long instanceId) {
|
||||
Optional<LinkedInstance> found = repo.findById(instanceId);
|
||||
@@ -99,13 +87,30 @@ public class AccountLinkService {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an active instance from a device credential, or empty if it does not authenticate.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<LinkedInstance> resolveActiveInstance(String deviceId, String deviceSecret) {
|
||||
if (deviceId == null || deviceSecret == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return repo.findByDeviceIdAndRevokedAtIsNull(deviceId)
|
||||
.filter(
|
||||
instance ->
|
||||
MessageDigest.isEqual(
|
||||
sha256Hex(deviceSecret).getBytes(StandardCharsets.UTF_8),
|
||||
instance.getDeviceSecretHash()
|
||||
.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
private String randomSecret() {
|
||||
byte[] buf = new byte[SECRET_BYTES];
|
||||
random.nextBytes(buf);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
|
||||
}
|
||||
|
||||
/** SHA-256 hex of a value. The device secret is high-entropy, so no salt is required. */
|
||||
/** SHA-256 hex of a value. */
|
||||
static String sha256Hex(String value) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
package stirling.software.saas.accountlink;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.saas.accountlink.LeaderTeamResolver.LeaderTeam;
|
||||
|
||||
/** Browser-mediated "connect this server" handshake. */
|
||||
@Slf4j
|
||||
@Hidden
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/account-link/connect")
|
||||
@Profile("saas")
|
||||
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||
public class ConnectController {
|
||||
|
||||
/** Same headers the device-credential filter uses on the {@code /api/v1/instance} paths. */
|
||||
static final String HEADER_DEVICE_ID = "X-Device-Id";
|
||||
|
||||
static final String HEADER_DEVICE_SECRET = "X-Device-Secret";
|
||||
|
||||
/** Frontend route serving the approval page. */
|
||||
static final String LINK_PATH = "/link";
|
||||
|
||||
private final ConnectRequestService service;
|
||||
private final LeaderTeamResolver leaderTeams;
|
||||
private final AccountLinkService accountLinkService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
public ConnectController(
|
||||
ConnectRequestService service,
|
||||
LeaderTeamResolver leaderTeams,
|
||||
AccountLinkService accountLinkService,
|
||||
ApplicationProperties applicationProperties) {
|
||||
this.service = service;
|
||||
this.leaderTeams = leaderTeams;
|
||||
this.accountLinkService = accountLinkService;
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
/** Sent by the instance's own backend, before it holds any credential. */
|
||||
public record CreateBody(String name, String callbackUrl, String nonce, String claimSecret) {}
|
||||
|
||||
/** {@code authorizeUrl} is where the instance should send its admin. */
|
||||
public record CreateResponse(String requestId, int expiresIn, String authorizeUrl) {}
|
||||
|
||||
/** What the approval page renders. */
|
||||
public record ViewResponse(
|
||||
String requestId,
|
||||
String name,
|
||||
String callbackOrigin,
|
||||
boolean insecureTransport,
|
||||
String mode,
|
||||
String status) {}
|
||||
|
||||
/** Where the approver's browser goes next, and the correlator the instance is waiting on. */
|
||||
public record ApproveResponse(String callbackUrl, String nonce) {}
|
||||
|
||||
public record ClaimBody(String requestId, String claimSecret) {}
|
||||
|
||||
public record ClaimResponse(String deviceId, String deviceSecret, Long teamId) {}
|
||||
|
||||
/** Opens a handshake. */
|
||||
@PostMapping("/request")
|
||||
public ResponseEntity<?> request(
|
||||
@RequestBody(required = false) CreateBody body, HttpServletRequest http) {
|
||||
if (body == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "BAD_REQUEST"));
|
||||
}
|
||||
String deviceId = http.getHeader(HEADER_DEVICE_ID);
|
||||
String deviceSecret = http.getHeader(HEADER_DEVICE_SECRET);
|
||||
boolean reauthRequested = deviceId != null || deviceSecret != null;
|
||||
|
||||
ConnectRequestService.CreateResult result;
|
||||
if (reauthRequested) {
|
||||
Long pinnedTeamId =
|
||||
accountLinkService
|
||||
.resolveActiveInstance(deviceId, deviceSecret)
|
||||
.map(LinkedInstance::getTeamId)
|
||||
.orElse(null);
|
||||
result =
|
||||
service.createReauth(
|
||||
body.name(),
|
||||
body.callbackUrl(),
|
||||
body.nonce(),
|
||||
body.claimSecret(),
|
||||
clientIp(http),
|
||||
pinnedTeamId);
|
||||
} else {
|
||||
result =
|
||||
service.create(
|
||||
body.name(),
|
||||
body.callbackUrl(),
|
||||
body.nonce(),
|
||||
body.claimSecret(),
|
||||
clientIp(http));
|
||||
}
|
||||
if (result.isRejected()) {
|
||||
return switch (result.rejection()) {
|
||||
case RATE_LIMITED ->
|
||||
ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||
.body(Map.of("error", "RATE_LIMITED"));
|
||||
case BAD_CALLBACK ->
|
||||
ResponseEntity.badRequest().body(Map.of("error", "BAD_CALLBACK"));
|
||||
case BAD_NONCE -> ResponseEntity.badRequest().body(Map.of("error", "BAD_NONCE"));
|
||||
case BAD_SECRET -> ResponseEntity.badRequest().body(Map.of("error", "BAD_SECRET"));
|
||||
// A credential was offered and did not authenticate. Same answer as any other bad
|
||||
// credential, and deliberately not distinguishable from "revoked".
|
||||
case NOT_LINKED ->
|
||||
ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "NOT_LINKED"));
|
||||
};
|
||||
}
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(
|
||||
new CreateResponse(
|
||||
result.requestId(),
|
||||
result.expiresInSeconds(),
|
||||
authorizeUrl(result.requestId(), http)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to send the admin to approve a handshake. {@code system.frontendUrl} is the web app's
|
||||
* own base URL, including any base path; without it the API's origin has to serve the app too.
|
||||
*/
|
||||
private String authorizeUrl(String requestId, HttpServletRequest http) {
|
||||
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
|
||||
String base =
|
||||
frontendUrl != null && !frontendUrl.isBlank()
|
||||
? frontendUrl.strip().replaceAll("/+$", "")
|
||||
: requestOrigin(http);
|
||||
return base
|
||||
+ LINK_PATH
|
||||
+ "?request="
|
||||
+ URLEncoder.encode(requestId, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
/** Scheme, host and context path as the browser reached us, honouring a reverse proxy. */
|
||||
private static String requestOrigin(HttpServletRequest request) {
|
||||
String proto = firstHop(request.getHeader("X-Forwarded-Proto"));
|
||||
String host = firstHop(request.getHeader("X-Forwarded-Host"));
|
||||
String scheme = proto != null ? proto : request.getScheme();
|
||||
// A forwarded host already carries its own port, if it needs one.
|
||||
String hostPort =
|
||||
host != null
|
||||
? host
|
||||
: Origins.hostPort(
|
||||
scheme, request.getServerName(), request.getServerPort());
|
||||
String context = request.getContextPath() == null ? "" : request.getContextPath();
|
||||
return scheme + "://" + hostPort + context;
|
||||
}
|
||||
|
||||
private static String firstHop(String headerValue) {
|
||||
if (headerValue == null || headerValue.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String first = headerValue.split(",")[0].strip();
|
||||
return first.isEmpty() ? null : first;
|
||||
}
|
||||
|
||||
/** Detail for the approval page. */
|
||||
@GetMapping("/{requestId}")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<ViewResponse> view(@PathVariable String requestId) {
|
||||
return service.lookup(requestId)
|
||||
.map(
|
||||
v ->
|
||||
ResponseEntity.ok(
|
||||
new ViewResponse(
|
||||
v.requestId(),
|
||||
v.name(),
|
||||
v.callbackOrigin(),
|
||||
v.insecureTransport(),
|
||||
v.mode().name(),
|
||||
v.status().name())))
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
/** Approves a handshake. */
|
||||
@PostMapping("/{requestId}/approve")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<?> approve(@PathVariable String requestId, Authentication auth) {
|
||||
Optional<ConnectRequestService.ConnectView> view = service.lookup(requestId);
|
||||
if (view.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
boolean reauth = view.get().mode() == ConnectRequest.Mode.REAUTH;
|
||||
LeaderTeam lt = reauth ? leaderTeams.resolveMember(auth) : leaderTeams.resolve(auth);
|
||||
if (lt.isError()) {
|
||||
return ResponseEntity.status(lt.error()).build();
|
||||
}
|
||||
ConnectRequestService.ApproveResult result =
|
||||
service.approve(requestId, lt.teamId(), lt.userId());
|
||||
if (result.isRejected()) {
|
||||
return switch (result.rejection()) {
|
||||
// Named separately so the page can say "you are signed in to a different account"
|
||||
// rather than implying the request itself was bad.
|
||||
case WRONG_TEAM ->
|
||||
ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "WRONG_TEAM"));
|
||||
case UNAVAILABLE -> ResponseEntity.notFound().build();
|
||||
};
|
||||
}
|
||||
return ResponseEntity.ok(
|
||||
new ApproveResponse(result.target().callbackUrl(), result.target().nonce()));
|
||||
}
|
||||
|
||||
@PostMapping("/{requestId}/deny")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<Void> deny(@PathVariable String requestId, Authentication auth) {
|
||||
LeaderTeam lt = leaderTeams.resolve(auth);
|
||||
if (lt.isError()) {
|
||||
return ResponseEntity.status(lt.error()).build();
|
||||
}
|
||||
return service.deny(requestId)
|
||||
? ResponseEntity.noContent().build()
|
||||
: ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
/** Collects the device credential. */
|
||||
@PostMapping("/claim")
|
||||
public ResponseEntity<?> claim(@RequestBody(required = false) ClaimBody body) {
|
||||
if (body == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "BAD_REQUEST"));
|
||||
}
|
||||
ConnectRequestService.ClaimResult result =
|
||||
service.claim(body.requestId(), body.claimSecret());
|
||||
return switch (result.outcome()) {
|
||||
case GRANTED ->
|
||||
ResponseEntity.ok(
|
||||
new ClaimResponse(
|
||||
result.deviceId(), result.deviceSecret(), result.teamId()));
|
||||
// A re-authentication carries no credential: the instance already has one. It only
|
||||
// needs to know the browser leg succeeded, and which team it was confirmed against.
|
||||
case CONFIRMED ->
|
||||
ResponseEntity.ok(Map.of("status", "confirmed", "teamId", result.teamId()));
|
||||
case PENDING ->
|
||||
ResponseEntity.status(HttpStatus.ACCEPTED).body(Map.of("status", "pending"));
|
||||
case REJECTED -> ResponseEntity.badRequest().body(Map.of("error", "CONNECT_REJECTED"));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Source address for the creation cap.
|
||||
*
|
||||
* <p>Deliberately not reading {@code X-Forwarded-For}: the caller sets it, so keying a cap on
|
||||
* it lets one rotate fake addresses and have no cap at all. {@code
|
||||
* server.forward-headers-strategy} is NATIVE, so the container has already resolved the real
|
||||
* client from trusted proxies.
|
||||
*/
|
||||
private static String clientIp(HttpServletRequest request) {
|
||||
String remote = request.getRemoteAddr();
|
||||
return remote == null || remote.length() <= 45 ? remote : remote.substring(0, 45);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package stirling.software.saas.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/** One in-flight "connect this server" handshake. Short lived and single use. */
|
||||
@Entity
|
||||
@Table(
|
||||
name = "account_link_connect_request",
|
||||
indexes = @Index(name = "idx_alcr_ip_created", columnList = "requester_ip,created_at"))
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class ConnectRequest {
|
||||
|
||||
public enum Mode {
|
||||
LINK,
|
||||
REAUTH
|
||||
}
|
||||
|
||||
public enum Status {
|
||||
PENDING,
|
||||
APPROVED,
|
||||
DENIED,
|
||||
CONSUMED
|
||||
}
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "request_id", nullable = false, unique = true, length = 64)
|
||||
private String requestId;
|
||||
|
||||
@Column(name = "name", length = 255)
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* Read back from here on approval, never from the request: that is what stops an open redirect.
|
||||
*/
|
||||
@Column(name = "callback_url", nullable = false, length = 2048)
|
||||
private String callbackUrl;
|
||||
|
||||
@Column(name = "callback_origin", nullable = false, length = 255)
|
||||
private String callbackOrigin;
|
||||
|
||||
@Column(name = "nonce", nullable = false, length = 128)
|
||||
private String nonce;
|
||||
|
||||
/** SHA-256; the secret itself is never stored. */
|
||||
@Column(name = "claim_secret_hash", nullable = false, length = 64)
|
||||
private String claimSecretHash;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "mode", nullable = false, length = 16)
|
||||
private Mode mode = Mode.LINK;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 16)
|
||||
private Status status = Status.PENDING;
|
||||
|
||||
/** LINK: set on approval. REAUTH: pinned at creation, so approval can only confirm it. */
|
||||
@Column(name = "team_id")
|
||||
private Long teamId;
|
||||
|
||||
@Column(name = "approved_by_user_id")
|
||||
private Long approvedByUserId;
|
||||
|
||||
@Column(name = "requester_ip", length = 45)
|
||||
private String requesterIp;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private LocalDateTime expiresAt;
|
||||
|
||||
@Column(name = "approved_at")
|
||||
private LocalDateTime approvedAt;
|
||||
|
||||
@Column(name = "consumed_at")
|
||||
private LocalDateTime consumedAt;
|
||||
|
||||
public boolean isExpired(LocalDateTime now) {
|
||||
return expiresAt != null && expiresAt.isBefore(now);
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package stirling.software.saas.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Removes connect requests that are past use.
|
||||
*
|
||||
* <p>Needed rather than merely tidy: anyone can create a row on {@code POST /connect/request}, and
|
||||
* nothing else deletes one. Requests hold a callback URL and the requester's address, so they are
|
||||
* swept soon after expiry rather than kept.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||
@RequiredArgsConstructor
|
||||
public class ConnectRequestCleanupService {
|
||||
|
||||
/** Long enough to answer "what happened to my link?" the next morning, and no longer. */
|
||||
private static final int RETAIN_HOURS = 24;
|
||||
|
||||
private final ConnectRequestRepository repo;
|
||||
|
||||
@Scheduled(cron = "0 30 3 * * *")
|
||||
@Transactional
|
||||
public void purgeExpired() {
|
||||
try {
|
||||
LocalDateTime cutoff = LocalDateTime.now().minusHours(RETAIN_HOURS);
|
||||
int deleted = repo.deleteByExpiresAtBefore(cutoff);
|
||||
if (deleted > 0) {
|
||||
log.info("Account-link connect: purged {} expired requests", deleted);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// A failed sweep must not take the scheduler down; the next run retries.
|
||||
log.error("Account-link connect: purge failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package stirling.software.saas.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import jakarta.persistence.LockModeType;
|
||||
|
||||
/** Data access for {@link ConnectRequest}. */
|
||||
public interface ConnectRequestRepository extends JpaRepository<ConnectRequest, Long> {
|
||||
|
||||
Optional<ConnectRequest> findByRequestId(String requestId);
|
||||
|
||||
/** Row-locking read used by approve, deny and claim. */
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT r FROM ConnectRequest r WHERE r.requestId = :requestId")
|
||||
Optional<ConnectRequest> findByRequestIdForUpdate(@Param("requestId") String requestId);
|
||||
|
||||
/** Backs the per-IP creation cap, since creating a request needs no authentication. */
|
||||
long countByRequesterIpAndCreatedAtAfter(String requesterIp, LocalDateTime after);
|
||||
|
||||
/** Sweeps rows past use, whatever they settled as. Anyone can create these. */
|
||||
int deleteByExpiresAtBefore(LocalDateTime cutoff);
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
package stirling.software.saas.accountlink;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Base64;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/** The "connect this server" handshake, SaaS side. */
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||
public class ConnectRequestService {
|
||||
|
||||
/**
|
||||
* Long enough for the approver to sign in, pick the right account and read the origin. Sized
|
||||
* for the slowest real route: signing up, waiting for a confirmation email, and coming back.
|
||||
*/
|
||||
static final int LIFETIME_MINUTES = 30;
|
||||
|
||||
/** Creating a request needs no authentication, so the only brake is per-source volume. */
|
||||
static final int MAX_REQUESTS_PER_IP = 10;
|
||||
|
||||
private static final int REQUEST_ID_BYTES = 32;
|
||||
private static final int MAX_NONCE_LENGTH = 128;
|
||||
private static final int MAX_CALLBACK_LENGTH = 2048;
|
||||
private static final int MAX_NAME_LENGTH = 255;
|
||||
|
||||
private final ConnectRequestRepository repo;
|
||||
private final AccountLinkService accountLinkService;
|
||||
private final SecureRandom random = new SecureRandom();
|
||||
|
||||
public ConnectRequestService(
|
||||
ConnectRequestRepository repo, AccountLinkService accountLinkService) {
|
||||
this.repo = repo;
|
||||
this.accountLinkService = accountLinkService;
|
||||
}
|
||||
|
||||
/** Rejected creation attempts, so the controller can pick a status without parsing messages. */
|
||||
public enum CreateRejection {
|
||||
BAD_CALLBACK,
|
||||
BAD_NONCE,
|
||||
BAD_SECRET,
|
||||
RATE_LIMITED,
|
||||
/**
|
||||
* A re-authentication was asked for by something that could not prove it is a linked
|
||||
* instance.
|
||||
*/
|
||||
NOT_LINKED
|
||||
}
|
||||
|
||||
/** Either a created request id, or the reason we would not create one. */
|
||||
public record CreateResult(String requestId, int expiresInSeconds, CreateRejection rejection) {
|
||||
static CreateResult ok(String requestId, int expiresInSeconds) {
|
||||
return new CreateResult(requestId, expiresInSeconds, null);
|
||||
}
|
||||
|
||||
static CreateResult rejected(CreateRejection rejection) {
|
||||
return new CreateResult(null, 0, rejection);
|
||||
}
|
||||
|
||||
public boolean isRejected() {
|
||||
return rejection != null;
|
||||
}
|
||||
}
|
||||
|
||||
/** What the approval page shows. */
|
||||
public record ConnectView(
|
||||
String requestId,
|
||||
String name,
|
||||
String callbackOrigin,
|
||||
boolean insecureTransport,
|
||||
ConnectRequest.Mode mode,
|
||||
ConnectRequest.Status status) {}
|
||||
|
||||
/** Where to send the browser once approved, plus the correlator the instance is expecting. */
|
||||
public record ApprovalTarget(String callbackUrl, String nonce) {}
|
||||
|
||||
public enum ClaimOutcome {
|
||||
/** Approved and collected; {@code credential} is populated. */
|
||||
GRANTED,
|
||||
/** A re-authentication was approved. */
|
||||
CONFIRMED,
|
||||
/** Still waiting on a human. */
|
||||
PENDING,
|
||||
/** Declined, expired, unknown, already collected, or a bad claim secret. */
|
||||
REJECTED
|
||||
}
|
||||
|
||||
public record ClaimResult(
|
||||
ClaimOutcome outcome, String deviceId, String deviceSecret, Long teamId) {
|
||||
static ClaimResult of(ClaimOutcome outcome) {
|
||||
return new ClaimResult(outcome, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
/** Records a handshake on behalf of an instance that has no credential yet. */
|
||||
@Transactional
|
||||
public CreateResult create(
|
||||
String name, String callbackUrl, String nonce, String claimSecret, String requesterIp) {
|
||||
return create(name, callbackUrl, nonce, claimSecret, requesterIp, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* As {@link #create}, but for an instance that is already linked and only needs its admin's
|
||||
* browser signed in again.
|
||||
*/
|
||||
@Transactional
|
||||
public CreateResult createReauth(
|
||||
String name,
|
||||
String callbackUrl,
|
||||
String nonce,
|
||||
String claimSecret,
|
||||
String requesterIp,
|
||||
Long pinnedTeamId) {
|
||||
if (pinnedTeamId == null) {
|
||||
return CreateResult.rejected(CreateRejection.NOT_LINKED);
|
||||
}
|
||||
return create(name, callbackUrl, nonce, claimSecret, requesterIp, pinnedTeamId);
|
||||
}
|
||||
|
||||
private CreateResult create(
|
||||
String name,
|
||||
String callbackUrl,
|
||||
String nonce,
|
||||
String claimSecret,
|
||||
String requesterIp,
|
||||
Long pinnedTeamId) {
|
||||
if (nonce == null || nonce.isBlank() || nonce.length() > MAX_NONCE_LENGTH) {
|
||||
return CreateResult.rejected(CreateRejection.BAD_NONCE);
|
||||
}
|
||||
if (claimSecret == null || claimSecret.isBlank()) {
|
||||
return CreateResult.rejected(CreateRejection.BAD_SECRET);
|
||||
}
|
||||
Optional<URI> parsed = validateCallback(callbackUrl);
|
||||
if (parsed.isEmpty()) {
|
||||
return CreateResult.rejected(CreateRejection.BAD_CALLBACK);
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (requesterIp != null
|
||||
&& repo.countByRequesterIpAndCreatedAtAfter(requesterIp, now.minusHours(1))
|
||||
>= MAX_REQUESTS_PER_IP) {
|
||||
return CreateResult.rejected(CreateRejection.RATE_LIMITED);
|
||||
}
|
||||
|
||||
URI uri = parsed.get();
|
||||
ConnectRequest request = new ConnectRequest();
|
||||
request.setRequestId(randomToken());
|
||||
request.setName(trim(name, MAX_NAME_LENGTH));
|
||||
request.setCallbackUrl(uri.toString());
|
||||
request.setCallbackOrigin(originOf(uri));
|
||||
request.setNonce(nonce);
|
||||
request.setClaimSecretHash(sha256Hex(claimSecret));
|
||||
request.setStatus(ConnectRequest.Status.PENDING);
|
||||
request.setMode(
|
||||
pinnedTeamId == null ? ConnectRequest.Mode.LINK : ConnectRequest.Mode.REAUTH);
|
||||
request.setTeamId(pinnedTeamId);
|
||||
request.setRequesterIp(requesterIp);
|
||||
request.setExpiresAt(now.plusMinutes(LIFETIME_MINUTES));
|
||||
repo.save(request);
|
||||
|
||||
// Never log the nonce or the claim secret; both are live. The request id is the safe
|
||||
// handle for correlating a support request against this row.
|
||||
log.info(
|
||||
"Account-link connect: request {} created for origin {}",
|
||||
request.getRequestId(),
|
||||
request.getCallbackOrigin());
|
||||
return CreateResult.ok(request.getRequestId(), LIFETIME_MINUTES * 60);
|
||||
}
|
||||
|
||||
/** The approver's view of a handshake. */
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<ConnectView> lookup(String requestId) {
|
||||
return repo.findByRequestId(requestId)
|
||||
.filter(r -> !r.isExpired(LocalDateTime.now()))
|
||||
.map(
|
||||
r ->
|
||||
new ConnectView(
|
||||
r.getRequestId(),
|
||||
r.getName(),
|
||||
r.getCallbackOrigin(),
|
||||
!"https".equals(schemeOf(r.getCallbackOrigin())),
|
||||
r.getMode(),
|
||||
r.getStatus()));
|
||||
}
|
||||
|
||||
/** Why an approval was refused, so the page can say something useful. */
|
||||
public enum ApproveRejection {
|
||||
/** Unknown, expired, or already settled. */
|
||||
UNAVAILABLE,
|
||||
/** The approver's team is not the team this server already belongs to. */
|
||||
WRONG_TEAM
|
||||
}
|
||||
|
||||
public record ApproveResult(ApprovalTarget target, ApproveRejection rejection) {
|
||||
public boolean isRejected() {
|
||||
return target == null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Binds a pending handshake to the approver's team and returns where to send them next. */
|
||||
@Transactional
|
||||
public ApproveResult approve(String requestId, Long teamId, Long userId) {
|
||||
Optional<ConnectRequest> found = repo.findByRequestIdForUpdate(requestId);
|
||||
if (found.isEmpty()) {
|
||||
return new ApproveResult(null, ApproveRejection.UNAVAILABLE);
|
||||
}
|
||||
ConnectRequest request = found.get();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (request.isExpired(now) || request.getStatus() != ConnectRequest.Status.PENDING) {
|
||||
return new ApproveResult(null, ApproveRejection.UNAVAILABLE);
|
||||
}
|
||||
Long pinned = request.getTeamId();
|
||||
if (pinned != null && !pinned.equals(teamId)) {
|
||||
log.warn(
|
||||
"Account-link connect: request {} approved by team {} but is pinned to team {};"
|
||||
+ " refusing",
|
||||
requestId,
|
||||
teamId,
|
||||
pinned);
|
||||
return new ApproveResult(null, ApproveRejection.WRONG_TEAM);
|
||||
}
|
||||
request.setStatus(ConnectRequest.Status.APPROVED);
|
||||
request.setTeamId(teamId);
|
||||
request.setApprovedByUserId(userId);
|
||||
request.setApprovedAt(now);
|
||||
repo.save(request);
|
||||
log.info(
|
||||
"Account-link connect: request {} approved for team {} ({})",
|
||||
requestId,
|
||||
teamId,
|
||||
request.getMode());
|
||||
return new ApproveResult(
|
||||
new ApprovalTarget(request.getCallbackUrl(), request.getNonce()), null);
|
||||
}
|
||||
|
||||
/** Declines a pending handshake. */
|
||||
@Transactional
|
||||
public boolean deny(String requestId) {
|
||||
Optional<ConnectRequest> found = repo.findByRequestIdForUpdate(requestId);
|
||||
if (found.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
ConnectRequest request = found.get();
|
||||
if (request.getStatus() != ConnectRequest.Status.PENDING) {
|
||||
return false;
|
||||
}
|
||||
request.setStatus(ConnectRequest.Status.DENIED);
|
||||
repo.save(request);
|
||||
log.info("Account-link connect: request {} denied", requestId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Collects the device credential for an approved handshake. */
|
||||
@Transactional
|
||||
public ClaimResult claim(String requestId, String claimSecret) {
|
||||
if (requestId == null || claimSecret == null) {
|
||||
return ClaimResult.of(ClaimOutcome.REJECTED);
|
||||
}
|
||||
Optional<ConnectRequest> found = repo.findByRequestIdForUpdate(requestId);
|
||||
if (found.isEmpty()) {
|
||||
return ClaimResult.of(ClaimOutcome.REJECTED);
|
||||
}
|
||||
ConnectRequest request = found.get();
|
||||
if (!secretMatches(claimSecret, request.getClaimSecretHash())) {
|
||||
// Same answer as an unknown id: a caller probing ids learns nothing from the
|
||||
// difference.
|
||||
log.warn("Account-link connect: claim for request {} had a bad secret", requestId);
|
||||
return ClaimResult.of(ClaimOutcome.REJECTED);
|
||||
}
|
||||
if (request.isExpired(LocalDateTime.now())) {
|
||||
return ClaimResult.of(ClaimOutcome.REJECTED);
|
||||
}
|
||||
return switch (request.getStatus()) {
|
||||
case PENDING -> ClaimResult.of(ClaimOutcome.PENDING);
|
||||
case APPROVED -> mint(request);
|
||||
case DENIED, CONSUMED -> ClaimResult.of(ClaimOutcome.REJECTED);
|
||||
};
|
||||
}
|
||||
|
||||
/** Settles an approved handshake. */
|
||||
private ClaimResult mint(ConnectRequest request) {
|
||||
if (request.getMode() == ConnectRequest.Mode.REAUTH) {
|
||||
request.setStatus(ConnectRequest.Status.CONSUMED);
|
||||
request.setConsumedAt(LocalDateTime.now());
|
||||
repo.save(request);
|
||||
log.info(
|
||||
"Account-link connect: request {} re-authenticated for team {}",
|
||||
request.getRequestId(),
|
||||
request.getTeamId());
|
||||
return new ClaimResult(ClaimOutcome.CONFIRMED, null, null, request.getTeamId());
|
||||
}
|
||||
AccountLinkService.RegisteredInstance registered =
|
||||
accountLinkService.register(
|
||||
request.getTeamId(), request.getApprovedByUserId(), request.getName());
|
||||
request.setStatus(ConnectRequest.Status.CONSUMED);
|
||||
request.setConsumedAt(LocalDateTime.now());
|
||||
repo.save(request);
|
||||
log.info(
|
||||
"Account-link connect: request {} claimed, instance {} bound to team {}",
|
||||
request.getRequestId(),
|
||||
registered.instanceId(),
|
||||
request.getTeamId());
|
||||
return new ClaimResult(
|
||||
ClaimOutcome.GRANTED,
|
||||
registered.deviceId(),
|
||||
registered.deviceSecret(),
|
||||
request.getTeamId());
|
||||
}
|
||||
|
||||
/** Absolute http(s) URL, with a host, no credentials and no fragment of its own. */
|
||||
static Optional<URI> validateCallback(String candidate) {
|
||||
if (candidate == null || candidate.isBlank() || candidate.length() > MAX_CALLBACK_LENGTH) {
|
||||
return Optional.empty();
|
||||
}
|
||||
URI uri;
|
||||
try {
|
||||
uri = new URI(candidate.strip());
|
||||
} catch (URISyntaxException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (!uri.isAbsolute() || uri.getScheme() == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String scheme = uri.getScheme().toLowerCase(Locale.ROOT);
|
||||
if (!"http".equals(scheme) && !"https".equals(scheme)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (uri.getHost() == null || uri.getHost().isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (uri.getUserInfo() != null || uri.getFragment() != null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(uri);
|
||||
}
|
||||
|
||||
/** Scheme, host and port, with the default port omitted so origins compare cleanly. */
|
||||
static String originOf(URI uri) {
|
||||
String scheme = uri.getScheme().toLowerCase(Locale.ROOT);
|
||||
return scheme + "://" + Origins.hostPort(scheme, uri.getHost(), uri.getPort());
|
||||
}
|
||||
|
||||
private static String schemeOf(String origin) {
|
||||
int sep = origin.indexOf("://");
|
||||
return sep < 0 ? "" : origin.substring(0, sep);
|
||||
}
|
||||
|
||||
private static String trim(String value, int max) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String stripped = value.strip();
|
||||
if (stripped.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return stripped.length() <= max ? stripped : stripped.substring(0, max);
|
||||
}
|
||||
|
||||
private String randomToken() {
|
||||
byte[] buf = new byte[REQUEST_ID_BYTES];
|
||||
random.nextBytes(buf);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
|
||||
}
|
||||
|
||||
/** Constant-time comparison so a claim cannot be brute-forced a byte at a time. */
|
||||
private static boolean secretMatches(String candidate, String expectedHash) {
|
||||
if (expectedHash == null) {
|
||||
return false;
|
||||
}
|
||||
return MessageDigest.isEqual(
|
||||
sha256Hex(candidate).getBytes(StandardCharsets.UTF_8),
|
||||
expectedHash.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static String sha256Hex(String value) {
|
||||
return AccountLinkService.sha256Hex(value);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -19,7 +19,7 @@ import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Authenticates a linked self-hosted instance by its device credential (combined-billing "Mode A").
|
||||
* Authenticates a linked self-hosted instance by its device credential (combined billing).
|
||||
*
|
||||
* <p>Reads {@code X-Device-Id} + {@code X-Device-Secret}, looks up the active {@link
|
||||
* LinkedInstance}, and constant-time compares the SHA-256 of the presented secret against the
|
||||
|
||||
@@ -32,9 +32,9 @@ import stirling.software.saas.payg.policy.PricingPolicy;
|
||||
import stirling.software.saas.payg.policy.PricingPolicyService;
|
||||
|
||||
/**
|
||||
* Instance-facing surface (combined-billing "Mode A"), authenticated by the <b>device
|
||||
* credential</b> — not a user JWT. Separate path prefix ({@code /api/v1/instance/**}) so the device
|
||||
* credential is scoped here and nowhere else.
|
||||
* Instance-facing surface (combined billing), authenticated by the <b>device credential</b> — not a
|
||||
* user JWT. Separate path prefix ({@code /api/v1/instance/**}) so the device credential is scoped
|
||||
* here and nowhere else.
|
||||
*
|
||||
* <p>{@code GET /whoami} is the MVP round-trip proof: a registered instance presenting a valid
|
||||
* device credential gets back its resolved {@code instanceId} + {@code teamId}. {@code GET
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package stirling.software.saas.accountlink;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import stirling.software.common.model.enumeration.TeamRole;
|
||||
import stirling.software.proprietary.model.TeamMembership;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
|
||||
import stirling.software.saas.util.AuthenticationUtils;
|
||||
|
||||
/** Who is allowed to bind a self-hosted instance to a team. */
|
||||
@Component
|
||||
@Profile("saas")
|
||||
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||
public class LeaderTeamResolver {
|
||||
|
||||
private final TeamMembershipRepository memberRepo;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public LeaderTeamResolver(TeamMembershipRepository memberRepo, UserRepository userRepository) {
|
||||
this.memberRepo = memberRepo;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved caller, or an {@code error} status to return ({@code teamId}/{@code userId} null).
|
||||
*/
|
||||
public record LeaderTeam(Long teamId, Long userId, HttpStatus error) {
|
||||
public boolean isError() {
|
||||
return error != null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Caller must lead their team. */
|
||||
public LeaderTeam resolve(Authentication auth) {
|
||||
return resolve(auth, true);
|
||||
}
|
||||
|
||||
/** Caller need only belong to a team. */
|
||||
public LeaderTeam resolveMember(Authentication auth) {
|
||||
return resolve(auth, false);
|
||||
}
|
||||
|
||||
private LeaderTeam resolve(Authentication auth, boolean requireLeader) {
|
||||
User user;
|
||||
try {
|
||||
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
||||
} catch (SecurityException e) {
|
||||
return new LeaderTeam(null, null, HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
|
||||
if (rows.isEmpty()) {
|
||||
return new LeaderTeam(null, null, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
TeamMembership membership = rows.getFirst();
|
||||
if (requireLeader && membership.getRole() != TeamRole.LEADER) {
|
||||
return new LeaderTeam(null, null, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
return new LeaderTeam(membership.getTeam().getId(), user.getId(), null);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* One self-hosted instance that has linked a SaaS account (combined-billing "Mode A", {@code
|
||||
* One self-hosted instance that has linked a SaaS account (combined billing, {@code
|
||||
* linked_instance}, V22).
|
||||
*
|
||||
* <p>Created by {@code POST /api/v1/account-link/register}, authenticated with the admin's
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
|
||||
/**
|
||||
* Authentication for a linked self-hosted instance (combined-billing "Mode A").
|
||||
* Authentication for a linked self-hosted instance (combined billing).
|
||||
*
|
||||
* <p>Deliberately <em>not</em> a user: the principal is the instance ({@code instanceId}) bound to
|
||||
* a {@code teamId}, with the single authority {@code ROLE_LINKED_INSTANCE}. It carries no {@code
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package stirling.software.saas.accountlink;
|
||||
|
||||
/**
|
||||
* Origin formatting shared by the connect handshake.
|
||||
*
|
||||
* <p>One place on purpose: the origin a request arrives on and the origin parsed out of a callback
|
||||
* URL are compared with each other, so if either side stopped omitting the default port the
|
||||
* comparison would start failing quietly.
|
||||
*/
|
||||
final class Origins {
|
||||
|
||||
private Origins() {}
|
||||
|
||||
/** {@code host} or {@code host:port}, dropping a port that is the scheme's default. */
|
||||
static String hostPort(String scheme, String host, int port) {
|
||||
boolean isDefault =
|
||||
port <= 0
|
||||
|| ("http".equals(scheme) && port == 80)
|
||||
|| ("https".equals(scheme) && port == 443);
|
||||
return isDefault ? host : host + ":" + port;
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ public final class SaasSchemaOwnership {
|
||||
*/
|
||||
public static final Set<String> MIGRATION_OWNED =
|
||||
Set.of(
|
||||
"account_link_connect_request",
|
||||
"ai_create_sessions",
|
||||
"audit_events",
|
||||
"authorities",
|
||||
@@ -78,6 +79,7 @@ public final class SaasSchemaOwnership {
|
||||
*/
|
||||
public static final Set<String> HIBERNATE_MANAGED =
|
||||
Set.of(
|
||||
"account_link_connect_state",
|
||||
"account_link_device_credential",
|
||||
"account_link_metered_signature",
|
||||
"account_link_sync_state",
|
||||
|
||||
+6
-6
@@ -18,12 +18,12 @@ import stirling.software.saas.payg.model.ProcessType;
|
||||
import stirling.software.saas.payg.repository.PaygInstanceUsageRepository;
|
||||
|
||||
/**
|
||||
* Ingests a linked instance's daily usage sync (combined-billing "Mode A"). The instance reports a
|
||||
* monotonic cumulative unit total per {@link BillingCategory}; we bill only the delta since the
|
||||
* last sync via {@link JobChargeService#chargeStandalone} (reusing the in-cloud free-grant split,
|
||||
* ledger DEBIT, Stripe meter and idempotency). Idempotent (a resend → delta 0 → no charge) and
|
||||
* tamper-evident (a backwards total is refused; a monotonic {@code syncSeq} dedups replays). The
|
||||
* cap is enforced at the instance gate, not here. Gated behind {@code account-link.enabled}.
|
||||
* Ingests a linked instance's daily usage sync (combined billing). The instance reports a monotonic
|
||||
* cumulative unit total per {@link BillingCategory}; we bill only the delta since the last sync via
|
||||
* {@link JobChargeService#chargeStandalone} (reusing the in-cloud free-grant split, ledger DEBIT,
|
||||
* Stripe meter and idempotency). Idempotent (a resend → delta 0 → no charge) and tamper-evident (a
|
||||
* backwards total is refused; a monotonic {@code syncSeq} dedups replays). The cap is enforced at
|
||||
* the instance gate, not here. Gated behind {@code account-link.enabled}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
|
||||
@@ -19,9 +19,9 @@ import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Last-seen cumulative usage a linked self-hosted instance has reported for one {@code (team,
|
||||
* billing period, category)} (combined-billing "Mode A"). The instance reports monotonic cumulative
|
||||
* unit totals on its daily sync; SaaS bills {@code reportedCumulative - lastCumulativeUnits} via
|
||||
* the standard charge path and advances this row. {@code lastSyncSeq} dedups replays.
|
||||
* billing period, category)} (combined billing). The instance reports monotonic cumulative unit
|
||||
* totals on its daily sync; SaaS bills {@code reportedCumulative - lastCumulativeUnits} via the
|
||||
* standard charge path and advances this row. {@code lastSyncSeq} dedups replays.
|
||||
*/
|
||||
@Entity
|
||||
@Table(
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.config.Customizer;
|
||||
@@ -71,6 +72,7 @@ public class SupabaseSecurityConfig {
|
||||
private final SaasTeamService saasTeamService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final ApiKeyAuthenticationService apiKeyAuthenticationService;
|
||||
private final Environment environment;
|
||||
|
||||
@Value("${app.supabase.issuer:}")
|
||||
private String issuer;
|
||||
@@ -105,6 +107,17 @@ public class SupabaseSecurityConfig {
|
||||
.permitAll()
|
||||
.requestMatchers("/actuator/health", "/api/v1/config/**")
|
||||
.permitAll()
|
||||
// Account-link connect handshake: an instance calls these
|
||||
// before it holds any credential, so there is nothing to
|
||||
// authenticate with yet. Neither grants anything on its
|
||||
// own — /request records an intent a human must approve,
|
||||
// and /claim requires a secret only the instance that
|
||||
// created the request has ever held.
|
||||
.requestMatchers(
|
||||
HttpMethod.POST,
|
||||
"/api/v1/account-link/connect/request",
|
||||
"/api/v1/account-link/connect/claim")
|
||||
.permitAll()
|
||||
.requestMatchers(
|
||||
req ->
|
||||
RequestUriUtils.isStaticResource(
|
||||
@@ -144,7 +157,7 @@ public class SupabaseSecurityConfig {
|
||||
SupabaseSecurityConfig
|
||||
::toAuthentication)));
|
||||
|
||||
// Device-credential auth for linked self-hosted instances (combined-billing Mode A).
|
||||
// Device-credential auth for linked self-hosted instances (combined billing).
|
||||
// The filter bean exists only when stirling.billing.account-link.enabled=true; when off it
|
||||
// is absent here, so the instance surface cannot authenticate at all until release.
|
||||
DeviceCredentialAuthenticationFilter deviceFilter =
|
||||
@@ -268,6 +281,28 @@ public class SupabaseSecurityConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loopback on any port, as Spring origin patterns. Only added outside production; see {@link
|
||||
* #corsConfigurationSource()}.
|
||||
*/
|
||||
private static final List<String> LOOPBACK_ANY_PORT =
|
||||
List.of("http://localhost:[*]", "http://127.0.0.1:[*]");
|
||||
|
||||
/**
|
||||
* Profiles that mean "a developer's machine or a preview environment", never the production
|
||||
* deployment. Production runs the bare {@code saas} profile.
|
||||
*/
|
||||
private static final List<String> NON_PRODUCTION_PROFILES = List.of("dev", "staging", "local");
|
||||
|
||||
private boolean isNonProduction() {
|
||||
for (String profile : environment.getActiveProfiles()) {
|
||||
if (NON_PRODUCTION_PROFILES.contains(profile)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Bean
|
||||
CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration cfg = new CorsConfiguration();
|
||||
@@ -297,7 +332,23 @@ public class SupabaseSecurityConfig {
|
||||
origins.add(desktopOrigin);
|
||||
}
|
||||
}
|
||||
if (origins.stream().anyMatch(o -> o.contains("*"))) {
|
||||
// Outside production, allow loopback on ANY port. Several dev servers run side by side
|
||||
// (editor, saas web app, one per flavour under test) and their ports move, so pinning a
|
||||
// list means every new local environment shows up as an opaque CORS failure. Unlike a
|
||||
// wildcard subdomain, a wildcard port on loopback cannot be taken over: nothing but this
|
||||
// machine can answer on it, so there is no lapsed-DNS or abandoned-vhost risk. Absent in
|
||||
// production, where the profile check below is false.
|
||||
if (!operatorOverride && isNonProduction()) {
|
||||
origins.addAll(LOOPBACK_ANY_PORT);
|
||||
log.info(
|
||||
"Non-production profile active: allowing loopback CORS origins on any port {}",
|
||||
LOOPBACK_ANY_PORT);
|
||||
}
|
||||
// Loopback port wildcards are exempt: the warning below is about hostname takeover, which
|
||||
// does not apply to an origin only this machine can serve.
|
||||
if (origins.stream()
|
||||
.filter(o -> !LOOPBACK_ANY_PORT.contains(o))
|
||||
.anyMatch(o -> o.contains("*"))) {
|
||||
log.warn(
|
||||
"CORS origins contain a wildcard paired with allowCredentials=true: {}."
|
||||
+ " Wildcard subdomains can be taken over by an attacker (lapsed DNS,"
|
||||
|
||||
@@ -519,8 +519,8 @@ public class SaasTeamService {
|
||||
* membership and its wallet) rather than deleting it, so a plain team is never orphaned. The
|
||||
* only real hazard is a team the user is the <em>last</em> leader of that still carries live
|
||||
* billing: an active paid/PAYG subscription, or a non-revoked linked self-hosted instance
|
||||
* ("Mode A"). Those block the join until the plan is cancelled / leadership transferred /
|
||||
* instances revoked. An unpaid, unlinked team (personal or shared) no longer blocks.
|
||||
* (combined billing). Those block the join until the plan is cancelled / leadership transferred
|
||||
* / instances revoked. An unpaid, unlinked team (personal or shared) no longer blocks.
|
||||
*
|
||||
* <p>The home team and the team being joined are excluded: neither is left by the join (home is
|
||||
* parked, the joined team is kept), so their live billing cannot be stranded.
|
||||
|
||||
+23
-23
@@ -1,6 +1,7 @@
|
||||
package stirling.software.saas.accountlink;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -23,8 +24,7 @@ import stirling.software.proprietary.model.TeamMembership;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
|
||||
import stirling.software.saas.accountlink.AccountLinkController.RegisterRequest;
|
||||
import stirling.software.saas.accountlink.AccountLinkController.RegisterResponse;
|
||||
import stirling.software.saas.accountlink.AccountLinkController.InstanceRow;
|
||||
import stirling.software.saas.util.AuthenticationUtils;
|
||||
|
||||
/**
|
||||
@@ -44,20 +44,27 @@ class AccountLinkControllerTest {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
controller = new AccountLinkController(service, memberRepo, userRepository);
|
||||
// Real resolver over the mocked repositories: the leader ladder moved into
|
||||
// LeaderTeamResolver, and these tests are still asserting that ladder's behaviour
|
||||
// through the controller.
|
||||
controller =
|
||||
new AccountLinkController(
|
||||
service, new LeaderTeamResolver(memberRepo, userRepository));
|
||||
auth =
|
||||
new AnonymousAuthenticationToken(
|
||||
"k", "anonymousUser", List.of(new SimpleGrantedAuthority("ROLE_USER")));
|
||||
}
|
||||
|
||||
// The leader ladder used to be asserted through POST /register, which has been removed along
|
||||
// with the JWT relay. It is exercised through /instances instead: same resolver, same rungs.
|
||||
|
||||
@Test
|
||||
void register_unauthenticated_returns401() {
|
||||
void list_unauthenticated_returns401() {
|
||||
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
|
||||
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
|
||||
.thenThrow(new SecurityException("not authenticated"));
|
||||
|
||||
ResponseEntity<RegisterResponse> resp =
|
||||
controller.register(new RegisterRequest("host"), auth);
|
||||
ResponseEntity<List<InstanceRow>> resp = controller.list(auth);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
verifyNoInteractions(service);
|
||||
@@ -65,14 +72,14 @@ class AccountLinkControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_noMembership_returns403() {
|
||||
void list_noMembership_returns403() {
|
||||
User user = mockUser(42L);
|
||||
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
|
||||
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
|
||||
.thenReturn(user);
|
||||
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of());
|
||||
|
||||
ResponseEntity<RegisterResponse> resp = controller.register(null, auth);
|
||||
ResponseEntity<List<InstanceRow>> resp = controller.list(auth);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
verifyNoInteractions(service);
|
||||
@@ -80,7 +87,7 @@ class AccountLinkControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_nonLeader_returns403() {
|
||||
void list_nonLeader_returns403() {
|
||||
User user = mockUser(42L);
|
||||
TeamMembership member = membership(7L, TeamRole.MEMBER);
|
||||
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
|
||||
@@ -88,7 +95,7 @@ class AccountLinkControllerTest {
|
||||
.thenReturn(user);
|
||||
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(member));
|
||||
|
||||
ResponseEntity<RegisterResponse> resp = controller.register(null, auth);
|
||||
ResponseEntity<List<InstanceRow>> resp = controller.list(auth);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
verifyNoInteractions(service);
|
||||
@@ -96,27 +103,20 @@ class AccountLinkControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_leader_mintsCredentialForCallerTeam() {
|
||||
void list_leader_readsOnlyTheCallersTeam() {
|
||||
User user = mockUser(42L);
|
||||
TeamMembership leader = membership(7L, TeamRole.LEADER);
|
||||
when(service.register(7L, 42L, "host"))
|
||||
.thenReturn(
|
||||
new AccountLinkService.RegisteredInstance(99L, "dev-x", "sec-x", "host"));
|
||||
when(service.list(7L)).thenReturn(List.of());
|
||||
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
|
||||
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
|
||||
.thenReturn(user);
|
||||
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(leader));
|
||||
|
||||
ResponseEntity<RegisterResponse> resp =
|
||||
controller.register(new RegisterRequest("host"), auth);
|
||||
ResponseEntity<List<InstanceRow>> resp = controller.list(auth);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED);
|
||||
RegisterResponse body = resp.getBody();
|
||||
assertThat(body).isNotNull();
|
||||
// Team comes from the caller's membership and is surfaced in the response.
|
||||
assertThat(body.teamId()).isEqualTo(7L);
|
||||
assertThat(body.instanceId()).isEqualTo(99L);
|
||||
assertThat(body.deviceSecret()).isEqualTo("sec-x");
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
// The team comes from the caller's membership, never from the request.
|
||||
verify(service).list(7L);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package stirling.software.saas.accountlink;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.saas.accountlink.ConnectController.CreateBody;
|
||||
import stirling.software.saas.accountlink.ConnectController.CreateResponse;
|
||||
|
||||
/**
|
||||
* The authorize URL the instance is told to send its admin to. Everything else on this controller
|
||||
* delegates; this is the only decision it makes on its own.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ConnectControllerTest {
|
||||
|
||||
private static final CreateBody BODY =
|
||||
new CreateBody("prod-1", "https://pdf.example.com/account-link/callback", "n", "s");
|
||||
|
||||
@Mock private ConnectRequestService service;
|
||||
@Mock private LeaderTeamResolver leaderTeams;
|
||||
@Mock private AccountLinkService accountLinkService;
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private ConnectController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
controller =
|
||||
new ConnectController(
|
||||
service, leaderTeams, accountLinkService, applicationProperties);
|
||||
when(service.create(anyString(), anyString(), anyString(), anyString(), any()))
|
||||
.thenReturn(ConnectRequestService.CreateResult.ok("req-1", 1800));
|
||||
}
|
||||
|
||||
private String authorizeUrl(MockHttpServletRequest request) {
|
||||
Object body = controller.request(BODY, request).getBody();
|
||||
assertThat(body).isInstanceOf(CreateResponse.class);
|
||||
return ((CreateResponse) body).authorizeUrl();
|
||||
}
|
||||
|
||||
private static MockHttpServletRequest request(String scheme, String host, int port) {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setScheme(scheme);
|
||||
request.setServerName(host);
|
||||
request.setServerPort(port);
|
||||
return request;
|
||||
}
|
||||
|
||||
@Test
|
||||
void prefersTheConfiguredFrontendUrl() {
|
||||
applicationProperties.getSystem().setFrontendUrl("https://app.example.com/app/");
|
||||
|
||||
// Trailing slash trimmed, base path kept, and the API's own origin ignored.
|
||||
assertThat(authorizeUrl(request("https", "api.example.com", 443)))
|
||||
.isEqualTo("https://app.example.com/app/link?request=req-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToTheOriginTheApiWasReachedOn() {
|
||||
assertThat(authorizeUrl(request("https", "api.example.com", 443)))
|
||||
.isEqualTo("https://api.example.com/link?request=req-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsANonDefaultPortAndTheContextPath() {
|
||||
MockHttpServletRequest request = request("http", "localhost", 8081);
|
||||
request.setContextPath("/stirling");
|
||||
|
||||
assertThat(authorizeUrl(request))
|
||||
.isEqualTo("http://localhost:8081/stirling/link?request=req-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void honoursTheForwardedSchemeAndHost() {
|
||||
MockHttpServletRequest request = request("http", "10.0.0.5", 8080);
|
||||
request.addHeader("X-Forwarded-Proto", "https");
|
||||
request.addHeader("X-Forwarded-Host", "api.example.com");
|
||||
|
||||
assertThat(authorizeUrl(request)).isEqualTo("https://api.example.com/link?request=req-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void takesOnlyTheFirstForwardedHop() {
|
||||
MockHttpServletRequest request = request("http", "10.0.0.5", 8080);
|
||||
request.addHeader("X-Forwarded-Proto", "https, http");
|
||||
request.addHeader("X-Forwarded-Host", "api.example.com, evil.example.com");
|
||||
|
||||
assertThat(authorizeUrl(request)).isEqualTo("https://api.example.com/link?request=req-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void percentEncodesTheRequestId() {
|
||||
when(service.create(anyString(), anyString(), anyString(), anyString(), any()))
|
||||
.thenReturn(ConnectRequestService.CreateResult.ok("a b&c", 1800));
|
||||
|
||||
assertThat(authorizeUrl(request("https", "api.example.com", 443)))
|
||||
.isEqualTo("https://api.example.com/link?request=a+b%26c");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aBodylessRequestIsRejectedBeforeAnythingIsRecorded() {
|
||||
assertThat(controller.request(null, request("https", "api.example.com", 443)).getBody())
|
||||
.isEqualTo(java.util.Map.of("error", "BAD_REQUEST"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void offeringNoCredentialTakesTheFirstLinkPath() {
|
||||
authorizeUrl(request("https", "api.example.com", 443));
|
||||
|
||||
// createReauth is the credentialled path; a first link must not reach it.
|
||||
org.mockito.Mockito.verify(service, org.mockito.Mockito.never())
|
||||
.createReauth(anyString(), anyString(), anyString(), anyString(), any(), isNull());
|
||||
}
|
||||
}
|
||||
+358
@@ -0,0 +1,358 @@
|
||||
package stirling.software.saas.accountlink;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import stirling.software.saas.accountlink.ConnectRequestService.ClaimOutcome;
|
||||
import stirling.software.saas.accountlink.ConnectRequestService.CreateRejection;
|
||||
|
||||
/**
|
||||
* Unit tests for the connect handshake's security properties, which are the reason this flow is
|
||||
* safe rather than an open redirect: the callback is validated once and then read back from
|
||||
* storage, the claim secret authenticates the collection, and one approval mints exactly one
|
||||
* credential.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ConnectRequestServiceTest {
|
||||
|
||||
private static final String CALLBACK = "https://pdf.example.com/account-link/callback";
|
||||
private static final String NONCE = "nonce-value";
|
||||
private static final String CLAIM_SECRET = "claim-secret-value";
|
||||
|
||||
@Mock private ConnectRequestRepository repo;
|
||||
@Mock private AccountLinkService accountLinkService;
|
||||
|
||||
private ConnectRequestService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new ConnectRequestService(repo, accountLinkService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_storesTheValidatedCallbackAndItsOrigin() {
|
||||
ConnectRequestService.CreateResult result =
|
||||
service.create("prod-1", CALLBACK, NONCE, CLAIM_SECRET, "10.0.0.1");
|
||||
|
||||
assertThat(result.isRejected()).isFalse();
|
||||
assertThat(result.requestId()).isNotBlank();
|
||||
|
||||
ArgumentCaptor<ConnectRequest> saved = ArgumentCaptor.forClass(ConnectRequest.class);
|
||||
verify(repo).save(saved.capture());
|
||||
ConnectRequest row = saved.getValue();
|
||||
assertThat(row.getCallbackUrl()).isEqualTo(CALLBACK);
|
||||
assertThat(row.getCallbackOrigin()).isEqualTo("https://pdf.example.com");
|
||||
assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.PENDING);
|
||||
assertThat(row.getName()).isEqualTo("prod-1");
|
||||
// The claim secret is only ever stored as a hash.
|
||||
assertThat(row.getClaimSecretHash()).isNotEqualTo(CLAIM_SECRET).hasSize(64);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_keepsANonDefaultPortInTheOrigin() {
|
||||
service.create(
|
||||
null, "http://pdf.internal:8080/account-link/callback", NONCE, CLAIM_SECRET, null);
|
||||
|
||||
ArgumentCaptor<ConnectRequest> saved = ArgumentCaptor.forClass(ConnectRequest.class);
|
||||
verify(repo).save(saved.capture());
|
||||
assertThat(saved.getValue().getCallbackOrigin()).isEqualTo("http://pdf.internal:8080");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(
|
||||
strings = {
|
||||
"/account-link/callback", // not absolute
|
||||
"ftp://pdf.example.com/cb", // wrong scheme
|
||||
"javascript:alert(1)", // not a hierarchical http(s) URL
|
||||
"https://user:pw@pdf.example.com/cb", // credentials in the URL
|
||||
"https://pdf.example.com/cb#already", // would collide with our fragment
|
||||
"https:///cb" // no host
|
||||
})
|
||||
void create_refusesCallbacksWeWouldNotWantToRedirectTo(String callback) {
|
||||
ConnectRequestService.CreateResult result =
|
||||
service.create(null, callback, NONCE, CLAIM_SECRET, null);
|
||||
|
||||
assertThat(result.rejection()).isEqualTo(CreateRejection.BAD_CALLBACK);
|
||||
verify(repo, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_refusesAMissingNonce() {
|
||||
assertThat(service.create(null, CALLBACK, " ", CLAIM_SECRET, null).rejection())
|
||||
.isEqualTo(CreateRejection.BAD_NONCE);
|
||||
verify(repo, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_namesTheSecretWhenTheSecretIsWhatIsMissing() {
|
||||
assertThat(service.create(null, CALLBACK, NONCE, " ", null).rejection())
|
||||
.isEqualTo(CreateRejection.BAD_SECRET);
|
||||
verify(repo, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_isCappedPerSourceAddress() {
|
||||
when(repo.countByRequesterIpAndCreatedAtAfter(anyString(), any()))
|
||||
.thenReturn((long) ConnectRequestService.MAX_REQUESTS_PER_IP);
|
||||
|
||||
ConnectRequestService.CreateResult result =
|
||||
service.create(null, CALLBACK, NONCE, CLAIM_SECRET, "10.0.0.1");
|
||||
|
||||
assertThat(result.rejection()).isEqualTo(CreateRejection.RATE_LIMITED);
|
||||
verify(repo, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void lookup_flagsPlaintextTransportSoTheApproverCanSeeIt() {
|
||||
ConnectRequest row = pending();
|
||||
row.setCallbackOrigin("http://pdf.internal:8080");
|
||||
when(repo.findByRequestId("req")).thenReturn(Optional.of(row));
|
||||
|
||||
assertThat(service.lookup("req")).get().extracting("insecureTransport").isEqualTo(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void lookup_hidesAnExpiredHandshake() {
|
||||
ConnectRequest row = pending();
|
||||
row.setExpiresAt(LocalDateTime.now().minusMinutes(1));
|
||||
when(repo.findByRequestId("req")).thenReturn(Optional.of(row));
|
||||
|
||||
assertThat(service.lookup("req")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void approve_bindsTheTeamAndReturnsTheStoredCallback() {
|
||||
ConnectRequest row = pending();
|
||||
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
|
||||
|
||||
ConnectRequestService.ApproveResult result = service.approve("req", 7L, 42L);
|
||||
|
||||
assertThat(result.isRejected()).isFalse();
|
||||
// The destination comes from the row, never from the caller.
|
||||
assertThat(result.target().callbackUrl()).isEqualTo(CALLBACK);
|
||||
assertThat(result.target().nonce()).isEqualTo(NONCE);
|
||||
assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.APPROVED);
|
||||
assertThat(row.getTeamId()).isEqualTo(7L);
|
||||
assertThat(row.getApprovedByUserId()).isEqualTo(42L);
|
||||
// Approval on its own must not mint anything.
|
||||
verifyNoInteractions(accountLinkService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void approve_isSingleUse() {
|
||||
ConnectRequest row = pending();
|
||||
row.setStatus(ConnectRequest.Status.APPROVED);
|
||||
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
|
||||
|
||||
assertThat(service.approve("req", 7L, 42L).isRejected()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void approve_refusesAnExpiredHandshake() {
|
||||
ConnectRequest row = pending();
|
||||
row.setExpiresAt(LocalDateTime.now().minusSeconds(1));
|
||||
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
|
||||
|
||||
assertThat(service.approve("req", 7L, 42L).isRejected()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createReauth_pinsTheTeamItWasToldByTheCredential() {
|
||||
ConnectRequestService.CreateResult result =
|
||||
service.createReauth(null, CALLBACK, NONCE, CLAIM_SECRET, null, 7L);
|
||||
|
||||
assertThat(result.isRejected()).isFalse();
|
||||
ArgumentCaptor<ConnectRequest> saved = ArgumentCaptor.forClass(ConnectRequest.class);
|
||||
verify(repo).save(saved.capture());
|
||||
assertThat(saved.getValue().getMode()).isEqualTo(ConnectRequest.Mode.REAUTH);
|
||||
assertThat(saved.getValue().getTeamId()).isEqualTo(7L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createReauth_withoutAnAuthenticatedInstanceIsRefused() {
|
||||
// The controller passes null when the offered device credential did not authenticate.
|
||||
assertThat(
|
||||
service.createReauth(null, CALLBACK, NONCE, CLAIM_SECRET, null, null)
|
||||
.rejection())
|
||||
.isEqualTo(CreateRejection.NOT_LINKED);
|
||||
verify(repo, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_leavesTheTeamOpenForAFirstLink() {
|
||||
service.create("n", CALLBACK, NONCE, CLAIM_SECRET, null);
|
||||
|
||||
ArgumentCaptor<ConnectRequest> saved = ArgumentCaptor.forClass(ConnectRequest.class);
|
||||
verify(repo).save(saved.capture());
|
||||
assertThat(saved.getValue().getMode()).isEqualTo(ConnectRequest.Mode.LINK);
|
||||
// Approval is what decides the team on a first link.
|
||||
assertThat(saved.getValue().getTeamId()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void approve_refusesAnApproverFromADifferentTeam() {
|
||||
ConnectRequest row = reauthPinnedTo(7L);
|
||||
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
|
||||
|
||||
ConnectRequestService.ApproveResult result = service.approve("req", 99L, 42L);
|
||||
|
||||
// This is the "signed in to the wrong account" case, and it must not silently rebind.
|
||||
assertThat(result.rejection()).isEqualTo(ConnectRequestService.ApproveRejection.WRONG_TEAM);
|
||||
assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.PENDING);
|
||||
assertThat(row.getTeamId()).isEqualTo(7L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void approve_acceptsTheTeamTheServerAlreadyBelongsTo() {
|
||||
ConnectRequest row = reauthPinnedTo(7L);
|
||||
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
|
||||
|
||||
assertThat(service.approve("req", 7L, 42L).isRejected()).isFalse();
|
||||
assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.APPROVED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void claim_onAReauthConfirmsWithoutMintingASecondCredential() {
|
||||
ConnectRequest row = reauthPinnedTo(7L);
|
||||
row.setStatus(ConnectRequest.Status.APPROVED);
|
||||
row.setApprovedByUserId(42L);
|
||||
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
|
||||
|
||||
ConnectRequestService.ClaimResult result = service.claim("req", CLAIM_SECRET);
|
||||
|
||||
assertThat(result.outcome()).isEqualTo(ClaimOutcome.CONFIRMED);
|
||||
assertThat(result.deviceId()).isNull();
|
||||
assertThat(result.deviceSecret()).isNull();
|
||||
assertThat(result.teamId()).isEqualTo(7L);
|
||||
assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.CONSUMED);
|
||||
// A second credential would orphan the one the instance already holds.
|
||||
verifyNoInteractions(accountLinkService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void claim_mintsOnceForAnApprovedHandshake() {
|
||||
ConnectRequest row = approved();
|
||||
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
|
||||
when(accountLinkService.register(anyLong(), anyLong(), any()))
|
||||
.thenReturn(
|
||||
new AccountLinkService.RegisteredInstance(9L, "dev-id", "dev-secret", "n"));
|
||||
|
||||
ConnectRequestService.ClaimResult result = service.claim("req", CLAIM_SECRET);
|
||||
|
||||
assertThat(result.outcome()).isEqualTo(ClaimOutcome.GRANTED);
|
||||
assertThat(result.deviceId()).isEqualTo("dev-id");
|
||||
assertThat(result.deviceSecret()).isEqualTo("dev-secret");
|
||||
assertThat(result.teamId()).isEqualTo(7L);
|
||||
assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.CONSUMED);
|
||||
verify(accountLinkService).register(7L, 42L, "n");
|
||||
}
|
||||
|
||||
@Test
|
||||
void claim_refusesASecondCollection() {
|
||||
ConnectRequest row = approved();
|
||||
row.setStatus(ConnectRequest.Status.CONSUMED);
|
||||
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
|
||||
|
||||
assertThat(service.claim("req", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.REJECTED);
|
||||
verifyNoInteractions(accountLinkService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void claim_withTheWrongSecretMintsNothing() {
|
||||
ConnectRequest row = approved();
|
||||
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
|
||||
|
||||
assertThat(service.claim("req", "not-the-secret").outcome())
|
||||
.isEqualTo(ClaimOutcome.REJECTED);
|
||||
assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.APPROVED);
|
||||
verifyNoInteractions(accountLinkService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void claim_beforeApprovalTellsTheInstanceToWait() {
|
||||
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(pending()));
|
||||
|
||||
assertThat(service.claim("req", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.PENDING);
|
||||
verifyNoInteractions(accountLinkService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void claim_afterDenialIsTerminal() {
|
||||
ConnectRequest row = pending();
|
||||
row.setStatus(ConnectRequest.Status.DENIED);
|
||||
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
|
||||
|
||||
assertThat(service.claim("req", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.REJECTED);
|
||||
verifyNoInteractions(accountLinkService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void claim_onAnExpiredHandshakeMintsNothing() {
|
||||
ConnectRequest row = approved();
|
||||
row.setExpiresAt(LocalDateTime.now().minusSeconds(1));
|
||||
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
|
||||
|
||||
assertThat(service.claim("req", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.REJECTED);
|
||||
verifyNoInteractions(accountLinkService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void claim_forAnUnknownIdLooksTheSameAsABadSecret() {
|
||||
when(repo.findByRequestIdForUpdate("nope")).thenReturn(Optional.empty());
|
||||
|
||||
assertThat(service.claim("nope", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.REJECTED);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
private static ConnectRequest pending() {
|
||||
ConnectRequest row = new ConnectRequest();
|
||||
row.setRequestId("req");
|
||||
row.setName("n");
|
||||
row.setCallbackUrl(CALLBACK);
|
||||
row.setCallbackOrigin("https://pdf.example.com");
|
||||
row.setNonce(NONCE);
|
||||
row.setClaimSecretHash(AccountLinkService.sha256Hex(CLAIM_SECRET));
|
||||
row.setStatus(ConnectRequest.Status.PENDING);
|
||||
row.setExpiresAt(LocalDateTime.now().plusMinutes(10));
|
||||
return row;
|
||||
}
|
||||
|
||||
/** A re-authentication whose team came from the instance's credential, not from a browser. */
|
||||
private static ConnectRequest reauthPinnedTo(Long teamId) {
|
||||
ConnectRequest row = pending();
|
||||
row.setMode(ConnectRequest.Mode.REAUTH);
|
||||
row.setTeamId(teamId);
|
||||
return row;
|
||||
}
|
||||
|
||||
private static ConnectRequest approved() {
|
||||
ConnectRequest row = pending();
|
||||
row.setStatus(ConnectRequest.Status.APPROVED);
|
||||
row.setTeamId(7L);
|
||||
row.setApprovedByUserId(42L);
|
||||
row.setApprovedAt(LocalDateTime.now());
|
||||
return row;
|
||||
}
|
||||
}
|
||||
+53
-1
@@ -14,6 +14,8 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.mock.env.MockEnvironment;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
@@ -48,13 +50,19 @@ class SupabaseSecurityConfigMoreTest {
|
||||
apiKeyAuthenticationService;
|
||||
|
||||
private SupabaseSecurityConfig config(ApplicationProperties props) {
|
||||
return config(props, new MockEnvironment());
|
||||
}
|
||||
|
||||
/** Loopback CORS origins are only added outside production, so the environment decides. */
|
||||
private SupabaseSecurityConfig config(ApplicationProperties props, Environment environment) {
|
||||
return new SupabaseSecurityConfig(
|
||||
userService,
|
||||
teamService,
|
||||
supabaseUserService,
|
||||
saasTeamService,
|
||||
props,
|
||||
apiKeyAuthenticationService);
|
||||
apiKeyAuthenticationService,
|
||||
environment);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@@ -204,6 +212,50 @@ class SupabaseSecurityConfigMoreTest {
|
||||
.hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("production does not allow loopback on arbitrary ports")
|
||||
void productionHasNoLoopbackWildcard() {
|
||||
CorsConfiguration cfg =
|
||||
cors(config(new ApplicationProperties()).corsConfigurationSource());
|
||||
|
||||
assertThat(cfg.getAllowedOriginPatterns())
|
||||
.doesNotContain("http://localhost:[*]", "http://127.0.0.1:[*]");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-production allows loopback on any port so dev servers can move")
|
||||
void devAllowsAnyLoopbackPort() {
|
||||
// Several dev servers run side by side and their ports change; pinning a list turns
|
||||
// every new local environment into an opaque CORS failure.
|
||||
MockEnvironment dev = new MockEnvironment();
|
||||
dev.setActiveProfiles("saas", "dev");
|
||||
|
||||
CorsConfiguration cfg =
|
||||
cors(config(new ApplicationProperties(), dev).corsConfigurationSource());
|
||||
|
||||
assertThat(cfg.getAllowedOriginPatterns())
|
||||
.contains("http://localhost:[*]", "http://127.0.0.1:[*]")
|
||||
// Still credentialed, which is the reason the pattern form matters.
|
||||
.contains("https://stirling.com");
|
||||
assertThat(cfg.getAllowCredentials()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an operator origin list is respected verbatim even in dev")
|
||||
void operatorOverrideSuppressesLoopbackWildcard() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getSystem().setCorsAllowedOrigins(List.of("https://custom.example.com"));
|
||||
MockEnvironment dev = new MockEnvironment();
|
||||
dev.setActiveProfiles("saas", "dev");
|
||||
|
||||
CorsConfiguration cfg = cors(config(props, dev).corsConfigurationSource());
|
||||
|
||||
// An operator who set the list meant it; we do not widen it behind their back.
|
||||
assertThat(cfg.getAllowedOriginPatterns())
|
||||
.contains("https://custom.example.com")
|
||||
.doesNotContain("http://localhost:[*]");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("operator override replaces the default origin list")
|
||||
void operatorOverrideUsed() {
|
||||
|
||||
@@ -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."
|
||||
@@ -11797,6 +11865,10 @@ title = "Watermark Text"
|
||||
image = "Image"
|
||||
text = "Text"
|
||||
|
||||
[workbench.sessionRestore]
|
||||
none = "Your previous files are no longer stored on this device."
|
||||
partial = "Restored {{restored}} of {{total}} files. The rest are no longer stored on this device."
|
||||
|
||||
[workbenchBar]
|
||||
activeFiles = "Active Files"
|
||||
annotations = "Annotations"
|
||||
|
||||
@@ -39,6 +39,7 @@ import { RedactionProvider } from "@app/contexts/RedactionContext";
|
||||
import { FormFillProvider } from "@app/tools/formFill/FormFillContext";
|
||||
import { FolderFileContextProvider } from "@app/contexts/FolderFileContext";
|
||||
import { FolderProvider } from "@app/contexts/FolderContext";
|
||||
import { WorkbenchSessionPersistence } from "@app/components/session/WorkbenchSessionPersistence";
|
||||
|
||||
// Component to initialize scarf tracking (must be inside AppConfigProvider)
|
||||
function ScarfTrackingInitializer() {
|
||||
@@ -163,6 +164,7 @@ export function AppProviders({
|
||||
<TourOrchestrationProvider>
|
||||
<AdminTourOrchestrationProvider>
|
||||
<FolderFileContextProvider>
|
||||
<WorkbenchSessionPersistence />
|
||||
{children}
|
||||
</FolderFileContextProvider>
|
||||
</AdminTourOrchestrationProvider>
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, waitFor, act } from "@testing-library/react";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getLeafStirlingFileStubs: vi.fn(),
|
||||
alert: vi.fn(),
|
||||
setActiveFileId: vi.fn(),
|
||||
restoreWorkbench: vi.fn(),
|
||||
workbench: "viewer" as string,
|
||||
authUser: null as { id: string } | null,
|
||||
authLoading: false,
|
||||
pathname: "/editor",
|
||||
activeFileId: null as string | null,
|
||||
}));
|
||||
|
||||
vi.mock("@app/services/fileStorage", () => ({
|
||||
fileStorage: { getLeafStirlingFileStubs: mocks.getLeafStirlingFileStubs },
|
||||
}));
|
||||
vi.mock("@app/components/toast", () => ({ alert: mocks.alert }));
|
||||
vi.mock("@app/contexts/NavigationContext", () => ({
|
||||
useNavigationState: () => ({ workbench: mocks.workbench }),
|
||||
useNavigationActions: () => ({
|
||||
actions: { restoreWorkbench: mocks.restoreWorkbench },
|
||||
}),
|
||||
}));
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useLocation: () => ({ pathname: mocks.pathname }),
|
||||
}));
|
||||
vi.mock("@app/auth/UseSession", () => ({
|
||||
useAuth: () => ({ user: mocks.authUser, loading: mocks.authLoading }),
|
||||
}));
|
||||
vi.mock("@app/contexts/ViewerContext", () => ({
|
||||
useViewer: () => ({
|
||||
activeFileId: mocks.activeFileId,
|
||||
setActiveFileId: mocks.setActiveFileId,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { WorkbenchSessionPersistence } from "@app/components/session/WorkbenchSessionPersistence";
|
||||
import { fingerprintOwner } from "@app/services/workbenchSession";
|
||||
import {
|
||||
FileStoreContext,
|
||||
FileActionsContext,
|
||||
} from "@app/contexts/file/contexts";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
|
||||
const SESSION_KEY = "stirling.workbench.session";
|
||||
|
||||
function stub(
|
||||
id: string,
|
||||
originalFileId: string,
|
||||
versionNumber = 1,
|
||||
): StirlingFileStub {
|
||||
return { id, originalFileId, versionNumber, name: `${id}.pdf` } as never;
|
||||
}
|
||||
|
||||
// A minimal stand-in for the FileContext store: mutable state plus subscribers.
|
||||
function makeStore(open: StirlingFileStub[] = [], selected: string[] = []) {
|
||||
const listeners = new Set<() => void>();
|
||||
const state = {
|
||||
files: {
|
||||
ids: open.map((s) => s.id),
|
||||
byId: Object.fromEntries(open.map((s) => [s.id, s])),
|
||||
},
|
||||
ui: { selectedFileIds: selected },
|
||||
};
|
||||
return {
|
||||
state,
|
||||
getState: () => state as never,
|
||||
subscribe: (listener: () => void) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
// reopenView waits on this to know the restored bytes have landed.
|
||||
selectors: {
|
||||
getFiles: (ids: string[]) => ids.map((id) => ({ id })),
|
||||
} as never,
|
||||
notify: () => listeners.forEach((listener) => listener()),
|
||||
};
|
||||
}
|
||||
|
||||
const actions = {
|
||||
addStirlingFileStubs: vi.fn().mockResolvedValue([]),
|
||||
setSelectedFiles: vi.fn(),
|
||||
};
|
||||
|
||||
function mount(store: ReturnType<typeof makeStore>) {
|
||||
return render(
|
||||
<FileStoreContext.Provider value={store as never}>
|
||||
<FileActionsContext.Provider
|
||||
value={{ actions, dispatch: vi.fn() } as never}
|
||||
>
|
||||
<WorkbenchSessionPersistence />
|
||||
</FileActionsContext.Provider>
|
||||
</FileStoreContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// The shared setup stubs crypto.subtle.digest to one constant for every input, so every account
|
||||
// would fingerprint alike - and ownership is exactly what these tests are about.
|
||||
vi.spyOn(globalThis.crypto.subtle, "digest").mockImplementation(
|
||||
async (_algorithm: AlgorithmIdentifier, data: BufferSource) => {
|
||||
const bytes = ArrayBuffer.isView(data)
|
||||
? new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
|
||||
: new Uint8Array(data);
|
||||
let hash = 0x811c9dc5;
|
||||
for (const byte of bytes) {
|
||||
hash = Math.imul(hash ^ byte, 0x01000193) >>> 0;
|
||||
}
|
||||
const out = new Uint8Array(32);
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
hash = Math.imul(hash ^ i, 0x01000193) >>> 0;
|
||||
out[i] = hash & 0xff;
|
||||
}
|
||||
return out.buffer;
|
||||
},
|
||||
);
|
||||
sessionStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
actions.addStirlingFileStubs.mockResolvedValue([]);
|
||||
mocks.getLeafStirlingFileStubs.mockResolvedValue([]);
|
||||
mocks.workbench = "viewer";
|
||||
mocks.authUser = null;
|
||||
mocks.authLoading = false;
|
||||
mocks.pathname = "/editor";
|
||||
mocks.activeFileId = null;
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("restore", () => {
|
||||
it("refills an empty workbench with each file's current leaf, in saved order", async () => {
|
||||
sessionStorage.setItem(
|
||||
SESSION_KEY,
|
||||
JSON.stringify({
|
||||
v: 2,
|
||||
fileIds: ["root-a", "root-b"],
|
||||
selectedFileIds: ["root-b"],
|
||||
}),
|
||||
);
|
||||
// root-a forked while the user was away: v3 must win over the stale v1 leaf.
|
||||
mocks.getLeafStirlingFileStubs.mockResolvedValue([
|
||||
stub("a-v1", "root-a", 1),
|
||||
stub("a-v3", "root-a", 3),
|
||||
stub("root-b", "root-b", 1),
|
||||
]);
|
||||
|
||||
mount(makeStore());
|
||||
|
||||
await waitFor(() =>
|
||||
expect(actions.addStirlingFileStubs).toHaveBeenCalled(),
|
||||
);
|
||||
const restored = actions.addStirlingFileStubs.mock.calls[0][0];
|
||||
expect(restored.map((s: StirlingFileStub) => s.id)).toEqual([
|
||||
"a-v3",
|
||||
"root-b",
|
||||
]);
|
||||
expect(actions.setSelectedFiles).toHaveBeenCalledWith(["root-b"]);
|
||||
expect(mocks.alert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not touch a workbench that already holds files", async () => {
|
||||
sessionStorage.setItem(
|
||||
SESSION_KEY,
|
||||
JSON.stringify({ v: 2, fileIds: ["root-a"], selectedFileIds: [] }),
|
||||
);
|
||||
mount(makeStore([stub("already-open", "already-open")]));
|
||||
|
||||
await act(async () => {});
|
||||
expect(actions.addStirlingFileStubs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores what still exists and says how much is gone", async () => {
|
||||
sessionStorage.setItem(
|
||||
SESSION_KEY,
|
||||
JSON.stringify({
|
||||
v: 2,
|
||||
fileIds: ["root-a", "gone"],
|
||||
selectedFileIds: [],
|
||||
}),
|
||||
);
|
||||
mocks.getLeafStirlingFileStubs.mockResolvedValue([
|
||||
stub("root-a", "root-a"),
|
||||
]);
|
||||
|
||||
mount(makeStore());
|
||||
|
||||
await waitFor(() => expect(mocks.alert).toHaveBeenCalled());
|
||||
expect(actions.addStirlingFileStubs.mock.calls[0][0]).toHaveLength(1);
|
||||
expect(mocks.alert.mock.calls[0][0].alertType).toBe("warning");
|
||||
});
|
||||
|
||||
it("does not say 'the rest' when nothing at all could be restored", async () => {
|
||||
sessionStorage.setItem(
|
||||
SESSION_KEY,
|
||||
JSON.stringify({
|
||||
v: 2,
|
||||
fileIds: ["gone-1", "gone-2"],
|
||||
selectedFileIds: [],
|
||||
}),
|
||||
);
|
||||
mocks.getLeafStirlingFileStubs.mockResolvedValue([]);
|
||||
|
||||
mount(makeStore());
|
||||
|
||||
await waitFor(() => expect(mocks.alert).toHaveBeenCalled());
|
||||
expect(mocks.alert.mock.calls[0][0].title).toBe(
|
||||
"workbench.sessionRestore.none",
|
||||
);
|
||||
});
|
||||
|
||||
it("does nothing when no session was recorded", async () => {
|
||||
mount(makeStore());
|
||||
await act(async () => {});
|
||||
expect(actions.addStirlingFileStubs).not.toHaveBeenCalled();
|
||||
expect(mocks.getLeafStirlingFileStubs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reopens the document the user was viewing, at its current version", async () => {
|
||||
sessionStorage.setItem(
|
||||
SESSION_KEY,
|
||||
JSON.stringify({
|
||||
v: 2,
|
||||
fileIds: ["root-a"],
|
||||
selectedFileIds: ["root-a"],
|
||||
workbench: "fileEditor",
|
||||
activeFileId: "root-a",
|
||||
}),
|
||||
);
|
||||
mocks.getLeafStirlingFileStubs.mockResolvedValue([
|
||||
stub("a-v2", "root-a", 2),
|
||||
]);
|
||||
|
||||
mount(makeStore());
|
||||
|
||||
await waitFor(() => expect(mocks.setActiveFileId).toHaveBeenCalled());
|
||||
expect(mocks.setActiveFileId).toHaveBeenCalledWith("a-v2");
|
||||
expect(mocks.restoreWorkbench).toHaveBeenCalledWith("fileEditor");
|
||||
});
|
||||
|
||||
it("leaves a URL-owned view to the return path", async () => {
|
||||
sessionStorage.setItem(
|
||||
SESSION_KEY,
|
||||
JSON.stringify({
|
||||
v: 2,
|
||||
fileIds: ["root-a"],
|
||||
selectedFileIds: [],
|
||||
workbench: "myFiles",
|
||||
}),
|
||||
);
|
||||
mocks.getLeafStirlingFileStubs.mockResolvedValue([
|
||||
stub("root-a", "root-a"),
|
||||
]);
|
||||
|
||||
mount(makeStore());
|
||||
|
||||
await waitFor(() =>
|
||||
expect(actions.addStirlingFileStubs).toHaveBeenCalled(),
|
||||
);
|
||||
expect(mocks.restoreWorkbench).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("whose workbench it is", () => {
|
||||
// Records hold a fingerprint of the owner, never the account id.
|
||||
const record = async (userId: string | null) =>
|
||||
sessionStorage.setItem(
|
||||
SESSION_KEY,
|
||||
JSON.stringify({
|
||||
v: 2,
|
||||
fileIds: ["root-a"],
|
||||
selectedFileIds: [],
|
||||
userId: userId == null ? null : await fingerprintOwner(userId),
|
||||
}),
|
||||
);
|
||||
|
||||
it("does not open one user's workbench for the next person in the tab", async () => {
|
||||
await record("user-a");
|
||||
mocks.authUser = { id: "user-b" };
|
||||
mocks.getLeafStirlingFileStubs.mockResolvedValue([
|
||||
stub("root-a", "root-a"),
|
||||
]);
|
||||
|
||||
mount(makeStore());
|
||||
|
||||
await act(async () => {});
|
||||
expect(actions.addStirlingFileStubs).not.toHaveBeenCalled();
|
||||
// The record is theirs now - the previous person's files are gone from it, so they cannot
|
||||
// resurface later in the session.
|
||||
const taken = JSON.parse(sessionStorage.getItem(SESSION_KEY)!);
|
||||
expect(taken.fileIds).toEqual([]);
|
||||
expect(taken.userId).toBe(await fingerprintOwner("user-b"));
|
||||
});
|
||||
|
||||
it("reopens it for the user who left it", async () => {
|
||||
await record("user-a");
|
||||
mocks.authUser = { id: "user-a" };
|
||||
mocks.getLeafStirlingFileStubs.mockResolvedValue([
|
||||
stub("root-a", "root-a"),
|
||||
]);
|
||||
|
||||
mount(makeStore());
|
||||
|
||||
await waitFor(() =>
|
||||
expect(actions.addStirlingFileStubs).toHaveBeenCalled(),
|
||||
);
|
||||
});
|
||||
|
||||
it("waits for the session before deciding", async () => {
|
||||
await record("user-a");
|
||||
mocks.authUser = null;
|
||||
mocks.authLoading = true;
|
||||
mocks.getLeafStirlingFileStubs.mockResolvedValue([
|
||||
stub("root-a", "root-a"),
|
||||
]);
|
||||
|
||||
mount(makeStore());
|
||||
|
||||
await act(async () => {});
|
||||
// Neither restored nor discarded - who is signed in is not known yet.
|
||||
expect(actions.addStirlingFileStubs).not.toHaveBeenCalled();
|
||||
expect(sessionStorage.getItem(SESSION_KEY)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a lost session that comes back", () => {
|
||||
const rerenderWith = (
|
||||
view: ReturnType<typeof mount>,
|
||||
store: ReturnType<typeof makeStore>,
|
||||
) =>
|
||||
view.rerender(
|
||||
<FileStoreContext.Provider value={store as never}>
|
||||
<FileActionsContext.Provider
|
||||
value={{ actions, dispatch: vi.fn() } as never}
|
||||
>
|
||||
<WorkbenchSessionPersistence />
|
||||
</FileActionsContext.Provider>
|
||||
</FileStoreContext.Provider>,
|
||||
);
|
||||
|
||||
it("survives a blip on the identity check", async () => {
|
||||
// A failed /auth/me - flaky wifi, a backend redeploy, a refreshSession() that did not land -
|
||||
// briefly reads as nobody signed in. It must not be mistaken for signing out.
|
||||
sessionStorage.setItem(
|
||||
SESSION_KEY,
|
||||
JSON.stringify({
|
||||
v: 2,
|
||||
fileIds: ["root-a"],
|
||||
selectedFileIds: [],
|
||||
userId: await fingerprintOwner("user-a"),
|
||||
}),
|
||||
);
|
||||
mocks.authUser = { id: "user-a" };
|
||||
const store = makeStore([stub("f1", "f1")]);
|
||||
const view = mount(store);
|
||||
await act(async () => {});
|
||||
|
||||
mocks.authUser = null;
|
||||
rerenderWith(view, store);
|
||||
await act(async () => {});
|
||||
|
||||
expect(sessionStorage.getItem(SESSION_KEY)).not.toBeNull();
|
||||
|
||||
// ...and once the identity is back, the workbench is still being recorded.
|
||||
mocks.authUser = { id: "user-a" };
|
||||
rerenderWith(view, store);
|
||||
// Let the fingerprint land: writes hold off while a known identity has none yet.
|
||||
await act(async () => {});
|
||||
store.state.files.ids = ["f2" as never];
|
||||
store.state.files.byId = { f2: stub("f2", "root-b") } as never;
|
||||
act(() => store.notify());
|
||||
view.unmount();
|
||||
expect(JSON.parse(sessionStorage.getItem(SESSION_KEY)!).fileIds).toEqual([
|
||||
"root-b",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("on the login screen", () => {
|
||||
it("neither restores nor records - signing out must not rebuild the workbench there", async () => {
|
||||
mocks.pathname = "/login";
|
||||
sessionStorage.setItem(
|
||||
SESSION_KEY,
|
||||
JSON.stringify({ v: 2, fileIds: ["root-a"], selectedFileIds: [] }),
|
||||
);
|
||||
mocks.getLeafStirlingFileStubs.mockResolvedValue([
|
||||
stub("root-a", "root-a"),
|
||||
]);
|
||||
|
||||
const store = makeStore();
|
||||
const { unmount } = mount(store);
|
||||
await act(async () => {});
|
||||
expect(actions.addStirlingFileStubs).not.toHaveBeenCalled();
|
||||
|
||||
// And the unmount flush must not write either.
|
||||
store.state.files.ids = ["f1" as never];
|
||||
store.state.files.byId = { f1: stub("f1", "f1") } as never;
|
||||
act(() => store.notify());
|
||||
unmount();
|
||||
expect(JSON.parse(sessionStorage.getItem(SESSION_KEY)!).fileIds).toEqual([
|
||||
"root-a",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("writer", () => {
|
||||
it("mirrors the open files and selection as original ids, debounced", async () => {
|
||||
vi.useFakeTimers();
|
||||
const store = makeStore();
|
||||
mount(store);
|
||||
|
||||
store.state.files.ids = ["v2" as never];
|
||||
store.state.files.byId = { v2: stub("v2", "root-a", 2) } as never;
|
||||
store.state.ui.selectedFileIds = ["v2"];
|
||||
act(() => store.notify());
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
});
|
||||
expect(JSON.parse(sessionStorage.getItem(SESSION_KEY)!)).toMatchObject({
|
||||
fileIds: ["root-a"],
|
||||
selectedFileIds: ["root-a"],
|
||||
workbench: "viewer",
|
||||
});
|
||||
});
|
||||
|
||||
it("records the current view, so the return lands where the user left", async () => {
|
||||
vi.useFakeTimers();
|
||||
mocks.workbench = "fileEditor";
|
||||
const store = makeStore([stub("f1", "f1")]);
|
||||
mount(store);
|
||||
|
||||
act(() => store.notify());
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
});
|
||||
expect(JSON.parse(sessionStorage.getItem(SESSION_KEY)!).workbench).toBe(
|
||||
"fileEditor",
|
||||
);
|
||||
});
|
||||
|
||||
it("writes nothing until the restore has settled", () => {
|
||||
vi.useFakeTimers();
|
||||
sessionStorage.setItem(
|
||||
SESSION_KEY,
|
||||
JSON.stringify({ v: 2, fileIds: ["root-a"], selectedFileIds: [] }),
|
||||
);
|
||||
// Restore is still awaiting storage, so this mount's empty state is not the truth.
|
||||
mocks.getLeafStirlingFileStubs.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
const store = makeStore();
|
||||
const { unmount } = mount(store);
|
||||
act(() => store.notify());
|
||||
unmount();
|
||||
|
||||
expect(JSON.parse(sessionStorage.getItem(SESSION_KEY)!).fileIds).toEqual([
|
||||
"root-a",
|
||||
]);
|
||||
});
|
||||
|
||||
it("flushes on unmount, so the state at the shell switch survives", () => {
|
||||
vi.useFakeTimers();
|
||||
const store = makeStore();
|
||||
const { unmount } = mount(store);
|
||||
|
||||
store.state.files.ids = ["f1" as never];
|
||||
store.state.files.byId = { f1: stub("f1", "f1") } as never;
|
||||
act(() => store.notify());
|
||||
unmount();
|
||||
|
||||
expect(JSON.parse(sessionStorage.getItem(SESSION_KEY)!).fileIds).toEqual([
|
||||
"f1",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
// The editor/processor shell switch unmounts every editor provider, and a reload starts from nothing:
|
||||
// this mirrors the workbench into sessionStorage and refills an empty one from that record on mount.
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
FileStoreContext,
|
||||
type FileStateStore,
|
||||
} from "@app/contexts/file/contexts";
|
||||
import { useFileActions } from "@app/contexts/FileContext";
|
||||
import {
|
||||
useNavigationActions,
|
||||
useNavigationState,
|
||||
} from "@app/contexts/NavigationContext";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { isAuthRoute } from "@app/constants/routes";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { WORKBENCH_SESSION_RESTORE } from "@app/constants/featureFlags";
|
||||
import {
|
||||
beginRestoredView,
|
||||
clearWorkbenchSession,
|
||||
fingerprintOwner,
|
||||
resumeWorkbenchSession,
|
||||
endRestoredView,
|
||||
isSeedableView,
|
||||
originalIdOf,
|
||||
readWorkbenchSession,
|
||||
writeWorkbenchSession,
|
||||
} from "@app/services/workbenchSession";
|
||||
import type { WorkbenchType } from "@app/types/workbench";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
const WRITE_DEBOUNCE_MS = 300;
|
||||
|
||||
// Current leaf per original id; a forked chain resolves to the highest version.
|
||||
function leafByOriginalId(
|
||||
leaves: StirlingFileStub[],
|
||||
): Map<string, StirlingFileStub> {
|
||||
const map = new Map<string, StirlingFileStub>();
|
||||
for (const leaf of leaves) {
|
||||
const key = originalIdOf(leaf);
|
||||
const current = map.get(key);
|
||||
if (!current || (leaf.versionNumber ?? 1) > (current.versionNumber ?? 1)) {
|
||||
map.set(key, leaf);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** How long to wait for the NEXT file to hydrate before giving up on holding the view. Restarted on
|
||||
* each arrival, so a slow device with large documents keeps the view as long as it makes progress. */
|
||||
const SETTLE_TIMEOUT_MS = 5000;
|
||||
|
||||
/** Released a beat late, so effects reacting to the same commit still see the restore in progress. */
|
||||
const RELEASE_GRACE_MS = 250;
|
||||
|
||||
/**
|
||||
* Reopen the recorded view, then hold the restore guard until the files have hydrated.
|
||||
*
|
||||
* The view is written ONCE. Re-asserting it after hydration would also overwrite a view the user
|
||||
* picked in the meantime; holding the guard is what keeps HomePage's defaults off it instead.
|
||||
*/
|
||||
function reopenView(
|
||||
store: FileStateStore,
|
||||
reopen: (view: WorkbenchType) => void,
|
||||
{
|
||||
view,
|
||||
fileCount,
|
||||
token,
|
||||
}: { view: WorkbenchType; fileCount: number; token: number },
|
||||
): void {
|
||||
reopen(view);
|
||||
const loaded = () =>
|
||||
store.selectors.getFiles(store.getState().files.ids).length;
|
||||
|
||||
const release = () =>
|
||||
setTimeout(() => endRestoredView(token), RELEASE_GRACE_MS);
|
||||
if (loaded() >= fileCount) {
|
||||
release();
|
||||
return;
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const stop = () => {
|
||||
clearTimeout(timer);
|
||||
unsubscribe();
|
||||
release();
|
||||
};
|
||||
const waitForNext = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(stop, SETTLE_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
let seen = loaded();
|
||||
const unsubscribe = store.subscribe(() => {
|
||||
const now = loaded();
|
||||
if (now >= fileCount) return stop();
|
||||
// Progress, not completion: give the remaining files a fresh window.
|
||||
if (now > seen) {
|
||||
seen = now;
|
||||
waitForNext();
|
||||
}
|
||||
});
|
||||
waitForNext();
|
||||
}
|
||||
|
||||
export function WorkbenchSessionPersistence() {
|
||||
const store = useContext(FileStoreContext);
|
||||
const { actions } = useFileActions();
|
||||
const { workbench } = useNavigationState();
|
||||
const { actions: navigationActions } = useNavigationActions();
|
||||
const { activeFileId, setActiveFileId } = useViewer();
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
// Login/signup mount the editor's providers too. Nothing there is the user's workbench, so this
|
||||
// records nothing and restores nothing - otherwise signing out rebuilds it on the login screen.
|
||||
const onAuthRoute = isAuthRoute(useLocation().pathname);
|
||||
const userId = user?.id != null ? String(user.id) : null;
|
||||
// Fingerprinted, never stored raw - see fingerprintOwner. Computed asynchronously, so writes
|
||||
// hold off until it lands rather than stamping the record "nobody's" and then failing its own
|
||||
// ownership check.
|
||||
const [owner, setOwner] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (userId == null) {
|
||||
setOwner(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void fingerprintOwner(userId).then((fingerprint) => {
|
||||
if (!cancelled) setOwner(fingerprint);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [userId]);
|
||||
const { t } = useTranslation();
|
||||
// Captured before the writer below can overwrite it with the empty boot state.
|
||||
const [saved] = useState(readWorkbenchSession);
|
||||
const restoreStarted = useRef(false);
|
||||
// Until the restore has run, this mount's empty state is not the truth to record.
|
||||
const restoreSettled = useRef(false);
|
||||
|
||||
// Published so a build's restore setting is legible without reading the bundle.
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.workbenchRestore = String(
|
||||
WORKBENCH_SESSION_RESTORE,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const write = useCallback(() => {
|
||||
if (!store || !restoreSettled.current) return;
|
||||
// A known identity whose fingerprint has not landed yet: wait, do not stamp it as nobody's.
|
||||
if (userId != null && owner == null) return;
|
||||
const state = store.getState();
|
||||
const toOriginal = (id: FileId): string | null => {
|
||||
const stub = state.files.byId[id];
|
||||
return stub ? originalIdOf(stub) : null;
|
||||
};
|
||||
const isPresent = (id: string | null): id is string => id !== null;
|
||||
writeWorkbenchSession({
|
||||
fileIds: state.files.ids.map(toOriginal).filter(isPresent),
|
||||
selectedFileIds: state.ui.selectedFileIds
|
||||
.map(toOriginal)
|
||||
.filter(isPresent),
|
||||
workbench,
|
||||
userId: owner,
|
||||
activeFileId: activeFileId
|
||||
? (toOriginal(activeFileId as FileId) ?? undefined)
|
||||
: undefined,
|
||||
});
|
||||
}, [store, workbench, activeFileId, userId, owner]);
|
||||
|
||||
// Read by the file subscription, which must not resubscribe on every view change.
|
||||
const writeRef = useRef(write);
|
||||
writeRef.current = write;
|
||||
|
||||
useEffect(() => {
|
||||
if (!store || onAuthRoute) return;
|
||||
// This mount is a new session: undo any suspension left by a sign-out in this page's lifetime.
|
||||
resumeWorkbenchSession();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const unsubscribe = store.subscribe(() => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => writeRef.current(), WRITE_DEBOUNCE_MS);
|
||||
});
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
// Flush, so the state at the moment of the shell switch is what survives.
|
||||
writeRef.current();
|
||||
unsubscribe();
|
||||
};
|
||||
}, [store, onAuthRoute]);
|
||||
|
||||
// Changing view touches no file state, so the subscription above never sees it.
|
||||
useEffect(() => write(), [write]);
|
||||
|
||||
useEffect(() => {
|
||||
if (restoreStarted.current) return;
|
||||
if (onAuthRoute) return;
|
||||
// Who is signed in decides whether this record is theirs to reopen, so settle that first.
|
||||
if (authLoading) return;
|
||||
restoreStarted.current = true;
|
||||
|
||||
const nothingToDo =
|
||||
!WORKBENCH_SESSION_RESTORE ||
|
||||
!store ||
|
||||
!saved ||
|
||||
saved.fileIds.length === 0 ||
|
||||
store.getState().files.ids.length > 0;
|
||||
if (nothingToDo) {
|
||||
restoreSettled.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
// A tab can outlive a sign-out (the logout clears it, but a 401 bounce or an expiry does
|
||||
// not), and the next person to sign in here must not open the last person's documents.
|
||||
const currentOwner =
|
||||
userId == null ? null : await fingerprintOwner(userId);
|
||||
if ((saved.userId ?? null) !== currentOwner) {
|
||||
clearWorkbenchSession();
|
||||
restoreSettled.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Held while the files land: they are added one at a time, and each landing re-runs the
|
||||
// default-view heuristic, which must not overwrite the recorded view mid-restore.
|
||||
let held: number | null = null;
|
||||
try {
|
||||
// Resolve each id to its CURRENT leaf: a policy or another tab may have versioned it since.
|
||||
const leaves = leafByOriginalId(
|
||||
await fileStorage.getLeafStirlingFileStubs(),
|
||||
);
|
||||
const stubs = saved.fileIds
|
||||
.map((id) => leaves.get(id))
|
||||
.filter((stub): stub is StirlingFileStub => stub !== undefined);
|
||||
|
||||
if (stubs.length > 0) {
|
||||
const view = isSeedableView(saved.workbench) ? saved.workbench : null;
|
||||
if (view) held = beginRestoredView();
|
||||
// The same entry point My Files uses, so a restored file is governed by the same rules as
|
||||
// any other file entering the workbench - including whether a policy has already run on it.
|
||||
await actions.addStirlingFileStubs(stubs);
|
||||
const selected = saved.selectedFileIds
|
||||
.map((id) => leaves.get(id)?.id)
|
||||
.filter((id): id is FileId => id !== undefined);
|
||||
if (selected.length > 0) actions.setSelectedFiles(selected);
|
||||
// After the files land: the viewer drops an active id it cannot find.
|
||||
const active = saved.activeFileId
|
||||
? leaves.get(saved.activeFileId)?.id
|
||||
: undefined;
|
||||
if (active) setActiveFileId(active as string);
|
||||
if (view && held !== null) {
|
||||
reopenView(store, navigationActions.restoreWorkbench, {
|
||||
view,
|
||||
fileCount: stubs.length,
|
||||
token: held,
|
||||
});
|
||||
held = null; // reopenView owns the release from here.
|
||||
}
|
||||
}
|
||||
|
||||
const missing = saved.fileIds.length - stubs.length;
|
||||
if (missing > 0) {
|
||||
alert({
|
||||
alertType: "warning",
|
||||
title:
|
||||
stubs.length === 0
|
||||
? t(
|
||||
"workbench.sessionRestore.none",
|
||||
"Your previous files are no longer stored on this device.",
|
||||
)
|
||||
: t(
|
||||
"workbench.sessionRestore.partial",
|
||||
"Restored {{restored}} of {{total}} files. The rest are no longer stored on this device.",
|
||||
{ restored: stubs.length, total: saved.fileIds.length },
|
||||
),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (held !== null) endRestoredView(held);
|
||||
// Even a failed restore must release the writer, or the record freezes for the session.
|
||||
restoreSettled.current = true;
|
||||
}
|
||||
})();
|
||||
}, [
|
||||
saved,
|
||||
store,
|
||||
actions,
|
||||
navigationActions,
|
||||
setActiveFileId,
|
||||
t,
|
||||
authLoading,
|
||||
userId,
|
||||
onAuthRoute,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -11,3 +11,6 @@
|
||||
// Annotated as `boolean` (not the literal `false`) so call sites aren't treated
|
||||
// as constant/unreachable conditions by the type checker and linter.
|
||||
export const WATCHED_FOLDERS_ENABLED: boolean = false;
|
||||
|
||||
// Refill an empty workbench from the tab's last session (survives a provider remount or a reload).
|
||||
export const WORKBENCH_SESSION_RESTORE: boolean = true;
|
||||
|
||||
@@ -94,6 +94,9 @@ export interface NavigationWarningHandlers {
|
||||
// Navigation context actions interface
|
||||
export interface NavigationContextActions {
|
||||
setWorkbench: (workbench: WorkbenchType) => void;
|
||||
/** Reopen a view the user already had, bypassing the unsaved-changes prompt that
|
||||
* guards a user-initiated switch - a restore is not the user leaving anything. */
|
||||
restoreWorkbench: (workbench: WorkbenchType) => void;
|
||||
setSelectedTool: (toolId: ToolId | null) => void;
|
||||
setToolAndWorkbench: (
|
||||
toolId: ToolId | null,
|
||||
@@ -221,6 +224,10 @@ export const NavigationProvider: React.FC<{
|
||||
[state.workbench, state.hasUnsavedChanges],
|
||||
);
|
||||
|
||||
const restoreWorkbench = useCallback((workbench: WorkbenchType) => {
|
||||
dispatch({ type: "SET_WORKBENCH", payload: { workbench } });
|
||||
}, []);
|
||||
|
||||
const setSelectedTool = useCallback((toolId: ToolId | null) => {
|
||||
dispatch({ type: "SET_SELECTED_TOOL", payload: { toolId } });
|
||||
}, []);
|
||||
@@ -402,6 +409,7 @@ export const NavigationProvider: React.FC<{
|
||||
const actions: NavigationContextActions = useMemo(
|
||||
() => ({
|
||||
setWorkbench,
|
||||
restoreWorkbench,
|
||||
setSelectedTool,
|
||||
setToolAndWorkbench,
|
||||
setHasUnsavedChanges,
|
||||
@@ -419,6 +427,7 @@ export const NavigationProvider: React.FC<{
|
||||
}),
|
||||
[
|
||||
setWorkbench,
|
||||
restoreWorkbench,
|
||||
setSelectedTool,
|
||||
setToolAndWorkbench,
|
||||
setHasUnsavedChanges,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { suspendWorkbenchSession } from "@app/services/workbenchSession";
|
||||
|
||||
type SignOutFn = () => Promise<void>;
|
||||
|
||||
interface AccountLogoutDeps {
|
||||
@@ -21,6 +23,10 @@ export function useAccountLogout() {
|
||||
"1",
|
||||
);
|
||||
}
|
||||
// The tab outlives the session; the next person to sign in here must not
|
||||
// inherit this workbench. Suspends writing too - signing out unmounts the
|
||||
// editor, and its flush would otherwise write the record straight back.
|
||||
suspendWorkbenchSession();
|
||||
await signOut();
|
||||
} finally {
|
||||
redirectToLogin();
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
useNavigationState,
|
||||
useNavigationActions,
|
||||
} from "@app/contexts/NavigationContext";
|
||||
import { isApplyingRestoredView } from "@app/services/workbenchSession";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import AppsIcon from "@mui/icons-material/AppsRounded";
|
||||
@@ -161,8 +162,14 @@ export default function HomePage() {
|
||||
if (navigationState.workbench !== "myFiles") {
|
||||
actions.setWorkbench("myFiles");
|
||||
}
|
||||
} else if (navigationState.workbench === "myFiles") {
|
||||
// Leaving the file manager - drop back to a sensible default.
|
||||
} else if (
|
||||
navigationState.workbench === "myFiles" &&
|
||||
!isApplyingRestoredView()
|
||||
) {
|
||||
// The URL no longer supports the file manager - drop back to a sensible default. Stays a
|
||||
// state check rather than a transition one: HomePage remounts without NavigationContext
|
||||
// (a share link, a login bounce), and the view has to be corrected on arrival too.
|
||||
// Skipped mid-restore, which is reopening a recorded view onto files still loading.
|
||||
actions.setWorkbench(activeFiles.length > 1 ? "fileEditor" : "viewer");
|
||||
}
|
||||
}, [
|
||||
@@ -204,7 +211,9 @@ export default function HomePage() {
|
||||
navigationState.workbench,
|
||||
);
|
||||
|
||||
if (action) {
|
||||
// A session restore fills an empty workbench too, but it already knows which view the user
|
||||
// left - so it wins over this heuristic rather than being overwritten by it.
|
||||
if (action && !isApplyingRestoredView()) {
|
||||
actions.setWorkbench(action.workbench);
|
||||
if (typeof action.activeFileIndex === "number") {
|
||||
setActiveFileIndex(action.activeFileIndex);
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import {
|
||||
originalIdOf,
|
||||
readWorkbenchSession,
|
||||
writeWorkbenchSession,
|
||||
saveEditorReturnPath,
|
||||
takeEditorReturnPath,
|
||||
isSeedableView,
|
||||
clearWorkbenchSession,
|
||||
suspendWorkbenchSession,
|
||||
resumeWorkbenchSession,
|
||||
} from "@app/services/workbenchSession";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
|
||||
const SESSION_KEY = "stirling.workbench.session";
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
resumeWorkbenchSession();
|
||||
});
|
||||
|
||||
describe("workbench session record", () => {
|
||||
it("round-trips the open files and selection", () => {
|
||||
writeWorkbenchSession({ fileIds: ["a", "b"], selectedFileIds: ["b"] });
|
||||
expect(readWorkbenchSession()).toMatchObject({
|
||||
fileIds: ["a", "b"],
|
||||
selectedFileIds: ["b"],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when nothing was recorded", () => {
|
||||
expect(readWorkbenchSession()).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a malformed record instead of throwing", () => {
|
||||
sessionStorage.setItem(SESSION_KEY, "not json");
|
||||
expect(readWorkbenchSession()).toBeNull();
|
||||
sessionStorage.setItem(
|
||||
SESSION_KEY,
|
||||
JSON.stringify({ v: 2, fileIds: "nope" }),
|
||||
);
|
||||
expect(readWorkbenchSession()).toBeNull();
|
||||
});
|
||||
|
||||
it("drops non-string ids and defaults a missing selection", () => {
|
||||
sessionStorage.setItem(
|
||||
SESSION_KEY,
|
||||
JSON.stringify({ v: 2, fileIds: ["a", 7, null, "b"] }),
|
||||
);
|
||||
expect(readWorkbenchSession()).toMatchObject({
|
||||
fileIds: ["a", "b"],
|
||||
selectedFileIds: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("editor return path", () => {
|
||||
it("is consumed by the first take", () => {
|
||||
saveEditorReturnPath("/compress?x=1");
|
||||
expect(takeEditorReturnPath()).toBe("/compress?x=1");
|
||||
expect(takeEditorReturnPath()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("originalIdOf", () => {
|
||||
it("prefers the original id and falls back to the file id", () => {
|
||||
expect(
|
||||
originalIdOf({ id: "v3", originalFileId: "root" } as StirlingFileStub),
|
||||
).toBe("root");
|
||||
expect(
|
||||
originalIdOf({ id: "v1", originalFileId: "" } as StirlingFileStub),
|
||||
).toBe("v1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("record hygiene", () => {
|
||||
it("discards a record written by an older schema", () => {
|
||||
// No version stamp: a shape this build no longer understands.
|
||||
sessionStorage.setItem(
|
||||
SESSION_KEY,
|
||||
JSON.stringify({ fileIds: ["a"], selectedFileIds: [] }),
|
||||
);
|
||||
expect(readWorkbenchSession()).toBeNull();
|
||||
|
||||
// v1 recorded userId before it meant anything, so those must go too rather than
|
||||
// look like a workbench that legitimately belongs to an anonymous session.
|
||||
sessionStorage.setItem(
|
||||
SESSION_KEY,
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
fileIds: ["a"],
|
||||
selectedFileIds: [],
|
||||
userId: null,
|
||||
}),
|
||||
);
|
||||
expect(readWorkbenchSession()).toBeNull();
|
||||
});
|
||||
|
||||
it("drops the previous record when a write fails, rather than leaving it stale", () => {
|
||||
writeWorkbenchSession({ fileIds: ["old"], selectedFileIds: [] });
|
||||
const setItem = vi
|
||||
.spyOn(Storage.prototype, "setItem")
|
||||
.mockImplementation(() => {
|
||||
throw new Error("QuotaExceededError");
|
||||
});
|
||||
|
||||
writeWorkbenchSession({ fileIds: ["new"], selectedFileIds: [] });
|
||||
setItem.mockRestore();
|
||||
|
||||
// Better to restore nothing than to restore a workbench the user has moved on from.
|
||||
expect(readWorkbenchSession()).toBeNull();
|
||||
});
|
||||
|
||||
it("records who the workbench belonged to", () => {
|
||||
writeWorkbenchSession({
|
||||
fileIds: ["a"],
|
||||
selectedFileIds: [],
|
||||
userId: "user-1",
|
||||
});
|
||||
expect(readWorkbenchSession()?.userId).toBe("user-1");
|
||||
});
|
||||
|
||||
it("stays gone after sign-out, even though the teardown writes once more", () => {
|
||||
writeWorkbenchSession({ fileIds: ["a", "b"], selectedFileIds: [] });
|
||||
|
||||
suspendWorkbenchSession();
|
||||
// Signing out unmounts the editor, whose flush writes the workbench one last time -
|
||||
// with no user attached. Clearing alone let that recreate the record.
|
||||
writeWorkbenchSession({
|
||||
fileIds: ["a", "b"],
|
||||
selectedFileIds: [],
|
||||
userId: null,
|
||||
});
|
||||
|
||||
expect(sessionStorage.getItem(SESSION_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the owner when the identity is momentarily unknown", () => {
|
||||
// A sign-out teardown and a failed /auth/me both write with no user attached. Losing the
|
||||
// owner here would make the record unrestorable for the person it belongs to.
|
||||
writeWorkbenchSession({
|
||||
fileIds: ["a", "b"],
|
||||
selectedFileIds: [],
|
||||
userId: "user-a",
|
||||
});
|
||||
|
||||
writeWorkbenchSession({
|
||||
fileIds: ["a", "b"],
|
||||
selectedFileIds: [],
|
||||
userId: null,
|
||||
});
|
||||
|
||||
expect(readWorkbenchSession()?.userId).toBe("user-a");
|
||||
});
|
||||
|
||||
it("still records for a genuinely anonymous session", () => {
|
||||
// Core has no auth at all, so null is the normal owner there and must keep working.
|
||||
writeWorkbenchSession({
|
||||
fileIds: ["a"],
|
||||
selectedFileIds: [],
|
||||
userId: null,
|
||||
});
|
||||
expect(readWorkbenchSession()?.fileIds).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("records again once a new editor session starts", () => {
|
||||
suspendWorkbenchSession();
|
||||
resumeWorkbenchSession();
|
||||
writeWorkbenchSession({ fileIds: ["a"], selectedFileIds: [] });
|
||||
expect(readWorkbenchSession()?.fileIds).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("clears on request", () => {
|
||||
writeWorkbenchSession({ fileIds: ["a"], selectedFileIds: [] });
|
||||
clearWorkbenchSession();
|
||||
expect(readWorkbenchSession()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("views the restore may reopen", () => {
|
||||
it("accepts the workbench views a session can land on", () => {
|
||||
expect(isSeedableView("viewer")).toBe(true);
|
||||
expect(isSeedableView("fileEditor")).toBe(true);
|
||||
expect(isSeedableView("pageEditor")).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves URL-owned and tool-owned views alone", () => {
|
||||
// HomePage pins myFiles to /files and bounces it elsewhere; custom views belong to a tool.
|
||||
expect(isSeedableView("myFiles")).toBe(false);
|
||||
expect(isSeedableView("custom:compare")).toBe(false);
|
||||
expect(isSeedableView(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
// The tab's last editor session (open files, selection, view), so a processor switch or reload
|
||||
// does not cost the user their workbench. sessionStorage on purpose: per-tab, tabs never clobber.
|
||||
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
|
||||
const SESSION_KEY = "stirling.workbench.session";
|
||||
/** Bumped when the record's shape or meaning changes, so an old one is discarded rather than
|
||||
* half-read. v2: `userId` became meaningful - v1 records were written without a real owner and
|
||||
* would otherwise look like they belonged to an anonymous session forever. */
|
||||
const SESSION_VERSION = 2;
|
||||
const RETURN_PATH_KEY = "stirling.workbench.editorReturnPath";
|
||||
|
||||
// All ids are ORIGINAL file ids - a file's stable identity across versions.
|
||||
export interface WorkbenchSession {
|
||||
fileIds: string[];
|
||||
selectedFileIds: string[];
|
||||
/** Which view was on screen. Absent for a record written before this was tracked. */
|
||||
workbench?: string;
|
||||
activeFileId?: string;
|
||||
/** Fingerprint of who the workbench belonged to, so the next person in this tab does not
|
||||
* inherit it. Never the account id itself - see {@link fingerprintOwner}. */
|
||||
userId?: string | null;
|
||||
}
|
||||
|
||||
/** A file's stable identity across versions - what the session records. */
|
||||
export function originalIdOf(stub: StirlingFileStub): string {
|
||||
return stub.originalFileId || (stub.id as string);
|
||||
}
|
||||
|
||||
export function readWorkbenchSession(): WorkbenchSession | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<WorkbenchSession> & {
|
||||
v?: number;
|
||||
};
|
||||
if (parsed.v !== SESSION_VERSION) return null;
|
||||
if (!Array.isArray(parsed.fileIds)) return null;
|
||||
return {
|
||||
fileIds: parsed.fileIds.filter((id) => typeof id === "string"),
|
||||
selectedFileIds: Array.isArray(parsed.selectedFileIds)
|
||||
? parsed.selectedFileIds.filter((id) => typeof id === "string")
|
||||
: [],
|
||||
workbench:
|
||||
typeof parsed.workbench === "string" ? parsed.workbench : undefined,
|
||||
activeFileId:
|
||||
typeof parsed.activeFileId === "string"
|
||||
? parsed.activeFileId
|
||||
: undefined,
|
||||
userId: typeof parsed.userId === "string" ? parsed.userId : null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Sign-out clears the record, but signing out also tears the editor down - and that teardown
|
||||
// flushes the workbench one last time, recreating what we just deleted (with no user attached).
|
||||
// So a sign-out has to stop writing too, not merely clear.
|
||||
let writesSuspended = false;
|
||||
|
||||
/** Sign-out: drop the record and stop recording, so the teardown cannot put it back. */
|
||||
export function suspendWorkbenchSession(): void {
|
||||
writesSuspended = true;
|
||||
clearWorkbenchSession();
|
||||
}
|
||||
|
||||
/** A fresh editor mount is a new session, so recording starts again. */
|
||||
export function resumeWorkbenchSession(): void {
|
||||
writesSuspended = false;
|
||||
}
|
||||
|
||||
export function writeWorkbenchSession(session: WorkbenchSession): void {
|
||||
if (writesSuspended) return;
|
||||
try {
|
||||
// Never downgrade a known owner to "nobody". Signing out and a failed identity check both
|
||||
// read as no user, and dropping the owner would either hand the workbench to whoever signs
|
||||
// in next or lose it for the person it belongs to. Keeping the owner leaves the restore's
|
||||
// ownership check to decide, which it does with a settled identity.
|
||||
const owner = session.userId ?? readWorkbenchSession()?.userId ?? null;
|
||||
sessionStorage.setItem(
|
||||
SESSION_KEY,
|
||||
JSON.stringify({ ...session, userId: owner, v: SESSION_VERSION }),
|
||||
);
|
||||
} catch {
|
||||
// Storage refused (quota, privacy mode). setItem is atomic, so the PREVIOUS record would
|
||||
// survive and restore an older workbench - drop it, so the failure is "no restore" instead.
|
||||
clearWorkbenchSession();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A one-way fingerprint of the signed-in user. Owners are only ever compared, never read back, so
|
||||
* the account id itself never needs to reach storage. Falls back to a non-cryptographic digest
|
||||
* where SubtleCrypto is absent (a self-hosted instance served over plain http): the fingerprint
|
||||
* only has to tell two accounts sharing one tab apart, and the files it gates are reachable from
|
||||
* My Files regardless, since IndexedDB is per-origin.
|
||||
*/
|
||||
export async function fingerprintOwner(userId: string): Promise<string> {
|
||||
if (globalThis.crypto?.subtle) {
|
||||
const digest = await globalThis.crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(userId),
|
||||
);
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
.slice(0, 32);
|
||||
}
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < userId.length; i++) {
|
||||
hash ^= userId.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193) >>> 0;
|
||||
}
|
||||
return `fnv-${hash.toString(16)}`;
|
||||
}
|
||||
|
||||
/** Drop the record: on sign-out, and whenever it would otherwise be restored for the wrong person. */
|
||||
export function clearWorkbenchSession(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
} catch {
|
||||
// A record we cannot remove is also one we cannot read.
|
||||
}
|
||||
}
|
||||
|
||||
/** Views a restore may seed directly. "myFiles" is URL-owned (HomePage pins it to /files) and a
|
||||
* custom view belongs to its tool - the editor return path restores those instead. */
|
||||
const SEEDABLE_VIEWS = ["viewer", "fileEditor", "pageEditor"];
|
||||
|
||||
// Raised while a restore is applying its recorded view, so writers that pick a default view from
|
||||
// whatever is loaded at the time defer to the restore rather than race it.
|
||||
let applyingRestoredView = false;
|
||||
let restoreGeneration = 0;
|
||||
|
||||
/** Returns a token for endRestoredView, so a stale release cannot end a newer restore. */
|
||||
export function beginRestoredView(): number {
|
||||
applyingRestoredView = true;
|
||||
return ++restoreGeneration;
|
||||
}
|
||||
|
||||
export function endRestoredView(token: number): void {
|
||||
if (token === restoreGeneration) applyingRestoredView = false;
|
||||
}
|
||||
|
||||
export function isApplyingRestoredView(): boolean {
|
||||
return applyingRestoredView;
|
||||
}
|
||||
|
||||
export function isSeedableView(
|
||||
view: string | undefined,
|
||||
): view is "viewer" | "fileEditor" | "pageEditor" {
|
||||
return view !== undefined && SEEDABLE_VIEWS.includes(view);
|
||||
}
|
||||
|
||||
export function saveEditorReturnPath(path: string): void {
|
||||
try {
|
||||
sessionStorage.setItem(RETURN_PATH_KEY, path);
|
||||
} catch {
|
||||
// Best-effort: the switch back just lands on the editor root.
|
||||
}
|
||||
}
|
||||
|
||||
/** One-shot: consumed by the switch back so a stale path cannot linger. */
|
||||
export function takeEditorReturnPath(): string | null {
|
||||
try {
|
||||
const path = sessionStorage.getItem(RETURN_PATH_KEY);
|
||||
if (path !== null) sessionStorage.removeItem(RETURN_PATH_KEY);
|
||||
return path;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,12 @@ test.describe("engine capabilities", { tag: "@engine-capability" }, () => {
|
||||
|
||||
await uploadFiles(page, SAMPLE_PDF);
|
||||
|
||||
// Dropped before the reload boots, so it cannot reopen the file for us: the eye
|
||||
// below toggles, and whether the restore runs is a build flag this spec does not own.
|
||||
await page.addInitScript(() =>
|
||||
sessionStorage.removeItem("stirling.workbench.session"),
|
||||
);
|
||||
|
||||
// Full reload: FileContext rehydrates from IndexedDB, not from memory.
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
import { uploadFiles } from "@app/tests/helpers/ui-helpers";
|
||||
import path from "path";
|
||||
|
||||
const FIXTURES_DIR = path.join(import.meta.dirname, "../test-fixtures");
|
||||
const SAMPLES = [
|
||||
"compare_sample_a.pdf",
|
||||
"compare_sample_b.pdf",
|
||||
"sample.pdf",
|
||||
"rotated-pages.pdf",
|
||||
"annotations_out_of_order.pdf",
|
||||
].map((name) => path.join(FIXTURES_DIR, name));
|
||||
|
||||
// Read from the running app, not imported: a spec resolves @app/* to a different layer than
|
||||
// the browser build does, so an imported WORKBENCH_SESSION_RESTORE can disagree with reality.
|
||||
async function restoreEnabled(
|
||||
page: import("@playwright/test").Page,
|
||||
): Promise<boolean> {
|
||||
await page.waitForFunction(
|
||||
() => document.documentElement.dataset.workbenchRestore !== undefined,
|
||||
null,
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
return page.evaluate(
|
||||
() => document.documentElement.dataset.workbenchRestore === "true",
|
||||
);
|
||||
}
|
||||
|
||||
const NO_RESTORE = "this build ships the workbench restore off";
|
||||
|
||||
// Switching editor -> processor unmounts every editor provider; the session record
|
||||
// in sessionStorage is what brings the workbench back on return.
|
||||
test.describe("Workbench survives the editor/processor switch", () => {
|
||||
test.use({
|
||||
stubOptions: {
|
||||
enableLogin: true,
|
||||
user: {
|
||||
id: 44,
|
||||
username: "owner",
|
||||
email: "owner@example.com",
|
||||
role: "ROLE_USER",
|
||||
portalAccess: true,
|
||||
},
|
||||
},
|
||||
seedJwt: true,
|
||||
});
|
||||
|
||||
test("open files and the library return after a round-trip", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.skip(!(await restoreEnabled(page)), NO_RESTORE);
|
||||
|
||||
// Portal endpoints the processor shell fetches on mount.
|
||||
for (const [pattern, json] of [
|
||||
["**/api/v1/policies", []],
|
||||
["**/api/v1/policies/runs", []],
|
||||
["**/api/v1/policies/overview", { pipelines: [] }],
|
||||
["**/api/v1/sources", { sources: [] }],
|
||||
["**/api/v1/team/my", []],
|
||||
] as const) {
|
||||
await page.route(pattern, (route) => route.fulfill({ json }));
|
||||
}
|
||||
|
||||
await uploadFiles(page, SAMPLES);
|
||||
await expect(page.locator(".file-sidebar-file-item")).toHaveCount(
|
||||
SAMPLES.length,
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
|
||||
// Uploading lands on the file grid, not the viewer - so the return has a
|
||||
// view it can get wrong (NavigationContext boots to "viewer").
|
||||
await expect(
|
||||
page.getByRole("radio", { name: /Active Files/i }),
|
||||
).toBeChecked();
|
||||
|
||||
// Out through the sidebar footer switch - the real user path.
|
||||
await page.getByRole("button", { name: "Open PDF Processor" }).click();
|
||||
await expect(page).toHaveURL(/\/processor/, { timeout: 15000 });
|
||||
|
||||
// Split the two halves of the feature: if this fails the writer is at fault,
|
||||
// if it passes but the view below is wrong the seeding is.
|
||||
expect(
|
||||
await page.evaluate(() => ({
|
||||
session: JSON.parse(
|
||||
sessionStorage.getItem("stirling.workbench.session") ?? "{}",
|
||||
),
|
||||
returnPath: sessionStorage.getItem(
|
||||
"stirling.workbench.editorReturnPath",
|
||||
),
|
||||
})),
|
||||
).toMatchObject({
|
||||
session: { workbench: "fileEditor" },
|
||||
returnPath: "/editor",
|
||||
});
|
||||
|
||||
// Load the editor cold. Every provider mounts from nothing here, which is
|
||||
// the loss the restore has to cover on the way back.
|
||||
await page.goto("/editor", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(page.locator(".file-sidebar-file-item")).toHaveCount(
|
||||
SAMPLES.length,
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
await expect(page.getByText(/compare_sample_a/i).first()).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("radio", { name: /Active Files/i }),
|
||||
).toBeChecked({ timeout: 15000 });
|
||||
await expect(page.locator(".file-sidebar-loading")).toHaveCount(0, {
|
||||
timeout: 15000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("The view survives a reload", () => {
|
||||
test.use({
|
||||
stubOptions: {
|
||||
enableLogin: true,
|
||||
user: {
|
||||
id: 44,
|
||||
username: "owner",
|
||||
email: "o@e.com",
|
||||
role: "ROLE_USER",
|
||||
portalAccess: true,
|
||||
},
|
||||
},
|
||||
seedJwt: true,
|
||||
});
|
||||
|
||||
const currentView = (page: import("@playwright/test").Page) =>
|
||||
page.evaluate(() => {
|
||||
const r = Array.from(
|
||||
document.querySelectorAll<HTMLInputElement>("input[type=radio]"),
|
||||
).find((x) => x.checked);
|
||||
return r?.value ?? "none";
|
||||
});
|
||||
|
||||
test("comes back on the same view the user left", async ({ page }) => {
|
||||
test.skip(!(await restoreEnabled(page)), NO_RESTORE);
|
||||
|
||||
await uploadFiles(page, SAMPLES.slice(0, 3));
|
||||
await expect(page.locator(".file-sidebar-file-item")).toHaveCount(3, {
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
// Open a document, so the view under test is the viewer rather than the grid.
|
||||
await page
|
||||
.getByRole("button", { name: /Open in Viewer/i })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
await expect
|
||||
.poll(() => currentView(page), { timeout: 10000 })
|
||||
.toBe("viewer");
|
||||
|
||||
// Whatever the workbench settled on is what a reload must reproduce.
|
||||
const before = await currentView(page);
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".file-sidebar-file-item")).toHaveCount(3, {
|
||||
timeout: 20000,
|
||||
});
|
||||
await page.waitForTimeout(3000);
|
||||
expect(await currentView(page)).toBe(before);
|
||||
});
|
||||
|
||||
// The conjunction neither neighbour covers: the spec above proves the VIEW comes back,
|
||||
// engine-capabilities proves stored bytes decode, and nothing proved that the file the
|
||||
// restore reopened is one whose pixels actually arrive.
|
||||
test("a file the restore reopened renders its pages", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
test.skip(!(await restoreEnabled(page)), NO_RESTORE);
|
||||
|
||||
await uploadFiles(page, SAMPLES.slice(0, 3));
|
||||
await expect(page.locator(".file-sidebar-file-item")).toHaveCount(3, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: /Open in Viewer/i })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
await expect
|
||||
.poll(() => currentView(page), { timeout: 10_000 })
|
||||
.toBe("viewer");
|
||||
// Let the record settle: the writer debounces, so a reload can outrun it.
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".file-sidebar-file-item")).toHaveCount(3, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// A tile that decoded has non-zero naturalWidth. The restore resolves each recorded id
|
||||
// to its current leaf, so an empty tile here means it reopened something unreadable.
|
||||
const tile = page
|
||||
.locator('[data-page-index="0"]')
|
||||
.first()
|
||||
.locator('img[src^="blob:"]')
|
||||
.first();
|
||||
await expect(tile).toBeAttached({ timeout: 30_000 });
|
||||
await expect
|
||||
.poll(() => tile.evaluate((img: HTMLImageElement) => img.naturalWidth), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
// Stub: desktop opens OS-launched files on boot; a session restore would collide with that.
|
||||
export function WorkbenchSessionPersistence() {
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
import { suspendWorkbenchSession } from "@app/services/workbenchSession";
|
||||
|
||||
type SignOutFn = () => Promise<void>;
|
||||
|
||||
@@ -16,6 +17,10 @@ export function useAccountLogout() {
|
||||
redirectToLogin,
|
||||
}: AccountLogoutDeps): Promise<void> => {
|
||||
try {
|
||||
// The tab outlives the session; the next person to sign in here must not
|
||||
// inherit this workbench. Suspends writing too - signing out unmounts the
|
||||
// editor, and its flush would otherwise write the record straight back.
|
||||
suspendWorkbenchSession();
|
||||
await signOut();
|
||||
|
||||
const currentConfig = await connectionModeService.getCurrentConfig();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useUI } from "@portal/contexts/UIContext";
|
||||
import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem";
|
||||
import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl";
|
||||
import { EDITOR_BASENAME } from "@app/routes/editorBasename";
|
||||
import { takeEditorReturnPath } from "@app/services/workbenchSession";
|
||||
import { CloseIcon } from "@portal/components/icons";
|
||||
import {
|
||||
GROUP_PROCESSOR,
|
||||
@@ -57,7 +58,7 @@ export function Sidebar() {
|
||||
// the switch stays client-side; an absolute EDITOR_URL (dev cross-app setup)
|
||||
// needs a full page load.
|
||||
const goToEditor = () => {
|
||||
if (EDITOR_IS_SAME_APP) navigate(EDITOR_BASENAME);
|
||||
if (EDITOR_IS_SAME_APP) navigate(takeEditorReturnPath() ?? EDITOR_BASENAME);
|
||||
else window.location.href = EDITOR_URL;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type AuthUser,
|
||||
type AuthTranslate,
|
||||
} from "@app/auth/types";
|
||||
import { suspendWorkbenchSession } from "@app/services/workbenchSession";
|
||||
|
||||
/**
|
||||
* Strip the configured base path so route comparisons work under subpath
|
||||
@@ -97,6 +98,11 @@ export function SpringAuthProvider({
|
||||
const signOut = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
// Signing out is deliberate, unlike an identity check that merely failed: drop the
|
||||
// workbench record here and stop recording, so the teardown that follows cannot
|
||||
// write it back for whoever signs in next.
|
||||
suspendWorkbenchSession();
|
||||
|
||||
const { error } = await springAuth.signOut();
|
||||
|
||||
// Always clear the in-memory session: springAuth.signOut() removes the
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
|
||||
// A file can re-enter the workbench without being a new upload (My Files reopen, session restore).
|
||||
// The persisted dispatch record must stop the upload policy (and its billing) firing a second time.
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
workspace: [] as Array<{ id: string; derivedFromTool?: boolean }>,
|
||||
runStoredPolicy: vi.fn(),
|
||||
getPolicyRun: vi.fn(),
|
||||
listPolicyRuns: vi.fn(),
|
||||
getStirlingFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@app/hooks/useAiEngineEnabled", () => ({
|
||||
useAiEngineEnabled: () => true,
|
||||
}));
|
||||
vi.mock("@app/contexts/FileContext", () => ({
|
||||
useAllFiles: () => ({ fileStubs: mocks.workspace }),
|
||||
useFileManagement: () => ({
|
||||
addFiles: vi.fn(),
|
||||
updateStirlingFileStub: vi.fn(),
|
||||
}),
|
||||
useFileContext: () => ({ consumeFiles: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@app/contexts/IndexedDBContext", () => ({
|
||||
useIndexedDB: () => ({ bumpRevision: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@app/hooks/usePolicies", () => ({
|
||||
usePolicies: () => ({
|
||||
policies: {
|
||||
security: {
|
||||
configured: true,
|
||||
status: "active",
|
||||
backendId: "backend-security",
|
||||
runOn: "upload",
|
||||
order: 0,
|
||||
outputMode: "new_version",
|
||||
outputName: "",
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("@app/services/policyApi", () => ({
|
||||
runStoredPolicy: mocks.runStoredPolicy,
|
||||
getPolicyRun: mocks.getPolicyRun,
|
||||
listPolicyRuns: mocks.listPolicyRuns,
|
||||
downloadPolicyOutput: vi.fn(),
|
||||
resolvePolicyRunTarget: () => "saas",
|
||||
}));
|
||||
vi.mock("@app/services/fileStorage", () => ({
|
||||
fileStorage: {
|
||||
getStirlingFile: mocks.getStirlingFile,
|
||||
getStirlingFileStub: vi.fn().mockResolvedValue(null),
|
||||
persistVersionedOutputs: vi.fn(),
|
||||
updateFileMetadata: vi.fn().mockResolvedValue(true),
|
||||
},
|
||||
}));
|
||||
|
||||
import { usePolicyAutoRun } from "@app/components/policies/usePolicyAutoRun";
|
||||
import {
|
||||
markDispatched,
|
||||
resetPolicyRuns,
|
||||
} from "@app/components/policies/policyRunStore";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
resetPolicyRuns();
|
||||
vi.clearAllMocks();
|
||||
mocks.listPolicyRuns.mockResolvedValue([]);
|
||||
mocks.getStirlingFile.mockResolvedValue(
|
||||
new File(["x"], "doc.pdf", { type: "application/pdf" }),
|
||||
);
|
||||
mocks.runStoredPolicy.mockResolvedValue("run-0");
|
||||
// Completed with no outputs: the run settles without the import machinery.
|
||||
mocks.getPolicyRun.mockResolvedValue({
|
||||
runId: "run-0",
|
||||
policyId: null,
|
||||
status: "COMPLETED",
|
||||
currentStep: 1,
|
||||
stepCount: 1,
|
||||
error: null,
|
||||
outputs: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe("upload policies and files re-entering the workbench", () => {
|
||||
it("does not re-run on a file the policy already ran on", async () => {
|
||||
markDispatched("security", "already-enforced");
|
||||
mocks.workspace = [{ id: "already-enforced" }, { id: "fresh-upload" }];
|
||||
|
||||
renderHook(() => usePolicyAutoRun());
|
||||
|
||||
await waitFor(() => expect(mocks.runStoredPolicy).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.getStirlingFile).toHaveBeenCalledWith("fresh-upload");
|
||||
expect(mocks.getStirlingFile).not.toHaveBeenCalledWith("already-enforced");
|
||||
});
|
||||
|
||||
it("stays silent when every file in the workbench has already been enforced", async () => {
|
||||
markDispatched("security", "one");
|
||||
markDispatched("security", "two");
|
||||
mocks.workspace = [{ id: "one" }, { id: "two" }];
|
||||
|
||||
renderHook(() => usePolicyAutoRun());
|
||||
|
||||
// Give the dispatch effect a tick to (wrongly) fire before asserting silence.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(mocks.runStoredPolicy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still skips a policy's own output, which is not an upload at all", async () => {
|
||||
mocks.workspace = [{ id: "policy-output", derivedFromTool: true }];
|
||||
|
||||
renderHook(() => usePolicyAutoRun());
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(mocks.runStoredPolicy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -13,3 +13,6 @@
|
||||
* Watched Folders implementation to navigate to).
|
||||
*/
|
||||
export const WATCHED_FOLDERS_ENABLED: boolean = false;
|
||||
|
||||
// Refill an empty workbench from the tab's last session (survives the editor/processor switch).
|
||||
export const WORKBENCH_SESSION_RESTORE: boolean = true;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { suspendWorkbenchSession } from "@app/services/workbenchSession";
|
||||
|
||||
type SignOutFn = () => Promise<void>;
|
||||
|
||||
interface AccountLogoutDeps {
|
||||
@@ -21,6 +23,10 @@ export function useAccountLogout() {
|
||||
"1",
|
||||
);
|
||||
}
|
||||
// The tab outlives the session; the next person to sign in here must not
|
||||
// inherit this workbench. Suspends writing too - signing out unmounts the
|
||||
// editor, and its flush would otherwise write the record straight back.
|
||||
suspendWorkbenchSession();
|
||||
await signOut();
|
||||
} finally {
|
||||
redirectToLogin();
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
navigate: vi.fn(),
|
||||
requestNavigation: vi.fn(),
|
||||
portalAccess: true,
|
||||
}));
|
||||
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useNavigate: () => mocks.navigate,
|
||||
useLocation: () => ({ pathname: "/compress", search: "?mode=fast" }),
|
||||
}));
|
||||
vi.mock("@app/auth/context", () => ({
|
||||
useAuth: () => ({ portalAccess: mocks.portalAccess }),
|
||||
}));
|
||||
vi.mock("@app/contexts/NavigationContext", () => ({
|
||||
useNavigationActions: () => ({
|
||||
actions: { requestNavigation: mocks.requestNavigation },
|
||||
}),
|
||||
}));
|
||||
|
||||
import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch";
|
||||
import { takeEditorReturnPath } from "@app/services/workbenchSession";
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
mocks.portalAccess = true;
|
||||
});
|
||||
|
||||
describe("useOtherAppSwitch", () => {
|
||||
it("offers no switch without portal access", () => {
|
||||
mocks.portalAccess = false;
|
||||
const { result } = renderHook(() => useOtherAppSwitch());
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("routes the switch through the unsaved-changes guard", () => {
|
||||
const { result } = renderHook(() => useOtherAppSwitch());
|
||||
result.current?.onOpen();
|
||||
|
||||
expect(mocks.navigate).not.toHaveBeenCalled();
|
||||
expect(mocks.requestNavigation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("records where to return to, then navigates to the processor", () => {
|
||||
const { result } = renderHook(() => useOtherAppSwitch());
|
||||
result.current?.onOpen();
|
||||
mocks.requestNavigation.mock.calls[0][0]();
|
||||
|
||||
expect(takeEditorReturnPath()).toBe("/compress?mode=fast");
|
||||
expect(mocks.navigate).toHaveBeenCalledWith("/processor");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "@app/auth/context";
|
||||
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
||||
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
|
||||
import { saveEditorReturnPath } from "@app/services/workbenchSession";
|
||||
import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFooter";
|
||||
|
||||
/**
|
||||
@@ -10,6 +12,16 @@ import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFoote
|
||||
export function useOtherAppSwitch(): NavFooterAppLink | null {
|
||||
const { portalAccess } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { actions } = useNavigationActions();
|
||||
if (!portalAccess) return null;
|
||||
return { app: "processor", onOpen: () => navigate(PORTAL_BASENAME) };
|
||||
return {
|
||||
app: "processor",
|
||||
onOpen: () =>
|
||||
// Through the guard, so unsaved edits get the same warning as any other navigation.
|
||||
actions.requestNavigation(() => {
|
||||
saveEditorReturnPath(location.pathname + location.search);
|
||||
navigate(PORTAL_BASENAME);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { act, render, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Session, User } from "@supabase/supabase-js";
|
||||
import { expectConsole } from "@app/tests/failOnConsole";
|
||||
|
||||
/**
|
||||
* Request-count tests for {@link AuthProvider}'s data loading. It used to fetch
|
||||
@@ -16,6 +17,7 @@ const createSignedUrl = vi.fn();
|
||||
const storageFrom = vi.fn((_bucket: string) => ({ createSignedUrl }));
|
||||
const getSession = vi.fn();
|
||||
const onAuthStateChange = vi.fn();
|
||||
const supabaseSignOut = vi.fn();
|
||||
const unsubscribe = vi.fn();
|
||||
|
||||
vi.mock("@app/auth/supabase", () => ({
|
||||
@@ -26,7 +28,7 @@ vi.mock("@app/auth/supabase", () => ({
|
||||
refreshSession: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ data: { session: null }, error: null }),
|
||||
signOut: vi.fn().mockResolvedValue({ error: null }),
|
||||
signOut: () => supabaseSignOut(),
|
||||
},
|
||||
rpc: (...args: unknown[]) => rpc(...args),
|
||||
storage: { from: (bucket: string) => storageFrom(bucket) },
|
||||
@@ -53,6 +55,8 @@ vi.mock("@app/services/userService", () => ({
|
||||
|
||||
// Imported after the mocks so the provider picks them up.
|
||||
const { AuthProvider, useAuth } = await import("@app/auth/UseSession");
|
||||
const { writeWorkbenchSession, readWorkbenchSession, resumeWorkbenchSession } =
|
||||
await import("@app/services/workbenchSession");
|
||||
|
||||
/** Surfaces `loading` so a test can assert on it rather than on the container. */
|
||||
function LoadingProbe() {
|
||||
@@ -337,3 +341,55 @@ describe("AuthProvider user-data loading", () => {
|
||||
expect(rpc).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Signing out suspends workbench recording before the request, so a teardown cannot write the
|
||||
* record back for whoever signs in next. When the request fails the session stands, and a
|
||||
* still-signed-in user must not be left silently not recording.
|
||||
*/
|
||||
describe("a sign-out that fails", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sessionStorage.clear();
|
||||
resumeWorkbenchSession();
|
||||
getSession.mockResolvedValue({
|
||||
data: { session: makeSession() },
|
||||
error: null,
|
||||
});
|
||||
onAuthStateChange.mockImplementation(() => ({
|
||||
data: { subscription: { unsubscribe } },
|
||||
}));
|
||||
rpc.mockResolvedValue({ data: null, error: null });
|
||||
getProfilePictureMetadata.mockResolvedValue(null);
|
||||
syncOAuthAvatar.mockResolvedValue(undefined);
|
||||
synchronizeUserUpgrade.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("leaves the workbench still being recorded", async () => {
|
||||
expectConsole.error(/\[Auth Debug\] Sign out error/);
|
||||
supabaseSignOut.mockResolvedValue({ error: new Error("network down") });
|
||||
|
||||
let signOut: (() => Promise<void>) | null = null;
|
||||
function SignOutProbe() {
|
||||
signOut = useAuth().signOut;
|
||||
return null;
|
||||
}
|
||||
render(
|
||||
<AuthProvider>
|
||||
<SignOutProbe />
|
||||
</AuthProvider>,
|
||||
);
|
||||
await waitFor(() => expect(signOut).not.toBeNull());
|
||||
|
||||
await act(async () => {
|
||||
await signOut!();
|
||||
});
|
||||
|
||||
writeWorkbenchSession({
|
||||
fileIds: ["still-here"],
|
||||
selectedFileIds: [],
|
||||
userId: "user-a",
|
||||
});
|
||||
expect(readWorkbenchSession()?.fileIds).toEqual(["still-here"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
getProviderAvatarUrl,
|
||||
type ProfilePictureMetadata,
|
||||
} from "@app/services/avatarSyncService";
|
||||
import {
|
||||
resumeWorkbenchSession,
|
||||
suspendWorkbenchSession,
|
||||
} from "@app/services/workbenchSession";
|
||||
|
||||
// Extend Supabase User to include optional username for compatibility
|
||||
export type User = SupabaseUser & { username?: string };
|
||||
@@ -355,11 +359,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const signOut = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
// Signing out is deliberate, unlike an identity check that merely failed: drop the
|
||||
// workbench record here and stop recording, so the teardown that follows cannot
|
||||
// write it back for whoever signs in next.
|
||||
suspendWorkbenchSession();
|
||||
|
||||
const { error } = await supabase.auth.signOut();
|
||||
|
||||
if (error) {
|
||||
console.error("[Auth Debug] Sign out error:", error);
|
||||
setError(error);
|
||||
// The sign-out did not happen and the session stands, so keep recording:
|
||||
// otherwise a still-signed-in user silently stops persisting their workbench.
|
||||
resumeWorkbenchSession();
|
||||
} else {
|
||||
console.debug("[Auth Debug] Signed out successfully");
|
||||
setSession(null);
|
||||
@@ -367,6 +379,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
} catch (err) {
|
||||
console.error("[Auth Debug] Unexpected error during sign out:", err);
|
||||
setError(err as AuthError);
|
||||
resumeWorkbenchSession();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { usePortalAccess } from "@app/hooks/usePortalAccess";
|
||||
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
||||
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
|
||||
import { saveEditorReturnPath } from "@app/services/workbenchSession";
|
||||
import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFooter";
|
||||
|
||||
/**
|
||||
@@ -11,6 +13,16 @@ import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFoote
|
||||
export function useOtherAppSwitch(): NavFooterAppLink | null {
|
||||
const portalAccess = usePortalAccess();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { actions } = useNavigationActions();
|
||||
if (!portalAccess) return null;
|
||||
return { app: "processor", onOpen: () => navigate(PORTAL_BASENAME) };
|
||||
return {
|
||||
app: "processor",
|
||||
onOpen: () =>
|
||||
// Through the guard, so unsaved edits get the same warning as any other navigation.
|
||||
actions.requestNavigation(() => {
|
||||
saveEditorReturnPath(location.pathname + location.search);
|
||||
navigate(PORTAL_BASENAME);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user