Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ jobs:
# Empty inactivity variables (what compose passes when .env leaves them unset)
# fall back to the app's built-in timings.
jq -e --argjson idle "$DEFAULT_IDLE_MS" '.inactivityIdleMs == $idle and .inactivityWarningMs == 60000' config.json
# Unset FSH_DEFAULT_LANGUAGE falls back to the bundle's own default.
jq -e '.defaultLanguage == "en-US"' config.json
}
check
if curl -fsS http://localhost:8080/config.json.template | grep -qF '${FSH_API_URL}'; then
Expand Down
31 changes: 26 additions & 5 deletions clients/admin/src/api/users.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { apiFetch } from "@/lib/api-client";
import { ApiRequestError, apiFetch } from "@/lib/api-client";
import type { PagedResponse } from "@/lib/api-types";

export type UserDto = {
Expand Down Expand Up @@ -92,20 +92,41 @@ export type UpdateMyProfileInput = {
* unconditionally from the command, so a partial update (e.g. the language
* switcher sending only `locale`) would wipe the others. Reads the current
* profile from the server first and merges, so any field the caller omits
* keeps its persisted value instead of being nulled — never trust a possibly
* stale/undefined client-side snapshot for the echoed fields.
* keeps its persisted value.
*
* The PUT echoes the read's ETag in `If-Match`, so a profile edit that lands
* between the read and the write makes the server answer 412 instead of being
* silently overwritten. On 412 the read-merge-write runs once more against the
* fresh profile; the caller only sends the fields it means to change.
*/
export async function updateMyProfile(input: UpdateMyProfileInput): Promise<void> {
const profile = await getMyProfile();
try {
await putMyProfile(input);
} catch (err) {
if (!(err instanceof ApiRequestError) || err.status !== 412) throw err;
await putMyProfile(input);
}
}

async function putMyProfile(input: UpdateMyProfileInput): Promise<void> {
let etag: string | null = null;
const profile = await apiFetch<UserDto>(`${IDENTITY}/profile`, {
onResponse: (response) => {
// The endpoint emits a strong validator; a compressing proxy can downgrade it to W/,
// which the server's strong If-Match comparison would never match.
const header = response.headers.get("ETag");
etag = header ? header.replace(/^W\//, "") : null;
},
});
await apiFetch<void>(`${IDENTITY}/profile`, {
method: "PUT",
headers: etag ? { "If-Match": etag } : undefined,
body: JSON.stringify({
id: profile.id,
firstName: input.firstName ?? profile.firstName ?? null,
lastName: input.lastName ?? profile.lastName ?? null,
phoneNumber: input.phoneNumber ?? profile.phoneNumber ?? null,
locale: input.locale ?? profile.locale ?? null,
email: profile.email,
deleteCurrentImage: false,
}),
});
Expand Down
26 changes: 0 additions & 26 deletions clients/admin/src/components/theme/theme-toggle.tsx

This file was deleted.

2 changes: 1 addition & 1 deletion clients/admin/src/hooks/use-file-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ export function useFileUpload(options: UploadOptions): UseFileUploadResult {
xhrRef.current = null;
return dto;
},
[options],
[options, t],
);

return {
Expand Down
11 changes: 9 additions & 2 deletions clients/admin/src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,12 @@ export function describeError(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}

type RequestInitEx = RequestInit & { skipAuth?: boolean; timeoutMs?: number };
type RequestInitEx = RequestInit & {
skipAuth?: boolean;
timeoutMs?: number;
/** Sees the final response (after any token refresh) before the body is read, e.g. to capture an ETag. */
onResponse?: (response: Response) => void;
};

const DEFAULT_TIMEOUT_MS = 30_000;

Expand Down Expand Up @@ -128,7 +133,7 @@ export async function apiFetch<T = unknown>(
path: string,
init: RequestInitEx = {},
): Promise<T> {
const { skipAuth, headers, timeoutMs, signal, ...rest } = init;
const { skipAuth, headers, timeoutMs, signal, onResponse, ...rest } = init;
const effectiveTimeout = timeoutMs ?? DEFAULT_TIMEOUT_MS;

const mergedHeaders = new Headers(headers);
Expand Down Expand Up @@ -193,6 +198,8 @@ export async function apiFetch<T = unknown>(
});
}

onResponse?.(response);

if (!response.ok) {
const problem = await parseError(response);
throw new ApiRequestError(
Expand Down
3 changes: 2 additions & 1 deletion clients/dashboard/docker/config.json.template
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
"apiBase": "${FSH_API_URL}",
"defaultTenant": "${FSH_DEFAULT_TENANT}",
"inactivityIdleMs": ${FSH_INACTIVITY_IDLE_MS},
"inactivityWarningMs": ${FSH_INACTIVITY_WARNING_MS}
"inactivityWarningMs": ${FSH_INACTIVITY_WARNING_MS},
"defaultLanguage": "${FSH_DEFAULT_LANGUAGE}"
}
4 changes: 3 additions & 1 deletion clients/dashboard/docker/docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ set -e

: "${FSH_API_URL:?FSH_API_URL is required (e.g. https://api.example.com)}"
: "${FSH_DEFAULT_TENANT:=root}"
# Matches the bundle's own fallback, so an unset variable and an absent config.json agree.
: "${FSH_DEFAULT_LANGUAGE:=en-US}"

# The inactivity timings are bare JSON numbers in the template, so each needs a value and it
# must be a positive integer. The defaults mirror DEFAULT_INACTIVITY_* in src/env.ts.
Expand All @@ -16,7 +18,7 @@ require_positive_ms() {
require_positive_ms FSH_INACTIVITY_IDLE_MS "$FSH_INACTIVITY_IDLE_MS"
require_positive_ms FSH_INACTIVITY_WARNING_MS "$FSH_INACTIVITY_WARNING_MS"

export FSH_API_URL FSH_DEFAULT_TENANT FSH_INACTIVITY_IDLE_MS FSH_INACTIVITY_WARNING_MS
export FSH_API_URL FSH_DEFAULT_TENANT FSH_INACTIVITY_IDLE_MS FSH_INACTIVITY_WARNING_MS FSH_DEFAULT_LANGUAGE

envsubst < /etc/fsh/config.json.template \
> /usr/share/nginx/html/config.json
Expand Down
6 changes: 3 additions & 3 deletions clients/dashboard/src/components/file/file-preview-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -359,16 +359,16 @@ function TextPreview({ url }: { url: string }) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const t = await res.text();
if (!cancelled) setText(t.slice(0, 64 * 1024)); // cap at 64 KiB to keep the modal snappy
const body = await res.text();
if (!cancelled) setText(body.slice(0, 64 * 1024)); // cap at 64 KiB to keep the modal snappy
} catch (e) {
if (!cancelled) setErr(e instanceof Error ? e.message : t("preview.fetchFailed"));
}
})();
return () => {
cancelled = true;
};
}, [url]);
}, [url, t]);

