Compare commits

...
Author SHA1 Message Date
Reece 59fa915bf9 test(proprietary): Storybook coverage for admin settings, auth and billing
Config sections, auth screens and guards, billing controls, the policy overlay
and the chat quick actions, plus the fixes their stories surfaced — thirteen
unlabelled switches, an under-contrast PRO badge, and an unnamed progress bar.

Independent of the core, portal and desktop coverage branches.
2026-08-09 13:35:47 +01:00
Reece c879b56fac feat(storybook): make every layer storyable, and fix the shared a11y primitives
Everything a story needs to render, in one place, so the coverage that follows
is reviewable on its own.

Harness:
- @app/* resolves per layer, so desktop, saas, cloud and prototypes can host a
  story at all. Imports from core, proprietary and portal never match the new
  resolver, so nothing about existing stories changes.
- The preview mounts the providers roughly a hundred components reach for
  through Tooltip.
- Six contexts are exported so a story can supply the few fields a component
  reads instead of standing up a provider that reaches half the app.
- Three shared fixtures, and a coverage tool that counts surfaces by import.

Shared a11y fixes, each of which many components inherit:
- Mantine's "dimmed" measured about 3:1 against the app surface; it now points
  at the design system's muted token, which clears 4.5:1 in both themes.
- Seventeen slider thumbs were announced unnamed.
- The danger accent filled with red-500 under white text at 3.76:1; red-600
  gives 4.85:1.
2026-08-09 13:34:39 +01:00
Reece 1f27655c1b Merge branch 'fix/danger-accent-contrast' into chore/storybook-foundation 2026-08-09 13:32:30 +01:00
Reece 2a403d3210 Merge branch 'fix/name-every-slider' into chore/storybook-foundation 2026-08-09 13:32:29 +01:00
Reece d98a4875db Merge branch 'fix/mantine-dimmed-contrast' into chore/storybook-foundation 2026-08-09 13:32:28 +01:00
Reece 8ec80eb9fc Merge branch 'feat/storybook-flavour-aliases' into chore/storybook-foundation 2026-08-09 13:32:24 +01:00
Reece e97c16db92 test(saas,desktop): cover the magic-link form and the auth shell
Also fixes the shared auth card: it caps at 96vh, scrolls, and hides its
scrollbar, but was not focusable — on a short viewport a keyboard user could
not reach the bottom of the login form, with nothing on screen to indicate
there was more. It is now focusable and announced as a group.

The shell is shared by the editor and the portal, so this is not desktop-only.

No stories for desktop/routes/Login or portal-saas/views/Components: both are
bare <Navigate> redirects that render nothing.
2026-08-08 14:44:41 +01:00
Reece 7058a41af9 fix(ui): raise the danger accent's fill so white text clears 4.5:1
The danger accent filled with red-500 and put white text on it, which measures
3.76:1 — short of the 4.5:1 body-size text requires. red-600 gives 4.85:1 and,
being a fixed red rather than a theme-lightened one, holds in both themes. The
hover darkens from there rather than landing on the old fill.

Confirmed against a real button: the same scan that reported white-on-#ef4444
at 3.76:1 reports no contrast violation for it afterwards.

Not changed here: the same rule sets --_text to red-500 for danger text on a
surface, which is 3.49:1 on light. No single fixed red clears 4.5:1 as text in
both themes — red-600 passes on light and fails on dark, red-500 the reverse —
so that one needs a theme-aware token and a design decision, not a swap.
2026-08-08 12:54:17 +01:00
Reece 366516dcdf feat(storybook): resolve @app/* per layer so desktop, saas and cloud can have stories
Storybook resolved @app/* through a single tsconfig — the proprietary one — so
a story under src/desktop asking for a desktop module got the proprietary or
core file, or nothing. Those four layers could not host a story at all, which
is 62 uncovered surfaces.

A resolver keyed on the importer's layer mirrors each flavour's own vite
tsconfig: desktop searches desktop, cloud, proprietary, core; saas searches
saas, cloud, proprietary, core; prototypes skips cloud. Imports from core,
proprietary and portal never match it, so nothing about existing stories
changes.

The first three desktop components covered found two defects:

- The file save-state dot carried aria-label on a bare span, where the
  attribute is prohibited and silently ignored. Save state was conveyed by
  colour alone with nothing announced. It is now role="img".
- DisabledButtonWithTooltip exists to look disabled while explaining why, but
  said none of that to assistive technology and reached the explanation only by
  hover. It now announces itself as a disabled button described by its tooltip,
  and shows that tooltip on focus as well.
2026-08-08 11:55:21 +01:00
Reece 422df509f1 a11y: name the remaining unlabelled slider thumbs
Mantine's Slider renders its thumb as a div carrying role="slider", so the
heading above it cannot name it through a label association — thumbLabel is
what does, and Slider writes an empty aria-label over anything passed through
thumbProps.

Seventeen thumbs across the annotation controls, the viewer toolbars, the
annotate panel and the profile-picture cropper were announced unnamed. Each now
carries the same text as the heading rendered above it.

The mechanism was confirmed in a browser on two of these components elsewhere in
this stack; these seventeen have no stories yet, so they are verified by
typecheck and by matching that established pattern rather than by a scan.
2026-08-08 07:50:09 +01:00
Reece 9562c1622a fix(theme): point Mantine's muted text at the semantic token
Mantine's stock "dimmed" is #868e96, which measures about 3:1 against the
app's surfaces — short of the 4.5:1 body text needs. It is also a fixed grey,
so dark mode inherited a value only ever chosen for light backgrounds.

--c-text-muted already carries muted text for the design system and is defined
per theme, so pointing Mantine at it fixes all 654 `c="dimmed"` call sites at
once and lets them follow the theme.

The selector is doubled because Mantine declares the same custom property on
:root and its stylesheet loads later; a single :root loses on source order.

Measured across the 140 story files whose components use `c="dimmed"`:
377 stories, 311 -> 151 with violations, 165 previously failing stories now
clean, and no regressions.
2026-08-08 02:23:37 +01:00
83 changed files with 3834 additions and 288 deletions
+46
View File
@@ -1,3 +1,4 @@
import { existsSync, statSync } from "node:fs";
import { resolve } from "node:path";
import type { StorybookConfig } from "@storybook/react-vite";
import tsconfigPaths from "vite-tsconfig-paths";
@@ -10,6 +11,45 @@ import tsconfigPaths from "vite-tsconfig-paths";
* the portal layer at editor/src/portal/). MDX docs pages live in
* editor/src/portal/docs/.
*/
/** Layer search order for @app/*, mirroring each flavour's vite tsconfig. */
const FLAVOUR_LAYERS: Record<string, string[]> = {
desktop: ["desktop", "cloud", "proprietary", "core"],
saas: ["saas", "cloud", "proprietary", "core"],
cloud: ["cloud", "proprietary", "core"],
prototypes: ["prototypes", "proprietary", "core"],
};
const SUFFIXES = ["", ".tsx", ".ts", "/index.tsx", "/index.ts"];
function flavourAppAlias() {
return {
name: "storybook-app-alias-by-flavour",
// Ahead of vite-tsconfig-paths, which would otherwise answer first with the
// proprietary order.
enforce: "pre" as const,
resolveId(source: string, importer: string | undefined) {
if (!importer || !source.startsWith("@app/")) return null;
const layer = importer
.split("\\")
.join("/")
.match(/\/editor\/src\/(desktop|saas|cloud|prototypes)\//)?.[1];
if (!layer) return null;
const rest = source.slice("@app/".length);
for (const candidate of FLAVOUR_LAYERS[layer]) {
for (const suffix of SUFFIXES) {
const full = resolve(
__dirname,
`../editor/src/${candidate}/${rest}${suffix}`,
);
if (existsSync(full) && statSync(full).isFile()) return full;
}
}
return null;
},
};
}
const config: StorybookConfig = {
stories: [
"../editor/src/portal/**/*.mdx",
@@ -53,6 +93,12 @@ const config: StorybookConfig = {
// shared Storybook can host editor components without duplicating the alias
// map here.
config.plugins = config.plugins ?? [];
// Stories under desktop/, saas/, cloud/ and prototypes/ import @app/* too,
// but each of those builds resolves it against its own layer first — an
// order the single tsconfig below cannot express. Resolving by the
// importer's layer lets those stories load without changing what @app/*
// means for core, proprietary or portal stories, which never match here.
config.plugins.push(flavourAppAlias());
config.plugins.push(
tsconfigPaths({
projects: [
+69 -7
View File
@@ -18,7 +18,14 @@ import { TierProvider, type Tier } from "@portal/contexts/TierContext";
import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext";
import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
import { UIProvider } from "@portal/contexts/UIContext";
import { PreferencesProvider } from "@core/contexts/PreferencesContext";
import { SidebarProvider } from "@core/contexts/SidebarContext";
import { SuiProvider } from "@portal/theme/SuiProvider";
import { MantineProvider } from "@mantine/core";
import {
mantineTheme as editorMantineTheme,
editorCssVariablesResolver,
} from "@core/theme/mantineTheme";
import { handlers } from "@portal/mocks/handlers";
import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient";
import i18next from "i18next";
@@ -29,6 +36,11 @@ import { rtlLanguages, supportedLanguages } from "@core/i18n/languages";
import "@mantine/core/styles.css";
import "@core/tokens/tokens.css";
import "@core/theme/index.css";
// The editor's Mantine theme resolves its palette through the --color-* vocab
// defined here. The app picks this up via styles/tailwind.css; Storybook has no
// tailwind entry, so without it every var(--color-*) in the theme is undefined
// and Mantine silently falls back to its stock palette.
import "@core/styles/theme.css";
import "@core/tokens/base.css";
// Storybook-only: bundle every shipped locale's TOML at build time via a ?raw
@@ -190,6 +202,37 @@ const withLocale: Decorator = (Story, context) => {
return <Story />;
};
/**
* Applies the Mantine theme the story's component actually runs under in the
* app: PortalApp wraps the Processor in SuiProvider, while the editor wraps
* everything else in its own ThemeProvider. Getting this wrong is not just
* cosmetic — the two themes carry different neutral ramps, so rendering an
* editor component under the Processor's theme drops it onto Mantine's stock
* greys and reports contrast failures the app doesn't have.
*/
function StoryTheme({
isPortalStory,
colorScheme,
children,
}: {
isPortalStory: boolean;
colorScheme: "light" | "dark";
children: React.ReactNode;
}) {
if (isPortalStory) {
return <SuiProvider colorScheme={colorScheme}>{children}</SuiProvider>;
}
return (
<MantineProvider
theme={editorMantineTheme}
cssVariablesResolver={editorCssVariablesResolver}
forceColorScheme={colorScheme}
>
{children}
</MantineProvider>
);
}
const withProviders: Decorator = (Story, context) => {
const tier = (context.globals.tier as Tier) ?? "pro";
const linkState =
@@ -201,25 +244,36 @@ const withProviders: Decorator = (Story, context) => {
// anything that isn't "dark" as light — matching the addon's own
// `selected || defaultTheme` fallback where defaultTheme is light.
const colorScheme = context.globals.theme === "dark" ? "dark" : "light";
// Storybook titles are the routing key here: the Processor's stories are all
// filed under "Portal/".
const isPortalStory = (context.title ?? "").startsWith("Portal/");
return (
<MemoryRouter initialEntries={["/"]}>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<SchemeSetup scheme={colorScheme} />
<ThemeBridge theme={colorScheme}>
<SuiProvider colorScheme={colorScheme}>
<StoryTheme isPortalStory={isPortalStory} colorScheme={colorScheme}>
{/* LinkProvider must wrap TierProvider: TierContext derives its tier
from useLink() (matches App.tsx's nesting). */}
<LinkProvider key={linkState} initialState={linkState}>
<TierKey tier={tier}>
<UIProvider>
<Suspense fallback={null}>
<Story />
</Suspense>
</UIProvider>
{/* Tooltip reads the user's logo preference and the sidebar
geometry it positions against. It is used by ~100
components, so without these a story that renders one
throws. The real app always has both mounted. */}
<PreferencesProvider>
<SidebarProvider>
<UIProvider>
<Suspense fallback={null}>
<Story />
</Suspense>
</UIProvider>
</SidebarProvider>
</PreferencesProvider>
</TierKey>
</LinkProvider>
</SuiProvider>
</StoryTheme>
</ThemeBridge>
</ThemeProvider>
</QueryClientProvider>
@@ -246,6 +300,14 @@ const preview: Preview = {
// any violation. Context is left at the addon default (the document root)
// so it resolves under both the Storybook UI and the Vitest browser mount.
test: "error",
context: {
// Nodes carrying this attribute render a facsimile of the user's own
// document — their stamp text, their watermark, in the colour and
// opacity they chose. WCAG contrast governs the interface, not the
// content authored through it, and the controls that set those values
// are checked normally.
exclude: ["[data-user-content-preview]"],
},
},
},
globalTypes: {
@@ -0,0 +1,241 @@
#!/usr/bin/env node
// Storybook coverage report — which rendered surfaces have a story, and what
// stands between the ones that don't and having one. Modes:
//
// node storybook-coverage.mjs summary by area (report only)
// node storybook-coverage.mjs --todo every uncovered surface, with the
// work each needs
// node storybook-coverage.mjs --area core/components/tools
//
// Coverage is counted by *import*, not by an adjacent .stories.tsx: several
// components are covered by a shared story file (MantineForms covers Select,
// MultiSelect, NumberInput and ColorInput between them), and counting siblings
// reports those as gaps and invites duplicate stories.
//
// Each uncovered surface is classified by what a story would have to supply:
//
// props no context to supply. Note this means "no provider needed", not
// "cheap" — a props-only component can still be expensive to
// story if its props are heavy (ButtonAppearanceOverlay wants
// real PDF bytes; AppConfigModalLazy lazy-loads the whole
// settings tree). Read the props before assuming it is quick.
// context it (or something it renders) reads a React context. The cheap
// fix is usually to export the context and hand the story the
// slice the component actually touches, rather than mounting the
// provider and whatever chain sits behind it.
// data it fetches, so the story needs MSW handlers
// router it reads router state
//
// Not counted as surfaces at all: providers, contexts, gates, routers, test
// helpers, and modules that return a config object rather than markup.
//
// Known blocker — four flavours cannot be storied as things stand.
//
// Storybook resolves @app/* through editor/tsconfig.proprietary.vite.json,
// which maps it to src/proprietary/* then src/core/* and excludes src/desktop.
// A file in another flavour that imports an @app/* asset living only in its own
// tree therefore fails to resolve, and the story file does not load at all:
//
// desktop 80 of 152 @app/ imports unresolvable
// saas 54 of 232
// cloud 28 of 77
// prototypes 4 of 40
// portal-saas 0 of 7 (fine)
//
// That accounts for the 0% areas below — it is a build-config gap, not
// neglect. Closing it means either per-flavour alias projects in
// .storybook/main.ts or hoisting the shared assets, and it is a decision with
// blast radius across every existing story, so it is deliberately not made
// here.
//
// Run from frontend/.
import { readFileSync, readdirSync } from "node:fs";
import { join, relative, resolve } from "node:path";
const SRC = resolve(process.cwd(), "editor/src");
const args = process.argv.slice(2);
const wantTodo = args.includes("--todo");
const areaFilter = args.includes("--area")
? args[args.indexOf("--area") + 1]
: null;
/* ── file walk ────────────────────────────────────────────────────────────── */
function walk(dir, out = []) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) walk(full, out);
else if (entry.name.endsWith(".tsx")) out.push(full);
}
return out;
}
const all = walk(SRC);
const rel = (f) => relative(SRC, f).split("\\").join("/");
const storyFiles = all.filter((f) => f.endsWith(".stories.tsx"));
const sources = all.filter(
(f) => !f.endsWith(".stories.tsx") && !f.endsWith(".test.tsx"),
);
/* ── what the stories already reach ───────────────────────────────────────── */
const importedNames = new Set();
const importedPaths = new Set();
for (const f of storyFiles) {
const src = readFileSync(f, "utf8");
for (const m of src.matchAll(
/import\s+(?:type\s+)?(?:\{([^}]*)\}|(\w+))\s*(?:,\s*\{([^}]*)\})?\s*from\s+["']([^"']+)/g,
)) {
for (const group of [m[1], m[3]]) {
if (!group) continue;
for (const name of group.split(","))
importedNames.add(
name.trim().split(" as ")[0].replace("type ", "").trim(),
);
}
if (m[2]) importedNames.add(m[2]);
importedPaths.add(m[4]);
}
}
/* ── classification ───────────────────────────────────────────────────────── */
// Bridge: the viewer's *APIBridge components register an API into context and
// render null — wiring, like the rest of these.
const INFRA_NAME =
/(Provider|Providers|Context|Gate|Boundary|Mount|Router|Guard|Bridge)\.tsx$/;
const INFRA_DIR = /\/(contexts|test|tests|mocks|hooks|types|utils|api|data)\//;
const RENDERS = /return\s*\(?\s*<|=>\s*\(?\s*</;
// A module whose exported function returns a config object, not markup.
const CONFIG_FACTORY = /:\s*(SlideConfig|ToolFlowConfig|\w+Config)\s*\{/;
const DATA = /\buse(Query|Mutation|SWR|InfiniteQuery)\b|\bfetch\w*\(/;
const ROUTER = /\buse(Navigate|Params|Location|SearchParams)\b/;
const CONTEXT = /\buse[A-Z]\w*\(/g;
// Hooks that are plainly not context reads.
const LOCAL_HOOK =
/^use(State|Effect|Memo|Callback|Ref|Id|Reducer|Context|Translation|LayoutEffect|ImperativeHandle|Transition|DeferredValue|SyncExternalStore|Debounced\w*|Media\w*|Disclosure|Form)$/;
// Contexts .storybook/preview.tsx already mounts for every story. A component
// that reads only these needs no fixture work, so it counts as props-level.
const HARNESS_PROVIDED =
/^use(Preferences|SidebarContext|Tier|Link|UI|Theme|QueryClient|Navigate|Location|Params|SearchParams)$/;
const byPath = new Map(sources.map((f) => [rel(f), f]));
function localImports(src, fromRel) {
const out = [];
for (const m of src.matchAll(
/from\s+["'](@app\/|@core\/|\.\.?\/)([^"']+)/g,
)) {
const spec = m[1] + m[2];
const guess = spec.replace(/^@app\//, "core/").replace(/^@core\//, "core/");
for (const cand of [`${guess}.tsx`, `${guess}/index.tsx`]) {
if (byPath.has(cand)) out.push(cand);
}
void fromRel;
}
return out;
}
/** Does this file, or anything it renders, read a context? Depth-limited. */
function needsContext(relPath, seen = new Set(), depth = 0) {
if (depth > 3 || seen.has(relPath)) return false;
seen.add(relPath);
const file = byPath.get(relPath);
if (!file) return false;
const src = readFileSync(file, "utf8");
for (const m of src.matchAll(CONTEXT)) {
const name = m[0].slice(0, -1);
if (!LOCAL_HOOK.test(name) && !HARNESS_PROVIDED.test(name)) return true;
}
return localImports(src, relPath).some((child) =>
needsContext(child, seen, depth + 1),
);
}
const rows = [];
for (const file of sources) {
const r = rel(file);
const base = r.split("/").pop();
if (!/^[A-Z]/.test(base)) continue;
const src = readFileSync(file, "utf8");
if (!RENDERS.test(src)) continue;
if (INFRA_NAME.test(base) || INFRA_DIR.test("/" + r)) continue;
if (CONFIG_FACTORY.test(src)) continue;
const stem = base.replace(".tsx", "");
const tail = r.replace(".tsx", "");
const covered =
importedNames.has(stem) ||
[...importedPaths].some((p) => p.endsWith(tail) || p.endsWith("/" + stem));
let needs = "props";
if (DATA.test(src)) needs = "data";
else if (ROUTER.test(src)) needs = "router";
else if (needsContext(r)) needs = "context";
const parts = r.split("/");
rows.push({
area: parts.slice(0, Math.min(3, parts.length - 1)).join("/"),
file: r,
covered,
needs,
loc: src.split("\n").length,
});
}
/* ── report ───────────────────────────────────────────────────────────────── */
const shown = areaFilter
? rows.filter((r) => r.file.startsWith(areaFilter))
: rows;
const todo = shown.filter((r) => !r.covered);
if (wantTodo || areaFilter) {
const order = { props: 0, context: 1, router: 2, data: 3 };
for (const r of todo.sort(
(a, b) => order[a.needs] - order[b.needs] || a.loc - b.loc,
)) {
console.log(
` ${r.needs.padEnd(8)} ${String(r.loc).padStart(5)} loc ${r.file}`,
);
}
console.log("");
}
const areas = new Map();
for (const r of shown) {
const a = areas.get(r.area) ?? { total: 0, covered: 0 };
a.total += 1;
if (r.covered) a.covered += 1;
areas.set(r.area, a);
}
console.log(
`${"area".padEnd(38)}${"total".padStart(6)}${"covered".padStart(9)}${"%".padStart(6)}`,
);
for (const [area, a] of [...areas].sort(
(x, y) => y[1].total - y[1].covered - (x[1].total - x[1].covered),
)) {
if (a.total === a.covered) continue;
const pct = Math.round((100 * a.covered) / a.total);
console.log(
`${area.padEnd(38)}${String(a.total).padStart(6)}${String(a.covered).padStart(9)}${String(pct).padStart(5)}%`,
);
}
const covered = shown.filter((r) => r.covered).length;
const byNeed = todo.reduce(
(acc, r) => ((acc[r.needs] = (acc[r.needs] ?? 0) + 1), acc),
{},
);
console.log(
`\nsurfaces ${shown.length} covered ${covered} (${Math.round((100 * covered) / shown.length)}%) remaining ${todo.length}`,
);
console.log(
`remaining by what a story needs: ` +
Object.entries(byNeed)
.sort((a, b) => b[1] - a[1])
.map(([k, v]) => `${k} ${v}`)
.join(" "),
);
@@ -45,6 +45,7 @@ export function OpacityControl({
min={10}
max={100}
label={(val) => `${val}%`}
thumbLabel={t("annotation.opacity", "Opacity")}
/>
</Stack>
</Popover.Dropdown>
@@ -75,6 +75,7 @@ export function PropertiesPopover({
min={8}
max={32}
label={(val) => `${val}pt`}
thumbLabel={t("annotation.fontSize", "Font size")}
/>
</div>
@@ -86,6 +87,7 @@ export function PropertiesPopover({
<Slider
value={Math.round((obj?.opacity ?? 1) * 100)}
onChange={(val) => onUpdate({ opacity: val / 100 })}
thumbLabel={t("annotation.opacity", "Opacity")}
min={10}
max={100}
label={(val) => `${val}%`}
@@ -144,6 +146,7 @@ export function PropertiesPopover({
fillOpacity: newOpacity,
});
}}
thumbLabel={t("annotation.opacity", "Opacity")}
min={10}
max={100}
label={(val) => `${val}%`}
@@ -169,6 +172,7 @@ export function PropertiesPopover({
min={0}
max={12}
label={(val) => `${val}pt`}
thumbLabel={t("annotation.strokeWidth", "Stroke")}
/>
</div>
<Button
@@ -49,6 +49,7 @@ export function WidthControl({
min={min}
max={max}
label={(val) => `${val}pt`}
thumbLabel={t("annotation.width", "Width")}
/>
</Stack>
</Popover.Dropdown>
@@ -0,0 +1,98 @@
/**
* Shared fixtures for the file manager stories.
*
* These components sit deep in a provider chain — FileManagerContext needs
* FileContext for useFileActions/useFileManagement, list rows additionally read
* AppConfig — and none of it is part of the shared preview decorators. Rather
* than each story rebuilding that tree, they mount the real providers here over
* static data, so what a story exercises is the component and not a stub.
*/
import type { ReactElement } from "react";
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
import { FileContextProvider } from "@app/contexts/FileContext";
import { FileManagerProvider } from "@app/contexts/FileManagerContext";
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
/** A grey rectangle, so thumbnail lookups short-circuit instead of reading
* file bytes out of IndexedDB. */
const THUMBNAIL =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='160'%3E%3Crect width='120' height='160' fill='%23e9ecef'/%3E%3C/svg%3E";
/** Fixed so stories render identically on every run. */
const LAST_MODIFIED = Date.parse("2026-03-14T09:30:00Z");
export function makeStub(
id: string,
name: string,
overrides: Partial<StirlingFileStub> = {},
): StirlingFileStub {
return {
id: id as FileId,
name,
type: "application/pdf",
size: 2_400_000,
lastModified: LAST_MODIFIED,
isLeaf: true,
originalFileId: id,
versionNumber: 1,
thumbnailUrl: THUMBNAIL,
...overrides,
};
}
export const mockFile = makeStub("story-file-1", "quarterly-report.pdf");
/** Storage off keeps the row's upload and share affordances out of the way;
* stories that want them pass their own config. */
const BASE_CONFIG = {
storageEnabled: false,
storageSharingEnabled: false,
storageShareLinksEnabled: false,
frontendUrl: "https://stirling.example",
};
interface FixtureOptions {
recentFiles?: StirlingFileStub[];
activeFileIds?: FileId[];
isLoading?: boolean;
config?: Partial<typeof BASE_CONFIG>;
}
export function withFileManager({
recentFiles = [mockFile],
activeFileIds = [],
isLoading = false,
config,
}: FixtureOptions = {}) {
return (Story: () => ReactElement) => (
<AppConfigProvider
initialConfig={{ ...BASE_CONFIG, ...config } as never}
bootstrapMode="non-blocking"
autoFetch={false}
>
{/* The empty state's wordmark resolves a logo variant through
PreferencesContext, which the preview does not mount. */}
<PreferencesProvider>
<FileContextProvider>
<FileManagerProvider
recentFiles={recentFiles}
onRecentFilesSelected={() => {}}
onNewFilesSelect={() => {}}
onClose={() => {}}
isFileSupported={() => true}
isOpen
onFileRemove={() => {}}
modalHeight="600px"
refreshRecentFiles={async () => {}}
isLoading={isLoading}
activeFileIds={activeFileIds}
>
<Story />
</FileManagerProvider>
</FileContextProvider>
</PreferencesProvider>
</AppConfigProvider>
);
}
@@ -0,0 +1,127 @@
/**
* Shared context slices for the tool-panel stories.
*
* These components sit under providers that reach most of the app — the tool
* workflow stands up the registry and navigation, the workbench bar derives its
* buttons from the whole workbench. Each component here reads only a handful of
* fields, so the slices below supply those instead. What a story mounts is then
* an honest record of what its component actually depends on.
*/
import type { ReactElement } from "react";
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
import { FileContextProvider } from "@app/contexts/FileContext";
import {
NavigationStateContext,
type NavigationContextStateValue,
} from "@app/contexts/NavigationContext";
import {
ToolWorkflowContext,
ToolWorkflowDataContext,
ToolWorkflowActionsContext,
type ToolWorkflowContextValue,
type ToolWorkflowDataValue,
type ToolWorkflowActionsValue,
} from "@app/contexts/ToolWorkflowContext";
import {
HotkeyContext,
type HotkeyContextValue,
} from "@app/contexts/HotkeyContext";
import {
FilesModalContext,
type FilesModalContextType,
} from "@app/contexts/FilesModalContext";
import {
ViewerContext,
type ViewerContextType,
} from "@app/contexts/ViewerContext";
export interface ToolContextOptions {
/** Which workbench is active; several components render only in one. */
workbench?: string;
/** Favourited tool ids, for anything drawing a star. */
favourites?: string[];
/** Index the viewer is showing, for scope-aware copy. */
activeFileIndex?: number;
/** Extra viewer fields for components that reach further into it. */
viewer?: Partial<Record<string, unknown>>;
/** Extra workflow fields — search state, panel view, the open tool. */
workflow?: Partial<Record<string, unknown>>;
}
export function withToolContexts({
workbench = "viewer",
favourites = [],
activeFileIndex = 0,
viewer = {},
workflow = {},
}: ToolContextOptions = {}) {
return (Story: () => ReactElement): ReactElement => (
<AppConfigProvider
initialConfig={{ premiumEnabled: true } as never}
bootstrapMode="non-blocking"
autoFetch={false}
>
<HotkeyContext.Provider value={{ hotkeys: {} } as HotkeyContextValue}>
<NavigationStateContext.Provider
value={{ workbench } as unknown as NavigationContextStateValue}
>
<ToolWorkflowContext.Provider
value={
{
getSelectedTool: () => null,
toolPanelMode: "normal",
leftPanelView: "tools",
readerMode: false,
...workflow,
} as unknown as ToolWorkflowContextValue
}
>
<ToolWorkflowDataContext.Provider
value={
{
isFavorite: (id: string) => favourites.includes(id),
toolAvailability: {},
toolRegistry: {},
favoriteTools: favourites,
} as unknown as ToolWorkflowDataValue
}
>
<ToolWorkflowActionsContext.Provider
value={
{
toggleFavorite: () => {},
handleToolSelect: () => {},
} as unknown as ToolWorkflowActionsValue
}
>
<ViewerContext.Provider
value={
{
activeFileIndex,
...viewer,
} as unknown as ViewerContextType
}
>
<FilesModalContext.Provider
value={
{
openFilesModal: () => {},
onFileUpload: () => {},
} as unknown as FilesModalContextType
}
>
{/* Real FileContext: the file list drives scope-aware copy,
and it stands up standalone with no files. */}
<FileContextProvider>
<Story />
</FileContextProvider>
</FilesModalContext.Provider>
</ViewerContext.Provider>
</ToolWorkflowActionsContext.Provider>
</ToolWorkflowDataContext.Provider>
</ToolWorkflowContext.Provider>
</NavigationStateContext.Provider>
</HotkeyContext.Provider>
</AppConfigProvider>
);
}
@@ -334,6 +334,7 @@ export function PdfViewerToolbar({
max={500}
step={5}
onChange={(val) => zoomActions.setZoomLevel?.(val / 100)}
thumbLabel={t("viewer.zoomLevel", "Zoom level")}
size="xs"
styles={{
root: { minWidth: "6rem", width: "6rem", flexShrink: 0 },
@@ -451,6 +451,7 @@ export function useViewerWorkbenchBarButtons(
<Slider
value={speechRate}
onChange={handleSpeechRateChange}
thumbLabel={readAloudSpeedLabel}
min={0.5}
max={2}
step={0.1}
@@ -29,7 +29,7 @@ import { openFilesFromDisk } from "@app/services/openFilesFromDisk";
export { pendingFilePathMappings } from "@app/services/pendingFilePathMappings";
// Type for the context value - now contains everything directly
interface FileManagerContextValue {
export interface FileManagerContextValue {
// State
activeSource: "recent" | "local" | "drive";
storageFilter: "all" | "local" | "sharedWithMe" | "sharedByMe";
@@ -80,8 +80,11 @@ interface FileManagerContextValue {
modalHeight: string;
}
// Create the context
const FileManagerContext = createContext<FileManagerContextValue | null>(null);
// Create the context. Exported so a test or story can mount a component
// against a slice of the value instead of the whole provider chain.
export const FileManagerContext = createContext<FileManagerContextValue | null>(
null,
);
// Provider component props
interface FileManagerProviderProps {
@@ -26,7 +26,7 @@ import {
readResponseHeader,
} from "@app/services/shareBundleUtils";
interface FilesModalContextType {
export interface FilesModalContextType {
isFilesModalOpen: boolean;
openFilesModal: (options?: {
insertAfterPage?: number;
@@ -41,7 +41,10 @@ interface FilesModalContextType {
setOnModalClose: (callback: () => void) => void;
}
const FilesModalContext = createContext<FilesModalContextType | null>(null);
// Exported so a test or story can mount a component against a slice of the
// value: the provider itself pulls in FileContext and NavigationContext.
export const FilesModalContext =
createContext<FilesModalContextType | null>(null);
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({
children,
@@ -22,7 +22,7 @@ import { ToolCategoryId, ToolRegistryEntry } from "@app/data/toolsTaxonomy";
type Bindings = Partial<Record<ToolId, HotkeyBinding>>;
interface HotkeyContextValue {
export interface HotkeyContextValue {
hotkeys: Bindings;
defaults: Bindings;
isMac: boolean;
@@ -38,7 +38,12 @@ interface HotkeyContextValue {
getDisplayParts: (binding: HotkeyBinding | null | undefined) => string[];
}
const HotkeyContext = createContext<HotkeyContextValue | undefined>(undefined);
// Exported so a component that only reads a slice of this (HotkeyDisplay wants
// getDisplayParts) can be mounted without HotkeyProvider, which pulls in the
// whole tool-workflow chain behind it.
export const HotkeyContext = createContext<HotkeyContextValue | undefined>(
undefined,
);
const STORAGE_KEY = "stirlingpdf.hotkeys";
@@ -128,8 +128,11 @@ export interface NavigationContextActionsValue {
actions: NavigationContextActions;
}
// Create contexts
const NavigationStateContext = createContext<
// Create contexts. The state context is exported so a test or story can mount a
// component against a slice of it: the provider itself reaches the tool
// registry, which is far more than a component reading one navigation field
// needs standing up.
export const NavigationStateContext = createContext<
NavigationContextStateValue | undefined
>(undefined);
const NavigationActionsContext = createContext<
@@ -58,7 +58,7 @@ export interface CustomWorkbenchViewInstance extends CustomWorkbenchViewRegistra
data: any;
}
interface ToolWorkflowContextValue extends ToolWorkflowState {
export interface ToolWorkflowContextValue extends ToolWorkflowState {
// Tool management (from hook)
selectedToolKey: ToolId | null;
selectedTool: ToolRegistryEntry | null;
@@ -116,7 +116,10 @@ const __GLOBAL_CONTEXT_KEY__ = "__ToolWorkflowContext__";
const existingContext = (globalThis as any)[__GLOBAL_CONTEXT_KEY__] as
| React.Context<ToolWorkflowContextValue | undefined>
| undefined;
const ToolWorkflowContext =
// Exported so a test or story can mount a component against a slice of the
// value. The provider itself stands up the whole tool registry and navigation
// chain, which is far more than a component reading a few fields needs.
export const ToolWorkflowContext =
existingContext ??
createContext<ToolWorkflowContextValue | undefined>(undefined);
if (!existingContext) {
@@ -156,10 +159,14 @@ export interface ToolWorkflowDataValue {
isFavorite: (toolId: ToolId) => boolean;
}
const ToolWorkflowActionsContext = createContext<
// Exported alongside ToolWorkflowContext so a story can supply the callbacks a
// component reaches for without standing up the provider.
export const ToolWorkflowActionsContext = createContext<
ToolWorkflowActionsValue | undefined
>(undefined);
const ToolWorkflowDataContext = createContext<
// Exported alongside the other two so a story can supply the registry and
// favourites a component reads without standing up the provider.
export const ToolWorkflowDataContext = createContext<
ToolWorkflowDataValue | undefined
>(undefined);
@@ -21,7 +21,9 @@ interface WorkbenchBarContextValue {
clear: () => void;
}
const WorkbenchBarContext = createContext<WorkbenchBarContextValue | undefined>(
// Exported so a story can supply the bar's buttons without the provider,
// which derives them from the whole workbench.
export const WorkbenchBarContext = createContext<WorkbenchBarContextValue | undefined>(
undefined,
);
+11
View File
@@ -374,3 +374,14 @@ html[data-app-theme="custom"][data-accent="default"][data-mantine-color-scheme="
--c-border: var(--p-c-28282d);
--c-border-subtle: var(--p-zinc-700);
}
/* ── MANTINE MUTED TEXT ───────────────────────────────────────────────────── */
/* Mantine's stock "dimmed" is #868e96, which measures ~3:1 against the app's
surfaces — short of the 4.5:1 that body-size text needs. Pointing it at the
semantic muted token fixes every `c="dimmed"` at once, and follows the theme
rather than staying a fixed grey that only ever suited light mode. */
/* Doubled selector: Mantine declares this on :root too, and its stylesheet
loads later, so a single :root here would lose on source order. */
:root:root {
--mantine-color-dimmed: var(--c-text-muted);
}
@@ -572,6 +572,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
min={1}
max={12}
value={inkWidth}
thumbLabel={t("annotation.strokeWidth", "Width")}
onChange={setInkWidth}
/>
</Box>
@@ -587,6 +588,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
min={10}
max={100}
value={highlightOpacity}
thumbLabel={t("annotation.opacity", "Opacity")}
onChange={setHighlightOpacity}
/>
</Box>
@@ -601,6 +603,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
min={10}
max={100}
value={underlineOpacity}
thumbLabel={t("annotation.opacity", "Opacity")}
onChange={setUnderlineOpacity}
/>
</Box>
@@ -615,6 +618,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
min={1}
max={20}
value={freehandHighlighterWidth}
thumbLabel={t("annotation.strokeWidth", "Width")}
onChange={setFreehandHighlighterWidth}
/>
</Box>
@@ -630,6 +634,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
min={8}
max={32}
value={textSize}
thumbLabel={t("annotation.fontSize", "Font size")}
onChange={setTextSize}
/>
</Box>
@@ -785,6 +790,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
min={10}
max={100}
value={shapeOpacity}
thumbLabel={t("annotation.opacity", "Opacity")}
onChange={(value) => {
setShapeOpacity(value);
setShapeStrokeOpacity(value);
@@ -802,6 +808,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
min={1}
max={12}
value={shapeThickness}
thumbLabel={t("annotation.strokeWidth", "Width")}
onChange={setShapeThickness}
/>
</>
@@ -815,6 +822,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
min={0}
max={12}
value={shapeThickness}
thumbLabel={t("annotation.strokeWidth", "Stroke")}
onChange={setShapeThickness}
/>
</Box>
@@ -0,0 +1,133 @@
/**
* Form-fill context for the formFill stories.
*
* Every form-fill component reads its fields and values from FormFillProvider,
* and the provider only ever gets them by asking a data provider to parse a real
* PDF. Both shipped providers need either the pdfium WASM module or the backend,
* neither of which exists in a story — so these helpers hand the provider a stub
* that returns fixed fields, and drive it the way the app does: fetch on mount,
* then type into the form.
*/
import { useEffect, useRef, type ReactElement, type ReactNode } from "react";
import {
FormFillProvider,
useFormFill,
} from "@app/tools/formFill/FormFillContext";
import type { IFormDataProvider } from "@app/tools/formFill/providers/types";
import type { FormField } from "@app/tools/formFill/types";
/** Stands in for the open document; the stub provider never reads its bytes. */
export const STORY_PDF = new Blob(["%PDF-1.7"], { type: "application/pdf" });
export function field(
overrides: Partial<FormField> & { name: string },
): FormField {
return {
label: overrides.name,
type: "text",
value: "",
options: null,
displayOptions: null,
required: false,
readOnly: false,
multiSelect: false,
multiline: false,
tooltip: null,
widgets: [{ pageIndex: 0, x: 72, y: 120, width: 220, height: 22 }],
...overrides,
};
}
/** A small cross-section of field types, as a filled-in form would carry. */
export const SAMPLE_FIELDS: FormField[] = [
field({ name: "fullName", label: "Full name", required: true }),
field({
name: "address",
label: "Address",
multiline: true,
tooltip: "Include the postcode",
}),
field({
name: "agreeToTerms",
label: "I agree to the terms",
type: "checkbox",
}),
field({
name: "country",
label: "Country",
type: "combobox",
options: ["uk", "fr", "de"],
displayOptions: ["United Kingdom", "France", "Germany"],
}),
field({
name: "reference",
label: "Reference number",
readOnly: true,
value: "INV-20418",
}),
field({
name: "signature",
label: "Signature",
type: "signature",
widgets: [{ pageIndex: 1, x: 72, y: 480, width: 180, height: 60 }],
}),
];
export interface FormFillOptions {
/** Fields the stub provider reports for the document. */
fields?: FormField[];
/** Hold the fetch open, so the form stays in its loading state. */
pending?: boolean;
/** Values applied after load — this is what marks the form dirty. */
filled?: Record<string, string>;
/** Field to focus, as clicking its widget on the page would. */
activeField?: string;
}
function stubProvider({
fields = SAMPLE_FIELDS,
pending = false,
}: FormFillOptions): IFormDataProvider {
return {
name: "pdf-lib",
fetchFields: () =>
pending ? new Promise<FormField[]>(() => {}) : Promise.resolve(fields),
fillForm: async () => STORY_PDF,
};
}
/**
* Drives the provider once on mount. The fetch is what populates the fields and
* seeds the value store; anything applied afterwards counts as user input.
*/
function FormFillLoader({
filled,
activeField,
children,
}: FormFillOptions & { children: ReactNode }) {
const { fetchFields, setValue, setActiveField } = useFormFill();
const startedRef = useRef(false);
useEffect(() => {
if (startedRef.current) return;
startedRef.current = true;
void fetchFields(STORY_PDF, "story-document").then(() => {
Object.entries(filled ?? {}).forEach(([name, value]) =>
setValue(name, value),
);
if (activeField) setActiveField(activeField);
});
}, [fetchFields, setValue, setActiveField, filled, activeField]);
return <>{children}</>;
}
export function withFormFill(options: FormFillOptions = {}) {
return (Story: () => ReactElement): ReactElement => (
<FormFillProvider provider={stubProvider(options)}>
<FormFillLoader {...options}>
<Story />
</FormFillLoader>
</FormFillProvider>
);
}
+7 -3
View File
@@ -24,10 +24,14 @@
--_tert-tint: var(--c-hover);
}
/* Danger is pinned to a fixed deep red (not the theme-lightened coral), so the
fill and the outline/text are the SAME red in both light and dark. */
fill and the outline/text are the SAME red in both light and dark.
The fill is red-600 rather than red-500 because white sits on it: red-500
gives 3.76:1, short of the 4.5:1 body text needs, while red-600 gives 4.85:1
and holds in either theme. */
.sui-acc-danger {
--_solid: var(--p-red-500);
--_solid-hover: var(--p-red-600);
--_solid: var(--p-red-600);
--_solid-hover: color-mix(in srgb, var(--p-red-600) 85%, var(--p-black));
--_on: #ffffff;
--_text: var(--p-red-500);
--_bd: color-mix(in srgb, var(--p-red-500) 45%, transparent);
@@ -0,0 +1,41 @@
/**
* The frame every desktop setup-wizard screen sits in. It contributes the
* branding and centring; the screen itself is passed as children.
*/
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DesktopAuthLayout } from "@app/components/SetupWizard/DesktopAuthLayout";
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
const meta: Meta<typeof DesktopAuthLayout> = {
title: "Desktop/SetupWizard/DesktopAuthLayout",
component: DesktopAuthLayout,
parameters: { layout: "fullscreen" },
// The shell's wordmark resolves a logo variant through PreferencesContext.
decorators: [
(Story) => (
<PreferencesProvider>
<Story />
</PreferencesProvider>
),
],
};
export default meta;
type Story = StoryObj<typeof DesktopAuthLayout>;
export const Default: Story = {
args: { children: <p>Sign-in form goes here.</p> },
};
/** Taller content, where the frame has to scroll rather than clip. */
export const TallContent: Story = {
args: {
children: (
<div style={{ display: "grid", gap: "1rem" }}>
{Array.from({ length: 12 }, (_, i) => (
<p key={i}>Setup step detail line {i + 1}</p>
))}
</div>
),
},
};
@@ -0,0 +1,21 @@
/**
* The escape hatch at the bottom of the desktop sign-in screen, for people
* connecting to their own server rather than a Stirling account.
*/
import type { Meta, StoryObj } from "@storybook/react-vite";
import { SelfHostedLink } from "@app/components/SetupWizard/SelfHostedLink";
const meta: Meta<typeof SelfHostedLink> = {
title: "Desktop/SetupWizard/SelfHostedLink",
component: SelfHostedLink,
parameters: { layout: "centered" },
args: { onClick: () => {} },
};
export default meta;
type Story = StoryObj<typeof SelfHostedLink>;
export const Default: Story = {};
/** Disabled while the wizard is mid-request. */
export const Disabled: Story = { args: { disabled: true } };
@@ -0,0 +1,46 @@
/**
* The save-state dot on a desktop file thumbnail. Three states, decided from
* the file stub rather than a prop: never written to disk, written but with
* unsaved edits, and clean.
*/
import type { Meta, StoryObj } from "@storybook/react-vite";
import { FileEditorStatusDot } from "@app/components/fileEditor/FileEditorStatusDot";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
function stub(overrides: Partial<StirlingFileStub>): StirlingFileStub {
return {
id: "story-file" as FileId,
name: "report.pdf",
type: "application/pdf",
size: 120_000,
lastModified: Date.parse("2026-03-14T09:30:00Z"),
isLeaf: true,
originalFileId: "story-file",
versionNumber: 1,
...overrides,
} as StirlingFileStub;
}
const meta: Meta<typeof FileEditorStatusDot> = {
title: "Desktop/FileEditorStatusDot",
component: FileEditorStatusDot,
parameters: { layout: "centered" },
};
export default meta;
type Story = StoryObj<typeof FileEditorStatusDot>;
/** Held in memory only — nothing has been written to disk yet. */
export const NotSaved: Story = {
args: { file: stub({ localFilePath: undefined }) },
};
/** On disk, but edited since. */
export const UnsavedChanges: Story = {
args: { file: stub({ localFilePath: "/tmp/report.pdf", isDirty: true }) },
};
export const Saved: Story = {
args: { file: stub({ localFilePath: "/tmp/report.pdf", isDirty: false }) },
};
@@ -25,9 +25,12 @@ export function FileEditorStatusDot({ file }: FileEditorStatusDotProps) {
return (
<div className={styles.thumbBadgesRight}>
<Tooltip label={label}>
{/* A bare span cannot carry aria-label; without a role the save state
is conveyed by colour alone and announced as nothing at all. */}
<span
className={styles.statusDot}
style={{ backgroundColor: color }}
role="img"
aria-label={label}
/>
</Tooltip>
@@ -0,0 +1,49 @@
/**
* A div dressed as a disabled button. Mantine's `disabled` kills pointer events
* outright, which also kills the tooltip explaining *why* the control is
* unavailable — so this renders the disabled look by hand and keeps hover.
*
* Desktop-layer stories resolve `@app/*` against desktop → cloud → proprietary
* → core, matching the desktop build rather than the editor's order.
*/
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DisabledButtonWithTooltip } from "@app/components/shared/DisabledButtonWithTooltip";
const meta: Meta<typeof DisabledButtonWithTooltip> = {
title: "Desktop/DisabledButtonWithTooltip",
component: DisabledButtonWithTooltip,
parameters: { layout: "padded" },
args: {
tooltip: "Sign in to use this tool",
children: "Convert to PDF/A",
},
};
export default meta;
type Story = StoryObj<typeof DisabledButtonWithTooltip>;
/** The tooltip only appears on hover, so the resting state is what is captured. */
export const Default: Story = {};
export const LongLabel: Story = {
args: { children: "Convert this document to an archival PDF/A-3b file" },
};
/** A long reason wraps inside the tooltip rather than widening it. */
export const LongTooltip: Story = {
args: {
tooltip:
"This tool needs a desktop licence and an active connection to the cloud backend.",
},
};
/** In a narrow column the control still fills its container. */
export const Narrow: Story = {
decorators: [
(Story) => (
<div style={{ width: 200 }}>
<Story />
</div>
),
],
};
@@ -20,25 +20,39 @@ export function DisabledButtonWithTooltip({
className,
style,
}: DisabledButtonWithTooltipProps) {
const [hovered, setHovered] = React.useState(false);
const [shown, setShown] = React.useState(false);
const tooltipId = React.useId();
return (
<div
className="relative w-full"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onMouseEnter={() => setShown(true)}
onMouseLeave={() => setShown(false)}
>
{/* The point of this control is to look disabled while still explaining
why. That explanation has to reach a keyboard as well as a pointer, so
the element stays focusable and announces itself as a disabled button
described by its own tooltip. */}
<div
className={`locked-button${className ? ` ${className}` : ""}`}
style={style}
role="button"
aria-disabled="true"
aria-describedby={tooltipId}
tabIndex={0}
onFocus={() => setShown(true)}
onBlur={() => setShown(false)}
>
{children}
</div>
{hovered && (
<div className="locked-button-tooltip">
{tooltip}
<div className="locked-button-tooltip-arrow" />
</div>
)}
<div
id={tooltipId}
role="tooltip"
className="locked-button-tooltip"
style={shown ? undefined : { display: "none" }}
>
{tooltip}
<div className="locked-button-tooltip-arrow" />
</div>
</div>
);
}
@@ -0,0 +1,109 @@
import type { Decorator, Meta, StoryObj } from "@storybook/react-vite";
import { AuthContext } from "@app/auth/context";
import type { AuthContextValue, AuthSession } from "@app/auth/types";
import { RequireAdmin } from "@app/auth/guards/RequireAdmin";
/**
* The admin gate around the settings and processor route trees. Three fields
* of the auth context decide what it renders: while `loading` it shows the
* `loading` slot, with no session it shows `fallback`, and for a signed-in
* user who is not an admin it shows `forbidden` while calling `onForbidden`
* so the route can redirect. Only an authenticated admin sees the children.
*
* The guard reads three fields, so the stories supply a slice of the auth
* context rather than mounting a real provider, and each slot renders as a
* labelled panel so the branch taken is visible.
*/
/** Auth context slice; `session`, `loading` and `isAdmin` steer this guard. */
function authValue(
overrides: Partial<AuthContextValue> = {},
): AuthContextValue {
return {
session: null,
user: null,
displayName: null,
isAnonymous: false,
isAdmin: false,
portalAccess: false,
role: null,
loading: false,
error: null,
signOut: async () => {},
refreshSession: async () => {},
...overrides,
};
}
const activeSession: AuthSession = {
user: { id: "1", email: "ada@example.com", username: "ada", role: "USER" },
access_token: "storybook-token",
expires_in: 3600,
};
/** Labelled stand-in for whichever slot the guard chose to render. */
function Panel({ label }: { label: string }) {
return (
<div
style={{
padding: "1.5rem",
borderRadius: "0.75rem",
border: "1px solid var(--c-border)",
background: "var(--c-surface)",
color: "var(--c-text)",
textAlign: "center",
}}
>
{label}
</div>
);
}
function withAuth(value: AuthContextValue): Decorator {
return (Story) => (
<AuthContext.Provider value={value}>
<Story />
</AuthContext.Provider>
);
}
const meta: Meta<typeof RequireAdmin> = {
title: "Auth/Guards/Require Admin",
component: RequireAdmin,
parameters: { layout: "centered" },
args: {
children: <Panel label="Admin content" />,
fallback: <Panel label="Login screen (fallback)" />,
forbidden: <Panel label="Redirecting away…" />,
loading: <Panel label="Resolving session…" />,
onForbidden: () => {},
},
};
export default meta;
type Story = StoryObj<typeof RequireAdmin>;
/** An authenticated admin: the guarded tree renders. */
export const Admin: Story = {
decorators: [withAuth(authValue({ session: activeSession, isAdmin: true }))],
};
/**
* Signed in without the admin role. `onForbidden` fires so the route can send
* the user back to the editor; the forbidden slot covers the gap until it does.
*/
export const NotAdmin: Story = {
decorators: [withAuth(authValue({ session: activeSession }))],
};
/** No session at all: the login screen takes over, no redirect is triggered. */
export const SignedOut: Story = {
decorators: [withAuth(authValue())],
};
/**
* Session still resolving. The redirect is deliberately held back here — a
* half-loaded session must not be mistaken for a non-admin one.
*/
export const Loading: Story = {
decorators: [withAuth(authValue({ loading: true }))],
};
@@ -0,0 +1,104 @@
import type { Decorator, Meta, StoryObj } from "@storybook/react-vite";
import { AuthContext } from "@app/auth/context";
import type { AuthContextValue, AuthSession } from "@app/auth/types";
import { RequireAuth } from "@app/auth/guards/RequireAuth";
/**
* The session gate wrapped around protected route trees. It renders one of
* three slots depending purely on the auth context: `loading` while the
* session is still resolving, `fallback` when there is no session, and its
* children once a session exists.
*
* The guard reads only `session` and `loading`, so the stories hand it a slice
* of the auth context instead of mounting a real provider. Each slot is a
* labelled panel so it is obvious which branch was taken.
*/
/** Auth context slice; only `session` and `loading` steer this guard. */
function authValue(
overrides: Partial<AuthContextValue> = {},
): AuthContextValue {
return {
session: null,
user: null,
displayName: null,
isAnonymous: false,
isAdmin: false,
portalAccess: false,
role: null,
loading: false,
error: null,
signOut: async () => {},
refreshSession: async () => {},
...overrides,
};
}
const activeSession: AuthSession = {
user: { id: "1", email: "ada@example.com", username: "ada", role: "USER" },
access_token: "storybook-token",
expires_in: 3600,
};
/** Labelled stand-in for whichever slot the guard chose to render. */
function Panel({ label }: { label: string }) {
return (
<div
style={{
padding: "1.5rem",
borderRadius: "0.75rem",
border: "1px solid var(--c-border)",
background: "var(--c-surface)",
color: "var(--c-text)",
textAlign: "center",
}}
>
{label}
</div>
);
}
function withAuth(value: AuthContextValue): Decorator {
return (Story) => (
<AuthContext.Provider value={value}>
<Story />
</AuthContext.Provider>
);
}
const meta: Meta<typeof RequireAuth> = {
title: "Auth/Guards/Require Auth",
component: RequireAuth,
parameters: { layout: "centered" },
args: {
children: <Panel label="Protected content" />,
fallback: <Panel label="Login screen (fallback)" />,
loading: <Panel label="Resolving session…" />,
},
};
export default meta;
type Story = StoryObj<typeof RequireAuth>;
/** A resolved session: the guard renders the protected tree. */
export const Authenticated: Story = {
decorators: [withAuth(authValue({ session: activeSession }))],
};
/** No session: the caller's login screen takes over. */
export const SignedOut: Story = {
decorators: [withAuth(authValue())],
};
/** Session still resolving: neither content nor login screen flashes. */
export const Loading: Story = {
decorators: [withAuth(authValue({ loading: true }))],
};
/**
* With no `loading` slot the guard renders nothing until the session
* resolves, which is how routes that own their own spinner use it.
*/
export const LoadingWithoutSlot: Story = {
args: { loading: undefined },
decorators: [withAuth(authValue({ loading: true }))],
};
@@ -0,0 +1,113 @@
import type { Decorator, Meta, StoryObj } from "@storybook/react-vite";
import { AuthContext } from "@app/auth/context";
import type { AuthContextValue, AuthSession } from "@app/auth/types";
import { RequirePortalAccess } from "@app/auth/guards/RequirePortalAccess";
/**
* The processor/portal gate. It mirrors the admin guard but keys off
* `portalAccess`, the backend grant that covers admins plus anyone given
* access explicitly: `loading` while the session resolves, `fallback` when
* signed out, and `forbidden` (alongside a call to `onForbidden`) for a
* signed-in user without the grant.
*
* The guard reads three context fields, so the stories supply a slice of the
* auth context rather than mounting a real provider, and each slot renders as
* a labelled panel so the branch taken is visible.
*/
/**
* Auth context slice; `session`, `loading` and `portalAccess` steer this guard.
*/
function authValue(
overrides: Partial<AuthContextValue> = {},
): AuthContextValue {
return {
session: null,
user: null,
displayName: null,
isAnonymous: false,
isAdmin: false,
portalAccess: false,
role: null,
loading: false,
error: null,
signOut: async () => {},
refreshSession: async () => {},
...overrides,
};
}
const activeSession: AuthSession = {
user: { id: "1", email: "ada@example.com", username: "ada", role: "USER" },
access_token: "storybook-token",
expires_in: 3600,
};
/** Labelled stand-in for whichever slot the guard chose to render. */
function Panel({ label }: { label: string }) {
return (
<div
style={{
padding: "1.5rem",
borderRadius: "0.75rem",
border: "1px solid var(--c-border)",
background: "var(--c-surface)",
color: "var(--c-text)",
textAlign: "center",
}}
>
{label}
</div>
);
}
function withAuth(value: AuthContextValue): Decorator {
return (Story) => (
<AuthContext.Provider value={value}>
<Story />
</AuthContext.Provider>
);
}
const meta: Meta<typeof RequirePortalAccess> = {
title: "Auth/Guards/Require Portal Access",
component: RequirePortalAccess,
parameters: { layout: "centered" },
args: {
children: <Panel label="Processor content" />,
fallback: <Panel label="Login screen (fallback)" />,
forbidden: <Panel label="Redirecting away…" />,
loading: <Panel label="Resolving session…" />,
onForbidden: () => {},
},
};
export default meta;
type Story = StoryObj<typeof RequirePortalAccess>;
/** The grant is present: the processor renders. */
export const WithAccess: Story = {
decorators: [
withAuth(authValue({ session: activeSession, portalAccess: true })),
],
};
/**
* Signed in without the grant. `onForbidden` fires so the route can send the
* user back to the editor; the forbidden slot covers the gap until it does.
*/
export const WithoutAccess: Story = {
decorators: [withAuth(authValue({ session: activeSession }))],
};
/** No session at all: the login screen takes over, no redirect is triggered. */
export const SignedOut: Story = {
decorators: [withAuth(authValue())],
};
/**
* Session still resolving. The redirect is deliberately held back here — an
* unresolved session must not be mistaken for a missing grant.
*/
export const Loading: Story = {
decorators: [withAuth(authValue({ loading: true }))],
};
@@ -24,6 +24,8 @@ export const oauthProviderConfig: Record<
};
// Icon URLs + GENERIC_PROVIDER_ICON come from the shared oauthIcons resolver.
// Every provider icon is decorative (alt=""): the button it sits in already
// names the provider, so alt text would only repeat that name.
interface OAuthButtonsProps {
onProviderClick: (provider: OAuthProvider) => void;
@@ -116,7 +118,7 @@ export default function OAuthButtons({
>
<img
src={oauthIconUrl(p.file)}
alt={p.label}
alt=""
className="oauth-icon-small"
/>
</DSButton>
@@ -142,7 +144,7 @@ export default function OAuthButtons({
>
<img
src={oauthIconUrl(p.file)}
alt={p.label}
alt=""
className="oauth-icon-medium"
/>
</DSButton>
@@ -169,7 +171,7 @@ export default function OAuthButtons({
<span className="oauth-btn-group">
<img
src={oauthIconUrl(p.file)}
alt={p.label}
alt=""
className={`oauth-icon-medium oauth-icon--${p.providerId}`}
style={{ marginRight: "0.5rem", flexShrink: 0 }}
/>
@@ -210,7 +212,7 @@ export default function OAuthButtons({
<span className="oauth-icon-wrapper">
<img
src={oauthIconUrl(p.file)}
alt={p.label}
alt=""
className="oauth-icon-tiny"
/>
</span>
@@ -0,0 +1,86 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MeterBar } from "@app/billing";
import "@portal/components/billing/billing.css";
/**
* The usage-meter block shared by the editor's cloud surface and the admin
* portal. Everything visible is a prop: the caller owns the copy, and `state`
* (FULL / WARNED / DEGRADED) drives the status-chip tone and the fill colour
* together — the percentage alone never changes the palette. Two parts are
* optional and are what separates most of these states: the status chip
* (hidden when no label is given) and the fill bar (hidden when a plan has no
* ceiling to measure against).
*
* The `paygf-meter` styling belongs to each host app rather than the component,
* so these render against the portal's billing stylesheet — the same one the
* wallet, spend-limit and prepaid cards are seen under.
*/
const meta: Meta<typeof MeterBar> = {
title: "Portal/Billing/MeterBar",
component: MeterBar,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "34rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof MeterBar>;
/** Comfortably inside the free grant — green chip, green fill. */
export const Healthy: Story = {
args: {
state: "FULL",
pct: 24,
figure: "120",
capSuffix: "/ 500 free PDFs",
statusLabel: "Healthy",
meta: "Resets 1 July",
barLabel: "Free allowance",
},
};
/** Near the ceiling — amber chip and fill warn before anything is blocked. */
export const Approaching: Story = {
args: {
state: "WARNED",
pct: 86,
figure: "$860",
capSuffix: "/ $1,000 cap",
statusLabel: "Approaching cap",
meta: "Projected $1,020 by 30 June",
barLabel: "Spend limit",
},
};
/** At the ceiling — red chip and fill; billable work is refused past this point. */
export const CapReached: Story = {
args: {
state: "DEGRADED",
pct: 100,
figure: "$1,000",
capSuffix: "/ $1,000 cap",
statusLabel: "Cap reached",
meta: "Raise the cap to resume processing",
barLabel: "Spend limit",
},
};
/**
* No ceiling to fill against, so the bar is dropped and the figure carries the
* whole meter. The chip is omitted too — there is no threshold to be near.
*/
export const Uncapped: Story = {
args: {
state: "FULL",
pct: 0,
figure: "$1,430",
capSuffix: "no cap",
showBar: false,
meta: "Billed monthly in arrears",
barLabel: "Spend this period",
},
};
@@ -23,6 +23,8 @@ interface MeterBarProps {
meta?: ReactNode;
/** Hide the fill bar (e.g. uncapped). Shown by default. */
showBar?: boolean;
/** Accessible name for the fill bar — what the meter measures ("Spend limit"). */
barLabel: string;
}
/**
@@ -40,6 +42,7 @@ export function MeterBar({
statusLabel,
meta,
showBar = true,
barLabel,
}: MeterBarProps) {
return (
<div className="paygf-meter" data-state={state}>
@@ -61,6 +64,7 @@ export function MeterBar({
aria-valuenow={Math.round(pct)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={barLabel}
>
<div
className="payg-bar__fill"
@@ -0,0 +1,104 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { SpendCapControl, type SpendCapControlProps } from "@app/billing";
import "@portal/components/billing/billing.css";
/**
* The monthly spend-cap editor shared by the editor's cloud surface and the
* admin portal: preset chips, a custom-amount pill, a "no cap" chip and an
* optional inline Save.
*
* The control is fully controlled, so the states below differ by what the cap
* currently is and by which optional affordances the host asked for:
* - a cap matching a preset selects that chip;
* - any other number activates the custom pill instead;
* - null is the no-cap state, which drops the estimate and adds an explainer;
* - passing `onSave` adds the Save button, enabled only once the cap differs
* from the persisted `savedCapUsd`.
*
* Copy is injected by the host (the editor passes i18n strings, the portal
* passes literals), so these render the built-in English defaults. The `scc-*`
* styling likewise belongs to each host — the portal's billing stylesheet here.
*/
const PRICE_PER_DOC_MINOR = 2;
/**
* Holds the cap locally so the chips, custom field and Save button behave as
* they do in a host that owns the value.
*/
function ControlledCap({
initialCap,
...rest
}: { initialCap: number | null } & Omit<
SpendCapControlProps,
"capUsd" | "onChange"
>) {
const [cap, setCap] = useState<number | null>(initialCap);
return <SpendCapControl {...rest} capUsd={cap} onChange={setCap} />;
}
const meta: Meta<typeof SpendCapControl> = {
title: "Portal/Billing/SpendCapControl",
component: SpendCapControl,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "44rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof SpendCapControl>;
/** A cap on one of the presets: that chip is selected and drives the estimate. */
export const PresetSelected: Story = {
render: () => (
<ControlledCap initialCap={500} pricePerDocMinor={PRICE_PER_DOC_MINOR} />
),
};
/** An amount outside the presets moves the selection into the custom pill. */
export const CustomAmount: Story = {
render: () => (
<ControlledCap initialCap={1234} pricePerDocMinor={PRICE_PER_DOC_MINOR} />
),
};
/** No cap: nothing to estimate against, and the uncapped-billing note appears. */
export const NoCap: Story = {
render: () => (
<ControlledCap initialCap={null} pricePerDocMinor={PRICE_PER_DOC_MINOR} />
),
};
/**
* Hosts that persist the cap pass `onSave`, which adds the Save button. It stays
* disabled until the chosen cap differs from the saved one — shown here already
* dirty (saved 250, selected 1,000).
*/
export const WithSaveButton: Story = {
render: () => (
<ControlledCap
initialCap={1000}
savedCapUsd={250}
onSave={async () => {}}
pricePerDocMinor={PRICE_PER_DOC_MINOR}
note="Changes apply from the next billing period."
/>
),
};
/** Every control is inert while the host has an operation in flight. */
export const Disabled: Story = {
render: () => (
<ControlledCap
initialCap={500}
disabled
onSave={async () => {}}
savedCapUsd={250}
pricePerDocMinor={PRICE_PER_DOC_MINOR}
/>
),
};
@@ -64,7 +64,7 @@
height: 1.75rem;
border-radius: 9999px;
background: var(--mantine-color-blue-light);
color: var(--mantine-color-blue-filled);
color: var(--c-accent-text);
flex-shrink: 0;
position: relative;
}
@@ -139,7 +139,7 @@
var(--mantine-color-blue-filled) 18%,
transparent
);
color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled));
color: var(--c-accent-text);
}
/* Same treatment for the input pill and quick-action cards. */
@@ -224,7 +224,7 @@
border: 1px solid var(--mantine-color-blue-light);
border-radius: 9999px;
background: var(--mantine-color-blue-light);
color: var(--mantine-color-blue-filled);
color: var(--c-accent-text);
font-size: 0.8rem;
font-weight: 500;
line-height: 1.1;
@@ -302,7 +302,7 @@
height: 2rem;
border-radius: 0.5rem;
background: var(--mantine-color-blue-light);
color: var(--mantine-color-blue-filled);
color: var(--c-accent-text);
flex-shrink: 0;
}
@@ -453,7 +453,7 @@
}
.chat-message-action-btn--active {
color: var(--mantine-color-blue-filled);
color: var(--c-accent-text);
}
.chat-message-timestamp {
@@ -680,7 +680,7 @@
align-items: center;
gap: 0.5rem;
padding: 0.125rem 0.5rem;
color: var(--mantine-color-blue-filled);
color: var(--c-accent-text);
}
@media (prefers-reduced-motion: reduce) {
@@ -0,0 +1,52 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ChatQuickActions } from "@app/components/chat/ChatQuickActions";
import { FileContextProvider } from "@app/contexts/FileContext";
import {
FilesModalContext,
type FilesModalContextType,
} from "@app/contexts/FilesModalContext";
import "@app/components/chat/ChatPanel.css";
/**
* The suggestion block the assistant shows above its composer. It reads the
* workbench rather than taking a list of actions: an empty workbench offers a
* way to open files, one PDF offers split (only when it has more than one page)
* and compress, several files offer merge and compress, and anything that isn't
* a PDF adds a convert-to-PDF suggestion. With files present it also renders a
* pill per file — three, then a "+n more" that opens the files modal.
*
* Only the empty-workbench state is reachable in isolation: the populated ones
* need files loaded into FileContext, which has no seeding entry point, so they
* belong to a story of the chat panel running against a real workbench.
*/
const meta: Meta<typeof ChatQuickActions> = {
title: "Editor/Chat/ChatQuickActions",
component: ChatQuickActions,
parameters: { layout: "padded" },
args: {
heading: "What would you like to do?",
onAction: () => {},
},
decorators: [
(S) => (
<FileContextProvider>
{/* Only openFilesModal is read here — the real provider reaches back
into FileContext and NavigationContext, so a slice is supplied. */}
<FilesModalContext.Provider
value={
{ openFilesModal: () => {} } as unknown as FilesModalContextType
}
>
<div style={{ maxWidth: "24rem" }}>
<S />
</div>
</FilesModalContext.Provider>
</FileContextProvider>
),
],
};
export default meta;
type Story = StoryObj<typeof ChatQuickActions>;
/** Nothing in the workbench: no file pills, and a single "open files" action. */
export const EmptyWorkbench: Story = {};
@@ -0,0 +1,76 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AppSwitcher } from "@app/components/shared/AppSwitcher";
import { AuthContext } from "@app/auth/context";
import type { AuthContextValue } from "@app/auth/types";
/**
* The sidebar brand header on builds that bundle the admin portal. It reads a
* single field of the auth state — `portalAccess` — and that is the whole
* decision: users who may reach the portal get the brand switcher (clicking it
* navigates to the processor), everyone else gets the plain logo, exactly as
* core renders it.
*
* The rail also collapses, which swaps the wordmark for the icon-only mark, so
* both widths are worth seeing on the switcher variant.
*/
/** Only `portalAccess` is read; the rest is inert filler for the context shape. */
function authWith(portalAccess: boolean): AuthContextValue {
return {
session: null,
user: null,
displayName: "Ada Lovelace",
isAnonymous: false,
isAdmin: portalAccess,
portalAccess,
role: portalAccess ? "ROLE_ADMIN" : "ROLE_USER",
loading: false,
error: null,
signOut: async () => {},
refreshSession: async () => {},
};
}
const meta: Meta<typeof AppSwitcher> = {
// Distinct from core's "Shared/AppSwitcher": that file covers the logo-only
// component this one shadows.
title: "Shared/AppSwitcher (portal build)",
component: AppSwitcher,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof AppSwitcher>;
/** No portal access — indistinguishable from core's plain logo header. */
export const WithoutPortalAccess: Story = {
decorators: [
(S) => (
<AuthContext.Provider value={authWith(false)}>
<S />
</AuthContext.Provider>
),
],
};
/** Portal access — the logo becomes the editor⇄processor switcher. */
export const WithPortalAccess: Story = {
decorators: [
(S) => (
<AuthContext.Provider value={authWith(true)}>
<S />
</AuthContext.Provider>
),
],
};
/** The switcher on a collapsed rail: icon-only mark, no wordmark. */
export const CollapsedWithPortalAccess: Story = {
args: { collapsed: true },
decorators: [
(S) => (
<AuthContext.Provider value={authWith(true)}>
<S />
</AuthContext.Provider>
),
],
};
@@ -214,229 +214,244 @@ export default function ChangeUserPasswordModal({
);
return (
<Modal
// Composed rather than the plain <Modal>, because only Modal.Content lands
// props on the role="dialog" element — the modal draws its own heading, so
// the dialog needs an aria-label to have an accessible name.
<Modal.Root
opened={opened}
onClose={handleClose}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: "relative" }}>
<ActionIcon
aria-label={t("common.close", "Close")}
variant="tertiary"
onClick={handleClose}
size="lg"
disabled={processing}
style={{
position: "absolute",
top: -8,
right: -8,
zIndex: 1,
}}
>
<LocalIcon icon="close-rounded" />
</ActionIcon>
<Stack gap="lg" pt="md">
<Stack gap="md" align="center">
<LocalIcon
icon="lock"
width="3rem"
height="3rem"
style={{ color: "var(--mantine-color-gray-6)" }}
/>
<Text size="xl" fw={600} ta="center">
{t("workspace.people.changePassword.title", "Change password")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t(
"workspace.people.changePassword.subtitle",
"Update the password for",
)}{" "}
<strong>{user?.username}</strong>
</Text>
</Stack>
<Modal.Overlay />
<Modal.Content
aria-label={t(
"workspace.people.changePassword.title",
"Change password",
)}
>
<Modal.Body>
<div style={{ position: "relative" }}>
<ActionIcon
aria-label={t("common.close", "Close")}
variant="tertiary"
onClick={handleClose}
size="lg"
disabled={processing}
style={{
position: "absolute",
top: -8,
right: -8,
zIndex: 1,
}}
>
<LocalIcon icon="close-rounded" />
</ActionIcon>
<Stack gap="lg" pt="md">
<Stack gap="md" align="center">
<LocalIcon
icon="lock"
width="3rem"
height="3rem"
style={{ color: "var(--mantine-color-gray-6)" }}
/>
<Text size="xl" fw={600} ta="center">
{t(
"workspace.people.changePassword.title",
"Change password",
)}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t(
"workspace.people.changePassword.subtitle",
"Update the password for",
)}{" "}
<strong>{user?.username}</strong>
</Text>
</Stack>
<Stack gap="sm">
<PasswordInput
label={t(
"workspace.people.changePassword.newPassword",
"New password",
)}
placeholder={t(
"workspace.people.changePassword.placeholder",
"Enter a new password",
)}
value={form.newPassword}
onChange={(event) =>
setForm({
...form,
newPassword: event.currentTarget.value,
generateRandom: false,
})
}
disabled={processing || disabled || form.generateRandom}
data-autofocus
/>
<PasswordInput
label={t(
"workspace.people.changePassword.confirmPassword",
"Confirm password",
)}
placeholder={t(
"workspace.people.changePassword.confirmPlaceholder",
"Re-enter the new password",
)}
value={form.confirmPassword}
onChange={(event) =>
setForm({
...form,
confirmPassword: event.currentTarget.value,
generateRandom: false,
})
}
disabled={processing || disabled || form.generateRandom}
error={
!form.generateRandom &&
form.confirmPassword &&
form.newPassword !== form.confirmPassword
? t(
"workspace.people.changePassword.passwordMismatch",
"Passwords do not match",
)
: undefined
}
/>
<Group justify="space-between">
<Checkbox
label={t(
"workspace.people.changePassword.generateRandom",
"Generate secure password",
)}
checked={form.generateRandom}
disabled={processing || disabled}
onChange={(event) => {
const checked = event.currentTarget.checked;
setForm((prev) => ({ ...prev, generateRandom: checked }));
if (event.currentTarget.checked) {
handleGeneratePassword();
<Stack gap="sm">
<PasswordInput
label={t(
"workspace.people.changePassword.newPassword",
"New password",
)}
placeholder={t(
"workspace.people.changePassword.placeholder",
"Enter a new password",
)}
value={form.newPassword}
onChange={(event) =>
setForm({
...form,
newPassword: event.currentTarget.value,
generateRandom: false,
})
}
}}
/>
{passwordPreview && (
<Group gap="xs" align="center">
disabled={processing || disabled || form.generateRandom}
data-autofocus
/>
<PasswordInput
label={t(
"workspace.people.changePassword.confirmPassword",
"Confirm password",
)}
placeholder={t(
"workspace.people.changePassword.confirmPlaceholder",
"Re-enter the new password",
)}
value={form.confirmPassword}
onChange={(event) =>
setForm({
...form,
confirmPassword: event.currentTarget.value,
generateRandom: false,
})
}
disabled={processing || disabled || form.generateRandom}
error={
!form.generateRandom &&
form.confirmPassword &&
form.newPassword !== form.confirmPassword
? t(
"workspace.people.changePassword.passwordMismatch",
"Passwords do not match",
)
: undefined
}
/>
<Group justify="space-between">
<Checkbox
label={t(
"workspace.people.changePassword.generateRandom",
"Generate secure password",
)}
checked={form.generateRandom}
disabled={processing || disabled}
onChange={(event) => {
const checked = event.currentTarget.checked;
setForm((prev) => ({ ...prev, generateRandom: checked }));
if (event.currentTarget.checked) {
handleGeneratePassword();
}
}}
/>
{passwordPreview && (
<Group gap="xs" align="center">
<Text size="xs" c="dimmed">
{t(
"workspace.people.changePassword.generatedPreview",
"Generated password:",
)}{" "}
<strong>{passwordPreview}</strong>
</Text>
<Tooltip
label={t(
"workspace.people.changePassword.copyTooltip",
"Copy to clipboard",
)}
>
<ActionIcon
aria-label={t(
"workspace.people.changePassword.copyTooltip",
"Copy to clipboard",
)}
size="sm"
variant="tertiary"
onClick={handleCopyPassword}
disabled={processing}
>
<LocalIcon
icon="content-copy"
width="0.9rem"
height="0.9rem"
/>
</ActionIcon>
</Tooltip>
</Group>
)}
</Group>
</Stack>
<Stack gap="xs">
<Checkbox
label={t(
"workspace.people.changePassword.sendEmail",
"Email the user about this change",
)}
checked={canEmail && form.sendEmail}
onChange={(event) =>
setForm({ ...form, sendEmail: event.currentTarget.checked })
}
disabled={!canEmail || processing}
/>
<Checkbox
label={t(
"workspace.people.changePassword.includePassword",
"Include the new password in the email",
)}
checked={canEmail && form.sendEmail && form.includePassword}
onChange={(event) =>
setForm({
...form,
includePassword: event.currentTarget.checked,
})
}
disabled={!canEmail || !form.sendEmail || processing}
/>
<Checkbox
label={t(
"workspace.people.changePassword.forcePasswordChange",
"Force user to change password on next login",
)}
checked={form.forcePasswordChange}
onChange={(event) =>
setForm({
...form,
forcePasswordChange: event.currentTarget.checked,
})
}
disabled={processing || disabled}
/>
{!canEmail && (
<Text size="xs" c="dimmed">
{mailEnabled
? t(
"workspace.people.changePassword.emailUnavailable",
"This user's email is not a valid email address. Notifications are disabled.",
)
: t(
"workspace.people.changePassword.smtpDisabled",
"Email notifications require SMTP to be enabled in settings.",
)}
</Text>
)}
{canEmail && !form.includePassword && form.sendEmail && (
<Text size="xs" c="dimmed">
{t(
"workspace.people.changePassword.generatedPreview",
"Generated password:",
)}{" "}
<strong>{passwordPreview}</strong>
"workspace.people.changePassword.notifyOnly",
"An email will be sent without the password, letting the user know an admin changed it.",
)}
</Text>
<Tooltip
label={t(
"workspace.people.changePassword.copyTooltip",
"Copy to clipboard",
)}
>
<ActionIcon
aria-label={t(
"workspace.people.changePassword.copyTooltip",
"Copy to clipboard",
)}
size="sm"
variant="tertiary"
onClick={handleCopyPassword}
disabled={processing}
>
<LocalIcon
icon="content-copy"
width="0.9rem"
height="0.9rem"
/>
</ActionIcon>
</Tooltip>
</Group>
)}
</Group>
</Stack>
<Stack gap="xs">
<Checkbox
label={t(
"workspace.people.changePassword.sendEmail",
"Email the user about this change",
)}
checked={canEmail && form.sendEmail}
onChange={(event) =>
setForm({ ...form, sendEmail: event.currentTarget.checked })
}
disabled={!canEmail || processing}
/>
<Checkbox
label={t(
"workspace.people.changePassword.includePassword",
"Include the new password in the email",
)}
checked={canEmail && form.sendEmail && form.includePassword}
onChange={(event) =>
setForm({
...form,
includePassword: event.currentTarget.checked,
})
}
disabled={!canEmail || !form.sendEmail || processing}
/>
<Checkbox
label={t(
"workspace.people.changePassword.forcePasswordChange",
"Force user to change password on next login",
)}
checked={form.forcePasswordChange}
onChange={(event) =>
setForm({
...form,
forcePasswordChange: event.currentTarget.checked,
})
}
disabled={processing || disabled}
/>
{!canEmail && (
<Text size="xs" c="dimmed">
{mailEnabled
? t(
"workspace.people.changePassword.emailUnavailable",
"This user's email is not a valid email address. Notifications are disabled.",
)
: t(
"workspace.people.changePassword.smtpDisabled",
"Email notifications require SMTP to be enabled in settings.",
)}
</Text>
)}
{canEmail && !form.includePassword && form.sendEmail && (
<Text size="xs" c="dimmed">
{t(
"workspace.people.changePassword.notifyOnly",
"An email will be sent without the password, letting the user know an admin changed it.",
)}
</Text>
)}
</Stack>
</Stack>
<Button
onClick={handleSubmit}
loading={processing}
fullWidth
size="md"
disabled={disabled}
style={{ marginTop: "var(--mantine-spacing-md)" }}
>
{t("workspace.people.changePassword.submit", "Update password")}
</Button>
</Stack>
</div>
</Modal>
<Button
onClick={handleSubmit}
loading={processing}
fullWidth
size="md"
disabled={disabled}
style={{ marginTop: "var(--mantine-spacing-md)" }}
>
{t("workspace.people.changePassword.submit", "Update password")}
</Button>
</Stack>
</div>
</Modal.Body>
</Modal.Content>
</Modal.Root>
);
}
@@ -0,0 +1,68 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PolicyEnforcingOverlay } from "@app/components/shared/PolicyEnforcingOverlay";
/**
* The frosted-glass cover shown over a document while a policy runs against it.
* It fills its nearest positioned ancestor, so it serves both the full-screen
* viewer and a single thumbnail card.
*
* What changes between states: whether the run reports step counts (a
* determinate progress bar) or not (a spinner); whether the caller allows the
* user through anyway (the dismiss button); and which policy is enforcing —
* the accent colour and category icon are passed in so the overlay matches that
* policy's badge instead of a fixed blue shield. `enforcing: false` renders
* nothing at all, so it isn't a story.
*/
const meta: Meta<typeof PolicyEnforcingOverlay> = {
title: "Shared/PolicyEnforcingOverlay",
component: PolicyEnforcingOverlay,
parameters: { layout: "padded" },
args: { enforcing: true },
decorators: [
(S) => (
// Stands in for the surface being covered — the overlay needs a
// positioned ancestor with real dimensions to render into.
<div
style={{
position: "relative",
width: "100%",
maxWidth: "34rem",
height: "22rem",
borderRadius: "0.5rem",
border: "1px solid var(--c-border)",
background: "var(--c-surface)",
overflow: "hidden",
}}
>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof PolicyEnforcingOverlay>;
/** A run with no step counts: an indeterminate spinner and the generic shield. */
export const Indeterminate: Story = {};
/** A run that reports its steps swaps the spinner for a determinate bar. */
export const WithProgress: Story = {
args: { progress: 62 },
};
/** Callers that let the user look at the file anyway get a dismiss button. */
export const Dismissible: Story = {
args: { progress: 40, onDismiss: () => {} },
};
/**
* Tinted to the enforcing policy: the classification category's label icon and
* its badge colour replace the default shield and blue.
*/
export const ClassificationPolicy: Story = {
args: {
progress: 30,
categoryId: "classification",
accentVar: "var(--color-orange)",
},
};
@@ -106,6 +106,9 @@ export function PolicyEnforcingOverlay({
w="100%"
size="xs"
radius="xl"
/* The heading above is plain text, so the bar carries its own
name rather than being announced as an unlabelled progressbar. */
aria-label={t("policy.enforcingTitle", "Enforcing policy…")}
value={progress}
striped
animated
@@ -0,0 +1,51 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ReactElement } from "react";
import UpdateSeatsButton from "@app/components/shared/UpdateSeatsButton";
import UpdateSeatsContext from "@app/contexts/UpdateSeatsContext";
/**
* The "Update Seats" entry point on enterprise licences. It owns no state of its
* own: pressing it asks the seat-update flow to open, and the only thing that
* changes its appearance is that flow reporting work in progress — while a seat
* change is being prepared (a licence read, then a redirect to the Stripe
* billing portal) the button shows its loading state.
*
* Everything else on it is pass-through Button styling, so those belong to the
* shared Button's own stories rather than here.
*/
/** Stands in for the seat-update flow; only these four fields are read. */
function withSeatsFlow(isLoading: boolean) {
return function SeatsFlowDecorator(Story: () => ReactElement) {
return (
<UpdateSeatsContext.Provider
value={{
openUpdateSeats: async () => {},
closeUpdateSeats: () => {},
isOpen: false,
isLoading,
}}
>
<Story />
</UpdateSeatsContext.Provider>
);
};
}
const meta: Meta<typeof UpdateSeatsButton> = {
title: "Shared/UpdateSeatsButton",
component: UpdateSeatsButton,
parameters: { layout: "centered" },
};
export default meta;
type Story = StoryObj<typeof UpdateSeatsButton>;
/** Idle — the secondary-variant button awaiting a press. */
export const Default: Story = {
decorators: [withSeatsFlow(false)],
};
/** The seat-update flow is preparing the billing-portal redirect. */
export const Loading: Story = {
decorators: [withSeatsFlow(true)],
};
@@ -604,7 +604,7 @@ const AccountSection: React.FC = () => {
{t("account.mfa.manualKey", "Manual setup key")}:{" "}
<strong>{mfaSetupData.secret}</strong>
</Text>
<Text size="xs" c="orange">
<Text size="xs" c="var(--color-amber-dark)">
{t(
"account.mfa.secretWarning",
"Keep this key private. Anyone with access can generate valid authentication codes.",
@@ -658,7 +658,7 @@ export default function AdminConnectionsSection() {
href="https://docs.stirlingpdf.com/Functionality/Mobile-Scanner"
target="_blank"
size="xs"
c="blue"
c="var(--c-accent-text)"
>
{t(
"admin.settings.connections.documentation",
@@ -687,7 +687,7 @@ export default function AdminConnectionsSection() {
"Allow users to upload files from mobile devices by scanning a QR code",
)}
</Text>
<Text size="xs" c="orange" mt={8} fw={500}>
<Text size="xs" c="var(--color-amber-dark)" mt={8} fw={500}>
{t(
"admin.settings.connections.mobileScanner.note",
"Note: Requires Frontend URL to be configured. ",
@@ -698,7 +698,7 @@ export default function AdminConnectionsSection() {
e.preventDefault();
navigate("/settings/adminGeneral#frontendUrl");
}}
c="orange"
c="var(--color-amber-dark)"
td="underline"
>
{t(
@@ -0,0 +1,140 @@
import type React from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import AdminEndpointsSection from "@app/components/shared/config/configSections/AdminEndpointsSection";
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
import { UnsavedChangesProvider } from "@app/contexts/UnsavedChangesContext";
/**
* Admin settings section for switching individual API endpoints (and whole
* endpoint groups) off, plus the instance-wide defaults for how unavailable
* tools are presented to users.
*
* It drives two independent settings sections — `endpoints` for the two
* multi-selects and `ui` for the two preference switches — through separate
* useAdminSettings instances, so every story mocks both GETs; either one still
* loading holds the whole section on its loader. The selectable endpoint and
* group lists are hardcoded in the component, so the response only decides which
* of them are already picked.
*
* Login mode off suppresses both fetches, shows the login-required banner and
* locks every control, so it is supplied as a decorator rather than a response.
*/
function withProviders(enableLogin: boolean) {
return function Decorator(Story: () => React.JSX.Element) {
return (
<AppConfigProvider
autoFetch={false}
bootstrapMode="non-blocking"
initialConfig={{ enableLogin }}
>
<UnsavedChangesProvider>
<Story />
</UnsavedChangesProvider>
</AppConfigProvider>
);
};
}
function sectionHandlers(
endpoints: Record<string, unknown>,
ui: Record<string, unknown>,
) {
return [
http.get("/api/v1/admin/settings/section/endpoints", () =>
HttpResponse.json(endpoints),
),
http.get("/api/v1/admin/settings/section/ui", () => HttpResponse.json(ui)),
];
}
const meta = {
title: "Config/AdminEndpointsSection",
component: AdminEndpointsSection,
parameters: {
layout: "padded",
msw: {
handlers: [
...sectionHandlers({}, {}),
http.put("/api/v1/admin/settings/section/endpoints", () =>
HttpResponse.json({}),
),
http.put("/api/v1/admin/settings/section/ui", () =>
HttpResponse.json({}),
),
http.put("/api/v1/admin/settings", () => HttpResponse.json({})),
],
},
},
decorators: [withProviders(true)],
} satisfies Meta<typeof AdminEndpointsSection>;
export default meta;
type Story = StoryObj<typeof meta>;
/** A stock install: nothing disabled, so both multi-selects show their placeholders. */
export const Default: Story = {};
/**
* A locked-down instance: several endpoints and two whole groups disabled, and
* the hidden-when-unavailable defaults turned on.
*/
export const EndpointsDisabled: Story = {
parameters: {
msw: {
handlers: sectionHandlers(
{
toRemove: ["add-password", "remove-password", "show-javascript"],
groupsToRemove: ["DeveloperTools", "Automation"],
},
{
defaultHideUnavailableTools: true,
defaultHideUnavailableConversions: true,
},
),
},
},
};
/** While either settings request is in flight, a centred loader replaces the form. */
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get("/api/v1/admin/settings/section/endpoints", async () => {
await delay("infinite");
return HttpResponse.json({});
}),
http.get("/api/v1/admin/settings/section/ui", () =>
HttpResponse.json({}),
),
],
},
},
};
/**
* Restart-required changes on both sections at once: the pending selections and
* switch positions are shown, each flagged with a pending badge.
*/
export const PendingChanges: Story = {
parameters: {
msw: {
handlers: sectionHandlers(
{
toRemove: ["add-password"],
groupsToRemove: [],
_pending: { groupsToRemove: ["DeveloperTools"] },
},
{
defaultHideUnavailableTools: false,
_pending: { defaultHideUnavailableTools: true },
},
),
},
},
};
/** Login mode disabled: nothing is fetched, the banner shows and the controls lock. */
export const LoginDisabled: Story = {
decorators: [withProviders(false)],
};
@@ -0,0 +1,139 @@
import type React from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import AdminFeaturesSection from "@app/components/shared/config/configSections/AdminFeaturesSection";
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
import { UnsavedChangesProvider } from "@app/contexts/UnsavedChangesContext";
/**
* Admin settings section for the server certificate used by "Sign with
* Stirling-PDF".
*
* The whole form is read out of the `system` settings section's
* serverCertificate node, so each story is defined by what that one GET
* returns. When the node is absent the component substitutes its own defaults
* rather than showing an empty form — worth seeing, since a fresh install hits
* that path. Restart-required edits arrive under `system._pending`, which the
* component re-keys onto serverCertificate before merging.
*
* Login mode off suppresses the fetch, shows the login-required banner and locks
* every control, so it is supplied as a decorator rather than a response.
*/
function withProviders(enableLogin: boolean) {
return function Decorator(Story: () => React.JSX.Element) {
return (
<AppConfigProvider
autoFetch={false}
bootstrapMode="non-blocking"
initialConfig={{ enableLogin }}
>
<UnsavedChangesProvider>
<Story />
</UnsavedChangesProvider>
</AppConfigProvider>
);
};
}
function systemHandler(body: Record<string, unknown>) {
return http.get("/api/v1/admin/settings/section/system", () =>
HttpResponse.json(body),
);
}
const meta = {
title: "Config/AdminFeaturesSection",
component: AdminFeaturesSection,
parameters: {
layout: "padded",
msw: {
handlers: [
systemHandler({
serverCertificate: {
enabled: true,
organizationName: "Acme Legal Ltd",
validity: 730,
regenerateOnStartup: false,
},
}),
http.put("/api/v1/admin/settings", () => HttpResponse.json({})),
http.put("/api/v1/admin/settings/section/features", () =>
HttpResponse.json({}),
),
],
},
},
decorators: [withProviders(true)],
} satisfies Meta<typeof AdminFeaturesSection>;
export default meta;
type Story = StoryObj<typeof meta>;
/** A configured certificate: named organisation and a two-year validity. */
export const Default: Story = {};
/** Certificate signing turned off, leaving the detail fields editable but inert. */
export const CertificateDisabled: Story = {
parameters: {
msw: {
handlers: [
systemHandler({
serverCertificate: {
enabled: false,
organizationName: "Acme Legal Ltd",
validity: 730,
regenerateOnStartup: false,
},
}),
],
},
},
};
/** No serverCertificate node on the server — the form falls back to its built-in defaults. */
export const UnconfiguredFallsBackToDefaults: Story = {
parameters: { msw: { handlers: [systemHandler({})] } },
};
/** While the settings request is in flight, a centred loader replaces the form. */
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get("/api/v1/admin/settings/section/system", async () => {
await delay("infinite");
return HttpResponse.json({});
}),
],
},
},
};
/** Restart-required edits: the pending values are shown, each with a pending badge. */
export const PendingChanges: Story = {
parameters: {
msw: {
handlers: [
systemHandler({
serverCertificate: {
enabled: true,
organizationName: "Acme Legal Ltd",
validity: 730,
regenerateOnStartup: false,
},
_pending: {
serverCertificate: {
organizationName: "Acme Legal International",
validity: 365,
regenerateOnStartup: true,
},
},
}),
],
},
},
};
/** Login mode disabled: nothing is fetched, the banner shows and the controls lock. */
export const LoginDisabled: Story = {
decorators: [withProviders(false)],
};
@@ -173,9 +173,18 @@ export default function AdminFeaturesSection() {
)}
</Text>
<Badge
color="grape"
/* grape-6 puts white text at 4.02:1; the darker shade clears
4.5:1 at this size. */
color="grape.8"
size="sm"
style={{ cursor: "pointer" }}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
navigate("/settings/adminPlan");
}}
onClick={() => navigate("/settings/adminPlan")}
title={t(
"admin.settings.badge.clickToUpgrade",
@@ -216,6 +225,10 @@ export default function AdminFeaturesSection() {
</div>
<Group gap="xs">
<Switch
aria-label={t(
"admin.settings.features.serverCertificate.enabled.label",
"Enable Server Certificate",
)}
checked={settings.serverCertificate?.enabled ?? true}
onChange={(e) => {
if (!loginEnabled) return;
@@ -333,6 +346,10 @@ export default function AdminFeaturesSection() {
</div>
<Group gap="xs">
<Switch
aria-label={t(
"admin.settings.features.serverCertificate.regenerateOnStartup.label",
"Regenerate on Startup",
)}
checked={
settings.serverCertificate?.regenerateOnStartup ?? false
}
@@ -0,0 +1,117 @@
import type React from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import AdminLegalSection from "@app/components/shared/config/configSections/AdminLegalSection";
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
import { UnsavedChangesProvider } from "@app/contexts/UnsavedChangesContext";
/**
* Admin settings section for the links to legal documents, plus the login
* agreement users must accept after signing in.
*
* The five URL fields and the loginAgreement node all come from the `legal`
* settings section fetched on mount, so each story is defined by that one
* response. The embedded LoginAgreementEditor loads its own per-language
* markdown separately and degrades to empty text when that request is not
* mocked, which is fine here — this file is about the section around it.
*
* Login mode off suppresses the fetch, shows the login-required banner and locks
* every control, so it is supplied as a decorator rather than a response.
*/
function withProviders(enableLogin: boolean) {
return function Decorator(Story: () => React.JSX.Element) {
return (
<AppConfigProvider
autoFetch={false}
bootstrapMode="non-blocking"
initialConfig={{ enableLogin }}
>
<UnsavedChangesProvider>
<Story />
</UnsavedChangesProvider>
</AppConfigProvider>
);
};
}
function legalHandler(body: Record<string, unknown>) {
return http.get("/api/v1/admin/settings/section/legal", () =>
HttpResponse.json(body),
);
}
const CONFIGURED_LEGAL = {
termsAndConditions: "https://acme-legal.test/terms",
privacyPolicy: "https://acme-legal.test/privacy",
accessibilityStatement: "https://acme-legal.test/accessibility",
cookiePolicy: "https://acme-legal.test/cookies",
impressum: "https://acme-legal.test/impressum",
loginAgreement: {
enabled: true,
showInAnonymousMode: true,
},
};
const meta = {
title: "Config/AdminLegalSection",
component: AdminLegalSection,
parameters: {
layout: "padded",
msw: {
handlers: [
legalHandler(CONFIGURED_LEGAL),
http.put("/api/v1/admin/settings/section/legal", () =>
HttpResponse.json({}),
),
http.put("/api/v1/admin/settings", () => HttpResponse.json({})),
],
},
},
decorators: [withProviders(true)],
} satisfies Meta<typeof AdminLegalSection>;
export default meta;
type Story = StoryObj<typeof meta>;
/** All five documents hosted externally, with the login agreement turned on. */
export const Default: Story = {};
/** A stock install: no documents overridden and no login agreement. */
export const NothingConfigured: Story = {
parameters: { msw: { handlers: [legalHandler({})] } },
};
/** While the settings request is in flight, a centred loader replaces the form. */
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get("/api/v1/admin/settings/section/legal", async () => {
await delay("infinite");
return HttpResponse.json({});
}),
],
},
},
};
/** Restart-required document changes: the pending URLs show with pending badges. */
export const PendingChanges: Story = {
parameters: {
msw: {
handlers: [
legalHandler({
...CONFIGURED_LEGAL,
_pending: {
privacyPolicy: "https://acme-legal.test/privacy-2",
impressum: "https://acme-legal.test/legal-notice",
},
}),
],
},
},
};
/** Login mode disabled: nothing is fetched, the banner shows and the form locks. */
export const LoginDisabled: Story = {
decorators: [withProviders(false)],
};
@@ -0,0 +1,119 @@
import type React from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import AdminMailSection from "@app/components/shared/config/configSections/AdminMailSection";
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
import { UnsavedChangesProvider } from "@app/contexts/UnsavedChangesContext";
/**
* Admin settings section for the outbound SMTP configuration.
*
* Everything on screen comes from the `mail` settings section, fetched on mount,
* so each story is defined by that one response. The master "Enable Mail" switch
* gates the email-invites switch beneath it, which is the only conditional
* rendering in the form — hence a story either side of it.
*
* Unlike the neighbouring admin sections this one has no login-required banner:
* login state only reaches the sticky save footer, which stays hidden until the
* form is edited, so a login-disabled story would be indistinguishable.
* useSettingsDirty() and useLoginRequired() still need their providers.
*/
function withProviders(Story: () => React.JSX.Element) {
return (
<AppConfigProvider
autoFetch={false}
bootstrapMode="non-blocking"
initialConfig={{ enableLogin: true }}
>
<UnsavedChangesProvider>
<Story />
</UnsavedChangesProvider>
</AppConfigProvider>
);
}
function mailHandler(body: Record<string, unknown>) {
return http.get("/api/v1/admin/settings/section/mail", () =>
HttpResponse.json(body),
);
}
const CONFIGURED_SMTP = {
enabled: true,
enableInvites: true,
host: "smtp.acme-legal.test",
port: 587,
username: "postmaster@acme-legal.test",
password: "correct-horse-battery-staple",
from: "noreply@acme-legal.test",
};
const meta = {
title: "Config/AdminMailSection",
component: AdminMailSection,
parameters: {
layout: "padded",
msw: {
handlers: [
mailHandler(CONFIGURED_SMTP),
http.put("/api/v1/admin/settings/section/mail", () =>
HttpResponse.json({}),
),
http.put("/api/v1/admin/settings", () => HttpResponse.json({})),
],
},
},
decorators: [withProviders],
} satisfies Meta<typeof AdminMailSection>;
export default meta;
type Story = StoryObj<typeof meta>;
/** A working relay: host, credentials and sender all set, invites available. */
export const Default: Story = {};
/** Mail switched off — the invites switch below it is disabled along with it. */
export const MailDisabled: Story = {
parameters: {
msw: { handlers: [mailHandler({ enabled: false, enableInvites: false })] },
},
};
/** Nothing configured yet: every field falls back to its placeholder, port to 587. */
export const Unconfigured: Story = {
parameters: { msw: { handlers: [mailHandler({})] } },
};
/** While the settings request is in flight, a centred loader replaces the form. */
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get("/api/v1/admin/settings/section/mail", async () => {
await delay("infinite");
return HttpResponse.json({});
}),
],
},
},
};
/**
* A relay move saved but awaiting a restart: the pending host, port and sender
* are shown, each flagged with a pending badge.
*/
export const PendingChanges: Story = {
parameters: {
msw: {
handlers: [
mailHandler({
...CONFIGURED_SMTP,
_pending: {
host: "smtp.eu.acme-legal.test",
port: 465,
from: "no-reply@acme-legal.test",
},
}),
],
},
},
};
@@ -145,6 +145,10 @@ export default function AdminMailSection() {
</div>
<Group gap="xs">
<Switch
aria-label={t(
"admin.settings.mail.enabled.label",
"Enable Mail",
)}
checked={settings.enabled || false}
onChange={(e) =>
setSettings({ ...settings, enabled: e.target.checked })
@@ -168,7 +172,7 @@ export default function AdminMailSection() {
"Allow admins to invite users via email with auto-generated passwords",
)}
</Text>
<Text size="xs" c="orange" mt={8} fw={500}>
<Text size="xs" c="var(--color-amber-dark)" mt={8} fw={500}>
{t(
"admin.settings.mail.frontendUrlNote.note",
"Note: Requires Frontend URL to be configured. ",
@@ -179,7 +183,7 @@ export default function AdminMailSection() {
e.preventDefault();
navigate("/settings/adminGeneral#frontendUrl");
}}
c="orange"
c="var(--color-amber-dark)"
td="underline"
>
{t(
@@ -191,6 +195,10 @@ export default function AdminMailSection() {
</div>
<Group gap="xs">
<Switch
aria-label={t(
"admin.settings.mail.invites.label",
"Team invitation emails",
)}
checked={settings.enableInvites || false}
onChange={(e) =>
setSettings({
@@ -0,0 +1,114 @@
import type React from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import AdminPremiumSection from "@app/components/shared/config/configSections/AdminPremiumSection";
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
import { UnsavedChangesProvider } from "@app/contexts/UnsavedChangesContext";
/**
* Admin settings section for the premium/enterprise licence key.
*
* The component takes no props: it fetches the `premium` settings blob through
* useAdminSettings on mount and reads login state through useLoginRequired(),
* so what it renders is decided by two things — the mocked GET response and
* whether login mode is on. Login mode off suppresses the fetch entirely, shows
* the login-required banner and locks every control, which is why that state
* gets its own decorator rather than a different response.
*
* useSettingsDirty() and useAppConfig() both throw without their providers, so
* every story wraps in both.
*/
function withProviders(enableLogin: boolean) {
return function Decorator(Story: () => React.JSX.Element) {
return (
<AppConfigProvider
autoFetch={false}
bootstrapMode="non-blocking"
initialConfig={{ enableLogin }}
>
<UnsavedChangesProvider>
<Story />
</UnsavedChangesProvider>
</AppConfigProvider>
);
};
}
function premiumHandler(body: Record<string, unknown>) {
return http.get("/api/v1/admin/settings/section/premium", () =>
HttpResponse.json(body),
);
}
const meta = {
title: "Config/AdminPremiumSection",
component: AdminPremiumSection,
parameters: {
layout: "padded",
msw: {
handlers: [
premiumHandler({
key: "STORY-LICENCE-0000-0000-0000",
enabled: true,
}),
http.put("/api/v1/admin/settings/section/premium", () =>
HttpResponse.json({}),
),
http.put("/api/v1/admin/settings", () => HttpResponse.json({})),
],
},
},
decorators: [withProviders(true)],
} satisfies Meta<typeof AdminPremiumSection>;
export default meta;
type Story = StoryObj<typeof meta>;
/** An activated licence: key populated and premium features switched on. */
export const Default: Story = {};
/** No licence configured yet — the empty key field shows its placeholder. */
export const NoLicence: Story = {
parameters: {
msw: { handlers: [premiumHandler({ key: "", enabled: false })] },
},
};
/** While the settings request is in flight, a centred loader replaces the form. */
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get("/api/v1/admin/settings/section/premium", async () => {
await delay("infinite");
return HttpResponse.json({});
}),
],
},
},
};
/**
* Edits saved but awaiting a restart: the merged values are shown with a
* pending badge beside each field the restart will change.
*/
export const PendingChanges: Story = {
parameters: {
msw: {
handlers: [
premiumHandler({
key: "STORY-LICENCE-0000-0000-0000",
enabled: false,
_pending: {
key: "STORY-LICENCE-1111-1111-1111",
enabled: true,
},
}),
],
},
},
};
/** Login mode disabled: nothing is fetched, the banner shows and the controls lock. */
export const LoginDisabled: Story = {
decorators: [withProviders(false)],
};
@@ -213,6 +213,10 @@ export default function AdminPremiumSection() {
</div>
<Group gap="xs">
<Switch
aria-label={t(
"admin.settings.premium.enabled.label",
"Enable Premium Features",
)}
checked={settings.enabled || false}
onChange={(e) => {
if (!loginEnabled) return;
@@ -0,0 +1,122 @@
import type React from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import AdminPrivacySection from "@app/components/shared/config/configSections/AdminPrivacySection";
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
import { UnsavedChangesProvider } from "@app/contexts/UnsavedChangesContext";
/**
* Admin settings section for analytics, metrics and search-engine visibility.
*
* The three switches are stitched together from two separate backend sections —
* `system` carries enableAnalytics/googlevisibility, `metrics` carries enabled —
* so every story has to mock both GETs; a missing one leaves the section stuck
* on its loader. Pending (restart-required) changes arrive in a `_pending` block
* on whichever endpoint owns the field, and the component maps them onto its own
* camelCase names, so the pending story splits them across both responses.
*
* Login mode off skips the fetch entirely, shows the login-required banner and
* disables the switches, which is why it is a decorator rather than a response.
*/
function withProviders(enableLogin: boolean) {
return function Decorator(Story: () => React.JSX.Element) {
return (
<AppConfigProvider
autoFetch={false}
bootstrapMode="non-blocking"
initialConfig={{ enableLogin }}
>
<UnsavedChangesProvider>
<Story />
</UnsavedChangesProvider>
</AppConfigProvider>
);
};
}
function systemHandler(body: Record<string, unknown>) {
return http.get("/api/v1/admin/settings/section/system", () =>
HttpResponse.json(body),
);
}
function metricsHandler(body: Record<string, unknown>) {
return http.get("/api/v1/admin/settings/section/metrics", () =>
HttpResponse.json(body),
);
}
const meta = {
title: "Config/AdminPrivacySection",
component: AdminPrivacySection,
parameters: {
layout: "padded",
msw: {
handlers: [
systemHandler({ enableAnalytics: true, googlevisibility: false }),
metricsHandler({ enabled: true }),
http.put("/api/v1/admin/settings", () => HttpResponse.json({})),
http.put("/api/v1/admin/settings/section/privacy", () =>
HttpResponse.json({}),
),
],
},
},
decorators: [withProviders(true)],
} satisfies Meta<typeof AdminPrivacySection>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Telemetry on, search indexing off — the shipped defaults for a private instance. */
export const Default: Story = {};
/** Everything opted out, the strictest configuration the section can express. */
export const AllCollectionOff: Story = {
parameters: {
msw: {
handlers: [
systemHandler({ enableAnalytics: false, googlevisibility: false }),
metricsHandler({ enabled: false }),
],
},
},
};
/** While either settings request is in flight, a centred loader replaces the form. */
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get("/api/v1/admin/settings/section/system", async () => {
await delay("infinite");
return HttpResponse.json({});
}),
metricsHandler({ enabled: false }),
],
},
},
};
/**
* Restart-required edits on fields owned by both endpoints: the switches show
* the pending values, each flagged with a pending badge.
*/
export const PendingChanges: Story = {
parameters: {
msw: {
handlers: [
systemHandler({
enableAnalytics: false,
googlevisibility: false,
_pending: { enableAnalytics: true, googlevisibility: true },
}),
metricsHandler({ enabled: false, _pending: { enabled: true } }),
],
},
},
};
/** Login mode disabled: nothing is fetched, the banner shows and the switches lock. */
export const LoginDisabled: Story = {
decorators: [withProviders(false)],
};
@@ -181,6 +181,10 @@ export default function AdminPrivacySection() {
</div>
<Group gap="xs">
<Switch
aria-label={t(
"admin.settings.privacy.enableAnalytics.label",
"Enable Analytics",
)}
checked={settings?.enableAnalytics || false}
onChange={(e) => {
if (!loginEnabled) return;
@@ -219,6 +223,10 @@ export default function AdminPrivacySection() {
</div>
<Group gap="xs">
<Switch
aria-label={t(
"admin.settings.privacy.metricsEnabled.label",
"Enable Metrics",
)}
checked={settings?.metricsEnabled || false}
onChange={(e) => {
if (!loginEnabled) return;
@@ -269,6 +277,10 @@ export default function AdminPrivacySection() {
</div>
<Group gap="xs">
<Switch
aria-label={t(
"admin.settings.privacy.googleVisibility.label",
"Google Visibility",
)}
checked={settings?.googleVisibility || false}
onChange={(e) => {
if (!loginEnabled) return;
@@ -0,0 +1,173 @@
import type React from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import AdminStorageSharingSection from "@app/components/shared/config/configSections/AdminStorageSharingSection";
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
import { UnsavedChangesProvider } from "@app/contexts/UnsavedChangesContext";
/**
* Admin settings section for server-side file storage and the sharing options
* built on top of it.
*
* The five switches form a dependency chain: storage gates sharing and group
* signing, sharing gates share links and email sharing, and those last two carry
* their own external prerequisites — a configured frontend URL for links, a
* configured mail relay for email. Each unmet prerequisite both disables its
* switch and adds an amber "requires…" note, so the interesting states are
* points along that chain rather than variations of one payload.
*
* The settings are stitched from three sections — `storage` for the switches,
* `system` for the frontend URL and `mail` for the relay — so every story mocks
* all three; a missing one leaves the section stuck on its loader.
*/
function withProviders(enableLogin: boolean) {
return function Decorator(Story: () => React.JSX.Element) {
return (
<AppConfigProvider
autoFetch={false}
bootstrapMode="non-blocking"
initialConfig={{ enableLogin }}
>
<UnsavedChangesProvider>
<Story />
</UnsavedChangesProvider>
</AppConfigProvider>
);
};
}
function sectionHandlers({
storage,
frontendUrl = "",
mailEnabled = false,
}: {
storage: Record<string, unknown>;
frontendUrl?: string;
mailEnabled?: boolean;
}) {
return [
http.get("/api/v1/admin/settings/section/storage", () =>
HttpResponse.json(storage),
),
http.get("/api/v1/admin/settings/section/system", () =>
HttpResponse.json({ frontendUrl }),
),
http.get("/api/v1/admin/settings/section/mail", () =>
HttpResponse.json({ enabled: mailEnabled }),
),
];
}
const ALL_ON = {
enabled: true,
sharing: { enabled: true, linkEnabled: true, emailEnabled: true },
signing: { enabled: true },
};
const meta = {
title: "Config/AdminStorageSharingSection",
component: AdminStorageSharingSection,
parameters: {
layout: "padded",
msw: {
handlers: [
...sectionHandlers({
storage: ALL_ON,
frontendUrl: "https://pdf.acme-legal.test",
mailEnabled: true,
}),
http.put("/api/v1/admin/settings/section/storage", () =>
HttpResponse.json({}),
),
http.put("/api/v1/admin/settings", () => HttpResponse.json({})),
],
},
},
decorators: [withProviders(true)],
} satisfies Meta<typeof AdminStorageSharingSection>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Every prerequisite met, so the full chain of switches is on and editable. */
export const Default: Story = {};
/** Storage off — the switches that depend on it are disabled, whatever their stored value. */
export const StorageDisabled: Story = {
parameters: {
msw: {
handlers: sectionHandlers({
storage: {
enabled: false,
sharing: { enabled: true, linkEnabled: true, emailEnabled: true },
signing: { enabled: true },
},
frontendUrl: "https://pdf.acme-legal.test",
mailEnabled: true,
}),
},
},
};
/**
* Sharing is on but the frontend URL and mail relay are not configured: both
* sharing channels are disabled and each shows its "requires…" note.
*/
export const PrerequisitesMissing: Story = {
parameters: {
msw: {
handlers: sectionHandlers({
storage: {
enabled: true,
sharing: { enabled: true, linkEnabled: false, emailEnabled: false },
signing: { enabled: false },
},
}),
},
},
};
/** While any of the three settings requests is in flight, a centred loader replaces the form. */
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get("/api/v1/admin/settings/section/storage", async () => {
await delay("infinite");
return HttpResponse.json({});
}),
http.get("/api/v1/admin/settings/section/system", () =>
HttpResponse.json({}),
),
http.get("/api/v1/admin/settings/section/mail", () =>
HttpResponse.json({}),
),
],
},
},
};
/** Restart-required edits: the pending switch positions show with pending badges. */
export const PendingChanges: Story = {
parameters: {
msw: {
handlers: sectionHandlers({
storage: {
enabled: true,
sharing: { enabled: false, linkEnabled: false, emailEnabled: false },
signing: { enabled: false },
_pending: {
sharing: { enabled: true, linkEnabled: true },
signing: { enabled: true },
},
},
frontendUrl: "https://pdf.acme-legal.test",
mailEnabled: true,
}),
},
},
};
/** Login mode disabled: nothing is fetched, the banner shows and every switch locks. */
export const LoginDisabled: Story = {
decorators: [withProviders(false)],
};
@@ -183,6 +183,10 @@ export default function AdminStorageSharingSection() {
</Text>
</div>
<Switch
aria-label={t(
"admin.settings.storage.enabled.label",
"Enable Server File Storage",
)}
checked={storageEnabled}
onChange={(e) =>
setSettings({ ...settings, enabled: e.currentTarget.checked })
@@ -216,6 +220,10 @@ export default function AdminStorageSharingSection() {
</Text>
</div>
<Switch
aria-label={t(
"admin.settings.storage.sharing.enabled.label",
"Enable Sharing",
)}
checked={settings.sharing?.enabled ?? false}
onChange={(e) =>
setSettings({
@@ -254,7 +262,7 @@ export default function AdminStorageSharingSection() {
)}
</Text>
{!frontendUrlConfigured && (
<Text size="xs" c="orange">
<Text size="xs" c="var(--color-amber-dark)">
{t(
"admin.settings.storage.sharing.links.frontendUrlNote",
"Requires a Frontend URL. ",
@@ -265,7 +273,7 @@ export default function AdminStorageSharingSection() {
e.preventDefault();
navigate("/settings/adminGeneral#frontendUrl");
}}
c="orange"
c="var(--color-amber-dark)"
td="underline"
>
{t(
@@ -277,6 +285,10 @@ export default function AdminStorageSharingSection() {
)}
</div>
<Switch
aria-label={t(
"admin.settings.storage.sharing.links.label",
"Share links",
)}
checked={settings.sharing?.linkEnabled ?? false}
onChange={(e) =>
setSettings({
@@ -317,7 +329,7 @@ export default function AdminStorageSharingSection() {
)}
</Text>
{!mailEnabled && (
<Text size="xs" c="orange">
<Text size="xs" c="var(--color-amber-dark)">
{t(
"admin.settings.storage.sharing.email.mailNote",
"Requires mail configuration. ",
@@ -328,7 +340,7 @@ export default function AdminStorageSharingSection() {
e.preventDefault();
navigate("/settings/adminConnections");
}}
c="orange"
c="var(--color-amber-dark)"
td="underline"
>
{t(
@@ -340,6 +352,10 @@ export default function AdminStorageSharingSection() {
)}
</div>
<Switch
aria-label={t(
"admin.settings.storage.sharing.email.label",
"Share by email",
)}
checked={settings.sharing?.emailEnabled ?? false}
onChange={(e) =>
setSettings({
@@ -379,6 +395,10 @@ export default function AdminStorageSharingSection() {
</Text>
</div>
<Switch
aria-label={t(
"admin.settings.storage.signing.enabled.label",
"Enable Group Signing",
)}
checked={settings.signing?.enabled ?? false}
onChange={(e) =>
setSettings({
@@ -0,0 +1,125 @@
import type React from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import AdminUsageSection from "@app/components/shared/config/configSections/AdminUsageSection";
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
import type { EndpointStatisticsResponse } from "@app/services/usageAnalyticsService";
/**
* Admin dashboard of endpoint usage: a chart and table of the busiest endpoints,
* with controls for how many to show and whether to count API calls, UI calls or
* both.
*
* The section takes no props. What it shows is decided by the licence: only an
* ENTERPRISE licence with login mode on reaches the real statistics endpoint.
* Anything short of that falls back to a built-in demo dataset, freezes the
* controls and raises the enterprise banner — a deliberate teaser rather than an
* empty state, so it is the state most admins actually see. Login and licence
* both come from useAppConfig(), so each story sets them on the provider, and
* only the licensed stories need the endpoint mocked at all.
*/
function withConfig(config: { enableLogin: boolean; license?: string }) {
return function Decorator(Story: () => React.JSX.Element) {
return (
<AppConfigProvider
autoFetch={false}
bootstrapMode="non-blocking"
initialConfig={config}
>
<Story />
</AppConfigProvider>
);
};
}
const STATISTICS: EndpointStatisticsResponse = {
totalVisits: 4820,
totalEndpoints: 6,
endpoints: [
{ endpoint: "merge-pdfs", visits: 1640, percentage: 34.0 },
{ endpoint: "compress-pdf", visits: 1120, percentage: 23.2 },
{ endpoint: "sign", visits: 880, percentage: 18.3 },
{ endpoint: "ocr-pdf", visits: 540, percentage: 11.2 },
{ endpoint: "add-watermark", visits: 380, percentage: 7.9 },
{ endpoint: "split-pages", visits: 260, percentage: 5.4 },
],
};
const STATISTICS_PATH = "/api/v1/proprietary/ui-data/usage-endpoint-statistics";
const meta = {
title: "Config/AdminUsageSection",
component: AdminUsageSection,
parameters: {
layout: "padded",
msw: {
handlers: [http.get(STATISTICS_PATH, () => HttpResponse.json(STATISTICS))],
},
},
decorators: [withConfig({ enableLogin: true })],
} satisfies Meta<typeof AdminUsageSection>;
export default meta;
type Story = StoryObj<typeof meta>;
/** No enterprise licence: demo figures behind the enterprise banner, controls frozen. */
export const Default: Story = {};
/** Login mode off as well, so both the login and enterprise banners are raised. */
export const LoginDisabled: Story = {
decorators: [withConfig({ enableLogin: false })],
};
/**
* Licensed and logged in: real statistics, live controls, and the explanatory
* banner linking through to the audit dashboard.
*/
export const EnterpriseLicensed: Story = {
decorators: [withConfig({ enableLogin: true, license: "ENTERPRISE" })],
};
/** While the statistics request is in flight, a centred loader replaces the dashboard. */
export const Loading: Story = {
decorators: [withConfig({ enableLogin: true, license: "ENTERPRISE" })],
parameters: {
msw: {
handlers: [
http.get(STATISTICS_PATH, async () => {
await delay("infinite");
return HttpResponse.json(STATISTICS);
}),
],
},
},
};
/** The statistics request failed: an error alert replaces the dashboard entirely. */
export const LoadError: Story = {
decorators: [withConfig({ enableLogin: true, license: "ENTERPRISE" })],
parameters: {
msw: {
handlers: [
http.get(STATISTICS_PATH, () =>
HttpResponse.json(null, { status: 500 }),
),
],
},
},
};
/** Licensed, but the instance has recorded no traffic yet. */
export const NoTrafficRecorded: Story = {
decorators: [withConfig({ enableLogin: true, license: "ENTERPRISE" })],
parameters: {
msw: {
handlers: [
http.get(STATISTICS_PATH, () =>
HttpResponse.json({
totalVisits: 0,
totalEndpoints: 0,
endpoints: [],
} satisfies EndpointStatisticsResponse),
),
],
},
},
};
@@ -269,7 +269,7 @@ export default function LoginAgreementEditor({
{loading && <Loader size="xs" />}
{loadFailed && !loading && (
<Text size="xs" c="red">
<Text size="xs" c="var(--color-red-dark)">
{t(
"admin.settings.legal.loginAgreement.loadError",
"Failed to load the agreement for {{locale}}. Switch language and back to retry.",
@@ -350,7 +350,7 @@ export default function TeamDetailsSection({
if (!team) {
return (
<Stack align="center" py="xl">
<Text size="sm" c="red">
<Text size="sm" c="var(--color-red-dark)">
{t("workspace.teams.teamNotFound", "Team not found")}
</Text>
<Button variant="secondary" onClick={onBack}>
@@ -417,7 +417,11 @@ export default function TeamDetailsSection({
<Table.Th style={{ fontWeight: 600 }} fz="sm" w={100}>
{t("workspace.people.role")}
</Table.Th>
<Table.Th w={50}></Table.Th>
<Table.Th w={50}>
<span className="sr-only">
{t("workspace.people.memberActions", "Member actions")}
</span>
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -480,7 +484,9 @@ export default function TeamDetailsSection({
maw={200}
style={{
lineHeight: 1.3,
opacity: user.enabled ? 1 : 0.6,
color: user.enabled
? undefined
: "var(--c-text-muted)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
@@ -749,7 +755,7 @@ export default function TeamDetailsSection({
availableUsersForTeam.find(
(u) => u.id.toString() === selectedUserId,
)?.team && (
<Text size="xs" c="orange">
<Text size="xs" c="var(--color-amber-dark)">
{t("workspace.teams.addMemberToTeam.willBeMoved")}
</Text>
)}
@@ -317,7 +317,11 @@ export default function TeamsSection() {
>
{t("workspace.teams.totalMembers")}
</Table.Th>
<Table.Th style={{ width: 50 }}></Table.Th>
<Table.Th style={{ width: 50 }}>
<span className="sr-only">
{t("workspace.teams.teamActions", "Team actions")}
</span>
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -631,7 +635,7 @@ export default function TeamsSection() {
availableUsersForSelectedTeam.find(
(u) => u.id.toString() === selectedUserId,
)?.team && (
<Text size="xs" c="orange">
<Text size="xs" c="var(--color-amber-dark)">
{t("workspace.teams.addMemberToTeam.willBeMoved")}
</Text>
)}
@@ -26,7 +26,7 @@ export default function RefreshModal({
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Stack gap="md">
<Text size="sm" c="red">
<Text size="sm" c="var(--color-red-dark)">
{t(
"config.apiKeys.refreshModal.warning",
"⚠️ Warning: This action will generate new API keys and make your previous keys invalid.",
@@ -92,7 +92,6 @@ const AuditSystemStatus: React.FC<AuditSystemStatusProps> = ({ status }) => {
style={{
marginLeft: "0.5rem",
fontSize: "0.75rem",
opacity: 0.7,
}}
>
(
@@ -115,7 +114,6 @@ const AuditSystemStatus: React.FC<AuditSystemStatusProps> = ({ status }) => {
style={{
marginLeft: "0.5rem",
fontSize: "0.75rem",
opacity: 0.7,
}}
>
(
@@ -89,6 +89,7 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
</div>
{currency && onCurrencyChange && currencyOptions && (
<Select
aria-label={t("plan.availablePlans.currency", "Billing currency")}
value={currency}
onChange={(value) => onCurrencyChange(value || "usd")}
data={currencyOptions}
@@ -85,7 +85,7 @@ const FeatureComparisonTable: React.FC<FeatureComparisonTableProps> = ({
style={{ textAlign: "center", padding: "0.75rem" }}
>
{plan.features[featureIndex]?.included ? (
<Text c="green" fw={600} size="lg">
<Text c="var(--color-green-dark)" fw={600} size="lg">
</Text>
) : (
@@ -0,0 +1,107 @@
import type React from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { within, userEvent } from "storybook/test";
import LicenseKeySection from "@app/components/shared/config/configSections/plan/LicenseKeySection";
import type { LicenseInfo } from "@app/services/licenseService";
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
import { LicenseProvider } from "@app/contexts/LicenseContext";
/**
* The collapsible panel at the foot of the plan page for activating a licence
* bought outside the in-app checkout, either as a key string or a certificate
* file.
*
* Everything below the toggle lives behind local state — the collapse itself and
* the key/file choice — so the interesting states are only reachable by driving
* the control rather than by setting a prop. Each story below opens the panel
* first for that reason. The toggle is the only button while collapsed, so it is
* found by role without depending on the translated copy.
*
* A licence already installed adds two alerts above the form: an overwrite
* warning and a summary of what is currently active, which reads differently for
* a key than for a file path.
*
* useLicense() throws outside a provider. LicenseProvider is mounted with a
* non-admin config so it settles without issuing a licence request of its own.
*/
function withLicenseContext(enableLogin: boolean) {
return function Decorator(Story: () => React.JSX.Element) {
return (
<AppConfigProvider
autoFetch={false}
bootstrapMode="non-blocking"
initialConfig={{ enableLogin }}
>
<LicenseProvider>
<Story />
</LicenseProvider>
</AppConfigProvider>
);
};
}
async function openPanel(canvasElement: HTMLElement) {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button"));
}
const keyLicence: LicenseInfo = {
licenseType: "SERVER",
enabled: true,
maxUsers: 0,
hasKey: true,
licenseKey: "STORY-LICENCE-0000-0000-0000",
};
const fileLicence: LicenseInfo = {
licenseType: "ENTERPRISE",
enabled: true,
maxUsers: 250,
hasKey: true,
licenseKey: "file:/opt/stirling/licence.cert",
};
const meta = {
title: "Config/Plan/LicenseKeySection",
component: LicenseKeySection,
parameters: { layout: "padded" },
decorators: [withLicenseContext(true)],
} satisfies Meta<typeof LicenseKeySection>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Closed, which is how the panel first appears beneath the plan cards. */
export const Collapsed: Story = {};
/** Opened with no licence installed: just the explanatory alert and the key field. */
export const Expanded: Story = {
play: ({ canvasElement }) => openPanel(canvasElement),
};
/** The certificate-file alternative, which swaps the key field for a file picker. */
export const CertificateFileUpload: Story = {
play: async ({ canvasElement }) => {
await openPanel(canvasElement);
const canvas = within(canvasElement);
// The key/file choice is a radiogroup; the second option is the file upload.
await userEvent.click(canvas.getAllByRole("radio")[1]);
},
};
/** A key licence is already active: the overwrite warning and the active-licence summary appear. */
export const ExistingKeyLicence: Story = {
args: { currentLicenseInfo: keyLicence },
play: ({ canvasElement }) => openPanel(canvasElement),
};
/** The active licence came from a certificate file, so its summary names the path instead. */
export const ExistingFileLicence: Story = {
args: { currentLicenseInfo: fileLicence },
play: ({ canvasElement }) => openPanel(canvasElement),
};
/** Login mode disabled: the panel still opens but every input and the save action are locked. */
export const LoginDisabled: Story = {
decorators: [withLicenseContext(false)],
play: ({ canvasElement }) => openPanel(canvasElement),
};
@@ -176,7 +176,7 @@ const PlanCard: React.FC<PlanCardProps> = ({
isCurrentTier &&
currentLicenseInfo &&
currentLicenseInfo.maxUsers > 0 && (
<Text size="sm" c="green" fw={500} ta="center">
<Text size="sm" c="var(--color-green-dark)" fw={500} ta="center">
{t("plan.licensedSeats", "Licensed: {{count}} seats", {
count: currentLicenseInfo.maxUsers,
})}
@@ -0,0 +1,81 @@
import type React from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { within, userEvent } from "storybook/test";
import StaticCheckoutModal from "@app/components/shared/config/configSections/plan/StaticCheckoutModal";
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
import { LicenseProvider } from "@app/contexts/LicenseContext";
/**
* The checkout used when Stripe is not wired into the app: it collects an email,
* sends the buyer to a static Stripe payment link in a new tab, then waits for
* them to paste back the licence key that arrives by email.
*
* The modal walks three stages held in local state (email → billing period →
* licence activation), so only the first is reachable by props alone; the later
* ones are driven through the form. `planName` and `isUpgrade` change nothing
* but the heading — either one pointing at Enterprise produces the same title —
* so there is one story per distinct heading rather than one per prop.
*
* Mantine renders the modal into a portal outside the story canvas, so the
* queries below run against the document body.
*
* useLicense() throws outside a provider; LicenseProvider is mounted with a
* non-admin config so it settles without issuing a licence request of its own.
*/
function withLicenseContext(Story: () => React.JSX.Element) {
return (
<AppConfigProvider
autoFetch={false}
bootstrapMode="non-blocking"
initialConfig={{ enableLogin: true }}
>
<LicenseProvider>
<Story />
</LicenseProvider>
</AppConfigProvider>
);
}
const meta = {
title: "Config/Plan/StaticCheckoutModal",
component: StaticCheckoutModal,
parameters: { layout: "centered" },
args: {
opened: true,
onClose: () => {},
planName: "server",
},
decorators: [withLicenseContext],
} satisfies Meta<typeof StaticCheckoutModal>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Buying a Server licence from the free tier — the opening email step. */
export const ServerLicence: Story = {};
/** Moving up to Enterprise, which reframes the same email step as an upgrade. */
export const EnterpriseUpgrade: Story = {
args: { planName: "enterprise" },
};
/** A malformed address is rejected in place rather than advancing the flow. */
export const InvalidEmail: Story = {
play: async () => {
const body = within(document.body);
await userEvent.type(body.getByRole("textbox"), "not-an-email{enter}");
},
};
/**
* Past the email step: the monthly/yearly choice, each option opening the
* matching Stripe payment link.
*/
export const BillingPeriodChoice: Story = {
play: async () => {
const body = within(document.body);
await userEvent.type(
body.getByRole("textbox"),
"jane@acme-legal.test{enter}",
);
},
};
@@ -15,7 +15,10 @@ import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { EmailStage } from "@app/components/shared/stripeCheckout/stages/EmailStage";
import { validateEmail } from "@app/components/shared/stripeCheckout/utils/checkoutUtils";
import { getClickablePaperStyle } from "@app/components/shared/stripeCheckout/utils/cardStyles";
import {
getClickableCardProps,
getClickablePaperStyle,
} from "@app/components/shared/stripeCheckout/utils/cardStyles";
import {
STATIC_STRIPE_LINKS,
buildStripeUrlWithEmail,
@@ -194,7 +197,9 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
p="xl"
radius="md"
style={getClickablePaperStyle()}
onClick={() => handlePeriodSelect("monthly")}
{...getClickableCardProps(() =>
handlePeriodSelect("monthly"),
)}
>
<Stack
gap="md"
@@ -218,7 +223,7 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
p="xl"
radius="md"
style={getClickablePaperStyle()}
onClick={() => handlePeriodSelect("yearly")}
{...getClickableCardProps(() => handlePeriodSelect("yearly"))}
>
<Stack
gap="md"
@@ -0,0 +1,91 @@
import type React from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import StaticPlanSection from "@app/components/shared/config/configSections/plan/StaticPlanSection";
import type { LicenseInfo } from "@app/services/licenseService";
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
import { LicenseProvider } from "@app/contexts/LicenseContext";
/**
* The plan page shown when Stripe-backed checkout is unavailable — no Supabase
* configuration, or the live plans request failed. It lists the three tiers from
* hardcoded copy rather than fetched pricing, so the only thing that changes
* what it renders is the licence it is handed.
*
* Each card's action is derived from that licence's tier: the current tier gets
* "Manage", anything below it collapses to "Included", and Enterprise stays
* blocked until a Server licence exists. The stories below walk that ladder,
* since the button logic is the substance of the component.
*
* The embedded licence-key panel and checkout modal both call useLicense(),
* which throws outside a provider. LicenseProvider is mounted with a non-admin
* config so it settles without issuing a licence request of its own — the tier
* on screen comes from the prop, not from the context.
*/
function withLicenseContext(Story: () => React.JSX.Element) {
return (
<AppConfigProvider
autoFetch={false}
bootstrapMode="non-blocking"
initialConfig={{ enableLogin: true }}
>
<LicenseProvider>
<Story />
</LicenseProvider>
</AppConfigProvider>
);
}
const freeLicence: LicenseInfo = {
licenseType: "NORMAL",
enabled: false,
maxUsers: 5,
hasKey: false,
};
const serverLicence: LicenseInfo = {
licenseType: "SERVER",
enabled: true,
maxUsers: 0,
hasKey: true,
licenseKey: "STORY-LICENCE-2222-2222-2222",
};
const enterpriseLicence: LicenseInfo = {
licenseType: "ENTERPRISE",
enabled: true,
maxUsers: 250,
hasKey: true,
licenseKey: "file:/opt/stirling/licence.cert",
};
const meta = {
title: "Config/Plan/StaticPlanSection",
component: StaticPlanSection,
parameters: { layout: "padded" },
decorators: [withLicenseContext],
} satisfies Meta<typeof StaticPlanSection>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Free tier: Free is the current plan, Server offers an upgrade, Enterprise is gated behind it. */
export const FreeTier: Story = {
args: { currentLicenseInfo: freeLicence },
};
/** Server licence: Free collapses to "Included", Server offers billing management, Enterprise asks for contact. */
export const ServerTier: Story = {
args: { currentLicenseInfo: serverLicence },
};
/** Enterprise licence: both lower tiers collapse to "Included" and only Enterprise is manageable. */
export const EnterpriseTier: Story = {
args: { currentLicenseInfo: enterpriseLicence },
};
/**
* Licence details not supplied at all — the tier is indeterminate, so Free is
* marked current but the paid cards resolve to no action rather than an
* upgrade path. Worth keeping visible: it is what an admin briefly sees while
* licence info is still resolving.
*/
export const LicenceUnknown: Story = {};
@@ -17,7 +17,7 @@
.text-divider .text-divider__label {
color: rgb(
var(--text-divider-label-rgb, var(--gray-400)) /
var(--text-divider-label-rgb, var(--gray-600)) /
var(--text-divider-opacity, 1)
);
font-size: 0.75rem; /* 12px */
@@ -80,7 +80,11 @@ export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({
)}
<div style={{ marginTop: "auto", paddingTop: "1rem" }}>
<Button variant="secondary" fullWidth>
<Button
variant="secondary"
fullWidth
onClick={() => onSelectPlan("monthly")}
>
{t("payment.planStage.selectMonthly", "Select Monthly")}
</Button>
</div>
@@ -193,7 +197,7 @@ export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({
)}
<div style={{ marginTop: "auto", paddingTop: "1rem" }}>
<Button fullWidth>
<Button fullWidth onClick={() => onSelectPlan("yearly")}>
{t("payment.planStage.selectYearly", "Select Yearly")}
</Button>
</div>
@@ -1,4 +1,4 @@
import { CSSProperties } from "react";
import { CSSProperties, KeyboardEvent } from "react";
/**
* Shared styling utilities for plan cards
@@ -46,3 +46,22 @@ export function getClickablePaperStyle(
...getCardBorderStyle(isHighlighted),
};
}
/**
* Semantics for a card that is itself the control. `getClickablePaperStyle`
* only makes a card *look* clickable; without these a Paper with an onClick is
* a div, so the choice cannot be reached or made by keyboard.
*/
export function getClickableCardProps(onActivate: () => void) {
return {
role: "button",
tabIndex: 0,
onClick: onActivate,
onKeyDown: (e: KeyboardEvent<HTMLElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onActivate();
}
},
};
}
@@ -406,7 +406,7 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
{certValidation.status === "valid" && (
<Text
size="sm"
c="green"
c="var(--color-green-dark)"
data-testid="cert-validation-feedback"
>
{t(
@@ -432,7 +432,7 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
{certValidation.status === "error" && (
<Text
size="sm"
c="red"
c="var(--color-red-dark)"
data-testid="cert-validation-feedback"
>
{t(
@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import AuthCallback from "@app/routes/AuthCallback";
import "@app/auth/ui/auth.css";
/**
* The landing screen an OAuth or SSO provider redirects back to. It has a
* single visual state — a branded "completing authentication" card with a
* spinner — because every outcome of the token exchange resolves by
* navigating elsewhere: on to the requested page, or back to the login screen
* carrying an error. Only the waiting moment is ever rendered here.
*
* With no token in the URL fragment the route redirects immediately, so the
* story shows the card as the user sees it during a real exchange.
*/
const meta: Meta<typeof AuthCallback> = {
title: "Auth/Auth Callback",
component: AuthCallback,
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj<typeof AuthCallback>;
export const Default: Story = {};
@@ -469,7 +469,7 @@ export default function Login() {
border:
"1px solid color-mix(in srgb, var(--c-success) 30%, transparent)",
borderRadius: "0.5rem",
color: "var(--c-success)",
color: "var(--color-green-dark)",
}}
>
<p
@@ -135,7 +135,7 @@ export default function Signup() {
variant="tertiary"
onClick={() => navigate("/login")}
className="auth-link-black"
style={{ color: "var(--c-primary)" }}
style={{ color: "var(--c-accent-text)" }}
>
{t("login.logIn", "Log In")}
</Button>
@@ -0,0 +1,50 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import AuthLayout from "@app/routes/authShared/AuthLayout";
import LoginHeader from "@app/routes/login/LoginHeader";
import "@app/auth/ui/auth.css";
/**
* The editor's wrapper around the shared auth card: the same centred shell the
* portal uses, with the editor's legal and cookie footer pinned to the bottom
* of the viewport. It takes no props beyond its children, so what varies
* between stories is the height of the content sitting inside the card and how
* that content sits against the fixed footer.
*/
const meta: Meta<typeof AuthLayout> = {
title: "Auth/Auth Layout",
component: AuthLayout,
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj<typeof AuthLayout>;
/** A short screen — the card floats well clear of the footer. */
export const Default: Story = {
args: {
children: (
<LoginHeader
title="Sign in"
subtitle="Enter your credentials to continue."
/>
),
},
};
/**
* A tall screen: the card grows towards the fixed footer, which is the case
* that shows whether the two collide on short viewports.
*/
export const TallContent: Story = {
args: {
children: (
<>
<LoginHeader title="Create your account" />
{Array.from({ length: 8 }, (_, i) => (
<p key={i} style={{ color: "var(--c-text)" }}>
Placeholder form row {i + 1}
</p>
))}
</>
),
},
};
@@ -0,0 +1,71 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AuthContext } from "@app/auth/context";
import type { AuthContextValue, AuthUser } from "@app/auth/types";
import LoggedInState from "@app/routes/login/LoggedInState";
/**
* The interstitial the login route shows when someone who already has a
* session lands on it: a confirmation card naming the signed-in address,
* which redirects to the workspace a couple of seconds later.
*
* It reads only the user off the auth context, so the stories supply a slice
* of that context rather than mounting a real provider. The redirect timer
* still runs; in Storybook there is nowhere to navigate to, so the card stays
* on screen.
*/
/** Minimal auth context slice — only `user` is read by this screen. */
function authValue(user: AuthUser | null): AuthContextValue {
return {
session: null,
user,
displayName: user?.username ?? null,
isAnonymous: false,
isAdmin: false,
portalAccess: false,
role: user?.role ?? null,
loading: false,
error: null,
signOut: async () => {},
refreshSession: async () => {},
};
}
const signedInUser: AuthUser = {
id: "1",
email: "ada@example.com",
username: "ada",
role: "USER",
};
const meta: Meta<typeof LoggedInState> = {
title: "Auth/Logged In State",
component: LoggedInState,
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj<typeof LoggedInState>;
export const Default: Story = {
decorators: [
(Story) => (
<AuthContext.Provider value={authValue(signedInUser)}>
<Story />
</AuthContext.Provider>
),
],
};
/**
* Sessions without an email address — anonymous or SSO users the backend
* returns no address for — leave the label with nothing after the colon.
*/
export const WithoutEmail: Story = {
decorators: [
(Story) => (
<AuthContext.Provider value={authValue({ ...signedInUser, email: "" })}>
<Story />
</AuthContext.Provider>
),
],
};
@@ -55,7 +55,7 @@ export default function LoggedInState() {
style={{
fontSize: "24px",
fontWeight: "bold",
color: "var(--c-success)",
color: "var(--color-green-dark)",
marginBottom: "8px",
}}
>
@@ -0,0 +1,54 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import LoginHeader from "@app/routes/login/LoginHeader";
import "@app/auth/ui/auth.css";
/**
* The wordmark-and-title block that opens every auth screen. The wordmark
* variant is resolved from the user's logo preference and the active colour
* scheme, so it needs no props; what callers vary is the copy (title, optional
* subtitle) and whether the block is centred.
*/
const meta: Meta<typeof LoginHeader> = {
title: "Auth/Login Header",
component: LoginHeader,
parameters: { layout: "centered" },
args: {
title: "Sign in to Stirling PDF",
},
decorators: [
(Story) => (
<div style={{ width: 360 }}>
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof LoginHeader>;
export const Default: Story = {};
/** Screens that need a line of guidance beneath the title pass a subtitle. */
export const WithSubtitle: Story = {
args: {
subtitle: "Enter your credentials to continue.",
},
};
/**
* Centred layout, used where the header stands alone rather than above a
* left-aligned form — status and callback screens, for instance.
*/
export const Centered: Story = {
args: {
subtitle: "Enter your credentials to continue.",
centerOnly: true,
},
};
/** Long titles wrap within the card instead of widening it. */
export const LongTitle: Story = {
args: {
title: "Sign in to continue to your organisation's document workspace",
},
};
@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import NavigationLink from "@app/routes/login/NavigationLink";
import "@app/auth/ui/auth.css";
/**
* The quiet text link the auth screens use to move between login, signup and
* password reset. Its only variation is the disabled state, which the screens
* apply while a submission is in flight so the user cannot navigate away
* mid-request.
*/
const meta: Meta<typeof NavigationLink> = {
title: "Auth/Navigation Link",
component: NavigationLink,
parameters: { layout: "centered" },
args: {
text: "Back to login",
onClick: () => {},
},
};
export default meta;
type Story = StoryObj<typeof NavigationLink>;
export const Default: Story = {};
/** Held inert while the surrounding form is submitting. */
export const Disabled: Story = {
args: { isDisabled: true },
};
@@ -0,0 +1,106 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import SignupForm from "@app/routes/signup/SignupForm";
import "@app/auth/ui/auth.css";
/**
* The account-creation form body. Three things change what it shows: the
* password field (the confirmation input stays collapsed until the password
* reaches four characters), and the `showName` / `showTerms` flags, which the
* cloud signup turns on and the self-hosted one leaves off.
*
* The form is fully controlled, so the stories pass fixed values and no-op
* setters — each story pins one state rather than exposing a live form.
*/
const meta: Meta<typeof SignupForm> = {
title: "Auth/Signup Form",
component: SignupForm,
parameters: { layout: "centered" },
args: {
email: "",
password: "",
confirmPassword: "",
setEmail: () => {},
setPassword: () => {},
setConfirmPassword: () => {},
onSubmit: () => {},
isSubmitting: false,
},
decorators: [
(Story) => (
<div style={{ width: 360 }}>
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof SignupForm>;
/** Untouched form: confirmation collapsed, submit disabled. */
export const Default: Story = {};
/**
* Past the four-character threshold the confirmation field animates open, so
* the form grows a row without the layout jumping.
*/
export const ConfirmationRevealed: Story = {
args: {
email: "ada@example.com",
password: "hunter2",
confirmPassword: "hunter2",
},
};
/** The cloud signup additionally collects a name and terms acceptance. */
export const WithNameAndTerms: Story = {
args: {
name: "Ada Lovelace",
email: "ada@example.com",
password: "hunter2",
confirmPassword: "hunter2",
setName: () => {},
setAgree: () => {},
showName: true,
showTerms: true,
},
};
/** Terms presented but not yet accepted, which holds the submit disabled. */
export const TermsNotAccepted: Story = {
args: {
email: "ada@example.com",
password: "hunter2",
confirmPassword: "hunter2",
agree: false,
setAgree: () => {},
showTerms: true,
},
};
/** Server-side validation returned per-field messages. */
export const WithFieldErrors: Story = {
args: {
name: "Ada Lovelace",
email: "not-an-email",
password: "short",
confirmPassword: "shore",
setName: () => {},
showName: true,
fieldErrors: {
name: "Please enter your name.",
email: "Enter a valid email address.",
password: "Password must be at least 8 characters.",
confirmPassword: "Passwords do not match.",
},
},
};
/** In flight: the submit button takes over as the progress indicator. */
export const Submitting: Story = {
args: {
email: "ada@example.com",
password: "hunter2",
confirmPassword: "hunter2",
isSubmitting: true,
},
};
@@ -99,8 +99,12 @@ export default function SignupForm({
/>
</div>
{/* Collapsed to zero height rather than unmounted so it can animate open,
so `inert` is what keeps the field out of the tab order while
aria-hidden keeps it off the accessibility tree. */}
<div
aria-hidden={!showConfirm}
inert={!showConfirm}
className="auth-confirm"
style={{
maxHeight: showConfirm ? 96 : 0,
@@ -176,6 +176,7 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
step={0.1}
onChange={setZoom}
disabled={processing}
thumbLabel={t("config.account.profilePicture.cropper.zoom", "Zoom")}
/>
</Stack>
@@ -0,0 +1,61 @@
/**
* Magic-link sign-in on the SaaS login screen. Collapsed it is a single link;
* expanded it becomes an email field and a send button, so the two states are
* really two different components sharing a prop.
*/
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import MagicLinkForm from "@app/routes/login/MagicLinkForm";
const meta: Meta<typeof MagicLinkForm> = {
title: "SaaS/Login/MagicLinkForm",
component: MagicLinkForm,
parameters: { layout: "padded" },
args: {
showMagicLink: false,
magicLinkEmail: "",
setMagicLinkEmail: () => {},
setShowMagicLink: () => {},
onSubmit: () => {},
isSubmitting: false,
},
};
export default meta;
type Story = StoryObj<typeof MagicLinkForm>;
/** Collapsed — just the link that opens the form. */
export const Collapsed: Story = {};
export const Expanded: Story = { args: { showMagicLink: true } };
export const WithEmail: Story = {
args: { showMagicLink: true, magicLinkEmail: "a.whitfield@example.com" },
};
/** Sending, which disables the field and the button together. */
export const Submitting: Story = {
args: {
showMagicLink: true,
magicLinkEmail: "a.whitfield@example.com",
isSubmitting: true,
},
};
/** Typing for real, so the field and its clear/submit states track input. */
export const Interactive: Story = {
render: function Interactive() {
const [open, setOpen] = useState(true);
const [email, setEmail] = useState("");
return (
<MagicLinkForm
showMagicLink={open}
magicLinkEmail={email}
setMagicLinkEmail={setEmail}
setShowMagicLink={setOpen}
onSubmit={() => {}}
isSubmitting={false}
/>
);
},
};