if (err) {
return <ErrorBand message={t("preview.textError", { err })} />;
Expand Down
2 changes: 1 addition & 1 deletion clients/dashboard/src/hooks/use-file-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ export function useFileUpload(options: UploadOptions): UseFileUploadResult {
xhrRef.current = null;
return dto;
},
[options],
[options, t],
);

return {
Expand Down
12 changes: 1 addition & 11 deletions clients/dashboard/src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,6 @@ export class ApiRequestError extends Error {
/** The deactivated-tenant guard's error key (MultitenancyModule). */
const TENANT_DEACTIVATED_CODE = "Multitenancy.TenantDeactivated";

/**
* The guard's message before the ProblemDetails `code` existed. Kept as a second
* match so this app works against an API that predates the localized-errors slice:
* until that ships, `code` is absent and the only signal is this exact English text.
* Drop this branch once the API always sends `code`.
*/
const TENANT_DEACTIVATED_LEGACY_DETAIL =
"This tenant has been deactivated. Contact your administrator.";

/**
* True when an error is the API's deactivated-tenant 403. The guard
* (MultitenancyModule) rejects *every* request once a tenant is switched off, so
Expand All @@ -55,8 +46,7 @@ const TENANT_DEACTIVATED_LEGACY_DETAIL =
*/
export function isTenantDeactivatedError(error: unknown): boolean {
if (!(error instanceof ApiRequestError) || error.status !== 403) return false;
if (error.problem?.code === TENANT_DEACTIVATED_CODE) return true;
return error.problem?.detail === TENANT_DEACTIVATED_LEGACY_DETAIL;
return error.problem?.code === TENANT_DEACTIVATED_CODE;
}

/**
Expand Down
15 changes: 0 additions & 15 deletions clients/dashboard/tests/system/tenant-deactivated.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,19 +72,4 @@ test.describe("tenant deactivated mid-session", () => {
await page.waitForLoadState("networkidle");
await expect(page).not.toHaveURL(/\/tenant-deactivated/);
});

// Compatibility: against an API that predates the localized-errors slice the 403
// carries no `code` at all. Routing must still happen, otherwise this app only
// works once that slice has shipped.
test("routes on an uncoded 403 carrying the legacy English detail", async ({ page }) => {
await mockProblemDetails(page, "**/api/v1/catalog/products**", 403, {
title: "Forbidden",
detail: "This tenant has been deactivated. Contact your administrator.",
});

await page.goto("/catalog/products");

await expect(page).toHaveURL(/\/tenant-deactivated$/);
await expect(page.getByRole("heading", { name: /tenant deactivated/i })).toBeVisible();
});
});
4 changes: 4 additions & 0 deletions deploy/docker/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ FSH_DEFAULT_TENANT=root
# FSH_DASHBOARD_INACTIVITY_IDLE_MS=1200000
# FSH_INACTIVITY_WARNING_MS=60000

# UI language for users who haven't picked one (both frontends). A BCP 47
# tag the apps ship a catalog for: en-US (default) or pt-BR.
# FSH_DEFAULT_LANGUAGE=en-US

# ── Host ports for the external proxy to point at ───────────────────
# FSH services and the RustFS S3 API publish ports (browsers upload
# straight to it). Postgres/Redis stay compose-internal by default
Expand Down
2 changes: 2 additions & 0 deletions deploy/docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ services:
FSH_DEFAULT_TENANT: ${FSH_DEFAULT_TENANT:-root}
FSH_INACTIVITY_IDLE_MS: ${FSH_ADMIN_INACTIVITY_IDLE_MS:-}
FSH_INACTIVITY_WARNING_MS: ${FSH_INACTIVITY_WARNING_MS:-}
FSH_DEFAULT_LANGUAGE: ${FSH_DEFAULT_LANGUAGE:-}
ports:
- "${FSH_ADMIN_PORT:-8081}:80"

Expand All @@ -221,6 +222,7 @@ services:
FSH_DEFAULT_TENANT: ${FSH_DEFAULT_TENANT:-root}
FSH_INACTIVITY_IDLE_MS: ${FSH_DASHBOARD_INACTIVITY_IDLE_MS:-}
FSH_INACTIVITY_WARNING_MS: ${FSH_INACTIVITY_WARNING_MS:-}
FSH_DEFAULT_LANGUAGE: ${FSH_DEFAULT_LANGUAGE:-}
ports:
- "${FSH_DASHBOARD_PORT:-8082}:80"

Expand Down
7 changes: 4 additions & 3 deletions deploy/terraform/apps/starter/app_stack/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -219,9 +219,10 @@ module "dashboard_site" {
response_headers_policy_id = var.frontend_response_headers_policy_id

runtime_config = {
apiBase = local.api_origin
defaultTenant = var.frontend_default_tenant
demoMode = var.dashboard_demo_mode
apiBase = local.api_origin
defaultTenant = var.frontend_default_tenant
demoMode = var.dashboard_demo_mode
defaultLanguage = var.frontend_default_language
}

tags = local.common_tags
Expand Down
Loading