diff --git a/.changeset/backend-relative-support-link.md b/.changeset/backend-relative-support-link.md new file mode 100644 index 00000000000..2958b5eacb1 --- /dev/null +++ b/.changeset/backend-relative-support-link.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': patch +--- + +Use a root-relative link (`/contact/support`) for the `passwordHasher` "contact support" reference so the generated API reference renders it as an internal same-tab link instead of an external one. diff --git a/.changeset/bright-taxis-sing.md b/.changeset/bright-taxis-sing.md deleted file mode 100644 index 32eeaea7105..00000000000 --- a/.changeset/bright-taxis-sing.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@clerk/expo': minor ---- - -Add an experimental `useSSO()` hook at `@clerk/expo/experimental` that uses future auth resources and activates completed SSO sessions automatically. - -```tsx -import { useSSO } from '@clerk/expo/experimental'; - -const { startSSOFlow } = useSSO(); - -await startSSOFlow({ - strategy: 'oauth_google', -}); -``` diff --git a/.changeset/decodejwt-malformed-token.md b/.changeset/decodejwt-malformed-token.md deleted file mode 100644 index 513ffedfd80..00000000000 --- a/.changeset/decodejwt-malformed-token.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@clerk/backend': patch ---- - -Return a `TokenVerificationError` from `decodeJwt` and `verifyToken` for tokens whose header, payload, or signature cannot be decoded. diff --git a/.changeset/bright-glasses-hammer.md b/.changeset/headless-return-focus.md similarity index 100% rename from .changeset/bright-glasses-hammer.md rename to .changeset/headless-return-focus.md diff --git a/.changeset/ignore-claude-worktrees.md b/.changeset/migrate-mosaic-input-to-stylex.md similarity index 100% rename from .changeset/ignore-claude-worktrees.md rename to .changeset/migrate-mosaic-input-to-stylex.md diff --git a/.changeset/mosaic-button-variants.md b/.changeset/mosaic-button-variants.md deleted file mode 100644 index a845151cc84..00000000000 --- a/.changeset/mosaic-button-variants.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/mosaic-item.md b/.changeset/mosaic-item.md deleted file mode 100644 index a845151cc84..00000000000 --- a/.changeset/mosaic-item.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/tame-bats-tell.md b/.changeset/tame-bats-tell.md deleted file mode 100644 index 0cedcf55619..00000000000 --- a/.changeset/tame-bats-tell.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@clerk/backend': minor ---- - -Update fields for BillingSubscription and BillingSubscriptionItem diff --git a/.changeset/tame-donuts-shake.md b/.changeset/tame-donuts-shake.md deleted file mode 100644 index a10461d753e..00000000000 --- a/.changeset/tame-donuts-shake.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@clerk/astro': patch -'@clerk/vue': patch ---- - -Fix the `appearance` option rejecting valid properties such as `theme`, `variables`, and `elements` with a "does not exist in type `Appearance`" TypeScript error. This also affects `@clerk/nuxt`, which derives its module options from `@clerk/vue`. diff --git a/.claude/skills/mosaic/SKILL.md b/.claude/skills/mosaic/SKILL.md index fd0bb8db9dd..80165faf255 100644 --- a/.claude/skills/mosaic/SKILL.md +++ b/.claude/skills/mosaic/SKILL.md @@ -46,6 +46,7 @@ this skill is the _how-to_. | -------------------------------------------------------------------- | ------------------------------------------------------ | | Building on / authoring a headless primitive (`@clerk/headless`) | `references/headless.md` | | Styling a component with StyleX (tokens, `stylex.create`, CSS build) | `references/stylex.md` | +| Building an enter/exit transition, or any motion that reads as wrong | `references/motion.md` | | Styling a component the legacy way (slot recipes, `useRecipe`) | `references/styling.md` | | Authoring or debugging a state machine, or wiring one to React | `references/machines.md` → in-tree `machine/README.md` | | Writing the controller (Clerk adapter, permissions, revalidate) | `references/controllers.md` | diff --git a/.claude/skills/mosaic/references/motion.md b/.claude/skills/mosaic/references/motion.md new file mode 100644 index 00000000000..c2c5d5e8eba --- /dev/null +++ b/.claude/skills/mosaic/references/motion.md @@ -0,0 +1,441 @@ +# Motion: entrances and exits + +Token semantics live in `packages/ui/src/mosaic/tokens.stylex.ts`, above +`durationDefaults` / `easingDefaults` — read those comments first. This file is the +how-to layer: the rules that decide a transition's shape, and how to check one +rather than eyeball it. + +| Token | Value | For | +| ----------------------- | --------------------------------------- | --------------------------- | +| `--cl-duration-instant` | `0s` | hover and press arrival | +| `--cl-duration-fast` | `0.1s` | exits | +| `--cl-duration-base` | `0.15s` | entrances, hover exit | +| `--cl-duration-slow` | `0.25s` | larger surfaces | +| `--cl-duration-slower` | `0.35s` | — | +| `--cl-ease-default` | `cubic-bezier(0.175, 0.885, 0.32, 1.1)` | things ARRIVING (Swift Out) | +| `--cl-ease-exit` | `cubic-bezier(0.55, 0.085, 0.68, 0.53)` | things LEAVING (In Quad) | + +Named curves come from [easing.dev](https://www.easing.dev) (Lochie Axon's Easing +Graphs). Take one from there rather than inventing a bezier, so the catalog stays +the shared vocabulary. + +## A curve has a direction — don't run the entrance curve backwards + +The single most common motion bug in this codebase. `--cl-ease-default` is +front-loaded and carries its endpoint ~2% past target before settling: a change +departs fast and lands soft. That is exactly right for an entrance and wrong in +three separate ways for an exit. + +Measured on the Mosaic popover when both directions shared `--cl-ease-default` at +`--cl-duration-base` (scale `1 → 0.94`, 60fps): + +``` +ms scale opacity Δscale/frame +0 1.0000 1.000 — +33.2 0.9718 0.889 -0.0282 ┐ 90% of the shrink, 3 frames +50.7 0.9552 0.778 -0.0166 │ +66.7 0.9465 0.667 -0.0087 ┘ +83.4 0.9419 0.555 -0.0046 +100.2 0.9397 0.445 -0.0022 +117.4 0.9388 0.333 -0.0009 ← dipped BELOW the 0.94 target +133.4 0.9387 0.222 -0.0001 +150.5 0.9392 0.111 +0.0005 ← and came back up +167.4 0.9400 0.000 +0.0008 +186.0 REMOVED +``` + +1. **The velocity dies.** 90% of the travel happens in three frames, then six + frames (~two-thirds of the run) move a combined 0.008. Users report this as + "choppy" or "dropping frames" — and a performance profile will correctly show + zero dropped frames. Every frame renders; they are just nearly identical. +2. **The overshoot inverts.** Progress peaks at 1.023, so the value travels _past_ + the target and returns. Arriving, that reads as settling. Leaving, it is a + wobble with nothing to cover it. +3. **Shape and fade decouple.** Transform finished by ~50ms while a linear opacity + ran the full 150ms, leaving a motionless fading rectangle. + +Use `--cl-ease-exit` on `:where([data-ending-style])`. Same properties, opposite +direction, so both duration and timing function branch: + +```ts +transitionDuration: { + default: `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-base']}`, + ':where([data-ending-style])': durationVars['--cl-duration-fast'], +}, +transitionTimingFunction: { + default: `linear, ${easingVars['--cl-ease-default']}`, + ':where([data-ending-style])': `linear, ${easingVars['--cl-ease-exit']}`, +}, +``` + +Both lists are **positional against `transitionProperty`** (`opacity, transform`). + +## Asymmetry, in three places + +**Duration.** Exits are shorter than entrances — an arrival earns a moment to +settle, a dismissal is an acknowledgement. `--cl-duration-base` in, +`--cl-duration-fast` out, a 1.5:1 ratio. Stay on the duration scale; a ratio that +needs an off-scale value is not worth a new token for one component. + +**Opacity leads on the way in.** Give opacity the shorter duration so it lands +opaque as the transform reaches full size, and the settle plays at full strength. +A popup still fading while it moves reads as washed out. On the popover this moved +opacity-at-overshoot-peak from 0.78 to 1.00. + +**Exits land together.** Do _not_ split them going out. Matching durations are +what stop an exit reading as a lingering ghost. + +## Color and state changes (hover, press) + +A state change that only recolors — background, border, text, opacity — takes +**`linear`, always**. Nothing moves, so there is nothing for an ease to sell: +color interpolation is already perceptually non-uniform, an ease on top just drags +the midpoint, and `--cl-ease-default`'s overshoot would extrapolate past the target +color. Reserve the curves for geometry. + +**The arrival is always `0s`. Only the exit is a judgement call**, and what decides +it is whether a pointer traverses the element on its way somewhere else: + +| the highlight sits on… | in | out | +| ------------------------------------------------------------------ | ---- | ------- | +| an isolated control — button, card, standalone target | `0s` | `0.15s` | +| a traversed collection — menu item, list/table row, palette result | `0s` | `0s` | + +**Why the arrival never varies:** it follows from **who caused the change**. You +moved the pointer, so the highlight is confirmation of your own act, and any +duration on it is latency between doing and being told. It is the same reason a +press lands instantly. + +**Why the exit does vary:** leaving carries no information, so on an isolated +control 0.15s costs nothing and takes the hard edge off. Across a collection that +same fade becomes a comet trail — a wake of dimming rows strung out behind a fast +sweep, which is the arrival's ambiguity re-introduced from the other side. Rows +leave instantly for the same reason they arrive instantly. + +Note the axis is traversal, not element type. A button in a toolbar that the +pointer sweeps across follows the collection row, not the button row. + +**The mechanic:** the duration an element carries in a given state governs the +transition _into_ that state. So the asymmetry falls out of one declaration per +state — no doubled values, no JS: + +```ts +transitionProperty: 'background-color, border-color, color, opacity', +transitionTimingFunction: 'linear', +transitionDuration: { + default: durationVars['--cl-duration-base'], // 0.15s — leaving hover or press + ':enabled:active': durationVars['--cl-duration-instant'], + ':enabled:hover': durationVars['--cl-duration-instant'], +}, +``` + +The bare `:hover` is safe here only because hover and press carry the same value: +both match during a press, so which one wins does not matter. Give them different +durations and the hover branch needs `:not(:active)`, since the two are equal +specificity and the winner comes down to how StyleX orders them. + +Keep the duration itself outside `@media (hover: hover)` either way. A duration is +inert on its own — it times an appearance, and if that appearance is media-guarded +then nothing transitions on a touch device regardless. Wrapping it buys nothing and +costs the `:not(:active)` guard, because the at-rule doubles the class and outranks +`:active` (see `stylex.md`). + +Worked examples: `button.styles.ts` for the isolated control, `item.styles.ts` for +the traversed collection — which declares no `transition` at all, since both of its +durations are `0s`. + +### Children need the timing handed to them + +**Transitions do not inherit.** A child that recolors along with its container — a +`Button`'s `Icon`, anything reading a `--_cl-*` color the parent branches per state — +animates on _its own_ `transition-duration`, not the parent's. Give the parent one +timing and the child another and the child visibly trails it; at `0s` in, a child +still on `0.1s` reads as the icon lagging the button by a tenth of a second. + +Hand the duration down the same way the color goes down, so one declaration governs +both and they cannot drift apart: + +```ts +// container: alongside `--_cl-icon-color`, on the same conditions +'--_cl-icon-duration': { default: base, ':enabled:active': instant, ':enabled:hover': instant }, + +// child: read it, defaulting to instant +transitionDuration: `var(--_cl-icon-duration, ${durationVars['--cl-duration-instant']})`, +``` + +**The child's default is `instant`, not a middling fade.** The arrival never varies, +so any non-zero default is wrong for every container at once; the exit is the only +contextual half, and a container that wants one opts in through the var. That also +makes the standalone default correct for a traversed collection with no work — a row +gets `0s` both ways by doing nothing. The `transitionProperty` and +`transitionTimingFunction` still have to be declared even though the default duration +makes them inert, since they are what the var has to animate once a container sets it. + +### A keyword cannot tween — reach for its color-valued sibling + +Some properties that read as visual are discrete keywords, so they flip between +frames no matter what duration you set. `text-decoration-line` is the one that bites: +toggling `none` → `underline` on hover gives an instant arrival, which is right by +accident, and an instant exit, which is not. + +Draw the thing permanently and animate the color instead: + +```ts +textDecorationColor: { default: 'transparent', ':enabled:hover': 'currentColor' }, +textDecorationLine: 'underline', +// and add `text-decoration-color` to the shared `transitionProperty` +``` + +It costs nothing — a transparent decoration paints nothing and never participates in +layout — and it keeps the change a **color**, so the rule above applies unaltered +rather than needing a curve. Verified in Chrome and Safari; where a browser declines +to interpolate it, the failure is graceful, since it snaps exactly as it does today. + +Prefer this to the other animatable decoration properties. +`text-decoration-thickness` from `0` renders unreliably at sub-pixel values, and +`text-underline-offset` makes the underline _slide_, which is movement — that breaks +the "nothing moves" premise the linear curve rests on. + +### Three things that look like this and are not + +- **An element arriving** — tooltip, popover, dropdown, anything that mounts on + hover. That is an entrance, not a state change; it gets a real duration and a + curve. The rest of this file applies instead. +- **System-driven changes** — going disabled, a loading dim, a validation color. + Instant only reads as confirmation when the user just acted; when the system + acted it reads as a flash. Keep those symmetric and on the duration scale. +- **Anything moving alongside the color** — a sliding thumb, a drawing check. The + color has to take the movement's duration or the two desync. "Nothing moves" is + the premise that licenses both the linear curve and the instant arrival. + +### Why the arrival must be `0s`, seen most clearly in dense collections + +Menu items, list and table rows, command-palette results — anywhere a pointer +crosses many targets on its way somewhere — is where a non-zero fade-in stops being +a question of taste. The highlight's job there is to answer _which row am I on_, and +a transition makes it unable to. Mosaic `Item` rows are 52px, so at ordinary pointer +speeds a 100ms fade leaves several rows partly lit at once, the brightest of them +trailing behind the cursor: + +| pointer speed | ms/row | rows mid-transition | +| ------------- | ------ | ------------------- | +| 300 px/s | 173 | 0.6 | +| 600 px/s | 87 | 1.2 | +| 900 px/s | 58 | 1.7 | +| 1200 px/s | 43 | 2.3 | +| 2000 px/s | 26 | 3.8 | + +Users report this as lag, and they are describing it accurately — the highlight is +behind the pointer. It is a legibility failure, not a matter of polish, and no +duration short enough to fix it is long enough to be worth having. Instant tracking +is also what platform menus have always done. + +An isolated button never fails this visibly, but the arrival is the same rule either +way; there is no button-versus-row split on the way in. + +The same arithmetic sizes the comet trail: a 0.15s exit is a longer fade than the +0.1s modelled above, so it strings out proportionally more rows behind the pointer. +That is why the exit collapses to `0s` here even though it stays at 0.15s on a +button. + +`Item` declares no `transition` at all, which is `0s` in both directions and is +correct on both counts — instant arrival, and no exit to trail the pointer. Leave it +that way; do not "improve" it by porting a button's 0.15s exit onto rows. + +## Small deltas constrain the curve (the dead-frame test) + +A transition's usable curves depend on how much it actually moves. A scale delta +of 0.06 over 100ms is six frames; a sharply back-loaded curve puts half of them +below the threshold of visible change and re-creates the stall-then-lurch above. +Scored for that exit — _dead_ = frames moving <0.003, _ramp_ = largest frame step +÷ smallest: + +| curve | dead | ramp | +| --------------------------------------- | ---- | ----- | +| linear | 0 | 1.0× | +| In `(0.42, 0, 1, 1)` — the CSS keyword | 1 | 5.9× | +| **In Quad `(0.55, 0.085, 0.68, 0.53)`** | 1 | 6.6× | +| In Cubic `(0.55, 0.055, 0.675, 0.19)` | 2 | 17.9× | +| In Quart `(0.895, 0.03, 0.685, 0.22)` | 3 | >50× | +| In Circ `(0.6, 0.04, 0.98, 0.335)` | 2 | 32.0× | + +Hence In Quad, the gentlest of the in-family. Counterintuitively, a **longer** +duration makes this worse, not better: the delta is fixed, so more frames means +smaller steps and more of them below threshold (In Cubic goes from 2 dead frames +at 100ms to 4 at 150ms). If a curve stalls, shorten the duration or increase the +delta — don't stretch it. + +## `transform-origin`: anchor it to the trigger + +For anything anchored to a trigger, scale about the **trigger**, not the element's +own center, so it reads as emerging from what opened it. `cssVars` in +`packages/headless/src/utils/css-vars.ts` emits two origins on the floating +element; custom properties inherit, so a popup one level down reads them directly. + +| var | meaning | +| ----------------------- | -------------------------------------------------------------- | +| `--cl-transform-origin` | nearest **edge**, cross axis tracking the anchor (arrow-aware) | +| `--cl-anchor-origin` | the anchor's bounding-box **center**, both axes | + +```ts +transformOrigin: 'var(--cl-anchor-origin, center)', +``` + +**Do not redefine `--cl-transform-origin`** — Menu branches consume it with the +edge semantics. Add a var instead. + +Why a var and not a keyword: keyword origins (`top left`) anchor to the element's +own box, so they drift off the trigger the moment `shift()` or `flip()` moves it. +These are recomputed per position update and stay correct. + +**Timing is safe.** On a cold mount the var is unset for the mutation frame — but +the element is `opacity: 0` with `transition: none` then. Both the position and +the var settle by rAF 1; the transition arms at rAF 2. Origin is always correct +before anything animates. + +**Geometry.** Travel = `(1 − startScale) × distance(origin, element center)`, so +origin and start scale must be chosen together — at `scale(0.98)` a trigger-center +origin moves ~2px and is invisible; the popover uses `0.94` (~6px). Note the +distance, not the trigger's size, is what matters: on a centered placement the +trigger's center sits directly above/below the popup's, so trigger width cancels +out entirely no matter how wide it is. The one bad case is a **wide trigger with a +much narrower `-start`/`-end` popup**; matching the popup's width to the trigger +puts the origin back on its center. + +### Hold the travel constant, not the scale + +**~6px of travel is the target.** It reads as emerging from the trigger without +becoming a visible arc. The popover hits it at `0.94` because its origin sits +~103px from the popup's center. + +Scale is the dial, not the constant. Because travel is `(1 − s) × d`, a taller +popup pushes its own center further from the trigger, `d` grows, and the same +`0.94` overshoots the target — a large surface swinging 15px reads as +overexaggerated. Solve for the scale instead: + +``` +s = 1 − (6 / d) d = distance from --cl-anchor-origin to the element's center +``` + +| `d` | scale | +| ----- | ------ | +| 60px | `0.90` | +| 100px | `0.94` | +| 150px | `0.96` | +| 200px | `0.97` | +| 300px | `0.98` | + +Two effects push the same way, which is convenient: the absolute size change is +`(1 − s) ×` the element's own dimensions, so a big surface at a fixed scale is +already shrinking by more px than a small one. Scaling toward 1 as things grow +fixes both at once. + +Three limits on the rule: + +- **Floor the scale around `0.90`.** For an element whose center is very close to + its origin, the formula demands an aggressive scale to manufacture 6px — at + `d = 30px` it asks for `0.80`, which reads as a zoom, not an emergence. Accept + less travel rather than a scale that draws attention to itself. +- **Travel is measured at the element's center, by convention.** Scaling about a + point moves every other point in proportion to _its own_ distance from that + origin, so the far edge always travels further than the center and the near edge + barely moves. Keep the center as the yardstick so numbers stay comparable. +- **Content-driven height makes this an estimate.** A popover's width comes from + its `size` variant but its height comes from whatever is inside it, so `d` is + only known at runtime. Pick the scale for the typical height of that surface and + accept the spread; a component whose height genuinely varies by multiples wants + a scale per size variant, not one constant. + +## Reduced motion + +Gate the **moving property**, not the duration — the signal is about vestibular +safety, so the fade should survive: + +```ts +transitionProperty: { + default: 'opacity, transform', + '@media (prefers-reduced-motion: reduce)': 'opacity', +}, +``` + +With a positional duration list, the collapsed single-property list takes the +first value — check that it's the one you want for opacity. + +### That alone is not enough — also drop the value + +Removing `transform` from `transitionProperty` stops it _animating_; it does not +stop it _changing_. The `data-starting-style` / `data-ending-style` branch still +applies `scale(0.94)`, now instantly. Entering that is invisible (it happens at +`opacity: 0`), so this reads as correct in review and in a diff — but **exiting**, +the element snaps to 94% at full opacity and then fades. The reported symptom is +"reduced motion still animates, but only on the way out." + +Restate the state branch **inside** the media query. Note the nesting direction: +StyleX only accepts at-rule outer / pseudo inner, so `:where(...)` containing an +`@media` key fails `@stylexjs/valid-styles` ("Invalid Pseudo class or At Rule used +for conditional style value"). + +```ts +transform: { + default: 'scale(1)', + ':where([data-starting-style], [data-ending-style])': 'scale(0.94)', + '@media (prefers-reduced-motion: reduce)': { + default: 'scale(1)', + ':where([data-starting-style], [data-ending-style])': 'scale(1)', + }, +}, +``` + +A bare sibling `'@media (prefers-reduced-motion: reduce)': 'scale(1)'` also works +today, but it compiles to `(0,2,0)` — the same as the branch it needs to beat — so +the tiebreak is source order, which `@stylexjs/sort-keys` reorders on autofix. +Repeating the selector inside the at-rule earns a third class and wins outright: + +```css +.a.a:where([data-starting-style], [data-ending-style]) { + transform: scale(0.94); +} /* 0,2,0 */ +@media (prefers-reduced-motion: reduce) { + .b.b.b:where([data-starting-style], [data-ending-style]) { + transform: scale(1); + } /* 0,3,0 */ +} +``` + +Verify all four combinations — enter and exit, reduced and normal. Under reduced +motion both directions should hold the scale flat at `1.0000` for every frame. + +## How to check a transition + +Reading CSS will not tell you a transition stalls. Two cheap techniques: + +**Score the curve offline** before writing it — sample `cubic-bezier` at 16.7ms +intervals across the duration, convert to the property's real values, and count +frames whose step is below ~0.003 of the total delta. + +**Record the real thing** with a rAF sampler (see `references/stylex.md` for the +`data-*` attributes that drive enter/exit): + +```js +const el = document.querySelector('.cl-popover-popup'); +const t0 = performance.now(), + rows = []; +(function tick() { + const e = document.querySelector('.cl-popover-popup'); + if (!e) return console.table(rows); + const cs = getComputedStyle(e); + rows.push({ + ms: +(performance.now() - t0).toFixed(1), + scale: +new DOMMatrix(cs.transform).a.toFixed(4), + opacity: +(+cs.opacity).toFixed(3), + }); + requestAnimationFrame(tick); +})(); +``` + +Gotchas when driving this from a browser-automation CLI: a round-trip outlasts a +150ms transition, so slow it with a `transition-duration` override or pause via +`getAnimations()` and set `currentTime`; pausing at `currentTime = 0` also freezes +opacity at 0, so hold the opacity animation at its end if you want a visible +frame; and light dismiss listens on pointerdown, so a synthetic `.click()` will +not close a popover — send a real key instead. diff --git a/.claude/skills/mosaic/references/stylex.md b/.claude/skills/mosaic/references/stylex.md index 8ab5bac8569..38f86642269 100644 --- a/.claude/skills/mosaic/references/stylex.md +++ b/.claude/skills/mosaic/references/stylex.md @@ -295,6 +295,28 @@ device, while touch devices look correct. then grep `dist-mosaic/styles.css` for the two selectors and compare their specificity. +- **A button that opens something takes the pressed fill while open**, so a + disclosure trigger stays visibly engaged for as long as its surface is. Disclosure + primitives already set `data-open` on the trigger (`popover-trigger.tsx` and + friends), so this is styling-only — no headless change. It needs the _same_ + exclusion as `:active`, or hovering an open trigger lifts it back to the lighter + hover step: + + ```ts + backgroundColor: { + default: 'transparent', + ':enabled:active': neutralStep1, + ':enabled[data-open]': neutralStep1, + '@media (hover: hover)': { + default: null, + ':enabled:hover:not(:active):not([data-open])': neutralStep0, + }, + }, + ``` + + Worked example: `button.styles.ts`, applied across every filled/outline/ghost cell + (`link` opts out — it reads as text, not a control). + Worked example: `packages/ui/src/mosaic/components/button/button.styles.ts`. - **DO** use `:focus-visible` for focus rings (never bare `:focus`). For a @@ -341,6 +363,12 @@ Worked example: `packages/ui/src/mosaic/components/button/button.styles.ts`. drag, and an overshoot extrapolates past the target color for nothing. A transform at `--cl-duration-fast` still wants the curve. +- **DON'T** reuse `--cl-ease-default` for something **leaving**. It is an arrival + curve; run backwards it stalls for most of its duration and its overshoot + becomes a wobble past the target. Departures take `easingVars['--cl-ease-exit']` + at a shorter duration. See `motion.md` — enter/exit asymmetry has its own + reference, with the measurements behind these rules. + - **DO** gate transitions/animations of **motion-bearing** properties on reduced motion — `transform`, `translate`, `scale`, `rotate`, positional insets — in the same object. `prefers-reduced-motion` is a vestibular-safety signal, so color @@ -468,6 +496,61 @@ value, sub-pattern A collapses it to a single `--var` atom; reach for a raw inli > values, and even then the first move is usually to write a single `--cl`/`--_cl` > var rather than a raw inline style. +**Every condition is a value key, never a top-level object.** A pseudo/at-rule +goes _inside_ the property it modifies (`transitionProperty: { default: …, '@media …': … }`), +not as a bare key on the style object. A top-level `'@media …': { … }` block is +legacy syntax and the `@stylexjs/no-legacy-contextual-styles` + +`@stylexjs/valid-styles` rules reject it (only `::before`/`::after` may sit at the +top level). Reduced-motion is the common case: + +```ts +transitionProperty: { default: 'opacity, transform', '@media (prefers-reduced-motion: reduce)': 'none' }, +``` + +### Reacting to `data-*` state (the headless-transition case) + +Headless primitives drive animation off `data-*` attributes — e.g. the popover +popup carries its own `data-starting-style` (entering frame) and +`data-ending-style` (exiting). You can style off these in StyleX; it depends on +_whose_ attribute you're reading: + +- **The element's own attribute → wrap in `:where(...)`.** Conditional keys must + start with `:` or `@`, so a bare `[data-*]` is rejected — but `:where([data-*])` + is a valid pseudo-class string that matches the same element (zero specificity; + StyleX self-doubles the atom class so the conditional still wins): + + ```ts + popup: { + opacity: { default: 1, ':where([data-starting-style], [data-ending-style])': 0 }, + transform: { default: 'scale(1)', ':where([data-starting-style], [data-ending-style])': 'scale(0.94)' }, + // Reduced motion drops `transform` and keeps the fade — the gate belongs on the + // moving property, not the whole transition. + transitionProperty: { default: 'opacity, transform', '@media (prefers-reduced-motion: reduce)': 'opacity' }, + // Positional against `transitionProperty`, and branched by direction: the exit is + // shorter and takes the departure curve. See `motion.md`. + transitionDuration: { + default: `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-base']}`, + ':where([data-ending-style])': durationVars['--cl-duration-fast'], + }, + transitionTimingFunction: { + default: `linear, ${easingVars['--cl-ease-default']}`, + ':where([data-ending-style])': `linear, ${easingVars['--cl-ease-exit']}`, + }, + }, + ``` + +- **Another element's attribute → `stylex.when.*`.** For relational state use + `stylex.when.ancestor(sel)` / `.descendant(sel)` / `.siblingBefore(sel)` / + `.siblingAfter(sel)` / `.anySibling(sel)`, each taking a `:${string}` or + `[${string}]` selector and returning a valid conditional key + (`:where-ancestor(...)` etc.). Use this when a parent/sibling owns the state + (e.g. a `[data-open]` container theming its children); use `:where([data-*])` + when the element owns it. + +So: `:where(...)` for self-state, `stylex.when.*` for relational state. Both +compile to real attribute selectors in `styles.css`, so animation stays +CSS-native — no JS state plumbing through the component. + ## Public contract & composition (`props.ts`) The element carries three things, and nothing else is a contract: @@ -501,6 +584,39 @@ className left-to-right and merges `style` with the consumer object spread last: - **DON'T** call `stylex.props` twice on one element or spread `{...props}` after the merge result — fuse everything through the one `mergeStyleProps` call. +### Type every part with `MosaicComponentProps` + +`MosaicComponentProps` is the native props for `Tag` minus the non-standard HTML +`color` attribute, plus `render`. It drops `color` from the props **and** from the +`render` callback's argument, so a callback's props spread straight into a Mosaic +component whose own `color` is a variant union (`Button`, `Heading`, `Text`). + +```tsx +export interface PopoverPopupProps extends MosaicComponentProps<'div'> { … } +``` + +- **DON'T** type a Mosaic part with the headless `ComponentProps` (or + `React.ComponentPropsWithoutRef`). Those keep `color: string`, and + every consumer then has to strip it: `props: Omit, 'color'>`. +- **DON'T** re-export a headless part straight onto the Mosaic namespace object + (`Popover.Trigger = Primitive.Trigger`) — that leaks the wide type. Bridge it: + + ```tsx + const Trigger = React.forwardRef>( + function PopoverTrigger(props, ref) { + return ( + + ); + }, + ); + ``` + +- If a consumer needs to annotate a `render` callback, the API is wrong — fix the + part's props type instead. Inline callbacks infer with no annotation. + ## Build & CSS delivery (two contexts, same babel) - **Published** (`build:mosaic` → `@stylexjs/rollup-plugin`): compiles the @@ -526,7 +642,8 @@ token colors aren't down-leveled into an invalid polyfill. `::before`/`::after`/`::backdrop`, `@starting-style` (enter animations), `stylex.keyframes(...)`, `anchor-size(width|height)` (popover/menu matching its trigger), CSS counters, `@media (hover: hover)` / `(prefers-reduced-motion)` / - `(pointer: coarse)`. + `(pointer: coarse)`, `data-*` state via `:where([data-*])` (self) or + `stylex.when.*` (relational) — see "Reacting to `data-*` state" above. - Prefer CSS-native solutions over JS workarounds for anything StyleX supports. - Avoid manual `@layer` / `@property` inside `create` (StyleX owns layering; `@property` compiles but emits invalid output). diff --git a/.github/workflows/expo-native-build.yml b/.github/workflows/expo-native-build.yml index 118c3e8613c..9702cf7d505 100644 --- a/.github/workflows/expo-native-build.yml +++ b/.github/workflows/expo-native-build.yml @@ -27,7 +27,7 @@ env: SDK_PACK_DIR: /tmp/clerk-expo-pack E2E_INSTANCE_NAME: clerkstage-with-native-components BAPI_URL: https://api.clerkstage.dev - MAESTRO_RUNNER_VERSION: '1.1.21' + MAESTRO_VERSION: '2.8.0' jobs: native-build: @@ -214,33 +214,32 @@ jobs: path: ${{ steps.native-build-key.outputs.artifact }} key: ${{ steps.native-build-cache.outputs.cache-primary-key }} - - name: Cache maestro-runner + - name: Cache maestro CLI if: steps.keys.outputs.pk != '' uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: - path: ~/.maestro-runner - key: maestro-runner-${{ runner.os }}-${{ runner.arch }}-${{ env.MAESTRO_RUNNER_VERSION }} + path: ~/.maestro + key: maestro-${{ runner.os }}-${{ env.MAESTRO_VERSION }} - - name: Install maestro-runner + - name: Install maestro CLI if: steps.keys.outputs.pk != '' run: | set -o pipefail - if [ -x "$HOME/.maestro-runner/bin/maestro-runner" ]; then - echo "Using cached maestro-runner" + if [ -x "$HOME/.maestro/bin/maestro" ]; then + echo "Using cached Maestro" else installed=0 for i in 1 2 3; do - if curl -fsSL --retry 3 --retry-delay 5 "https://open.devicelab.dev/install/maestro-runner" | - bash -s -- --version "$MAESTRO_RUNNER_VERSION"; then - if [ -x "$HOME/.maestro-runner/bin/maestro-runner" ]; then installed=1; break; fi + if curl -fLs --retry 3 --retry-delay 5 "https://get.maestro.mobile.dev" | MAESTRO_VERSION="$MAESTRO_VERSION" bash; then + if [ -x "$HOME/.maestro/bin/maestro" ]; then installed=1; break; fi fi - echo "maestro-runner install attempt $i failed (or binary missing); retrying" + echo "Maestro install attempt $i failed (or binary missing); retrying" sleep 5 done - [ "$installed" = 1 ] || { echo "::error::maestro-runner install failed after 3 attempts"; exit 1; } + [ "$installed" = 1 ] || { echo "::error::Maestro install failed after 3 attempts"; exit 1; } fi - echo "$HOME/.maestro-runner/bin" >> "$GITHUB_PATH" - "$HOME/.maestro-runner/bin/maestro-runner" --version + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + "$HOME/.maestro/bin/maestro" --version - name: Boot iOS simulator id: sim @@ -281,16 +280,10 @@ jobs: - name: Run iOS e2e (Maestro) id: run_e2e_ios if: matrix.platform == 'ios' && steps.user.outputs.user_id != '' - # Burn-in: e2e cannot fail the check yet. Flip to hard-fail in a - # follow-up once the suite has proven quiet. - continue-on-error: true working-directory: ${{ env.FIXTURE_DIR }} env: CLERK_TEST_EMAIL: ${{ steps.user.outputs.email }} CLERK_TEST_PASSWORD: ${{ steps.user.outputs.password }} - MAESTRO_DEVICE: ${{ steps.sim.outputs.udid }} - MAESTRO_DRIVER: wda - MAESTRO_PLATFORM: ios SIM_UDID: ${{ steps.sim.outputs.udid }} run: | echo "Using simulator $SIM_UDID" @@ -349,13 +342,10 @@ jobs: - name: Run Android e2e (Maestro) id: run_e2e_android if: matrix.platform == 'android' && steps.user.outputs.user_id != '' - continue-on-error: true uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2 env: CLERK_TEST_EMAIL: ${{ steps.user.outputs.email }} CLERK_TEST_PASSWORD: ${{ steps.user.outputs.password }} - MAESTRO_DRIVER: devicelab - MAESTRO_PLATFORM: android with: api-level: 34 target: google_apis @@ -383,7 +373,7 @@ jobs: # Test reports record flow env (and typed input) in plaintext; # add-mask only covers step logs, not artifact contents. - - name: Scrub test credentials from maestro-runner reports + - name: Scrub test credentials from Maestro debug output if: always() && (steps.run_e2e_ios.outcome == 'failure' || steps.run_e2e_android.outcome == 'failure') env: CLERK_TEST_PASSWORD: ${{ steps.user.outputs.password }} @@ -395,7 +385,7 @@ jobs: \( -name '*.html' -o -name '*.json' -o -name '*.log' -o -name '*.txt' -o -name '*.xml' -o -name '*.yaml' \) \ -exec perl -pi -e 's/\Q$ENV{CLERK_TEST_PASSWORD}\E/[REDACTED]/g' {} + - - name: Upload maestro-runner artifacts on e2e failure + - name: Upload Maestro artifacts on e2e failure if: always() && (steps.run_e2e_ios.outcome == 'failure' || steps.run_e2e_android.outcome == 'failure') uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: @@ -413,5 +403,5 @@ jobs: [ "$outcome" = "skipped" ] && outcome="$ANDROID_OUTCOME" echo "## Maestro e2e (${{ matrix.platform }}): $outcome" >> "$GITHUB_STEP_SUMMARY" if [ "$outcome" = "failure" ]; then - echo "::warning::Maestro e2e failed (burn-in mode: not failing the job). See the maestro-${{ matrix.platform }} artifact." + echo "::error::Maestro e2e failed. See the maestro-${{ matrix.platform }} artifact." fi diff --git a/.typedoc/custom-plugin.mjs b/.typedoc/custom-plugin.mjs index 3b56bb739de..bd7570adda0 100644 --- a/.typedoc/custom-plugin.mjs +++ b/.typedoc/custom-plugin.mjs @@ -87,11 +87,19 @@ const LINK_REPLACEMENTS = [ ['enterprise-account', '/docs/reference/backend/types/backend-enterprise-account'], ['enterprise-account-connection', '/docs/reference/backend/types/backend-enterprise-account-connection'], ['enterprise-connection', '/docs/reference/backend/types/backend-enterprise-connection'], + [ + 'enterprise-connection-custom-attribute', + '/docs/reference/backend/types/backend-enterprise-connection-custom-attribute', + ], ['enterprise-connection-oauth-config', '/docs/reference/backend/types/backend-enterprise-connection-oauth-config'], [ 'enterprise-connection-saml-connection', '/docs/reference/backend/types/backend-enterprise-connection-saml-connection', ], + [ + 'enterprise-connection-saml-connection-login-hint', + '/docs/reference/backend/types/backend-enterprise-connection-saml-connection-login-hint', + ], ['external-account', '/docs/reference/backend/types/backend-external-account'], ['phone-number', '/docs/reference/backend/types/backend-phone-number'], ['protect-check-resource', '/docs/reference/types/protect-check-resource'], @@ -114,9 +122,11 @@ const LINK_REPLACEMENTS = [ ['billing-per-unit-total-tier', '/docs/reference/types/billing-per-unit-total-tier'], ['billing-subscription-item-resource', '/docs/reference/types/billing-subscription-item-resource'], ['billing-subscription-item-seats', '/docs/reference/types/billing-subscription-item-seats'], + ['billing-subscription-item-status', '/docs/reference/backend/types/billing-subscription-item-status'], ['feature-resource', '/docs/reference/types/feature-resource'], ['billing-statement-group', '/docs/reference/types/billing-statement-group'], ['billing-statement-resource', '/docs/reference/types/billing-statement-resource'], + ['billing-totals', '/docs/reference/types/billing-totals'], ['billing-subscription-resource', '/docs/reference/types/billing-subscription-resource'], ['clerk-api-response-error', '/docs/reference/types/clerk-api-response-error'], ['clerk-api-error', '/docs/reference/types/clerk-api-error'], diff --git a/integration/templates/expo-native/App.tsx b/integration/templates/expo-native/App.tsx index 3d7bdf3efa4..967814e9f68 100644 --- a/integration/templates/expo-native/App.tsx +++ b/integration/templates/expo-native/App.tsx @@ -1,5 +1,5 @@ import { ClerkProvider, useAuth, useUser } from '@clerk/expo'; -import { AuthView, UserButton } from '@clerk/expo/native'; +import { AuthView, UserButton, UserProfileView } from '@clerk/expo/native'; import { tokenCache } from '@clerk/expo/token-cache'; import { useState } from 'react'; import { Button, Modal, StyleSheet, Text, View } from 'react-native'; @@ -18,6 +18,7 @@ function NativeBuildFixture() { const { isLoaded, isSignedIn, signOut } = useAuth({ treatPendingAsSignedOut: false }); const { user } = useUser(); const [isAuthOpen, setIsAuthOpen] = useState(false); + const [isProfileOpen, setIsProfileOpen] = useState(false); const [e2eStatus, setE2eStatus] = useState(null); return ( @@ -38,6 +39,13 @@ function NativeBuildFixture() { {!isSignedIn && } {isSignedIn && } {e2eStatus && {e2eStatus}} + {isSignedIn && ( + + ; + ``` + + This also adds `Clerk.openInviteMembers()` and `Clerk.closeInviteMembers()` for opening and closing the modal programmatically. + +### Patch Changes + +- Updated dependencies [[`1ef84c3`](https://github.com/clerk/javascript/commit/1ef84c3592cee8a7d3ec5f40a9826862afe125e7), [`d639048`](https://github.com/clerk/javascript/commit/d639048e0e48ff3a120435134f9e01221697b6bc), [`a66cbbf`](https://github.com/clerk/javascript/commit/a66cbbf549477cf8afc155ad17d29e48078e60df)]: + - @clerk/shared@4.27.0 + +## 6.26.0 + +### Minor Changes + +- Support sign-in-or-sign-up combined flow with Clerk component ([#7928](https://github.com/clerk/javascript/pull/7928)) by [@dmoerner](https://github.com/dmoerner) + + when strict enumeration protection is enabled. + + On development instances, `` now logs a warning when the sign-in-or-up flow is rendered on an + instance that has both password and strict enumeration protection enabled. In that configuration + visitors without an account are routed to the password screen and cannot complete a sign-up, so the + warning names both settings and how to resolve them. + +### Patch Changes + +- Recover from partitioned-cookie startup races by removing stale non-partitioned cookies when partitioned cookies become available. ([#9286](https://github.com/clerk/javascript/pull/9286)) by [@thiskevinwang](https://github.com/thiskevinwang) + +- Complete the Safari ITP cookie refresh when `setActive({ redirectUrl })` navigates. ([#9308](https://github.com/clerk/javascript/pull/9308)) by [@dmoerner](https://github.com/dmoerner) + + Safari's ITP caps the client cookie at 7 days when it is re-issued from a fetch, so `setActive()` routes its redirect through `/v1/client/touch` to restore the full lifetime. That navigation was immediately followed by a second one to the undecorated redirect URL, which superseded it and aborted the touch request before it completed, leaving the cookie capped. + + This applies to flows that pass `redirectUrl` without a `navigate` callback — email link sign-in, the password reset success screen, the OAuth popup flow, and direct `setActive({ session, redirectUrl })` calls — in apps where Clerk performs a full page navigation rather than handing off to a router. Users still landed on the correct page, so the only symptom was Safari sessions ending after 7 days and returning devices being challenged as if they were new. + +- Updated dependencies [[`5c81479`](https://github.com/clerk/javascript/commit/5c81479d303fc6146dc81309d0b58564aa96706e)]: + - @clerk/shared@4.26.0 + +## 6.25.13 + +### Patch Changes + +- Enable self-serve OIDC configuration for every application. Organization admins can now select an OIDC provider in the `` Security tab without the `experimental.oidcSelfServe` option, and existing OIDC connections open their configuration steps instead of the unsupported-provider state. The `experimental.oidcSelfServe` option no longer does anything and can be removed from `` and `Clerk.load()`. ([#9288](https://github.com/clerk/javascript/pull/9288)) by [@NicolasLopes7](https://github.com/NicolasLopes7) + +- Updated dependencies [[`aaea141`](https://github.com/clerk/javascript/commit/aaea141d62804624cd8cd73036b4afe6f482184f)]: + - @clerk/shared@4.25.10 + ## 6.25.12 ### Patch Changes diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 95614c3de45..a9bf078f735 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -4,7 +4,7 @@ { "path": "./dist/clerk.browser.js", "maxSize": "75KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "117KB" }, { "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" }, - { "path": "./dist/clerk.native.js", "maxSize": "74KB" }, + { "path": "./dist/clerk.native.js", "maxSize": "76KB" }, { "path": "./dist/vendors*.js", "maxSize": "7KB" }, { "path": "./dist/coinbase*.js", "maxSize": "36KB" }, { "path": "./dist/base-account-sdk*.js", "maxSize": "207KB" }, diff --git a/packages/clerk-js/package.json b/packages/clerk-js/package.json index ab5f3e3f9b4..f2443838dba 100644 --- a/packages/clerk-js/package.json +++ b/packages/clerk-js/package.json @@ -1,6 +1,6 @@ { "name": "@clerk/clerk-js", - "version": "6.25.12", + "version": "6.27.0", "description": "Clerk JS library", "keywords": [ "clerk", diff --git a/packages/clerk-js/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts index 2e661a84de5..682f9b2f53e 100644 --- a/packages/clerk-js/sandbox/app.ts +++ b/packages/clerk-js/sandbox/app.ts @@ -472,6 +472,7 @@ void (async () => { mountOpenButton(app, 'Open Sign In', p => Clerk?.openSignIn(p), componentControls.signIn.getProps() ?? {}), '/open-sign-up': () => mountOpenButton(app, 'Open Sign Up', p => Clerk?.openSignUp(p), componentControls.signUp.getProps() ?? {}), + '/open-invite-members': () => mountOpenButton(app, 'Open Invite Members', p => Clerk?.openInviteMembers(p), {}), }; for (const [path, { mount, component, defaultProps }] of Object.entries(mountableRoutes)) { diff --git a/packages/clerk-js/sandbox/template.html b/packages/clerk-js/sandbox/template.html index 4a26ca93991..f59c231dceb 100644 --- a/packages/clerk-js/sandbox/template.html +++ b/packages/clerk-js/sandbox/template.html @@ -308,6 +308,10 @@ label="Configure SSO" component="" > + { const redirectUrl = new URL((sut.navigate as ReturnType).mock.calls[0][0]); expect(redirectUrl.pathname).toEqual('/v1/client/touch'); expect(redirectUrl.searchParams.get('redirect_url')).toEqual(`${mockWindowLocation.href}/redirect-url-path`); + // A second navigate would supersede the touch hop and abort it before it completes. + expect(sut.navigate).toHaveBeenCalledTimes(1); }); it('does not redirect the user to the /v1/client/touch endpoint if the cookie_expires_at is more than 8 days away', async () => { diff --git a/packages/clerk-js/src/core/auth/AuthCookieService.ts b/packages/clerk-js/src/core/auth/AuthCookieService.ts index 3ccc1dd4d38..a1c7dd72068 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -83,11 +83,11 @@ export class AuthCookieService { eventBus.on(events.UserSignOut, () => this.handleSignOut()); - // After Environment resolves, re-write dev browser cookies with correct - // partitioned attributes. Dev browser cookies are initially written before - // Environment is fetched, so they may have stale attributes. + // Environment can resolve after auth cookies are first written. eventBus.on(events.EnvironmentUpdate, () => { this.devBrowser.refreshCookies(); + void this.refreshSessionToken({ updateCookieImmediately: true }); + this.setClientUatCookieForDevelopmentInstances(); }); this.refreshTokenOnFocus(); @@ -266,6 +266,9 @@ export class AuthCookieService { } public setClientUatCookieForDevelopmentInstances() { + if (!this.clerk.client) { + return; + } if (this.instanceType !== 'production' && this.inCustomDevelopmentDomain()) { this.clientUat.set(this.clerk.client); } diff --git a/packages/clerk-js/src/core/auth/__tests__/AuthCookieService.test.ts b/packages/clerk-js/src/core/auth/__tests__/AuthCookieService.test.ts index b85c1552e05..e71356e5a16 100644 --- a/packages/clerk-js/src/core/auth/__tests__/AuthCookieService.test.ts +++ b/packages/clerk-js/src/core/auth/__tests__/AuthCookieService.test.ts @@ -1,11 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { eventBus, events } from '../../events'; +import { Environment } from '../../resources/Environment'; const mocks = vi.hoisted(() => ({ sessionCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn() }, clientUatCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn(() => 0) }, activeContextCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn<() => string | undefined>(() => undefined) }, + devBrowser: { + clear: vi.fn(), + setup: vi.fn(() => Promise.resolve()), + getDevBrowser: vi.fn(() => 'deadbeef'), + refreshCookies: vi.fn(), + }, inCrossOriginIframe: vi.fn(() => false), })); @@ -13,14 +20,7 @@ vi.mock('../cookies/session', () => ({ createSessionCookie: () => mocks.sessionC vi.mock('../cookies/clientUat', () => ({ createClientUatCookie: () => mocks.clientUatCookie })); vi.mock('../cookies/activeContext', () => ({ createActiveContextCookie: () => mocks.activeContextCookie })); vi.mock('../cookieSuffix', () => ({ getCookieSuffix: vi.fn(() => Promise.resolve('suffix')) })); -vi.mock('../devBrowser', () => ({ - createDevBrowser: () => ({ - clear: vi.fn(), - setup: vi.fn(() => Promise.resolve()), - getDevBrowser: vi.fn(() => 'deadbeef'), - refreshCookies: vi.fn(), - }), -})); +vi.mock('../devBrowser', () => ({ createDevBrowser: () => mocks.devBrowser })); vi.mock('@clerk/shared/internal/clerk-js/runtime', async importOriginal => { const actual = await importOriginal>(); return { ...actual, inCrossOriginIframe: () => mocks.inCrossOriginIframe() }; @@ -58,6 +58,7 @@ describe('AuthCookieService session cookie refresh', () => { mocks.inCrossOriginIframe.mockReturnValue(false); mocks.activeContextCookie.get.mockReturnValue(undefined); getToken.mockResolvedValue('fresh-jwt'); + Environment.getInstance().partitionedCookies = false; setFocus(true); setVisibility('visible'); }); @@ -136,4 +137,16 @@ describe('AuthCookieService session cookie refresh', () => { expect(getToken).toHaveBeenCalled(); }); + + it('rewrites the session cookie after partitioned cookies resolve', async () => { + service = await createService(); + getToken.mockResolvedValue('jwt-after-environment'); + Environment.getInstance().partitionedCookies = true; + mocks.sessionCookie.set.mockClear(); + + eventBus.emit(events.EnvironmentUpdate, null); + + await vi.waitFor(() => expect(mocks.sessionCookie.set).toHaveBeenCalledWith('jwt-after-environment')); + expect(mocks.devBrowser.refreshCookies).toHaveBeenCalled(); + }); }); diff --git a/packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts b/packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts index 82f832dc6f5..d0b1dff6fd6 100644 --- a/packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts +++ b/packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts @@ -20,8 +20,8 @@ describe('createClientUatCookie', () => { const mockExpires = new Date('2024-12-31'); const mockDomain = 'test.domain'; const defaultOptions = { usePartitionedCookies: () => false }; - const mockSet = vi.fn(); - const mockRemove = vi.fn(); + const mockSet = vi.fn<(name: string, value: string, attributes?: object) => void>(); + const mockRemove = vi.fn<(name: string, attributes?: object) => void>(); const mockGet = vi.fn(); beforeEach(() => { @@ -32,9 +32,13 @@ describe('createClientUatCookie', () => { (requiresSameSiteNone as ReturnType).mockReturnValue(false); (getCookieDomain as ReturnType).mockReturnValue(mockDomain); (getSecureAttribute as ReturnType).mockReturnValue(true); - (createCookieHandler as ReturnType).mockImplementation(() => ({ - set: mockSet, - remove: mockRemove, + (createCookieHandler as ReturnType).mockImplementation((name: string) => ({ + set: (value: string, attributes?: object) => { + mockSet(name, value, attributes); + }, + remove: (attributes?: object) => { + mockRemove(name, attributes); + }, get: mockGet, })); }); @@ -55,13 +59,14 @@ describe('createClientUatCookie', () => { }); expect(mockSet).toHaveBeenCalledTimes(2); - expect(mockSet).toHaveBeenCalledWith('1704067200', { + expect(mockSet).toHaveBeenCalledWith('__client_uat_test-suffix', '1704067200', { domain: mockDomain, expires: mockExpires, sameSite: 'Strict', secure: true, partitioned: false, }); + expect(mockSet).toHaveBeenCalledWith('__client_uat', '1704067200', expect.any(Object)); }); it('should set cookies with None sameSite in cross-origin context', () => { @@ -73,7 +78,7 @@ describe('createClientUatCookie', () => { signedInSessions: ['session1'], }); - expect(mockSet).toHaveBeenCalledWith('1704067200', { + expect(mockSet).toHaveBeenCalledWith('__client_uat_test-suffix', '1704067200', { domain: mockDomain, expires: mockExpires, sameSite: 'None', @@ -86,7 +91,7 @@ describe('createClientUatCookie', () => { const cookieHandler = createClientUatCookie(mockCookieSuffix, defaultOptions); cookieHandler.set(undefined); - expect(mockSet).toHaveBeenCalledWith('0', { + expect(mockSet).toHaveBeenCalledWith('__client_uat_test-suffix', '0', { domain: mockDomain, expires: mockExpires, sameSite: 'Strict', @@ -103,7 +108,7 @@ describe('createClientUatCookie', () => { signedInSessions: [], }); - expect(mockSet).toHaveBeenCalledWith('0', { + expect(mockSet).toHaveBeenCalledWith('__client_uat_test-suffix', '0', { domain: mockDomain, expires: mockExpires, sameSite: 'Strict', @@ -139,7 +144,7 @@ describe('createClientUatCookie', () => { signedInSessions: ['session1'], }); - expect(mockSet).toHaveBeenCalledWith('1704067200', { + expect(mockSet).toHaveBeenCalledWith('__client_uat_test-suffix', '1704067200', { domain: mockDomain, expires: mockExpires, sameSite: 'None', @@ -156,7 +161,7 @@ describe('createClientUatCookie', () => { signedInSessions: ['session1'], }); - expect(mockSet).toHaveBeenCalledWith('1704067200', { + expect(mockSet).toHaveBeenCalledWith('__client_uat_test-suffix', '1704067200', { domain: mockDomain, expires: mockExpires, sameSite: 'None', @@ -164,4 +169,65 @@ describe('createClientUatCookie', () => { partitioned: true, }); }); + + it('clears non-partitioned domain variants before writing partitioned cookies', () => { + let usePartitionedCookies = false; + const cookieHandler = createClientUatCookie(mockCookieSuffix, { + usePartitionedCookies: () => usePartitionedCookies, + }); + const client = { + id: 'test-client', + updatedAt: new Date('2024-01-01'), + signedInSessions: ['session1'], + }; + + cookieHandler.set(client); + usePartitionedCookies = true; + mockSet.mockClear(); + mockRemove.mockClear(); + cookieHandler.set(client); + + expect(mockRemove.mock.calls).toEqual([ + ['__client_uat_test-suffix', undefined], + ['__client_uat', undefined], + ['__client_uat_test-suffix', { domain: mockDomain, sameSite: 'Strict', secure: true, partitioned: false }], + ['__client_uat', { domain: mockDomain, sameSite: 'Strict', secure: true, partitioned: false }], + ['__client_uat_test-suffix', { domain: mockDomain, sameSite: 'None', secure: true, partitioned: false }], + ['__client_uat', { domain: mockDomain, sameSite: 'None', secure: true, partitioned: false }], + ]); + expect(mockSet.mock.calls).toEqual([ + [ + '__client_uat_test-suffix', + '1704067200', + { + domain: mockDomain, + expires: mockExpires, + sameSite: 'None', + secure: true, + partitioned: true, + }, + ], + [ + '__client_uat', + '1704067200', + { + domain: mockDomain, + expires: mockExpires, + sameSite: 'None', + secure: true, + partitioned: true, + }, + ], + ]); + const firstInvocationOrder = mockRemove.mock.invocationCallOrder[0]; + expect(mockRemove.mock.invocationCallOrder).toEqual([ + firstInvocationOrder, + firstInvocationOrder + 1, + firstInvocationOrder + 4, + firstInvocationOrder + 5, + firstInvocationOrder + 6, + firstInvocationOrder + 7, + ]); + expect(mockSet.mock.invocationCallOrder).toEqual([firstInvocationOrder + 8, firstInvocationOrder + 9]); + }); }); diff --git a/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts b/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts index 2b418d38a4f..22d686e8872 100644 --- a/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts +++ b/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts @@ -18,8 +18,8 @@ describe('createSessionCookie', () => { const mockToken = 'test-token'; const mockExpires = new Date('2024-12-31'); const defaultOptions = { usePartitionedCookies: () => false }; - const mockSet = vi.fn(); - const mockRemove = vi.fn(); + const mockSet = vi.fn<(name: string, value: string, attributes?: object) => void>(); + const mockRemove = vi.fn<(name: string, attributes?: object) => void>(); const mockGet = vi.fn(); beforeEach(() => { @@ -29,9 +29,13 @@ describe('createSessionCookie', () => { (inCrossOriginIframe as ReturnType).mockReturnValue(false); (requiresSameSiteNone as ReturnType).mockReturnValue(false); (getSecureAttribute as ReturnType).mockReturnValue(true); - (createCookieHandler as ReturnType).mockImplementation(() => ({ - set: mockSet, - remove: mockRemove, + (createCookieHandler as ReturnType).mockImplementation((name: string) => ({ + set: (value: string, attributes?: object) => { + mockSet(name, value, attributes); + }, + remove: (attributes?: object) => { + mockRemove(name, attributes); + }, get: mockGet, })); }); @@ -48,7 +52,7 @@ describe('createSessionCookie', () => { cookieHandler.set(mockToken); expect(mockSet).toHaveBeenCalledTimes(2); - expect(mockSet).toHaveBeenCalledWith(mockToken, { + expect(mockSet).toHaveBeenCalledWith('__session', mockToken, { expires: mockExpires, sameSite: 'Lax', secure: true, @@ -61,7 +65,7 @@ describe('createSessionCookie', () => { const cookieHandler = createSessionCookie(mockCookieSuffix, defaultOptions); cookieHandler.set(mockToken); - expect(mockSet).toHaveBeenCalledWith(mockToken, { + expect(mockSet).toHaveBeenCalledWith('__session', mockToken, { expires: mockExpires, sameSite: 'None', secure: true, @@ -87,17 +91,17 @@ describe('createSessionCookie', () => { partitioned: false, }; - expect(mockSet).toHaveBeenCalledWith(mockToken, { + expect(mockSet).toHaveBeenCalledWith('__session', mockToken, { expires: mockExpires, sameSite: 'Lax', secure: true, partitioned: false, }); - expect(mockRemove).toHaveBeenCalledWith(expectedAttributes); + expect(mockRemove).toHaveBeenCalledWith('__session', expectedAttributes); expect(mockRemove).toHaveBeenCalledTimes(2); - expect(mockRemove).toHaveBeenNthCalledWith(1, expectedAttributes); - expect(mockRemove).toHaveBeenNthCalledWith(2, expectedAttributes); + expect(mockRemove).toHaveBeenNthCalledWith(1, '__session', expectedAttributes); + expect(mockRemove).toHaveBeenNthCalledWith(2, '__session_test-suffix', expectedAttributes); }); it('should get cookie value from suffixed cookie first, then fallback to non-suffixed', () => { @@ -123,7 +127,7 @@ describe('createSessionCookie', () => { const cookieHandler = createSessionCookie(mockCookieSuffix, defaultOptions); cookieHandler.set(mockToken); - expect(mockSet).toHaveBeenCalledWith(mockToken, { + expect(mockSet).toHaveBeenCalledWith('__session', mockToken, { expires: mockExpires, sameSite: 'None', secure: true, @@ -135,12 +139,62 @@ describe('createSessionCookie', () => { const cookieHandler = createSessionCookie(mockCookieSuffix, { usePartitionedCookies: () => true }); cookieHandler.set(mockToken); - expect(mockRemove).toHaveBeenCalledTimes(2); - expect(mockSet).toHaveBeenCalledWith(mockToken, { + expect(mockRemove).toHaveBeenCalledTimes(4); + expect(mockSet).toHaveBeenCalledWith('__session', mockToken, { expires: mockExpires, sameSite: 'None', secure: true, partitioned: true, }); }); + + it('clears non-partitioned variants before writing partitioned cookies after the environment changes', () => { + let usePartitionedCookies = false; + const cookieHandler = createSessionCookie(mockCookieSuffix, { + usePartitionedCookies: () => usePartitionedCookies, + }); + + cookieHandler.set('non-partitioned-token'); + usePartitionedCookies = true; + mockSet.mockClear(); + mockRemove.mockClear(); + cookieHandler.set('partitioned-token'); + + expect(mockRemove.mock.calls).toEqual([ + ['__session', { sameSite: 'Lax', secure: true, partitioned: false }], + ['__session_test-suffix', { sameSite: 'Lax', secure: true, partitioned: false }], + ['__session', { sameSite: 'None', secure: true, partitioned: false }], + ['__session_test-suffix', { sameSite: 'None', secure: true, partitioned: false }], + ]); + expect(mockSet.mock.calls).toEqual([ + [ + '__session', + 'partitioned-token', + { + expires: mockExpires, + sameSite: 'None', + secure: true, + partitioned: true, + }, + ], + [ + '__session_test-suffix', + 'partitioned-token', + { + expires: mockExpires, + sameSite: 'None', + secure: true, + partitioned: true, + }, + ], + ]); + const firstInvocationOrder = mockRemove.mock.invocationCallOrder[0]; + expect(mockRemove.mock.invocationCallOrder).toEqual([ + firstInvocationOrder, + firstInvocationOrder + 1, + firstInvocationOrder + 2, + firstInvocationOrder + 3, + ]); + expect(mockSet.mock.invocationCallOrder).toEqual([firstInvocationOrder + 4, firstInvocationOrder + 5]); + }); }); diff --git a/packages/clerk-js/src/core/auth/cookies/clientUat.ts b/packages/clerk-js/src/core/auth/cookies/clientUat.ts index 4591f348a77..0187b73be75 100644 --- a/packages/clerk-js/src/core/auth/cookies/clientUat.ts +++ b/packages/clerk-js/src/core/auth/cookies/clientUat.ts @@ -63,6 +63,18 @@ export const createClientUatCookie = ( suffixedClientUatCookie.remove(); clientUatCookie.remove(); + if (partitioned) { + const nonPartitionedCookieAttributes = [ + { domain, sameSite: 'Strict', secure: getSecureAttribute('Strict'), partitioned: false }, + { domain, sameSite: 'None', secure: getSecureAttribute('None'), partitioned: false }, + ] as const; + + for (const attributes of nonPartitionedCookieAttributes) { + suffixedClientUatCookie.remove(attributes); + clientUatCookie.remove(attributes); + } + } + suffixedClientUatCookie.set(val, { domain, expires, partitioned, sameSite, secure }); clientUatCookie.set(val, { domain, expires, partitioned, sameSite, secure }); }; diff --git a/packages/clerk-js/src/core/auth/cookies/session.ts b/packages/clerk-js/src/core/auth/cookies/session.ts index 06a7e9fb2e1..2d49e0115f1 100644 --- a/packages/clerk-js/src/core/auth/cookies/session.ts +++ b/packages/clerk-js/src/core/auth/cookies/session.ts @@ -35,16 +35,25 @@ export const createSessionCookie = (cookieSuffix: string, options: SessionCookie const sessionCookie = createCookieHandler(SESSION_COOKIE_NAME); const suffixedSessionCookie = createCookieHandler(getSuffixedCookieName(SESSION_COOKIE_NAME, cookieSuffix)); + const removeNonPartitionedCookies = () => { + const nonPartitionedCookieAttributes = [ + { sameSite: 'Lax', secure: getSecureAttribute('Lax'), partitioned: false }, + { sameSite: 'None', secure: getSecureAttribute('None'), partitioned: false }, + ] as const; + + for (const attributes of nonPartitionedCookieAttributes) { + sessionCookie.remove(attributes); + suffixedSessionCookie.remove(attributes); + } + }; + const remove = () => { const attributes = getCookieAttributes(options); sessionCookie.remove(attributes); suffixedSessionCookie.remove(attributes); - // Also remove non-partitioned variants — the browser treats partitioned and - // non-partitioned cookies with the same name as distinct cookies. if (attributes.partitioned) { - sessionCookie.remove(); - suffixedSessionCookie.remove(); + removeNonPartitionedCookies(); } }; @@ -52,11 +61,8 @@ export const createSessionCookie = (cookieSuffix: string, options: SessionCookie const expires = addYears(Date.now(), 1); const { sameSite, secure, partitioned } = getCookieAttributes(options); - // Remove old non-partitioned cookies — the browser treats partitioned and - // non-partitioned cookies with the same name as distinct cookies. if (partitioned) { - sessionCookie.remove(); - suffixedSessionCookie.remove(); + removeNonPartitionedCookies(); } sessionCookie.set(token, { expires, sameSite, secure, partitioned }); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 9eaa2d6732c..a3d7b62e838 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -92,6 +92,7 @@ import type { HandleEmailLinkVerificationParams, HandleOAuthCallbackParams, InstanceType, + InviteMembersModalProps, JoinWaitlistParams, ListenerCallback, ListenerOptions, @@ -121,7 +122,6 @@ import type { SignOut, SignOutCallback, SignOutOptions, - SignUpField, SignUpProps, SignUpRedirectOptions, SignUpResource, @@ -149,7 +149,6 @@ import { ModuleManager } from '@/utils/moduleManager'; import { ALLOWED_PROTOCOLS, buildURL, - completeSignUpFlow, createAllowedRedirectOrigins, createBeforeUnloadTracker, createPageLifecycle, @@ -162,6 +161,7 @@ import { isError, isOrganizationId, isRedirectForFAPIInitiatedFlow, + navigateToNextStepSignUp, removeClerkQueryParam, requiresUserInput, stripOrigin, @@ -208,6 +208,7 @@ const CANNOT_RENDER_BILLING_DISABLED_ERROR_CODE = 'cannot_render_billing_disable const CANNOT_RENDER_USER_MISSING_ERROR_CODE = 'cannot_render_user_missing'; const CANNOT_RENDER_ORGANIZATIONS_DISABLED_ERROR_CODE = 'cannot_render_organizations_disabled'; const CANNOT_RENDER_ORGANIZATION_MISSING_ERROR_CODE = 'cannot_render_organization_missing'; +const CANNOT_RENDER_PERMISSION_MISSING_ERROR_CODE = 'cannot_render_permission_missing'; const CANNOT_RENDER_SINGLE_SESSION_ENABLED_ERROR_CODE = 'cannot_render_single_session_enabled'; const CANNOT_RENDER_API_KEYS_DISABLED_ERROR_CODE = 'cannot_render_api_keys_disabled'; const CANNOT_RENDER_API_KEYS_USER_DISABLED_ERROR_CODE = 'cannot_render_api_keys_user_disabled'; @@ -1034,6 +1035,52 @@ export class Clerk implements ClerkInterface { void this.#clerkUI?.then(ui => ui.ensureMounted()).then(controls => controls.closeModal('organizationProfile')); }; + public openInviteMembers = (props?: InviteMembersModalProps): void => { + const { isEnabled: isOrganizationsEnabled } = this.__internal_attemptToEnableEnvironmentSetting({ + for: 'organizations', + caller: 'InviteMembers', + onClose: () => { + throw new ClerkRuntimeError(warnings.cannotRenderAnyOrganizationComponent('InviteMembers'), { + code: CANNOT_RENDER_ORGANIZATIONS_DISABLED_ERROR_CODE, + }); + }, + }); + + if (!isOrganizationsEnabled) { + return; + } + + if (noOrganizationExists(this)) { + if (this.#instanceType === 'development') { + throw new ClerkRuntimeError(warnings.createCannotRenderComponentWhenOrgDoesNotExist('InviteMembers'), { + code: CANNOT_RENDER_ORGANIZATION_MISSING_ERROR_CODE, + }); + } + return; + } + + if (!this.session?.checkAuthorization({ permission: 'org:sys_memberships:manage' })) { + if (this.#instanceType === 'development') { + throw new ClerkRuntimeError( + warnings.createCannotRenderComponentWhenPermissionIsMissing('InviteMembers', 'org:sys_memberships:manage'), + { code: CANNOT_RENDER_PERMISSION_MISSING_ERROR_CODE }, + ); + } + return; + } + + this.assertComponentsReady(this.#clerkUI); + void this.#clerkUI + .then(ui => ui.ensureMounted()) + .then(controls => controls.openModal('inviteMembers', props || {})); + + this.telemetry?.record(eventPrebuiltComponentOpened('InviteMembers', props)); + }; + + public closeInviteMembers = (): void => { + void this.#clerkUI?.then(ui => ui.ensureMounted()).then(controls => controls.closeModal('inviteMembers')); + }; + public openCreateOrganization = (props?: CreateOrganizationProps): void => { const { isEnabled: isOrganizationsEnabled } = this.__internal_attemptToEnableEnvironmentSetting({ for: 'organizations', @@ -1864,14 +1911,7 @@ export class Clerk implements ClerkInterface { ); } } else if (redirectUrl) { - if (this.client.isEligibleForTouch()) { - const absoluteRedirectUrl = new URL(redirectUrl, window.location.href); - const redirectUrlWithAuth = this.buildUrlWithAuth( - this.client.buildTouchUrl({ redirectUrl: absoluteRedirectUrl }), - ); - await this.navigate(redirectUrlWithAuth); - } - await this.navigate(redirectUrl); + await this.navigate(this.#decorateUrlWithTouch(redirectUrl)); } }); } @@ -2297,6 +2337,14 @@ export class Clerk implements ClerkInterface { throw new EmailLinkError(EmailLinkErrorCodeStatus.Expired); } else if (verificationStatus === 'client_mismatch') { throw new EmailLinkError(EmailLinkErrorCodeStatus.ClientMismatch); + } else if (verificationStatus === 'transferable') { + // signUpIfMissing flow: the email was verified but the user doesn't exist, so there is + // no session to complete here. The sign-up transfer is banked on the client that owns + // the sign-in; consuming it is left to the caller, which knows where to route next. + if (typeof params.onVerifiedOnOtherDevice === 'function') { + params.onVerifiedOnOtherDevice(); + } + return; } else if (verificationStatus !== 'verified') { throw new EmailLinkError(EmailLinkErrorCodeStatus.Failed); } @@ -2443,54 +2491,20 @@ export class Clerk implements ClerkInterface { const redirectUrls = new RedirectUrls(this.#options, params); - const navigateToContinueSignUp = makeNavigate( + const continueSignUpUrl = params.continueSignUpUrl || - buildURL( - { - base: displayConfig.signUpUrl, - hashPath: '/continue', - }, - { stringify: true }, - ), - ); - - const navigateToSignUpProtectCheck = makeNavigate( + buildURL({ base: displayConfig.signUpUrl, hashPath: '/continue' }, { stringify: true }); + const verifyEmailAddressUrl = + params.verifyEmailAddressUrl || + buildURL({ base: displayConfig.signUpUrl, hashPath: '/verify-email-address' }, { stringify: true }); + const verifyPhoneNumberUrl = + params.verifyPhoneNumberUrl || + buildURL({ base: displayConfig.signUpUrl, hashPath: '/verify-phone-number' }, { stringify: true }); + const signUpProtectCheckUrl = params.signUpProtectCheckUrl || - buildURL({ base: displayConfig.signUpUrl, hashPath: '/protect-check' }, { stringify: true }), - ); - - const navigateToNextStepSignUp = ({ missingFields }: { missingFields: SignUpField[] }) => { - // A protect-gated sign-up always carries 'protect_check' in missing_fields, so this gate - // check must run BEFORE the generic missing-fields short-circuit below — otherwise the - // OAuth/SAML callback would land on /continue instead of the challenge. - if (signUp.protectCheck || missingFields.includes('protect_check')) { - return navigateToSignUpProtectCheck(); - } + buildURL({ base: displayConfig.signUpUrl, hashPath: '/protect-check' }, { stringify: true }); - if (missingFields.length) { - return navigateToContinueSignUp(); - } - - return completeSignUpFlow({ - signUp, - verifyEmailPath: - params.verifyEmailAddressUrl || - buildURL( - { - base: displayConfig.signUpUrl, - hashPath: '/verify-email-address', - }, - { stringify: true }, - ), - verifyPhonePath: - params.verifyPhoneNumberUrl || - buildURL({ base: displayConfig.signUpUrl, hashPath: '/verify-phone-number' }, { stringify: true }), - protectCheckPath: - params.signUpProtectCheckUrl || - buildURL({ base: displayConfig.signUpUrl, hashPath: '/protect-check' }, { stringify: true }), - navigate, - }); - }; + const navigateToSignUpProtectCheck = makeNavigate(signUpProtectCheckUrl); const signInUrl = params.signInUrl || displayConfig.signInUrl; const signUpUrl = params.signUpUrl || displayConfig.signUpUrl; @@ -2636,7 +2650,14 @@ export class Clerk implements ClerkInterface { }, }); case 'missing_requirements': - return navigateToNextStepSignUp({ missingFields: res.missingFields }); + return navigateToNextStepSignUp({ + signUp: res, + continueSignUpUrl, + verifyEmailAddressUrl, + verifyPhoneNumberUrl, + signUpProtectCheckUrl, + navigate, + }); default: clerkOAuthCallbackDidNotCompleteSignInSignUp('sign in'); } @@ -2691,7 +2712,14 @@ export class Clerk implements ClerkInterface { } if (su.externalAccountStatus === 'verified' && su.status === 'missing_requirements') { - return navigateToNextStepSignUp({ missingFields: signUp.missingFields }); + return navigateToNextStepSignUp({ + signUp, + continueSignUpUrl, + verifyEmailAddressUrl, + verifyPhoneNumberUrl, + signUpProtectCheckUrl, + navigate, + }); } if (this.session?.currentTask) { diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 20b206091f4..c2de93a5030 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -100,6 +100,16 @@ import { import { eventBus } from '../events'; import { BaseResource, UserData, Verification } from './internal'; +/** + * Terminal states for email-link verification polling: `verified` (success), `expired` + * (link timed out), or `transferable` (`signUpIfMissing` flows — the address was verified + * but no user exists, so the caller transfers to sign-up). Shared by the legacy + * `createEmailLinkFlow` poll and `SignInFuture.waitForEmailLinkVerification` so the two + * loops can't drift apart. + */ +const isTerminalEmailLinkVerificationStatus = (status: string | null) => + status === 'verified' || status === 'expired' || status === 'transferable'; + export class SignIn extends BaseResource implements SignInResource { pathRoot = '/client/sign_ins'; @@ -335,8 +345,7 @@ export class SignIn extends BaseResource implements SignInResource { void run(() => { return this.reload() .then(res => { - const status = res[verificationKey].status; - if (status === 'verified' || status === 'expired') { + if (isTerminalEmailLinkVerificationStatus(res[verificationKey].status)) { stop(); resolve(res); } @@ -1152,8 +1161,7 @@ class SignInFuture implements SignInFutureResource { void run(async () => { try { const res = await this.#resource.__internal_baseGet(); - const status = res.firstFactorVerification.status; - if (status === 'verified' || status === 'expired') { + if (isTerminalEmailLinkVerificationStatus(res.firstFactorVerification.status)) { stop(); resolve(res); } diff --git a/packages/clerk-js/src/core/resources/UserSettings.ts b/packages/clerk-js/src/core/resources/UserSettings.ts index aaabb6738b6..86c928f6d74 100644 --- a/packages/clerk-js/src/core/resources/UserSettings.ts +++ b/packages/clerk-js/src/core/resources/UserSettings.ts @@ -1,4 +1,5 @@ import type { + AttackProtectionData, Attributes, EnterpriseSSOSettings, OAuthProviders, @@ -103,6 +104,7 @@ export class UserSettings extends BaseResource implements UserSettingsResource { name: 'passkey', }, }; + attackProtection: AttackProtectionData = { enumeration_protection: { enabled: false } }; enterpriseSSO: EnterpriseSSOSettings = { enabled: false, self_serve_sso: false, @@ -214,6 +216,15 @@ export class UserSettings extends BaseResource implements UserSettingsResource { this.attributes, ); this.actions = this.withDefault(data.actions, this.actions); + // Normalize field-by-field rather than withDefault: a present-but-partial + // attack_protection object must not leave enumeration_protection undefined. + this.attackProtection = { + enumeration_protection: { + enabled: + data.attack_protection?.enumeration_protection?.enabled ?? + this.attackProtection.enumeration_protection.enabled, + }, + }; this.enterpriseSSO = this.withDefault(data.enterprise_sso, this.enterpriseSSO); this.passkeySettings = this.withDefault(data.passkey_settings, this.passkeySettings); this.passwordSettings = data.password_settings @@ -252,6 +263,7 @@ export class UserSettings extends BaseResource implements UserSettingsResource { public __internal_toSnapshot(): UserSettingsJSONSnapshot { return { actions: this.actions, + attack_protection: this.attackProtection, attributes: this.attributes, passkey_settings: this.passkeySettings, password_settings: this.passwordSettings, diff --git a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts index 236b62da151..df1d5a34891 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -1237,6 +1237,37 @@ describe('SignIn', () => { expect.anything(), ); }); + + it('polls until firstFactorVerification status is transferable', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce({ + client: null, + response: { + id: 'signin_123', + first_factor_verification: { status: 'unverified' }, + }, + }) + .mockResolvedValueOnce({ + client: null, + response: { + id: 'signin_123', + first_factor_verification: { status: 'transferable' }, + }, + }); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn({ id: 'signin_123' } as any); + await signIn.__internal_future.emailLink.waitForVerification(); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + path: '/client/sign_ins/signin_123', + }), + expect.anything(), + ); + }); }); describe('sendPhoneCode', () => { diff --git a/packages/clerk-js/src/utils/index.ts b/packages/clerk-js/src/utils/index.ts index 2a66443941e..db9d7631927 100644 --- a/packages/clerk-js/src/utils/index.ts +++ b/packages/clerk-js/src/utils/index.ts @@ -1,6 +1,7 @@ export * from './beforeUnloadTracker'; export * from './billing'; export * from '@clerk/shared/internal/clerk-js/completeSignUpFlow'; +export * from '@clerk/shared/internal/clerk-js/navigateToNextStepSignUp'; export * from '@clerk/shared/internal/clerk-js/email'; export * from '@clerk/shared/internal/clerk-js/encoders'; export * from './errors'; diff --git a/packages/electron/CHANGELOG.md b/packages/electron/CHANGELOG.md index 2b7048832a7..a12f4cf7990 100644 --- a/packages/electron/CHANGELOG.md +++ b/packages/electron/CHANGELOG.md @@ -1,5 +1,32 @@ # @clerk/electron +## 0.0.27 + +### Patch Changes + +- Updated dependencies [[`1ef84c3`](https://github.com/clerk/javascript/commit/1ef84c3592cee8a7d3ec5f40a9826862afe125e7), [`d639048`](https://github.com/clerk/javascript/commit/d639048e0e48ff3a120435134f9e01221697b6bc), [`a66cbbf`](https://github.com/clerk/javascript/commit/a66cbbf549477cf8afc155ad17d29e48078e60df), [`58d8ff5`](https://github.com/clerk/javascript/commit/58d8ff50b121ebf42744ba32302da6b22e90b704)]: + - @clerk/shared@4.27.0 + - @clerk/clerk-js@6.27.0 + - @clerk/react@6.13.0 + +## 0.0.26 + +### Patch Changes + +- Updated dependencies [[`bbe51ff`](https://github.com/clerk/javascript/commit/bbe51ffc343a878022c5863796450d6d97069ea0), [`bf1b62a`](https://github.com/clerk/javascript/commit/bf1b62a552f005bc3258c4e48b6a205eeca5fed5), [`5c81479`](https://github.com/clerk/javascript/commit/5c81479d303fc6146dc81309d0b58564aa96706e), [`7f0cac8`](https://github.com/clerk/javascript/commit/7f0cac8d92496efda67fd434eb16bf2bd61e897e)]: + - @clerk/react@6.12.11 + - @clerk/clerk-js@6.26.0 + - @clerk/shared@4.26.0 + +## 0.0.25 + +### Patch Changes + +- Updated dependencies [[`aaea141`](https://github.com/clerk/javascript/commit/aaea141d62804624cd8cd73036b4afe6f482184f)]: + - @clerk/clerk-js@6.25.13 + - @clerk/shared@4.25.10 + - @clerk/react@6.12.10 + ## 0.0.24 ### Patch Changes diff --git a/packages/electron/package.json b/packages/electron/package.json index a62e0dd4621..6f3c6e416e5 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -1,6 +1,6 @@ { "name": "@clerk/electron", - "version": "0.0.24", + "version": "0.0.27", "description": "Clerk SDK for Electron", "keywords": [ "clerk", diff --git a/packages/expo-passkeys/CHANGELOG.md b/packages/expo-passkeys/CHANGELOG.md index 106fa973fb7..78fe0007dea 100644 --- a/packages/expo-passkeys/CHANGELOG.md +++ b/packages/expo-passkeys/CHANGELOG.md @@ -1,5 +1,26 @@ # @clerk/expo-passkeys +## 2.0.6 + +### Patch Changes + +- Updated dependencies [[`1ef84c3`](https://github.com/clerk/javascript/commit/1ef84c3592cee8a7d3ec5f40a9826862afe125e7), [`d639048`](https://github.com/clerk/javascript/commit/d639048e0e48ff3a120435134f9e01221697b6bc), [`a66cbbf`](https://github.com/clerk/javascript/commit/a66cbbf549477cf8afc155ad17d29e48078e60df)]: + - @clerk/shared@4.27.0 + +## 2.0.5 + +### Patch Changes + +- Updated dependencies [[`5c81479`](https://github.com/clerk/javascript/commit/5c81479d303fc6146dc81309d0b58564aa96706e)]: + - @clerk/shared@4.26.0 + +## 2.0.4 + +### Patch Changes + +- Updated dependencies [[`aaea141`](https://github.com/clerk/javascript/commit/aaea141d62804624cd8cd73036b4afe6f482184f)]: + - @clerk/shared@4.25.10 + ## 2.0.3 ### Patch Changes diff --git a/packages/expo-passkeys/package.json b/packages/expo-passkeys/package.json index 2884b448530..db388e7b76f 100644 --- a/packages/expo-passkeys/package.json +++ b/packages/expo-passkeys/package.json @@ -1,6 +1,6 @@ { "name": "@clerk/expo-passkeys", - "version": "2.0.3", + "version": "2.0.6", "description": "Passkeys library to be used with Clerk for expo", "keywords": [ "react-native", diff --git a/packages/expo/CHANGELOG.md b/packages/expo/CHANGELOG.md index 95018fb698b..82d86c958cc 100644 --- a/packages/expo/CHANGELOG.md +++ b/packages/expo/CHANGELOG.md @@ -1,5 +1,63 @@ # Change Log +## 4.2.2 + +### Patch Changes + +- Updated dependencies [[`1ef84c3`](https://github.com/clerk/javascript/commit/1ef84c3592cee8a7d3ec5f40a9826862afe125e7), [`d639048`](https://github.com/clerk/javascript/commit/d639048e0e48ff3a120435134f9e01221697b6bc), [`a66cbbf`](https://github.com/clerk/javascript/commit/a66cbbf549477cf8afc155ad17d29e48078e60df), [`58d8ff5`](https://github.com/clerk/javascript/commit/58d8ff50b121ebf42744ba32302da6b22e90b704)]: + - @clerk/shared@4.27.0 + - @clerk/clerk-js@6.27.0 + - @clerk/react@6.13.0 + +## 4.2.1 + +### Patch Changes + +- Updated dependencies [[`bbe51ff`](https://github.com/clerk/javascript/commit/bbe51ffc343a878022c5863796450d6d97069ea0), [`bf1b62a`](https://github.com/clerk/javascript/commit/bf1b62a552f005bc3258c4e48b6a205eeca5fed5), [`5c81479`](https://github.com/clerk/javascript/commit/5c81479d303fc6146dc81309d0b58564aa96706e), [`7f0cac8`](https://github.com/clerk/javascript/commit/7f0cac8d92496efda67fd434eb16bf2bd61e897e)]: + - @clerk/react@6.12.11 + - @clerk/clerk-js@6.26.0 + - @clerk/shared@4.26.0 + +## 4.2.0 + +### Minor Changes + +- Add an experimental `useSSO()` hook at `@clerk/expo/experimental` that uses future auth resources and activates completed SSO sessions automatically. ([#9103](https://github.com/clerk/javascript/pull/9103)) by [@swolfand](https://github.com/swolfand) + + ```tsx + import { useSSO } from '@clerk/expo/experimental'; + + const { startSSOFlow } = useSSO(); + + await startSSOFlow({ + strategy: 'oauth_google', + }); + ``` + +- Support pushing the native `UserProfileView` and `AuthView` onto your app's own navigation stack. ([#9121](https://github.com/clerk/javascript/pull/9121)) by [@mikepitre](https://github.com/mikepitre) + + New optional `onHostBack` prop shows a back button on the component's root screen and calls you when it is tapped. The component keeps its own navigation chrome, so screen titles, back buttons, swipe-back, and transitions inside the component stay native — hide your route's header and pop your route from the callback: + + ```tsx + + router.back()} /> + ``` + + The component never leaves the route on its own, so react to auth state for flow completion — either swap the content in place or pop the route. + + Existing usage is unaffected: the prop is optional, and the components render exactly as before without it. Requires the corresponding clerk-ios and clerk-android SDK releases. + +### Patch Changes + +- Bump the bundled `clerk-android` SDK (`clerk-android-api` and `clerk-android-ui`) from `1.0.38` to `1.0.39`. See the Clerk Android release: https://github.com/clerk/clerk-android/releases/tag/v1.0.39. ([#9300](https://github.com/clerk/javascript/pull/9300)) by [@clerk-cookie](https://github.com/clerk-cookie) + +- Bump the bundled `clerk-ios` SDK from `1.3.5` to `1.3.6`. See the Clerk iOS release: https://github.com/clerk/clerk-ios/releases/tag/1.3.6. ([#9301](https://github.com/clerk/javascript/pull/9301)) by [@clerk-cookie](https://github.com/clerk-cookie) + +- Updated dependencies [[`aaea141`](https://github.com/clerk/javascript/commit/aaea141d62804624cd8cd73036b4afe6f482184f)]: + - @clerk/clerk-js@6.25.13 + - @clerk/shared@4.25.10 + - @clerk/react@6.12.10 + ## 4.1.2 ### Patch Changes diff --git a/packages/expo/android/build.gradle b/packages/expo/android/build.gradle index 7459467b1f0..3ebd4c78ec0 100644 --- a/packages/expo/android/build.gradle +++ b/packages/expo/android/build.gradle @@ -20,8 +20,8 @@ def clerkExpoVersion = clerkExpoPackageJson.version.toString() // See: https://docs.gradle.org/current/userguide/version_catalogs.html for app-level version catalogs ext { kotlinxCoroutinesVersion = "1.7.3" - clerkAndroidApiVersion = "1.0.38" - clerkAndroidUiVersion = "1.0.38" + clerkAndroidApiVersion = "1.0.39" + clerkAndroidUiVersion = "1.0.39" composeVersion = "1.7.0" activityComposeVersion = "1.9.0" lifecycleVersion = "2.8.0" diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt index 3b4a0fe4cf0..40d39f1119a 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt @@ -1,3 +1,5 @@ +@file:OptIn(FrameworkIntegrationApi::class) + package expo.modules.clerk import android.content.Context @@ -15,10 +17,12 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.ViewModelStore import androidx.lifecycle.ViewModelStoreOwner import com.clerk.api.Clerk +import com.clerk.api.FrameworkIntegrationApi import com.clerk.api.ui.ClerkDesign import com.clerk.api.ui.ClerkTheme import com.clerk.ui.auth.AuthMode import com.clerk.ui.auth.AuthView +import com.clerk.ui.navigation.ClerkHostBackActionProvider import expo.modules.kotlin.AppContext import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition @@ -38,6 +42,7 @@ class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkCompo var mode: String? = null var logoView: View? = null private set + var hostBackButton: Boolean = false private var logoWidth = 0 private var logoHeight = 0 @@ -53,6 +58,7 @@ class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkCompo } private val onAuthEvent by EventDispatcher() + private val onHostBack by EventDispatcher() init { // At cold start, ClerkExpoModule.configure() may run before React's @@ -83,8 +89,17 @@ class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkCompo @Composable override fun Content() { - debugLog(TAG, "setupView - mode: $mode, isDismissible: $isDismissible, activity: $activity") + debugLog(TAG, "setupView - mode: $mode, isDismissible: $isDismissible, hostBackButton: $hostBackButton, activity: $activity") + + if (hostBackButton) { + ClerkHostBackActionProvider(onHostBack = { onHostBack(mapOf()) }) { AuthContent() } + } else { + AuthContent() + } + } + @Composable + private fun AuthContent() { AuthView( modifier = Modifier.fillMaxSize(), clerkTheme = authTheme(), @@ -167,7 +182,7 @@ class ClerkAuthViewModule : Module() { Name("ClerkAuthView") View(ClerkAuthNativeView::class) { - Events("onAuthEvent") + Events("onAuthEvent", "onHostBack") GroupView { AddChildView { parent, child, _ -> @@ -201,10 +216,13 @@ class ClerkAuthViewModule : Module() { view.logoMaxHeight = logoMaxHeight } + Prop("hostBackButton") { view: ClerkAuthNativeView, hostBackButton: Boolean -> + view.hostBackButton = hostBackButton + } + OnViewDidUpdateProps { view: ClerkAuthNativeView -> view.setupView() } - } } } diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt index cbb83c72df3..51826181aff 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt @@ -1,13 +1,15 @@ +@file:OptIn(FrameworkIntegrationApi::class) + package expo.modules.clerk import android.content.Context import android.util.Log -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier import androidx.lifecycle.ViewModelStore import androidx.lifecycle.ViewModelStoreOwner import com.clerk.api.Clerk +import com.clerk.api.FrameworkIntegrationApi +import com.clerk.ui.navigation.ClerkHostBackActionProvider import com.clerk.ui.userprofile.UserProfileView import expo.modules.kotlin.AppContext import expo.modules.kotlin.modules.Module @@ -25,7 +27,9 @@ private fun debugLog(tag: String, message: String) { class ClerkUserProfileNativeView(context: Context, appContext: AppContext) : ClerkComposeNativeViewHost(context, appContext) { // clerk-android UserProfileView dismissibility is controlled by its onDismiss callback. var isDismissible: Boolean = true + var hostBackButton: Boolean = false private val onProfileEvent by EventDispatcher() + private val onHostBack by EventDispatcher() private val viewModelStoreOwner = object : ViewModelStoreOwner { private val store = ViewModelStore() @@ -40,15 +44,24 @@ class ClerkUserProfileNativeView(context: Context, appContext: AppContext) : Cle @Composable override fun Content() { - debugLog(TAG, "setupView - isDismissible: $isDismissible") + debugLog(TAG, "setupView - isDismissible: $isDismissible, hostBackButton: $hostBackButton") + + if (hostBackButton) { + ClerkHostBackActionProvider(onHostBack = { onHostBack(mapOf()) }) { ProfileView() } + } else { + ProfileView() + } + } + @Composable + private fun ProfileView() { UserProfileView( clerkTheme = Clerk.customTheme, isDismissible = isDismissible, onDismiss = { debugLog(TAG, "Profile dismissed") sendEvent("dismissed") - } + }, ) } @@ -62,12 +75,16 @@ class ClerkUserProfileViewModule : Module() { Name("ClerkUserProfileView") View(ClerkUserProfileNativeView::class) { - Events("onProfileEvent") + Events("onProfileEvent", "onHostBack") Prop("isDismissible") { view: ClerkUserProfileNativeView, isDismissible: Boolean -> view.isDismissible = isDismissible } + Prop("hostBackButton") { view: ClerkUserProfileNativeView, hostBackButton: Boolean -> + view.hostBackButton = hostBackButton + } + OnViewDidUpdateProps { view: ClerkUserProfileNativeView -> view.setupView() } diff --git a/packages/expo/ios/ClerkAuthNativeView.swift b/packages/expo/ios/ClerkAuthNativeView.swift index 6d3ac2f97cb..e76a8be1b1c 100644 --- a/packages/expo/ios/ClerkAuthNativeView.swift +++ b/packages/expo/ios/ClerkAuthNativeView.swift @@ -7,9 +7,11 @@ public class ClerkAuthNativeView: ClerkNativeViewHost { private var currentLogoMaxHeight: CGFloat? private let logoState = ClerkInlineAuthLogoState() private var logoBoundsObservation: NSKeyValueObservation? + private var currentHostBackButton: Bool = false private var didSendDismiss = false let onAuthEvent = EventDispatcher() + let onHostBack = EventDispatcher() func setMode(_ mode: String?) { let newMode = mode ?? "signInOrUp" @@ -31,6 +33,13 @@ public class ClerkAuthNativeView: ClerkNativeViewHost { setNeedsHostedViewUpdate() } + func setHostBackButton(_ hostBackButton: Bool?) { + let newHostBackButton = hostBackButton ?? false + guard newHostBackButton != currentHostBackButton else { return } + currentHostBackButton = newHostBackButton + setNeedsHostedViewUpdate() + } + private func sendAuthEvent(type: ClerkNativeViewEvent) { onAuthEvent(["type": type.rawValue]) } @@ -98,11 +107,16 @@ public class ClerkAuthNativeView: ClerkNativeViewHost { } override func makeHostedController() -> UIViewController? { + let hostBackAction: (() -> Void)? = currentHostBackButton + ? { [weak self] in self?.onHostBack([:]) } + : nil + return ClerkNativeBridge.shared.makeAuthViewController( mode: currentMode, dismissible: currentDismissible, logoState: logoState, logoMaxHeight: currentLogoMaxHeight, + hostBackAction: hostBackAction, onEvent: { [weak self] event, _ in if event == .dismissed { self?.sendDismissIfNeeded() @@ -117,7 +131,7 @@ public class ClerkAuthViewModule: Module { Name("ClerkAuthView") View(ClerkAuthNativeView.self) { - Events("onAuthEvent") + Events("onAuthEvent", "onHostBack") Prop("mode") { (view: ClerkAuthNativeView, mode: String?) in view.setMode(mode) @@ -130,6 +144,11 @@ public class ClerkAuthViewModule: Module { Prop("logoMaxHeight") { (view: ClerkAuthNativeView, logoMaxHeight: CGFloat?) in view.setLogoMaxHeight(logoMaxHeight) } + + Prop("hostBackButton") { (view: ClerkAuthNativeView, hostBackButton: Bool?) in + view.setHostBackButton(hostBackButton) + } + } } } diff --git a/packages/expo/ios/ClerkExpo.podspec b/packages/expo/ios/ClerkExpo.podspec index 81192314a1e..e941f54fa15 100644 --- a/packages/expo/ios/ClerkExpo.podspec +++ b/packages/expo/ios/ClerkExpo.podspec @@ -18,7 +18,7 @@ else end clerk_ios_repo = 'https://github.com/clerk/clerk-ios.git' -clerk_ios_version = '1.3.5' +clerk_ios_version = '1.3.6' Pod::Spec.new do |s| s.name = 'ClerkExpo' diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo/ios/ClerkNativeBridge.swift index 1d2cf07fe3f..fccd156a501 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -4,7 +4,7 @@ import UIKit import SwiftUI import Observation @_spi(FrameworkIntegration) import ClerkKit -import ClerkKitUI +@_spi(FrameworkIntegration) import ClerkKitUI /// Events emitted by the native view wrappers to their React Native host views. public enum ClerkNativeViewEvent: String { @@ -271,6 +271,7 @@ final class ClerkNativeBridge { dismissible: Bool, logoState: ClerkInlineAuthLogoState, logoMaxHeight: CGFloat?, + hostBackAction: (() -> Void)? = nil, onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void ) -> UIViewController? { guard Self.clerkConfigured else { return nil } @@ -279,6 +280,7 @@ final class ClerkNativeBridge { rootView: ClerkInlineAuthWrapperView( mode: Self.authMode(from: mode), dismissible: dismissible, + hostBackAction: hostBackAction.map(ClerkHostBackAction.init), lightTheme: lightTheme, darkTheme: darkTheme, logoState: logoState, @@ -290,6 +292,7 @@ final class ClerkNativeBridge { func makeUserProfileViewController( dismissible: Bool, + hostBackAction: (() -> Void)? = nil, onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void ) -> UIViewController? { guard Self.clerkConfigured else { return nil } @@ -297,6 +300,7 @@ final class ClerkNativeBridge { return makeHostingController( rootView: ClerkInlineProfileWrapperView( dismissible: dismissible, + hostBackAction: hostBackAction.map(ClerkHostBackAction.init), lightTheme: lightTheme, darkTheme: darkTheme ), @@ -531,6 +535,7 @@ struct ClerkInlineUserButtonWrapperView: View { struct ClerkInlineAuthWrapperView: View { let mode: AuthView.Mode let dismissible: Bool + let hostBackAction: ClerkHostBackAction? let lightTheme: ClerkTheme? let darkTheme: ClerkTheme? let logoState: ClerkInlineAuthLogoState @@ -541,6 +546,7 @@ struct ClerkInlineAuthWrapperView: View { @ViewBuilder private var themedAuthView: some View { let view = AuthView(mode: mode, isDismissible: dismissible) .environment(Clerk.shared) + .environment(\.clerkHostBackAction, hostBackAction) let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme let themedView = Group { if let theme { @@ -635,6 +641,7 @@ private final class ClerkNativeHostingController: UIHostingContro struct ClerkInlineProfileWrapperView: View { let dismissible: Bool + let hostBackAction: ClerkHostBackAction? let lightTheme: ClerkTheme? let darkTheme: ClerkTheme? @@ -643,6 +650,7 @@ struct ClerkInlineProfileWrapperView: View { var body: some View { let view = UserProfileView(isDismissible: dismissible) .environment(Clerk.shared) + .environment(\.clerkHostBackAction, hostBackAction) let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme let themedView = Group { if let theme { diff --git a/packages/expo/ios/ClerkUserProfileNativeView.swift b/packages/expo/ios/ClerkUserProfileNativeView.swift index 78d8e298159..12d6248b1dc 100644 --- a/packages/expo/ios/ClerkUserProfileNativeView.swift +++ b/packages/expo/ios/ClerkUserProfileNativeView.swift @@ -3,9 +3,11 @@ import UIKit public class ClerkUserProfileNativeView: ClerkNativeViewHost { private var currentDismissible: Bool = true + private var currentHostBackButton: Bool = false private var didSendDismiss = false let onProfileEvent = EventDispatcher() + let onHostBack = EventDispatcher() func setDismissible(_ isDismissible: Bool?) { let newDismissible = isDismissible ?? true @@ -14,6 +16,13 @@ public class ClerkUserProfileNativeView: ClerkNativeViewHost { setNeedsHostedViewUpdate() } + func setHostBackButton(_ hostBackButton: Bool?) { + let newHostBackButton = hostBackButton ?? false + guard newHostBackButton != currentHostBackButton else { return } + currentHostBackButton = newHostBackButton + setNeedsHostedViewUpdate() + } + private func sendProfileEvent(type: ClerkNativeViewEvent) { onProfileEvent(["type": type.rawValue]) } @@ -34,8 +43,13 @@ public class ClerkUserProfileNativeView: ClerkNativeViewHost { } override func makeHostedController() -> UIViewController? { + let hostBackAction: (() -> Void)? = currentHostBackButton + ? { [weak self] in self?.onHostBack([:]) } + : nil + return ClerkNativeBridge.shared.makeUserProfileViewController( dismissible: currentDismissible, + hostBackAction: hostBackAction, onEvent: { [weak self] event, _ in if event == .dismissed { self?.sendDismissIfNeeded() @@ -50,11 +64,15 @@ public class ClerkUserProfileViewModule: Module { Name("ClerkUserProfileView") View(ClerkUserProfileNativeView.self) { - Events("onProfileEvent") + Events("onProfileEvent", "onHostBack") Prop("isDismissible") { (view: ClerkUserProfileNativeView, isDismissible: Bool?) in view.setDismissible(isDismissible) } + + Prop("hostBackButton") { (view: ClerkUserProfileNativeView, hostBackButton: Bool?) in + view.setHostBackButton(hostBackButton) + } } } } diff --git a/packages/expo/package.json b/packages/expo/package.json index 981a61635cb..3114f5fca4c 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -1,6 +1,6 @@ { "name": "@clerk/expo", - "version": "4.1.2", + "version": "4.2.2", "description": "Clerk React Native/Expo library", "keywords": [ "react", diff --git a/packages/expo/src/native/AuthView.tsx b/packages/expo/src/native/AuthView.tsx index 3067b9daa7a..3f4b51ee346 100644 --- a/packages/expo/src/native/AuthView.tsx +++ b/packages/expo/src/native/AuthView.tsx @@ -1,4 +1,4 @@ -import { type ReactElement, useCallback } from 'react'; +import { useCallback } from 'react'; import type { NativeSyntheticEvent } from 'react-native'; import { Text, View } from 'react-native'; @@ -19,6 +19,9 @@ type AuthNativeEvent = NativeSyntheticEvent>; * Use `useAuth()`, `useUser()`, or `useSession()` to react to authentication * state changes. * + * To push the auth flow onto your own navigation stack, hide the route's header and + * pass `onHostBack` so Clerk's own chrome takes over. + * * @example * ```tsx * import { AuthView } from '@clerk/expo/native'; @@ -43,7 +46,8 @@ export function AuthView({ isDismissible = true, logoMaxHeight, onDismiss, -}: AuthViewProps): ReactElement { + onHostBack, +}: AuthViewProps) { const handleAuthEvent = useCallback( (event: AuthNativeEvent) => { if (event.nativeEvent.type === 'dismissed') { @@ -71,7 +75,9 @@ export function AuthView({ mode={mode} isDismissible={isDismissible} logoMaxHeight={logoMaxHeight} + hostBackButton={!!onHostBack} onAuthEvent={handleAuthEvent} + onHostBack={onHostBack ? () => onHostBack() : undefined} > {logo ? ( + * router.back()} /> + * ``` + * + * The component never leaves the route on its own, so react to auth state + * for flow completion — swap the content in place, or pop the route. + */ + onHostBack?: () => void; +} diff --git a/packages/expo/src/native/UserProfileView.tsx b/packages/expo/src/native/UserProfileView.tsx index 1263569d5c2..e1eba05cf6d 100644 --- a/packages/expo/src/native/UserProfileView.tsx +++ b/packages/expo/src/native/UserProfileView.tsx @@ -4,11 +4,12 @@ import { StyleSheet, Text, View } from 'react-native'; import NativeClerkUserProfileView from '../specs/NativeClerkUserProfileView'; import { isNativeSupported } from '../utils/native-module'; +import type { EmbeddedNavigationProps } from './EmbeddedNavigation.types'; /** * Props for the UserProfileView component. */ -export interface UserProfileViewProps { +export interface UserProfileViewProps extends EmbeddedNavigationProps { /** * Whether the inline profile view shows a dismiss button. * @@ -39,6 +40,9 @@ export interface UserProfileViewProps { * * To present the profile, render it inside your own `Modal`, sheet, or route. * + * To push the profile onto your own navigation stack, hide the route's header and + * pass `onHostBack` so Clerk's own chrome takes over. + * * Sign-out is detected automatically and synced with the JS SDK. Use `useAuth()` in a * `useEffect` to react to sign-out. * @@ -60,7 +64,7 @@ export interface UserProfileViewProps { * * @see {@link https://clerk.com/docs/components/user/user-profile} Clerk UserProfile Documentation */ -export function UserProfileView({ isDismissible = true, style, onDismiss }: UserProfileViewProps) { +export function UserProfileView({ isDismissible = true, style, onDismiss, onHostBack }: UserProfileViewProps) { const handleProfileEvent = useCallback( (event: { nativeEvent: { type: string } }) => { if (event.nativeEvent.type === 'dismissed') { @@ -86,7 +90,9 @@ export function UserProfileView({ isDismissible = true, style, onDismiss }: User onHostBack() : undefined} /> ); } diff --git a/packages/expo/src/native/__tests__/UserProfileView.test.tsx b/packages/expo/src/native/__tests__/UserProfileView.test.tsx new file mode 100644 index 00000000000..099e3308fbc --- /dev/null +++ b/packages/expo/src/native/__tests__/UserProfileView.test.tsx @@ -0,0 +1,70 @@ +import { render } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, test, vi } from 'vitest'; + +import { UserProfileView } from '../UserProfileView'; + +const mocks = vi.hoisted(() => { + return { + nativeProps: vi.fn(), + }; +}); + +vi.mock('../../specs/NativeClerkUserProfileView', () => { + return { + default: (props: Record) => { + mocks.nativeProps(props); + return null; + }, + }; +}); + +vi.mock('../../utils/native-module', () => { + return { + isNativeSupported: true, + }; +}); + +vi.mock('react-native', () => { + return { + Text: ({ children }: { children?: React.ReactNode }) => React.createElement('span', null, children), + View: ({ children }: { children?: React.ReactNode }) => React.createElement('div', null, children), + StyleSheet: { create: (styles: T) => styles }, + }; +}); + +function lastNativeProps() { + return mocks.nativeProps.mock.calls.at(-1)?.[0]; +} + +describe('UserProfileView', () => { + test('calls onDismiss when the native profile view emits dismissed', () => { + const onDismiss = vi.fn(); + + render(); + + lastNativeProps().onProfileEvent({ nativeEvent: { type: 'dismissed' } }); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + test('shows the root back button and calls onHostBack when it is tapped', () => { + const onHostBack = vi.fn(); + + render(); + + const props = lastNativeProps(); + expect(props.hostBackButton).toBe(true); + props.onHostBack(); + + expect(onHostBack).toHaveBeenCalledTimes(1); + }); + + test('does not show a root back button without onHostBack', () => { + render(); + + const props = lastNativeProps(); + expect(props.hostBackButton).toBe(false); + expect(props.onHostBack).toBeUndefined(); + }); +}); diff --git a/packages/expo/src/native/index.ts b/packages/expo/src/native/index.ts index b59a8eeb106..d892fb9a851 100644 --- a/packages/expo/src/native/index.ts +++ b/packages/expo/src/native/index.ts @@ -30,6 +30,7 @@ export { AuthView } from './AuthView'; export type { AuthViewProps, AuthViewMode } from './AuthView.types'; +export type { EmbeddedNavigationProps } from './EmbeddedNavigation.types'; export { UserButton } from './UserButton'; export { UserProfileView } from './UserProfileView'; export type { UserProfileViewProps } from './UserProfileView'; diff --git a/packages/expo/src/specs/NativeClerkAuthView.android.ts b/packages/expo/src/specs/NativeClerkAuthView.android.ts index 3d1ea374baa..43c0ec2a435 100644 --- a/packages/expo/src/specs/NativeClerkAuthView.android.ts +++ b/packages/expo/src/specs/NativeClerkAuthView.android.ts @@ -7,7 +7,9 @@ interface NativeProps extends ViewProps { mode?: string; isDismissible?: boolean; logoMaxHeight?: number; + hostBackButton?: boolean; onAuthEvent?: (event: NativeSyntheticEvent) => void; + onHostBack?: (event: NativeSyntheticEvent) => void; } export default requireNativeView('ClerkAuthView'); diff --git a/packages/expo/src/specs/NativeClerkAuthView.ts b/packages/expo/src/specs/NativeClerkAuthView.ts index 32dc196c4b2..9a0de17dc62 100644 --- a/packages/expo/src/specs/NativeClerkAuthView.ts +++ b/packages/expo/src/specs/NativeClerkAuthView.ts @@ -8,7 +8,9 @@ interface NativeProps extends ViewProps { mode?: string; isDismissible?: boolean; logoMaxHeight?: number; + hostBackButton?: boolean; onAuthEvent?: (event: NativeSyntheticEvent) => void; + onHostBack?: (event: NativeSyntheticEvent) => void; } const NativeClerkAuthView = diff --git a/packages/expo/src/specs/NativeClerkUserProfileView.android.ts b/packages/expo/src/specs/NativeClerkUserProfileView.android.ts index 7ac253fb341..80192f2219f 100644 --- a/packages/expo/src/specs/NativeClerkUserProfileView.android.ts +++ b/packages/expo/src/specs/NativeClerkUserProfileView.android.ts @@ -5,7 +5,9 @@ type ProfileEvent = Readonly<{ type: string }>; interface NativeProps extends ViewProps { isDismissible?: boolean; + hostBackButton?: boolean; onProfileEvent?: (event: NativeSyntheticEvent) => void; + onHostBack?: (event: NativeSyntheticEvent) => void; } export default requireNativeView('ClerkUserProfileView'); diff --git a/packages/expo/src/specs/NativeClerkUserProfileView.ts b/packages/expo/src/specs/NativeClerkUserProfileView.ts index 819efcef803..1e8494d9b75 100644 --- a/packages/expo/src/specs/NativeClerkUserProfileView.ts +++ b/packages/expo/src/specs/NativeClerkUserProfileView.ts @@ -6,7 +6,9 @@ type ProfileEvent = Readonly<{ type: string }>; interface NativeProps extends ViewProps { isDismissible?: boolean; + hostBackButton?: boolean; onProfileEvent?: (event: NativeSyntheticEvent) => void; + onHostBack?: (event: NativeSyntheticEvent) => void; } const NativeClerkUserProfileView = diff --git a/packages/express/CHANGELOG.md b/packages/express/CHANGELOG.md index b6d14fc9fe8..510a4646f5d 100644 --- a/packages/express/CHANGELOG.md +++ b/packages/express/CHANGELOG.md @@ -1,5 +1,31 @@ # Change Log +## 2.1.51 + +### Patch Changes + +- Updated dependencies [[`1ef84c3`](https://github.com/clerk/javascript/commit/1ef84c3592cee8a7d3ec5f40a9826862afe125e7), [`d639048`](https://github.com/clerk/javascript/commit/d639048e0e48ff3a120435134f9e01221697b6bc), [`f38cf02`](https://github.com/clerk/javascript/commit/f38cf02fd55a551fcf1d43c89371cf2132c2ba92), [`a66cbbf`](https://github.com/clerk/javascript/commit/a66cbbf549477cf8afc155ad17d29e48078e60df)]: + - @clerk/backend@3.16.0 + - @clerk/shared@4.27.0 + +## 2.1.50 + +### Patch Changes + +- Respond with 400 Bad Request instead of surfacing a 500 when an incoming request cannot be represented as a fetch `Request`. Vulnerability-scanner probes such as hostless `//` request targets, targets that parse as credentialed URLs, and forbidden methods like TRACE previously threw inside the middleware and polluted error logs. ([#9290](https://github.com/clerk/javascript/pull/9290)) by [@wobsoriano](https://github.com/wobsoriano) + +- Updated dependencies [[`a601cd7`](https://github.com/clerk/javascript/commit/a601cd7f45095fdbf8b0a23b01d9f559feeda347), [`5c81479`](https://github.com/clerk/javascript/commit/5c81479d303fc6146dc81309d0b58564aa96706e)]: + - @clerk/backend@3.15.1 + - @clerk/shared@4.26.0 + +## 2.1.49 + +### Patch Changes + +- Updated dependencies [[`9c51d74`](https://github.com/clerk/javascript/commit/9c51d74ac36391888367e4da44912c92999a7ac2), [`aaea141`](https://github.com/clerk/javascript/commit/aaea141d62804624cd8cd73036b4afe6f482184f), [`fe6ee54`](https://github.com/clerk/javascript/commit/fe6ee5489d9efcdc5aec53b1ba74b0260e539f80)]: + - @clerk/backend@3.15.0 + - @clerk/shared@4.25.10 + ## 2.1.48 ### Patch Changes diff --git a/packages/express/package.json b/packages/express/package.json index bb17ab0b785..bcfc8e9d124 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -1,6 +1,6 @@ { "name": "@clerk/express", - "version": "2.1.48", + "version": "2.1.51", "description": "Clerk server SDK for usage with Express", "keywords": [ "clerk", diff --git a/packages/express/src/__tests__/clerkMiddleware.test.ts b/packages/express/src/__tests__/clerkMiddleware.test.ts index 53f6e77c240..e9fe0f48f73 100644 --- a/packages/express/src/__tests__/clerkMiddleware.test.ts +++ b/packages/express/src/__tests__/clerkMiddleware.test.ts @@ -1,5 +1,7 @@ import type * as ClerkBackend from '@clerk/backend'; import type { Request, RequestHandler, Response } from 'express'; +import express from 'express'; +import supertest from 'supertest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { mockClerkFrontendApiProxy } = vi.hoisted(() => ({ @@ -565,19 +567,46 @@ describe('clerkMiddleware', () => { }); }); - it('calls next with an error when request URL is invalid', () => { - const req = { - url: '//', - cookies: {}, - headers: { host: 'example.com' }, - } as Request; - const res = {} as Response; - const mockNext = vi.fn(); + describe('requests that cannot be converted to a web Request', () => { + it('responds 400 without calling next when the request URL is invalid', async () => { + const req = { + method: 'GET', + url: '//', + cookies: {}, + headers: { host: 'example.com' }, + } as Request; + const status = vi.fn().mockReturnThis(); + const end = vi.fn(); + const res = { status, end } as unknown as Response; + const mockNext = vi.fn(); + + await clerkMiddleware()(req, res, mockNext); + + expect(status).toHaveBeenCalledWith(400); + expect(end).toHaveBeenCalled(); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('responds 400 to a hostless // request target', async () => { + await runMiddlewareOnPath(clerkMiddleware(), '//').expect(400); + }); - clerkMiddleware()(req, res, mockNext); + it('responds 400 to a request target that parses as a credentialed URL', async () => { + await runMiddlewareOnPath(clerkMiddleware(), '//$%7B%23context@example.com%7D.action').expect(400); + }); + + it('responds 400 to a forbidden method (TRACE)', async () => { + const app = express(); + app.use(clerkMiddleware()); + app.use((_req, res) => res.end('Hello world!')); + + await supertest(app).trace('/').expect(400); + }); - expect(mockNext.mock.calls[0][0].message).toBe('Invalid URL'); + it('responds 400 to a hostless // request target when proxy is enabled', async () => { + await runMiddlewareOnPath(clerkMiddleware({ frontendApiProxy: { enabled: true } }), '//').expect(400); - mockNext.mockReset(); + expect(mockClerkFrontendApiProxy).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/express/src/authenticateRequest.ts b/packages/express/src/authenticateRequest.ts index 4e63141d88f..805a0ebd9d7 100644 --- a/packages/express/src/authenticateRequest.ts +++ b/packages/express/src/authenticateRequest.ts @@ -1,5 +1,5 @@ import { createClerkClient } from '@clerk/backend'; -import type { RequestState } from '@clerk/backend/internal'; +import type { ClerkRequest, RequestState } from '@clerk/backend/internal'; import { AuthStatus, createClerkRequest } from '@clerk/backend/internal'; import { clerkFrontendApiProxy, DEFAULT_PROXY_PATH, stripTrailingSlashes } from '@clerk/backend/proxy'; import { isDevelopmentFromSecretKey } from '@clerk/shared/keys'; @@ -51,7 +51,7 @@ export const authenticateRequest = (opts: AuthenticateRequestParams) => { ...restOptions } = options || {}; - const clerkRequest = createClerkRequest(incomingMessageToRequest(request)); + const clerkRequest = opts.clerkRequest ?? createClerkRequest(incomingMessageToRequest(request)); const env = { ...loadApiEnv(), ...loadClientEnv() }; const secretKey = secretKeyInput || env.secretKey; @@ -163,13 +163,28 @@ export const authenticateAndDecorateRequest = (options: ClerkMiddlewareOptions = ); } + // Node accepts request targets/methods (`//`, TRACE) the fetch spec cannot represent; reject those instead of 500ing. + let clerkRequest: ClerkRequest; + try { + clerkRequest = createClerkRequest(incomingMessageToRequest(request)); + } catch { + response.status(400).end(); + return; + } + const env = { ...loadApiEnv(), ...loadClientEnv() }; const publishableKey = options.publishableKey || env.publishableKey; const secretKey = options.secretKey || env.secretKey; // Handle Frontend API proxy requests early, before authentication if (frontendApiProxy) { - const requestUrl = new URL(request.originalUrl || request.url, `http://${request.headers.host}`); + let requestUrl: URL; + try { + requestUrl = new URL(request.originalUrl || request.url, `http://${request.headers.host}`); + } catch { + response.status(400).end(); + return; + } const isEnabled = typeof frontendApiProxy.enabled === 'function' ? frontendApiProxy.enabled(requestUrl) @@ -177,7 +192,13 @@ export const authenticateAndDecorateRequest = (options: ClerkMiddlewareOptions = if (isEnabled && (requestUrl.pathname === proxyPath || requestUrl.pathname.startsWith(proxyPath + '/'))) { // Convert Express request to Fetch API Request - const proxyRequest = requestToProxyRequest(request); + let proxyRequest: Request; + try { + proxyRequest = requestToProxyRequest(request); + } catch { + response.status(400).end(); + return; + } // Call the core proxy function const proxyResponse = await clerkFrontendApiProxy(proxyRequest, { @@ -220,7 +241,13 @@ export const authenticateAndDecorateRequest = (options: ClerkMiddlewareOptions = // against the request's public origin (from x-forwarded-* headers). let resolvedOptions = options; if (frontendApiProxy && !options.proxyUrl) { - const requestUrl = new URL(request.originalUrl || request.url, `http://${request.headers.host}`); + let requestUrl: URL; + try { + requestUrl = new URL(request.originalUrl || request.url, `http://${request.headers.host}`); + } catch { + response.status(400).end(); + return; + } const isProxyEnabled = typeof frontendApiProxy.enabled === 'function' ? frontendApiProxy.enabled(requestUrl) @@ -235,6 +262,7 @@ export const authenticateAndDecorateRequest = (options: ClerkMiddlewareOptions = clerkClient, request, options: resolvedOptions, + clerkRequest, }); const err = setResponseHeaders(requestState, response); diff --git a/packages/express/src/types.ts b/packages/express/src/types.ts index 4d889de3dbb..5de6e2ec3e5 100644 --- a/packages/express/src/types.ts +++ b/packages/express/src/types.ts @@ -1,5 +1,10 @@ import type { createClerkClient } from '@clerk/backend'; -import type { AuthenticateRequestOptions, SignedInAuthObject, SignedOutAuthObject } from '@clerk/backend/internal'; +import type { + AuthenticateRequestOptions, + ClerkRequest, + SignedInAuthObject, + SignedOutAuthObject, +} from '@clerk/backend/internal'; import type { ShouldProxyFn } from '@clerk/shared/proxy'; import type { PendingSessionOptions } from '@clerk/shared/types'; import type { Request as ExpressRequest } from 'express'; @@ -59,4 +64,6 @@ export type AuthenticateRequestParams = { clerkClient: ClerkClient; request: ExpressRequest; options?: ClerkMiddlewareOptions; + /** Prebuilt ClerkRequest, so callers that already converted the request can skip re-conversion. */ + clerkRequest?: ClerkRequest; }; diff --git a/packages/fastify/CHANGELOG.md b/packages/fastify/CHANGELOG.md index 4b508e3ccaf..c621ed3cab0 100644 --- a/packages/fastify/CHANGELOG.md +++ b/packages/fastify/CHANGELOG.md @@ -1,5 +1,31 @@ # Change Log +## 3.1.61 + +### Patch Changes + +- Updated dependencies [[`1ef84c3`](https://github.com/clerk/javascript/commit/1ef84c3592cee8a7d3ec5f40a9826862afe125e7), [`d639048`](https://github.com/clerk/javascript/commit/d639048e0e48ff3a120435134f9e01221697b6bc), [`f38cf02`](https://github.com/clerk/javascript/commit/f38cf02fd55a551fcf1d43c89371cf2132c2ba92), [`a66cbbf`](https://github.com/clerk/javascript/commit/a66cbbf549477cf8afc155ad17d29e48078e60df)]: + - @clerk/backend@3.16.0 + - @clerk/shared@4.27.0 + +## 3.1.60 + +### Patch Changes + +- Respond with 400 Bad Request instead of surfacing a 500 when an incoming request cannot be represented as a fetch `Request`. Vulnerability-scanner probes such as hostless `//` request targets, targets that parse as credentialed URLs, and forbidden methods like TRACE previously threw inside the middleware and polluted error logs. ([#9290](https://github.com/clerk/javascript/pull/9290)) by [@wobsoriano](https://github.com/wobsoriano) + +- Updated dependencies [[`a601cd7`](https://github.com/clerk/javascript/commit/a601cd7f45095fdbf8b0a23b01d9f559feeda347), [`5c81479`](https://github.com/clerk/javascript/commit/5c81479d303fc6146dc81309d0b58564aa96706e)]: + - @clerk/backend@3.15.1 + - @clerk/shared@4.26.0 + +## 3.1.59 + +### Patch Changes + +- Updated dependencies [[`9c51d74`](https://github.com/clerk/javascript/commit/9c51d74ac36391888367e4da44912c92999a7ac2), [`aaea141`](https://github.com/clerk/javascript/commit/aaea141d62804624cd8cd73036b4afe6f482184f), [`fe6ee54`](https://github.com/clerk/javascript/commit/fe6ee5489d9efcdc5aec53b1ba74b0260e539f80)]: + - @clerk/backend@3.15.0 + - @clerk/shared@4.25.10 + ## 3.1.58 ### Patch Changes diff --git a/packages/fastify/package.json b/packages/fastify/package.json index 28572a9394b..c50b5c87439 100644 --- a/packages/fastify/package.json +++ b/packages/fastify/package.json @@ -1,6 +1,6 @@ { "name": "@clerk/fastify", - "version": "3.1.58", + "version": "3.1.61", "description": "Clerk SDK for Fastify", "keywords": [ "auth", diff --git a/packages/fastify/src/__tests__/frontendApiProxy.test.ts b/packages/fastify/src/__tests__/frontendApiProxy.test.ts index 5b19695f0e7..09333a4704e 100644 --- a/packages/fastify/src/__tests__/frontendApiProxy.test.ts +++ b/packages/fastify/src/__tests__/frontendApiProxy.test.ts @@ -158,6 +158,25 @@ describe('Frontend API proxy handling', () => { expect(mockClerkFrontendApiProxy).not.toHaveBeenCalled(); }); + it('responds 400 to a hostless // request target when proxy is enabled', async () => { + const response = await injectOnPath({ frontendApiProxy: { enabled: true } }, '//'); + + expect(response.statusCode).toEqual(400); + expect(mockClerkFrontendApiProxy).not.toHaveBeenCalled(); + expect(authenticateRequestMock).not.toHaveBeenCalled(); + }); + + it('responds 400 to a forbidden method (TRACE) on the proxy path', async () => { + const fastify = Fastify(); + await fastify.register(clerkPlugin, { frontendApiProxy: { enabled: true } }); + + const response = await fastify.inject({ method: 'TRACE' as 'GET', path: '/__clerk/v1/client' }); + + expect(response.statusCode).toEqual(400); + expect(mockClerkFrontendApiProxy).not.toHaveBeenCalled(); + expect(authenticateRequestMock).not.toHaveBeenCalled(); + }); + it('auto-derives proxyUrl for authentication when proxy is enabled', async () => { authenticateRequestMock.mockResolvedValueOnce({ headers: new Headers(), diff --git a/packages/fastify/src/__tests__/withClerkMiddleware.test.ts b/packages/fastify/src/__tests__/withClerkMiddleware.test.ts index 46a80e25d49..b9fda7b8a4e 100644 --- a/packages/fastify/src/__tests__/withClerkMiddleware.test.ts +++ b/packages/fastify/src/__tests__/withClerkMiddleware.test.ts @@ -243,6 +243,47 @@ describe('withClerkMiddleware(options)', () => { ); }); + describe('requests that cannot be converted to a web Request', () => { + const setup = async () => { + const fastify = Fastify(); + await fastify.register(clerkPlugin); + fastify.get('/', (request: FastifyRequest, reply: FastifyReply) => { + reply.send({ auth: getAuth(request) }); + }); + return fastify; + }; + + test('responds 400 to a hostless // request target instead of throwing', async () => { + const fastify = await setup(); + + const response = await fastify.inject({ method: 'GET', path: '//' }); + + expect(response.statusCode).toEqual(400); + expect(authenticateRequestMock).not.toHaveBeenCalled(); + }); + + test('responds 400 to a request target that parses as a credentialed URL', async () => { + const fastify = await setup(); + + const response = await fastify.inject({ + method: 'GET', + path: "//$%7B%23context['xwork.MethodAccessor.denyMethodExecution']@example.com%7D.action", + }); + + expect(response.statusCode).toEqual(400); + expect(authenticateRequestMock).not.toHaveBeenCalled(); + }); + + test('responds 400 to a forbidden method (TRACE) instead of throwing', async () => { + const fastify = await setup(); + + const response = await fastify.inject({ method: 'TRACE' as 'GET', path: '/' }); + + expect(response.statusCode).toEqual(400); + expect(authenticateRequestMock).not.toHaveBeenCalled(); + }); + }); + test('handles signout case by populating the req.auth', async () => { authenticateRequestMock.mockResolvedValueOnce({ headers: new Headers(), diff --git a/packages/fastify/src/withClerkMiddleware.ts b/packages/fastify/src/withClerkMiddleware.ts index 17751c0cf50..2212b23beb9 100644 --- a/packages/fastify/src/withClerkMiddleware.ts +++ b/packages/fastify/src/withClerkMiddleware.ts @@ -31,10 +31,15 @@ export const withClerkMiddleware = (options: ClerkFastifyOptions) => { // Handle Frontend API proxy requests and auto-derive proxyUrl let resolvedProxyUrl = options.proxyUrl; if (frontendApiProxy) { - const requestUrl = new URL( - fastifyRequest.url, - `${fastifyRequest.protocol}://${fastifyRequest.hostname || 'localhost'}`, - ); + let requestUrl: URL; + try { + requestUrl = new URL( + fastifyRequest.url, + `${fastifyRequest.protocol}://${fastifyRequest.hostname || 'localhost'}`, + ); + } catch { + return reply.code(400).send(); + } const isEnabled = typeof frontendApiProxy.enabled === 'function' ? frontendApiProxy.enabled(requestUrl) @@ -42,7 +47,12 @@ export const withClerkMiddleware = (options: ClerkFastifyOptions) => { if (isEnabled) { if (requestUrl.pathname === proxyPath || requestUrl.pathname.startsWith(proxyPath + '/')) { - const proxyRequest = requestToProxyRequest(fastifyRequest); + let proxyRequest: Request; + try { + proxyRequest = requestToProxyRequest(fastifyRequest); + } catch { + return reply.code(400).send(); + } const proxyResponse = await clerkFrontendApiProxy(proxyRequest, { proxyPath, @@ -84,7 +94,13 @@ export const withClerkMiddleware = (options: ClerkFastifyOptions) => { } } - const req = fastifyRequestToRequest(fastifyRequest); + // Node accepts request targets/methods (`//`, TRACE) the fetch spec cannot represent; reject those instead of 500ing. + let req: Request; + try { + req = fastifyRequestToRequest(fastifyRequest); + } catch { + return reply.code(400).send(); + } const requestState = await clerkClient.authenticateRequest(req, { ...options, diff --git a/packages/headless/CHANGELOG.md b/packages/headless/CHANGELOG.md index 9f606ea397f..469b729d9c8 100644 --- a/packages/headless/CHANGELOG.md +++ b/packages/headless/CHANGELOG.md @@ -1,5 +1,26 @@ # @clerk/headless +## 0.0.20 + +### Patch Changes + +- Updated dependencies [[`1ef84c3`](https://github.com/clerk/javascript/commit/1ef84c3592cee8a7d3ec5f40a9826862afe125e7), [`d639048`](https://github.com/clerk/javascript/commit/d639048e0e48ff3a120435134f9e01221697b6bc), [`a66cbbf`](https://github.com/clerk/javascript/commit/a66cbbf549477cf8afc155ad17d29e48078e60df)]: + - @clerk/shared@4.27.0 + +## 0.0.19 + +### Patch Changes + +- Updated dependencies [[`5c81479`](https://github.com/clerk/javascript/commit/5c81479d303fc6146dc81309d0b58564aa96706e)]: + - @clerk/shared@4.26.0 + +## 0.0.18 + +### Patch Changes + +- Updated dependencies [[`aaea141`](https://github.com/clerk/javascript/commit/aaea141d62804624cd8cd73036b4afe6f482184f)]: + - @clerk/shared@4.25.10 + ## 0.0.17 ### Patch Changes diff --git a/packages/headless/package.json b/packages/headless/package.json index 4833f2e1f0b..44c53d44433 100644 --- a/packages/headless/package.json +++ b/packages/headless/package.json @@ -1,6 +1,6 @@ { "name": "@clerk/headless", - "version": "0.0.17", + "version": "0.0.20", "private": true, "sideEffects": false, "type": "module", diff --git a/packages/headless/src/hooks/use-return-focus.test.ts b/packages/headless/src/hooks/use-return-focus.test.ts new file mode 100644 index 00000000000..7cca04b4c9d --- /dev/null +++ b/packages/headless/src/hooks/use-return-focus.test.ts @@ -0,0 +1,98 @@ +import type { FloatingEvents } from '@floating-ui/react'; +import { renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { useReturnFocus } from './use-return-focus'; + +function createEvents(): FloatingEvents & { close: (event?: Event) => void } { + const handlers = new Map void>>(); + + return { + emit(event, data) { + handlers.get(event)?.forEach(handler => handler(data)); + }, + on(event, handler) { + handlers.set(event, [...(handlers.get(event) ?? []), handler]); + }, + off(event, handler) { + handlers.set( + event, + (handlers.get(event) ?? []).filter(h => h !== handler), + ); + }, + close(event) { + this.emit('openchange', { open: false, event }); + }, + }; +} + +function renderReturnFocus(trigger: HTMLElement) { + const events = createEvents(); + + const { result, rerender } = renderHook( + ({ open }: { open: boolean }) => + useReturnFocus({ open, events, elements: { domReference: trigger, reference: trigger, floating: null } }), + { initialProps: { open: false } }, + ); + + return { events, result, open: (open: boolean) => rerender({ open }) }; +} + +let trigger: HTMLElement; + +afterEach(() => trigger?.remove()); + +describe('useReturnFocus', () => { + beforeEach(() => { + trigger = document.createElement('button'); + document.body.append(trigger); + }); + + it('resolves to the trigger while open', () => { + const { result, open } = renderReturnFocus(trigger); + + open(true); + + expect(result.current.current).toBe(trigger); + }); + + it('keeps the trigger when the close came from the keyboard', () => { + const { events, result, open } = renderReturnFocus(trigger); + open(true); + + events.close(new KeyboardEvent('keydown', { key: 'Escape' })); + + expect(result.current.current).toBe(trigger); + }); + + it('keeps the trigger when the close came from a control inside the popup', () => { + const { events, result, open } = renderReturnFocus(trigger); + open(true); + + // A Close button or menu item routes through the consumer's own state setter, + // so floating-ui reports the change with no event behind it. + events.close(); + + expect(result.current.current).toBe(trigger); + }); + + it('leaves focus alone when the close came from a pointer', () => { + const { events, result, open } = renderReturnFocus(trigger); + open(true); + + events.close(new MouseEvent('mousedown', { detail: 1 })); + + expect(result.current.current).toBeNull(); + }); + + it('restores the trigger on the next open', () => { + const { events, result, open } = renderReturnFocus(trigger); + open(true); + events.close(new MouseEvent('mousedown', { detail: 1 })); + + open(false); + open(true); + + expect(result.current.current).toBe(trigger); + }); +}); diff --git a/packages/headless/src/hooks/use-return-focus.ts b/packages/headless/src/hooks/use-return-focus.ts new file mode 100644 index 00000000000..5e45a0ad510 --- /dev/null +++ b/packages/headless/src/hooks/use-return-focus.ts @@ -0,0 +1,49 @@ +'use client'; + +import type { FloatingContext } from '@floating-ui/react'; +import { useEffect, useRef } from 'react'; + +import { isKeyboardEvent } from '../utils/interaction-modality'; + +/** + * The element `FloatingFocusManager` restores focus to when the floating element closes. + * + * The trigger is the default, which is what a keyboard user needs. Safari never focuses a + * button it was clicked on, so after a pointer dismiss the popup is the only thing the page + * has focused: restoring focus to the trigger then matches `:focus-visible` and paints a ring + * the user never asked for. A pointer dismiss therefore resolves to `null`, which leaves focus + * where the pointer left it, the same choice Base UI makes from its close interaction type. + * + * Pass the result to `FloatingFocusManager`'s `returnFocus`. On `null` it falls back to the + * hidden guard element it keeps next to the trigger, so the tab position survives; verify that + * still holds when upgrading `@floating-ui/react`. + */ +export function useReturnFocus( + context: Pick, +): React.MutableRefObject { + const { open, events, elements } = context; + const returnFocusRef = useRef(null); + const trigger = elements.domReference; + + useEffect(() => { + if (open) { + returnFocusRef.current = trigger instanceof HTMLElement ? trigger : null; + } + }, [open, trigger]); + + useEffect(() => { + // Closes routed straight through the consumer's own state setter (a Close button, an + // item click) never reach floating-ui, so only what floating-ui itself drives can + // downgrade the default. + function onOpenChange({ open, event }: { open: boolean; event?: Event }) { + if (!open && event && !isKeyboardEvent(event)) { + returnFocusRef.current = null; + } + } + + events.on('openchange', onOpenChange); + return () => events.off('openchange', onOpenChange); + }, [events]); + + return returnFocusRef; +} diff --git a/packages/headless/src/primitives/autocomplete/autocomplete.test.tsx b/packages/headless/src/primitives/autocomplete/autocomplete.test.tsx index bbdad88c7ba..18066c37bd7 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete.test.tsx +++ b/packages/headless/src/primitives/autocomplete/autocomplete.test.tsx @@ -643,6 +643,7 @@ describe('Autocomplete', () => { return ( { setPopoverOpen(open); @@ -786,6 +787,7 @@ describe('Autocomplete', () => { return ( { setPopoverOpen(open); @@ -880,6 +882,7 @@ describe('Autocomplete', () => { return ( { setPopoverOpen(open); diff --git a/packages/headless/src/primitives/dialog/dialog-context.ts b/packages/headless/src/primitives/dialog/dialog-context.ts index 863d26af38e..b730f698d36 100644 --- a/packages/headless/src/primitives/dialog/dialog-context.ts +++ b/packages/headless/src/primitives/dialog/dialog-context.ts @@ -11,6 +11,8 @@ export interface DialogContextValue { getReferenceProps: UseInteractionsReturn['getReferenceProps']; getFloatingProps: UseInteractionsReturn['getFloatingProps']; popupRef: React.RefObject; + /** Where focus goes when the dialog closes, or `null` to leave focus alone. */ + returnFocusRef: React.MutableRefObject; modal: boolean; labelId: string; descriptionId: string; diff --git a/packages/headless/src/primitives/dialog/dialog-popup.tsx b/packages/headless/src/primitives/dialog/dialog-popup.tsx index 6e11828a734..98a1d706987 100644 --- a/packages/headless/src/primitives/dialog/dialog-popup.tsx +++ b/packages/headless/src/primitives/dialog/dialog-popup.tsx @@ -12,8 +12,18 @@ export type DialogPopupProps = ComponentProps<'div'>; /** The dialog content container. Manages focus trapping via `FloatingFocusManager` and wires ARIA attributes from `Dialog.Title` and `Dialog.Description`. */ export const DialogPopup = React.forwardRef(function DialogPopup(props, ref) { const { render, ...otherProps } = props; - const { popupRef, refs, getFloatingProps, floatingContext, modal, labelId, descriptionId, mounted, transitionProps } = - useDialogContext(); + const { + popupRef, + refs, + getFloatingProps, + floatingContext, + modal, + returnFocusRef, + labelId, + descriptionId, + mounted, + transitionProps, + } = useDialogContext(); const ownProps = { 'aria-labelledby': labelId, @@ -43,6 +53,7 @@ export const DialogPopup = React.forwardRef(fu context={floatingContext} modal={modal} outsideElementsInert={modal} + returnFocus={returnFocusRef} > {element} diff --git a/packages/headless/src/primitives/dialog/dialog-root.tsx b/packages/headless/src/primitives/dialog/dialog-root.tsx index ef61f56b1a0..471029c7956 100644 --- a/packages/headless/src/primitives/dialog/dialog-root.tsx +++ b/packages/headless/src/primitives/dialog/dialog-root.tsx @@ -14,6 +14,7 @@ import { import { type ReactNode, useId, useMemo, useRef } from 'react'; import { useControllableState } from '../../hooks/use-controllable-state'; +import { useReturnFocus } from '../../hooks/use-return-focus'; import { useTransition } from '../../hooks/use-transition'; import { DialogContext, type DialogContextValue } from './dialog-context'; @@ -43,6 +44,8 @@ function DialogInner(props: DialogProps) { onOpenChange: setOpen, }); + const returnFocusRef = useReturnFocus(floatingContext); + const { mounted, transitionProps } = useTransition({ open, ref: popupRef, @@ -65,6 +68,7 @@ function DialogInner(props: DialogProps) { getReferenceProps, getFloatingProps, popupRef, + returnFocusRef, modal, labelId, descriptionId, @@ -78,6 +82,7 @@ function DialogInner(props: DialogProps) { refs, getReferenceProps, getFloatingProps, + returnFocusRef, modal, labelId, descriptionId, diff --git a/packages/headless/src/primitives/drawer/drawer-popup.tsx b/packages/headless/src/primitives/drawer/drawer-popup.tsx index 77df11eedd3..5388e23153f 100644 --- a/packages/headless/src/primitives/drawer/drawer-popup.tsx +++ b/packages/headless/src/primitives/drawer/drawer-popup.tsx @@ -24,6 +24,7 @@ export const DrawerPopup = React.forwardRef(fu getFloatingProps, floatingContext, modal, + returnFocusRef, labelId, descriptionId, mounted, @@ -107,6 +108,7 @@ export const DrawerPopup = React.forwardRef(fu modal={modal} outsideElementsInert={modal} initialFocus={autoFocus ? undefined : popupRef} + returnFocus={returnFocusRef} > {element} diff --git a/packages/headless/src/primitives/drawer/drawer-root.tsx b/packages/headless/src/primitives/drawer/drawer-root.tsx index b6b968b296f..34e400fdaeb 100644 --- a/packages/headless/src/primitives/drawer/drawer-root.tsx +++ b/packages/headless/src/primitives/drawer/drawer-root.tsx @@ -14,6 +14,7 @@ import { import { type ReactNode, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import { useControllableState } from '../../hooks/use-controllable-state'; +import { useReturnFocus } from '../../hooks/use-return-focus'; import { useTransition } from '../../hooks/use-transition'; import { DrawerAttrs, DrawerCssVars, registerDrawerCssVars } from './css-vars'; import { @@ -119,6 +120,8 @@ function DrawerInner(props: DrawerProps) { onOpenChange: setOpen, }); + const returnFocusRef = useReturnFocus(floatingContext); + const { mounted, transitionProps } = useTransition({ open, ref: popupRef }); const click = useClick(floatingContext); @@ -223,6 +226,7 @@ function DrawerInner(props: DrawerProps) { getFloatingProps, popupRef, backdropRef, + returnFocusRef, modal, labelId, descriptionId, @@ -247,6 +251,7 @@ function DrawerInner(props: DrawerProps) { refs, getReferenceProps, getFloatingProps, + returnFocusRef, modal, labelId, descriptionId, diff --git a/packages/headless/src/primitives/menu/menu-context.ts b/packages/headless/src/primitives/menu/menu-context.ts index 1d3b464b08a..2f89b7b830b 100644 --- a/packages/headless/src/primitives/menu/menu-context.ts +++ b/packages/headless/src/primitives/menu/menu-context.ts @@ -24,6 +24,8 @@ export interface MenuContextValue { labelsRef: React.MutableRefObject>; arrowRef: React.MutableRefObject; popupRef: React.RefObject; + /** Where focus goes when the menu closes, or `null` to leave focus alone. */ + returnFocusRef: React.MutableRefObject; isNested: boolean; mounted: boolean; transitionProps: TransitionProps; diff --git a/packages/headless/src/primitives/menu/menu-positioner.tsx b/packages/headless/src/primitives/menu/menu-positioner.tsx index d64fec49d3a..69e9a0c0712 100644 --- a/packages/headless/src/primitives/menu/menu-positioner.tsx +++ b/packages/headless/src/primitives/menu/menu-positioner.tsx @@ -3,7 +3,7 @@ import { FloatingFocusManager, FloatingList } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; +import { type ComponentProps, type DefaultProps, isKeyboardOpen, mergeProps, useRender } from '../../utils'; import { useMenuContext } from './menu-context'; export type MenuPositionerProps = ComponentProps<'div'>; @@ -21,6 +21,7 @@ export const MenuPositioner = React.forwardRef { + if (isNested) { + return baseRole; + } + const reference = { ...baseRole.reference }; + delete reference.role; + return { ...baseRole, reference }; + }, [baseRole, isNested]); const dismiss = useDismiss(floatingContext, { bubbles: true }); const listNavigation = useListNavigation(floatingContext, { listRef: elementsRef, @@ -166,6 +182,7 @@ function MenuInner(props: MenuProps) { labelsRef, arrowRef, popupRef, + returnFocusRef, isNested, mounted, transitionProps, @@ -181,6 +198,7 @@ function MenuInner(props: MenuProps) { getFloatingProps, getItemProps, activeIndex, + returnFocusRef, isNested, mounted, transitionProps, diff --git a/packages/headless/src/primitives/menu/menu.test.tsx b/packages/headless/src/primitives/menu/menu.test.tsx index dbc19745c69..0baf2917088 100644 --- a/packages/headless/src/primitives/menu/menu.test.tsx +++ b/packages/headless/src/primitives/menu/menu.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { axe } from '../../test-utils/axe'; +import { Popover } from '../popover'; import { Menu } from './index'; afterEach(() => cleanup()); @@ -399,6 +400,45 @@ describe('Menu', () => { }); describe('focus management', () => { + it('focuses the menu itself, not an item, when opened with a pointer', async () => { + const user = userEvent.setup(); + render( + + Actions + + + Cut + + + , + ); + + await user.click(screen.getByText('Actions')); + await new Promise(r => requestAnimationFrame(r)); + + expect(document.activeElement).toBe(document.querySelector('[data-testid="menu-positioner"]')); + }); + + it('focuses the first item when opened with the keyboard', async () => { + const user = userEvent.setup(); + render( + + Actions + + + Cut + + + , + ); + + screen.getByText('Actions').focus(); + await user.keyboard('{Enter}'); + await new Promise(r => requestAnimationFrame(r)); + + expect(document.activeElement).toBe(screen.getByText('Cut')); + }); + it('returns focus to trigger on close via Escape', async () => { const user = userEvent.setup(); render( @@ -559,6 +599,34 @@ describe('Menu', () => { expect(shareTrigger).toHaveAttribute('role', 'menuitem'); }); + it('leaves a trigger inside a popover as a plain button', async () => { + const user = userEvent.setup(); + render( + + Account + + + + Actions + + + Sign out + + + + + + , + ); + + await user.click(screen.getByText('Account')); + + // A popover is not a menu, so its children are not menu items. Only the floating tree is + // shared, and that is dismissal plumbing rather than menu hierarchy. + expect(screen.getByRole('button', { name: 'Actions' })).toBeInTheDocument(); + expect(screen.getByText('Actions')).not.toHaveAttribute('role'); + }); + it('opens submenu via controlled open prop', () => { render( diff --git a/packages/headless/src/primitives/popover/popover-context.ts b/packages/headless/src/primitives/popover/popover-context.ts index de0bdf5f0b0..32e72f6187b 100644 --- a/packages/headless/src/primitives/popover/popover-context.ts +++ b/packages/headless/src/primitives/popover/popover-context.ts @@ -21,6 +21,9 @@ export interface PopoverContextValue { popupRef: React.RefObject; arrowRef: React.MutableRefObject; modal: boolean; + initialFocus: 'auto' | 'first'; + /** Where focus goes when the popup closes, or `null` to leave focus alone. */ + returnFocusRef: React.MutableRefObject; labelId: string; descriptionId: string; hasTitle: boolean; diff --git a/packages/headless/src/primitives/popover/popover-positioner.tsx b/packages/headless/src/primitives/popover/popover-positioner.tsx index 777168f0081..b9aff77ad6a 100644 --- a/packages/headless/src/primitives/popover/popover-positioner.tsx +++ b/packages/headless/src/primitives/popover/popover-positioner.tsx @@ -3,7 +3,7 @@ import { FloatingFocusManager } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; +import { type ComponentProps, type DefaultProps, isKeyboardOpen, mergeProps, useRender } from '../../utils'; import { usePopoverContext } from './popover-context'; export type PopoverPositionerProps = ComponentProps<'div'>; @@ -19,6 +19,8 @@ export const PopoverPositioner = React.forwardRef {element} diff --git a/packages/headless/src/primitives/popover/popover-root.tsx b/packages/headless/src/primitives/popover/popover-root.tsx index 3edf3efabb4..d9831ea4e16 100644 --- a/packages/headless/src/primitives/popover/popover-root.tsx +++ b/packages/headless/src/primitives/popover/popover-root.tsx @@ -20,6 +20,7 @@ import { import { type ReactNode, useCallback, useId, useMemo, useRef, useState } from 'react'; import { useControllableState } from '../../hooks/use-controllable-state'; +import { useReturnFocus } from '../../hooks/use-return-focus'; import { useTransition } from '../../hooks/use-transition'; import { cssVars } from '../../utils/css-vars'; import { PopoverContext, type PopoverContextValue } from './popover-context'; @@ -31,12 +32,22 @@ export interface PopoverProps { placement?: Placement; sideOffset?: number; modal?: boolean; + /** + * Where focus lands when the popup opens. + * + * - `'auto'` (default): the first tabbable element when opened with the keyboard, + * the popup itself when opened with a pointer, so a mouse click never puts a + * focus ring on a control the user did not navigate to. + * - `'first'`: always the first tabbable element. Use it for popups whose content + * is meant to be typed into immediately, such as a combobox. + */ + initialFocus?: 'auto' | 'first'; children: ReactNode; } function PopoverInner(props: PopoverProps) { const nodeId = useFloatingNodeId(); - const { placement: placementProp = 'bottom', sideOffset = 4, modal = false, children } = props; + const { placement: placementProp = 'bottom', sideOffset = 4, modal = false, initialFocus = 'auto', children } = props; const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange); @@ -49,7 +60,6 @@ function PopoverInner(props: PopoverProps) { const arrowRef = useRef(null); const popupRef = useRef(null); - const { refs, floatingStyles, @@ -74,6 +84,8 @@ function PopoverInner(props: PopoverProps) { whileElementsMounted: autoUpdate, }); + const returnFocusRef = useReturnFocus(floatingContext); + const { mounted, transitionProps } = useTransition({ open, ref: popupRef, @@ -98,6 +110,8 @@ function PopoverInner(props: PopoverProps) { popupRef, arrowRef, modal, + initialFocus, + returnFocusRef, labelId, descriptionId, hasTitle, @@ -117,6 +131,8 @@ function PopoverInner(props: PopoverProps) { getReferenceProps, getFloatingProps, modal, + initialFocus, + returnFocusRef, labelId, descriptionId, hasTitle, diff --git a/packages/headless/src/primitives/popover/popover.test.tsx b/packages/headless/src/primitives/popover/popover.test.tsx index 862de9cc0b6..ebc8a285a61 100644 --- a/packages/headless/src/primitives/popover/popover.test.tsx +++ b/packages/headless/src/primitives/popover/popover.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { createRef } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -258,6 +258,36 @@ describe('Popover', () => { expect(positioner?.contains(document.activeElement)).toBe(true); }); + it('focuses the popup itself, not a control inside it, when opened with a pointer', async () => { + const user = userEvent.setup(); + renderPopover(); + + await user.click(screen.getByRole('button', { name: 'Open popover' })); + await new Promise(r => requestAnimationFrame(r)); + + expect(document.activeElement).toBe(document.querySelector('[data-testid="popover-positioner"]')); + }); + + it('focuses the first tabbable element when opened with the keyboard', async () => { + renderPopover(); + + // A button handles Enter/Space itself, so keyboard activation reaches the popover + // as a click with no pointer behind it. userEvent stamps its synthetic keyboard + // click with a pointerType, which browsers do not. + fireEvent.click(screen.getByRole('button', { name: 'Open popover' }), { detail: 0 }); + await waitFor(() => expect(screen.getByRole('button', { name: 'Close' })).toHaveFocus()); + }); + + it('focuses the first tabbable element on pointer open when initialFocus is "first"', async () => { + const user = userEvent.setup(); + renderPopover({ initialFocus: 'first' }); + + await user.click(screen.getByRole('button', { name: 'Open popover' })); + await new Promise(r => requestAnimationFrame(r)); + + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Close' })); + }); + it('returns focus to trigger on close via Escape', async () => { const user = userEvent.setup(); renderPopover(); diff --git a/packages/headless/src/primitives/select/select-context.ts b/packages/headless/src/primitives/select/select-context.ts index a9e16696e2f..7d56f607041 100644 --- a/packages/headless/src/primitives/select/select-context.ts +++ b/packages/headless/src/primitives/select/select-context.ts @@ -34,6 +34,8 @@ export interface SelectContextValue { labelsRef: React.MutableRefObject>; popupRef: RefObject; arrowRef: React.MutableRefObject; + /** Where focus goes when the listbox closes, or `null` to leave focus alone. */ + returnFocusRef: React.MutableRefObject; valueToLabelRef: React.MutableRefObject>; selectedItemRef: React.MutableRefObject; alignItemWithTrigger: boolean; diff --git a/packages/headless/src/primitives/select/select-positioner.tsx b/packages/headless/src/primitives/select/select-positioner.tsx index eceae876a31..32515127cb2 100644 --- a/packages/headless/src/primitives/select/select-positioner.tsx +++ b/packages/headless/src/primitives/select/select-positioner.tsx @@ -20,6 +20,7 @@ export const SelectPositioner = React.forwardRef { }); }); + describe('--cl-anchor-origin', () => { + it("keeps both axes of the anchor's center", async () => { + const mw = cssVars({ sideOffset: 8 }); + const state = createMockState({ + placement: 'bottom', + referenceWidth: 100, + referenceHeight: 40, + }); + await mw.fn(state); + + const vars = getVars(state); + // Unlike --cl-transform-origin, the cross axis is the anchor's center (40/2), not + // the floating element's own edge. + expect(vars.get('--cl-anchor-origin')).toBe('50px 20px'); + expect(vars.get('--cl-transform-origin')).toBe('50px -8px'); + }); + + it('is the same point on every side', async () => { + const mw = cssVars({ sideOffset: 8 }); + + for (const placement of ['top', 'bottom', 'left', 'right', 'bottom-end']) { + const state = createMockState({ placement, referenceWidth: 100, referenceHeight: 40 }); + await mw.fn(state); + expect(getVars(state).get('--cl-anchor-origin')).toBe('50px 20px'); + } + }); + + it('is relative to the floating element', async () => { + const mw = cssVars(); + const state = createMockState({ referenceWidth: 100, referenceHeight: 40 }); + // Floating element positioned away from the anchor. + (state as { x: number }).x = 30; + (state as { y: number }).y = 60; + await mw.fn(state); + + const vars = getVars(state); + expect(vars.get('--cl-anchor-origin')).toBe('20px -40px'); + }); + + it('ignores the arrow', async () => { + const mw = cssVars({ sideOffset: 4 }); + const state = createMockState({ + placement: 'bottom', + referenceWidth: 100, + referenceHeight: 40, + arrowX: 50, + arrowElWidth: 12, + }); + await mw.fn(state); + + const vars = getVars(state); + // The arrow moves --cl-transform-origin but not the anchor's own center. + expect(vars.get('--cl-transform-origin')).toBe('56px -4px'); + expect(vars.get('--cl-anchor-origin')).toBe('50px 20px'); + }); + }); + describe('return value', () => { it('returns empty object (no position changes)', async () => { const mw = cssVars(); @@ -247,8 +304,8 @@ describe('cssVars middleware', () => { }); }); - describe('all five CSS vars are set', () => { - it('sets exactly 5 CSS custom properties', async () => { + describe('all six CSS vars are set', () => { + it('sets exactly 6 CSS custom properties', async () => { const mw = cssVars({ sideOffset: 4 }); const state = createMockState({ placement: 'bottom' }); await mw.fn(state); @@ -263,6 +320,7 @@ describe('cssVars middleware', () => { '--cl-available-width', '--cl-available-height', '--cl-transform-origin', + '--cl-anchor-origin', ]); }); }); diff --git a/packages/headless/src/utils/css-vars.ts b/packages/headless/src/utils/css-vars.ts index 200184cba61..b2f51c2259f 100644 --- a/packages/headless/src/utils/css-vars.ts +++ b/packages/headless/src/utils/css-vars.ts @@ -8,6 +8,7 @@ import { detectOverflow, type Middleware } from '@floating-ui/react'; * - `--cl-available-width` – available width between anchor and viewport edge (px) * - `--cl-available-height` – available height between anchor and viewport edge (px) * - `--cl-transform-origin` – CSS transform-origin pointing back toward the anchor + * - `--cl-anchor-origin` – CSS transform-origin at the anchor's own center * * Place **after** `arrow()` so arrow position data is available for transform-origin. */ @@ -48,6 +49,10 @@ export function cssVars(opts?: { sideOffset?: number }): Middleware { // The arrow is the only FloatingArrow descendant carrying data-side. const arrowEl = elements.floating.querySelector('svg[data-side]'); + // The anchor's center, relative to the floating element. + const anchorX = rects.reference.x + rects.reference.width / 2 - state.x; + const anchorY = rects.reference.y + rects.reference.height / 2 - state.y; + let transformX: number; let transformY: number; @@ -57,9 +62,8 @@ export function cssVars(opts?: { sideOffset?: number }): Middleware { transformX = arrowX + arrowEl.clientWidth / 2; transformY = arrowY + arrowEl.clientHeight / 2; } else { - // No arrow — use the anchor's center relative to the floating element - transformX = rects.reference.x + rects.reference.width / 2 - state.x; - transformY = rects.reference.y + rects.reference.height / 2 - state.y; + transformX = anchorX; + transformY = anchorY; } const originMap: Record = { @@ -70,6 +74,10 @@ export function cssVars(opts?: { sideOffset?: number }): Middleware { }; style.setProperty('--cl-transform-origin', originMap[side]); + // Keeps both axes, where `--cl-transform-origin` pins the cross axis to the floating + // element's own edge. Scaling about this point makes the popup travel out of the + // anchor instead of growing in place. + style.setProperty('--cl-anchor-origin', `${anchorX}px ${anchorY}px`); return {}; }, diff --git a/packages/headless/src/utils/index.ts b/packages/headless/src/utils/index.ts index f2a2d4c17c4..566a8adacfa 100644 --- a/packages/headless/src/utils/index.ts +++ b/packages/headless/src/utils/index.ts @@ -1,4 +1,5 @@ export { cssVars } from './css-vars'; +export { isKeyboardEvent, isKeyboardOpen } from './interaction-modality'; export { resetLayoutStyles } from './reset-layout-styles'; export { type ComponentProps, @@ -6,5 +7,6 @@ export { mergeProps, type RenderProp, type RenderPropOrElement, + type RenderProps, useRender, } from './use-render'; diff --git a/packages/headless/src/utils/interaction-modality.test.ts b/packages/headless/src/utils/interaction-modality.test.ts new file mode 100644 index 00000000000..78e26f6e805 --- /dev/null +++ b/packages/headless/src/utils/interaction-modality.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import { isKeyboardEvent, isKeyboardOpen } from './interaction-modality'; + +// A click a browser dispatches for Enter/Space on a button: no pointer behind it. +function keyboardClick() { + return new MouseEvent('click', { detail: 0 }); +} + +function pointerClick() { + return new MouseEvent('click', { detail: 1 }); +} + +describe('isKeyboardEvent', () => { + it('is true for key events', () => { + expect(isKeyboardEvent(new KeyboardEvent('keydown', { key: 'Enter' }))).toBe(true); + expect(isKeyboardEvent(new KeyboardEvent('keyup', { key: ' ' }))).toBe(true); + }); + + it('is true for the click a button dispatches for Enter or Space', () => { + expect(isKeyboardEvent(keyboardClick())).toBe(true); + }); + + it('is false for a pointer click', () => { + expect(isKeyboardEvent(pointerClick())).toBe(false); + }); + + it('is false for a pointer press that dismisses the popup', () => { + expect(isKeyboardEvent(new MouseEvent('mousedown', { detail: 1 }))).toBe(false); + }); +}); + +describe('isKeyboardOpen', () => { + it('is false when nothing recorded an open event', () => { + expect(isKeyboardOpen({ dataRef: { current: {} } })).toBe(false); + }); + + it('follows the modality of the recorded open event', () => { + expect(isKeyboardOpen({ dataRef: { current: { openEvent: keyboardClick() } } })).toBe(true); + expect(isKeyboardOpen({ dataRef: { current: { openEvent: pointerClick() } } })).toBe(false); + }); +}); diff --git a/packages/headless/src/utils/interaction-modality.ts b/packages/headless/src/utils/interaction-modality.ts new file mode 100644 index 00000000000..99436bcc51f --- /dev/null +++ b/packages/headless/src/utils/interaction-modality.ts @@ -0,0 +1,31 @@ +import type { FloatingContext } from '@floating-ui/react'; +import { isVirtualClick } from '@floating-ui/react/utils'; + +/** + * Whether an event that opened or closed a floating element came from the keyboard. + * + * `useClick` lets native buttons handle Enter/Space themselves, so keyboard activation on a + * button arrives as a click with no pointer behind it, which is what `isVirtualClick` detects. + * Other triggers open and close on `keydown` (Enter) or `keyup` (Space). + */ +export function isKeyboardEvent(event: Event): boolean { + if (event.type.startsWith('key')) { + return true; + } + + // SAFETY: the remaining events come from `useClick`/`useHover`/`useDismiss`, which only ever + // hand us pointer or mouse events here. `isVirtualClick` reads optional MouseEvent fields and + // returns false for anything that lacks them. + return isVirtualClick(event as MouseEvent); +} + +/** + * Whether the floating element was opened by the keyboard rather than by a pointer. + * + * floating-ui records the event that caused the open on `dataRef.current.openEvent`. + */ +export function isKeyboardOpen(context: Pick): boolean { + const openEvent = context.dataRef.current.openEvent; + + return openEvent ? isKeyboardEvent(openEvent) : false; +} diff --git a/packages/headless/src/utils/use-render.test-d.ts b/packages/headless/src/utils/use-render.test-d.ts index 91701264962..051fe2eaad4 100644 --- a/packages/headless/src/utils/use-render.test-d.ts +++ b/packages/headless/src/utils/use-render.test-d.ts @@ -1,14 +1,36 @@ import type React from 'react'; import { describe, expectTypeOf, test } from 'vitest'; -import type { ComponentProps } from './use-render'; +import type { ComponentProps, RenderProps } from './use-render'; + +type RenderFn = Extract< + NonNullable['render']>, + (...args: never[]) => unknown +>; +type RenderArg = + RenderFn extends (props: infer P) => React.ReactElement ? P : never; +type HasColor

= 'color' extends keyof P ? true : false; describe('use-render', () => { - test('render prop arg is narrowed to the element tag props, not the generic HTMLAttributes', () => { - type Props = ComponentProps<'button'>; - type RenderFn = Extract, (...args: never[]) => unknown>; - type RenderArg = RenderFn extends (props: infer P) => React.ReactElement ? P : never; - expectTypeOf().toEqualTypeOf>(); + test('a part keeps its own tag props', () => { + expectTypeOf>().toExtend<{ type?: 'button' | 'submit' | 'reset' }>(); + }); + + test('the legacy `color` attribute is dropped, so it cannot widen a `color` variant', () => { + expectTypeOf>>().toEqualTypeOf(); + expectTypeOf>>().toEqualTypeOf(); + }); + + test('the render arg is the tag-agnostic RenderProps, not the default tag props', () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('render props spread onto an element other than the default tag', () => { + // The point of `render`: a `div` part rendering an ``. A tag-pinned `ref` + // would make this fail, which is what every call site used to work around. + expectTypeOf().toExtend>(); + expectTypeOf().toExtend>(); }); test('render also accepts an element to clone', () => { diff --git a/packages/headless/src/utils/use-render.tsx b/packages/headless/src/utils/use-render.tsx index 8a58fd224d1..b314ea49396 100644 --- a/packages/headless/src/utils/use-render.tsx +++ b/packages/headless/src/utils/use-render.tsx @@ -5,27 +5,48 @@ import * as React from 'react'; // Types // --------------------------------------------------------------------------- +/** + * The props a `render` callback receives, deliberately *not* the default tag's props. + * + * `render` exists to swap the rendered element, so the element's type is unknown at + * the point this is declared. Two things follow, and both were previously worked + * around at each call site instead of here: + * + * - `ref` is not pinned to the default tag. A `div` part rendering an `` could + * not spread its props, because `Ref` is not a `Ref`. + * - `color` is dropped. It is a non-standard HTML attribute typed `string`, so it + * collides with the `color` variant a styled component spreads these props into. + */ +export type RenderProps = Omit, 'color'> & { + // SAFETY: the rendered element is chosen by the callback, after this type is fixed, so + // no concrete element type is correct here. `Ref` does not work: `RefObject` + // is not a `RefObject`. `any` is what makes the ref spreadable onto + // whatever the callback returns, which is the whole point of `render`. Base UI's + // `HTMLProps` resolves this the same way. + ref?: React.Ref; +}; + /** * A render prop: a function that receives computed HTML props and returns a JSX element. */ -export type RenderProp> = (props: Props) => React.ReactElement; +export type RenderProp = (props: Props) => React.ReactElement; /** * A `render` prop: a render function receiving the part's computed props, or a - * React element to clone with them (`render={}`). The element form lets a - * part render a component whose own props diverge from the tag's, which a render - * function cannot express — it is typed to receive the tag's props verbatim. + * React element to clone with them (`render={}`). */ -export type RenderPropOrElement = - | RenderProp> - | React.ReactElement; +export type RenderPropOrElement = RenderProp | React.ReactElement; /** - * Props accepted by any primitive part. Extends the native props for `Tag` - * and adds the optional `render` escape hatch, narrowed to that tag's props. + * Props accepted by any primitive part: the native props for `Tag` plus the + * optional `render` escape hatch. `color` is dropped for the reason given on + * `RenderProps` — a part and its render callback expose the same contract. */ -export type ComponentProps = React.ComponentPropsWithRef & { - render?: RenderPropOrElement; +export type ComponentProps = Omit< + React.ComponentPropsWithRef, + 'color' +> & { + render?: RenderPropOrElement; }; /** @@ -125,7 +146,7 @@ interface UseRenderParamsBase< /** Fallback HTML tag when `render` is not provided. */ defaultTagName: Tag; /** Render prop or element from the consumer. */ - render?: RenderPropOrElement; + render?: RenderPropOrElement; /** Ref(s) to merge onto the rendered element. Merged with the element's own ref. */ ref?: React.Ref | Array | undefined>; /** State object. Keys are mapped to data attributes via `stateAttributesMapping`. */ @@ -197,9 +218,7 @@ export function useRender< const computedProps = { ...props, ...dataAttrs }; if (typeof render === 'function') { - // SAFETY: computedProps is the tag's props widened with data-* attrs; the render - // function is declared to receive this tag's props. - return render({ ...computedProps, ref: mergedRef } as React.ComponentPropsWithRef); + return render({ ...computedProps, ref: mergedRef }); } if (React.isValidElement(render)) { diff --git a/packages/hono/CHANGELOG.md b/packages/hono/CHANGELOG.md index 759f159e0a6..71cb49f60dc 100644 --- a/packages/hono/CHANGELOG.md +++ b/packages/hono/CHANGELOG.md @@ -1,5 +1,29 @@ # @clerk/hono +## 0.1.61 + +### Patch Changes + +- Updated dependencies [[`1ef84c3`](https://github.com/clerk/javascript/commit/1ef84c3592cee8a7d3ec5f40a9826862afe125e7), [`d639048`](https://github.com/clerk/javascript/commit/d639048e0e48ff3a120435134f9e01221697b6bc), [`f38cf02`](https://github.com/clerk/javascript/commit/f38cf02fd55a551fcf1d43c89371cf2132c2ba92), [`a66cbbf`](https://github.com/clerk/javascript/commit/a66cbbf549477cf8afc155ad17d29e48078e60df)]: + - @clerk/backend@3.16.0 + - @clerk/shared@4.27.0 + +## 0.1.60 + +### Patch Changes + +- Updated dependencies [[`a601cd7`](https://github.com/clerk/javascript/commit/a601cd7f45095fdbf8b0a23b01d9f559feeda347), [`5c81479`](https://github.com/clerk/javascript/commit/5c81479d303fc6146dc81309d0b58564aa96706e)]: + - @clerk/backend@3.15.1 + - @clerk/shared@4.26.0 + +## 0.1.59 + +### Patch Changes + +- Updated dependencies [[`9c51d74`](https://github.com/clerk/javascript/commit/9c51d74ac36391888367e4da44912c92999a7ac2), [`aaea141`](https://github.com/clerk/javascript/commit/aaea141d62804624cd8cd73036b4afe6f482184f), [`fe6ee54`](https://github.com/clerk/javascript/commit/fe6ee5489d9efcdc5aec53b1ba74b0260e539f80)]: + - @clerk/backend@3.15.0 + - @clerk/shared@4.25.10 + ## 0.1.58 ### Patch Changes diff --git a/packages/hono/package.json b/packages/hono/package.json index 814018dd337..b52ef1263e2 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -1,6 +1,6 @@ { "name": "@clerk/hono", - "version": "0.1.58", + "version": "0.1.61", "description": "Clerk SDK for Hono", "keywords": [ "auth", diff --git a/packages/localizations/CHANGELOG.md b/packages/localizations/CHANGELOG.md index e552a218d51..423cfb4f280 100644 --- a/packages/localizations/CHANGELOG.md +++ b/packages/localizations/CHANGELOG.md @@ -1,5 +1,37 @@ # Change Log +## 4.14.1 + +### Patch Changes + +- Updated dependencies [[`1ef84c3`](https://github.com/clerk/javascript/commit/1ef84c3592cee8a7d3ec5f40a9826862afe125e7), [`d639048`](https://github.com/clerk/javascript/commit/d639048e0e48ff3a120435134f9e01221697b6bc), [`a66cbbf`](https://github.com/clerk/javascript/commit/a66cbbf549477cf8afc155ad17d29e48078e60df)]: + - @clerk/shared@4.27.0 + +## 4.14.0 + +### Minor Changes + +- Support sign-in-or-sign-up combined flow with Clerk component ([#7928](https://github.com/clerk/javascript/pull/7928)) by [@dmoerner](https://github.com/dmoerner) + + when strict enumeration protection is enabled. + + On development instances, `` now logs a warning when the sign-in-or-up flow is rendered on an + instance that has both password and strict enumeration protection enabled. In that configuration + visitors without an account are routed to the password screen and cannot complete a sign-up, so the + warning names both settings and how to resolve them. + +### Patch Changes + +- Updated dependencies [[`5c81479`](https://github.com/clerk/javascript/commit/5c81479d303fc6146dc81309d0b58564aa96706e)]: + - @clerk/shared@4.26.0 + +## 4.13.10 + +### Patch Changes + +- Updated dependencies [[`aaea141`](https://github.com/clerk/javascript/commit/aaea141d62804624cd8cd73036b4afe6f482184f)]: + - @clerk/shared@4.25.10 + ## 4.13.9 ### Patch Changes diff --git a/packages/localizations/package.json b/packages/localizations/package.json index d7cfde0c2b4..ceab14a4071 100644 --- a/packages/localizations/package.json +++ b/packages/localizations/package.json @@ -1,6 +1,6 @@ { "name": "@clerk/localizations", - "version": "4.13.9", + "version": "4.14.1", "description": "Localizations for the Clerk components", "keywords": [ "react", diff --git a/packages/localizations/src/ar-SA.ts b/packages/localizations/src/ar-SA.ts index 40994083186..412c5ad3367 100644 --- a/packages/localizations/src/ar-SA.ts +++ b/packages/localizations/src/ar-SA.ts @@ -1383,6 +1383,10 @@ export const arSA: LocalizationResource = { subtitleNewTab: 'ارجع إلى علامة التبويب المفتوحة حديثًا للمتابعة', titleNewTab: 'تم تسجيل الدخول في علامة تبويب أخرى', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'استخدم رابط التحقق المرسل إلى بريدك الإلكتروني', diff --git a/packages/localizations/src/be-BY.ts b/packages/localizations/src/be-BY.ts index 83232a15181..6171efa76f1 100644 --- a/packages/localizations/src/be-BY.ts +++ b/packages/localizations/src/be-BY.ts @@ -1391,6 +1391,10 @@ export const beBY: LocalizationResource = { subtitleNewTab: 'Верніцеся на толькі што адчыненую ўкладку, каб працягнуць', titleNewTab: 'Залогіньцеся на іншай укладцы', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Выкарыстоўвайце спасылку для пацвярджэння, адпраўленую на вашу электронную пошту', diff --git a/packages/localizations/src/bg-BG.ts b/packages/localizations/src/bg-BG.ts index d3ac2d80177..5c24af73f66 100644 --- a/packages/localizations/src/bg-BG.ts +++ b/packages/localizations/src/bg-BG.ts @@ -1387,6 +1387,10 @@ export const bgBG: LocalizationResource = { subtitleNewTab: 'Върнете се в новоотворения таб, за да продължите', titleNewTab: 'Влезнали сте в друг таб', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Използвайте връзката за потвърждение, изпратена на вашия имейл', diff --git a/packages/localizations/src/bn-IN.ts b/packages/localizations/src/bn-IN.ts index e4b855441f3..dd477c1dacd 100644 --- a/packages/localizations/src/bn-IN.ts +++ b/packages/localizations/src/bn-IN.ts @@ -1395,6 +1395,10 @@ export const bnIN: LocalizationResource = { subtitleNewTab: 'চালিয়ে যেতে নতুন খোলা ট্যাবে ফিরে যান', titleNewTab: 'অন্য ট্যাবে সাইন ইন হয়েছে', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'আপনার ইমেইলে পাঠানো যাচাইকরণ লিঙ্কটি ব্যবহার করুন', diff --git a/packages/localizations/src/ca-ES.ts b/packages/localizations/src/ca-ES.ts index 5609cfa4b72..c7a12dcd007 100644 --- a/packages/localizations/src/ca-ES.ts +++ b/packages/localizations/src/ca-ES.ts @@ -1395,6 +1395,10 @@ export const caES: LocalizationResource = { subtitleNewTab: 'Torna a la pestanya recentment oberta per continuar', titleNewTab: "S'ha iniciat sessió en una altra pestanya", }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: "Utilitzeu l'enllaç de verificació enviat al vostre correu electrònic", diff --git a/packages/localizations/src/cs-CZ.ts b/packages/localizations/src/cs-CZ.ts index 4518dcb268d..794f8c5b0a8 100644 --- a/packages/localizations/src/cs-CZ.ts +++ b/packages/localizations/src/cs-CZ.ts @@ -1394,6 +1394,10 @@ export const csCZ: LocalizationResource = { subtitleNewTab: 'Vraťte se na nově otevřenou kartu pro pokračování', titleNewTab: 'Přihlášeno na jiné kartě', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Použijte ověřovací odkaz zaslaný na váš e-mail', diff --git a/packages/localizations/src/da-DK.ts b/packages/localizations/src/da-DK.ts index a0c308472a5..7726dd8f310 100644 --- a/packages/localizations/src/da-DK.ts +++ b/packages/localizations/src/da-DK.ts @@ -1385,6 +1385,10 @@ export const daDK: LocalizationResource = { subtitleNewTab: 'Vend tilbage til den nyligt åbnede fane for at fortsætte', titleNewTab: 'Logget ind på anden fane', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Brug bekræftelseslinket, der er sendt til din e-mail', diff --git a/packages/localizations/src/de-DE.ts b/packages/localizations/src/de-DE.ts index 748ec81cd65..2d615e35ee0 100644 --- a/packages/localizations/src/de-DE.ts +++ b/packages/localizations/src/de-DE.ts @@ -1402,6 +1402,10 @@ export const deDE: LocalizationResource = { subtitleNewTab: 'Kehren Sie zum neu geöffneten Tab zurück, um fortzufahren', titleNewTab: 'In einem anderen Tab angemeldet', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Verwenden Sie den an Ihre E-Mail gesendeten Bestätigungslink', diff --git a/packages/localizations/src/el-GR.ts b/packages/localizations/src/el-GR.ts index 3605d6956b7..b0f43aabb26 100644 --- a/packages/localizations/src/el-GR.ts +++ b/packages/localizations/src/el-GR.ts @@ -1395,6 +1395,10 @@ export const elGR: LocalizationResource = { subtitleNewTab: 'Επιστροφή στη νέα καρτέλα που άνοιξε για να συνεχίσετε', titleNewTab: 'Έχετε συνδεθεί σε άλλη καρτέλα', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Χρησιμοποιήστε τον σύνδεσμο επαλήθευσης που στάλθηκε στο email σας', diff --git a/packages/localizations/src/en-GB.ts b/packages/localizations/src/en-GB.ts index 65ae7776b4e..6c1415128ad 100644 --- a/packages/localizations/src/en-GB.ts +++ b/packages/localizations/src/en-GB.ts @@ -1387,6 +1387,10 @@ export const enGB: LocalizationResource = { subtitleNewTab: 'Return to the newly opened tab to continue', titleNewTab: 'Signed in on other tab', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Use the verification link sent to your email', diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 7e8450b354b..4c4e33bcd4c 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -1418,6 +1418,10 @@ export const enUS: LocalizationResource = { subtitleNewTab: 'Return to the newly opened tab to continue', titleNewTab: 'Signed in on other tab', }, + verifiedTransferable: { + subtitle: 'Return to original tab to continue', + title: 'Email verified', + }, }, emailLinkMfa: { formSubtitle: 'Use the verification link sent to your email', diff --git a/packages/localizations/src/es-CR.ts b/packages/localizations/src/es-CR.ts index 763951e89ce..051c1c8e592 100644 --- a/packages/localizations/src/es-CR.ts +++ b/packages/localizations/src/es-CR.ts @@ -1392,6 +1392,10 @@ export const esCR: LocalizationResource = { subtitleNewTab: 'Regresa a la pestaña recién abierta para continuar', titleNewTab: 'Sesión iniciada en otra pestaña', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Utiliza el enlace de verificación enviado a tu correo electrónico', diff --git a/packages/localizations/src/es-ES.ts b/packages/localizations/src/es-ES.ts index 50145eb44c7..a28358daa7e 100644 --- a/packages/localizations/src/es-ES.ts +++ b/packages/localizations/src/es-ES.ts @@ -1396,6 +1396,10 @@ export const esES: LocalizationResource = { subtitleNewTab: 'Regrese a la pestaña recién abierta para continuar', titleNewTab: 'Inició sesión en otra pestaña', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Utiliza el enlace de verificación enviado a tu correo electrónico', diff --git a/packages/localizations/src/es-MX.ts b/packages/localizations/src/es-MX.ts index 1bc4a32c7fd..43d21afac61 100644 --- a/packages/localizations/src/es-MX.ts +++ b/packages/localizations/src/es-MX.ts @@ -1393,6 +1393,10 @@ export const esMX: LocalizationResource = { subtitleNewTab: 'Regresa a la pestaña recién abierta para continuar', titleNewTab: 'Sesión iniciada en otra pestaña', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Utiliza el enlace de verificación enviado a tu correo electrónico', diff --git a/packages/localizations/src/es-UY.ts b/packages/localizations/src/es-UY.ts index e4a4f3cff3e..59cf112290f 100644 --- a/packages/localizations/src/es-UY.ts +++ b/packages/localizations/src/es-UY.ts @@ -1391,6 +1391,10 @@ export const esUY: LocalizationResource = { subtitleNewTab: 'Volvé a la nueva pestaña para continuar', titleNewTab: 'Sesión iniciada en otra pestaña', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Utiliza el enlace de verificación enviado a tu correo electrónico', diff --git a/packages/localizations/src/fa-IR.ts b/packages/localizations/src/fa-IR.ts index 418bf489df2..8b90baf9f22 100644 --- a/packages/localizations/src/fa-IR.ts +++ b/packages/localizations/src/fa-IR.ts @@ -1396,6 +1396,10 @@ export const faIR: LocalizationResource = { subtitleNewTab: 'برای ادامه به برگه تازه باز شده برگردید', titleNewTab: 'در برگه دیگر وارد سیستم شده‌اید', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'از لینک تأیید ارسال شده به ایمیل خود استفاده کنید', diff --git a/packages/localizations/src/fi-FI.ts b/packages/localizations/src/fi-FI.ts index 6c5e925e4ee..c7ee3488ae9 100644 --- a/packages/localizations/src/fi-FI.ts +++ b/packages/localizations/src/fi-FI.ts @@ -1397,6 +1397,10 @@ export const fiFI: LocalizationResource = { subtitleNewTab: 'Palaa uuteen välilehteen jatkaaksesi', titleNewTab: 'Kirjautunut toiseen välilehteen', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Käytä sähköpostiisi lähetettyä vahvistuslinkkiä', diff --git a/packages/localizations/src/fr-FR.ts b/packages/localizations/src/fr-FR.ts index 84777acd790..aa019e5fa48 100644 --- a/packages/localizations/src/fr-FR.ts +++ b/packages/localizations/src/fr-FR.ts @@ -1403,6 +1403,10 @@ export const frFR: LocalizationResource = { subtitleNewTab: "Revenez à l'onglet nouvellement ouvert pour continuer", titleNewTab: 'Connecté sur un autre onglet', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Utilisez le lien de vérification envoyé par e-mail', diff --git a/packages/localizations/src/he-IL.ts b/packages/localizations/src/he-IL.ts index c53bed8d69e..a95959d3aab 100644 --- a/packages/localizations/src/he-IL.ts +++ b/packages/localizations/src/he-IL.ts @@ -1379,6 +1379,10 @@ export const heIL: LocalizationResource = { subtitleNewTab: 'חזור לכרטיסייה שנפתחה חדשה להמשך', titleNewTab: 'נכנס בכרטיסייה אחרת', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'השתמש בקישור האימות שנשלח לדוא״ל שלך', diff --git a/packages/localizations/src/hi-IN.ts b/packages/localizations/src/hi-IN.ts index 0d429f2d76d..3331c95b3a3 100644 --- a/packages/localizations/src/hi-IN.ts +++ b/packages/localizations/src/hi-IN.ts @@ -1395,6 +1395,10 @@ export const hiIN: LocalizationResource = { subtitleNewTab: 'जारी रखने के लिए नए खोले गए टैब पर वापस जाएं', titleNewTab: 'दूसरे टैब पर साइन इन हो गया', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'अपने ईमेल पर भेजे गए सत्यापन लिंक का उपयोग करें', diff --git a/packages/localizations/src/hr-HR.ts b/packages/localizations/src/hr-HR.ts index 57d138a1021..3cd2486c91a 100644 --- a/packages/localizations/src/hr-HR.ts +++ b/packages/localizations/src/hr-HR.ts @@ -1397,6 +1397,10 @@ export const hrHR: LocalizationResource = { subtitleNewTab: 'Vratite se na novootvorenu karticu za nastavak', titleNewTab: 'Prijavljeni na drugoj kartici', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Koristite vezu za provjeru poslanu na vašu e-poštu', diff --git a/packages/localizations/src/hu-HU.ts b/packages/localizations/src/hu-HU.ts index 4fbdcd278bf..ecbc5857b1c 100644 --- a/packages/localizations/src/hu-HU.ts +++ b/packages/localizations/src/hu-HU.ts @@ -1399,6 +1399,10 @@ export const huHU: LocalizationResource = { subtitleNewTab: 'Menj át az újonan megnyitott lapra a folytatáshoz', titleNewTab: 'Egy másik lapon bejelezkeztél be', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Használja az e-mail címére küldött megerősítő linket', diff --git a/packages/localizations/src/id-ID.ts b/packages/localizations/src/id-ID.ts index e865afeb3b5..890a5432be7 100644 --- a/packages/localizations/src/id-ID.ts +++ b/packages/localizations/src/id-ID.ts @@ -1390,6 +1390,10 @@ export const idID: LocalizationResource = { subtitleNewTab: 'Kembali ke tab yang baru dibuka untuk melanjutkan', titleNewTab: 'Masuk di tab lain', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Gunakan tautan verifikasi yang dikirim ke email Anda', diff --git a/packages/localizations/src/is-IS.ts b/packages/localizations/src/is-IS.ts index f6a9725bbb5..bbea10b5afd 100644 --- a/packages/localizations/src/is-IS.ts +++ b/packages/localizations/src/is-IS.ts @@ -1398,6 +1398,10 @@ export const isIS: LocalizationResource = { subtitleNewTab: 'Farðu aftur í nýopnaða flipann til að halda áfram', titleNewTab: 'Skráður inn á öðrum flipa', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Notaðu staðfestingartengilinn sem sendur var á tölvupóstinn þinn', diff --git a/packages/localizations/src/it-IT.ts b/packages/localizations/src/it-IT.ts index 33bb54cb212..0919d7813ef 100644 --- a/packages/localizations/src/it-IT.ts +++ b/packages/localizations/src/it-IT.ts @@ -1395,6 +1395,10 @@ export const itIT: LocalizationResource = { subtitleNewTab: 'Ritorna sulla nuova scheda aperta per continuare', titleNewTab: "Accedi da un'altra scheda", }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Usa il link di verifica inviato alla tua email', diff --git a/packages/localizations/src/ja-JP.ts b/packages/localizations/src/ja-JP.ts index 79040d358be..79b739b0e20 100644 --- a/packages/localizations/src/ja-JP.ts +++ b/packages/localizations/src/ja-JP.ts @@ -1396,6 +1396,10 @@ export const jaJP: LocalizationResource = { subtitleNewTab: '新しく開いたタブに戻って続行してください', titleNewTab: '他のタブでサインイン済み', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'メールに送信された確認リンクを使用してください', diff --git a/packages/localizations/src/kk-KZ.ts b/packages/localizations/src/kk-KZ.ts index 0d1ac09634a..5739bb8e83f 100644 --- a/packages/localizations/src/kk-KZ.ts +++ b/packages/localizations/src/kk-KZ.ts @@ -1378,6 +1378,10 @@ export const kkKZ: LocalizationResource = { subtitleNewTab: 'Жалғастыру үшін жаңа бетке оралыңыз', titleNewTab: 'Басқа бетте кірдіңіз', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Электрондық поштаңызға жіберілген растау сілтемесін пайдаланыңыз', diff --git a/packages/localizations/src/ko-KR.ts b/packages/localizations/src/ko-KR.ts index c1abc4a3deb..4070b2a7d16 100644 --- a/packages/localizations/src/ko-KR.ts +++ b/packages/localizations/src/ko-KR.ts @@ -1383,6 +1383,10 @@ export const koKR: LocalizationResource = { subtitleNewTab: '계속하려면 새로 연 탭으로 돌아가세요', titleNewTab: '다른 탭에서 로그인', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: '이메일로 전송된 확인 링크를 사용하세요', diff --git a/packages/localizations/src/mn-MN.ts b/packages/localizations/src/mn-MN.ts index 16af9e40f59..66ad147f594 100644 --- a/packages/localizations/src/mn-MN.ts +++ b/packages/localizations/src/mn-MN.ts @@ -1388,6 +1388,10 @@ export const mnMN: LocalizationResource = { subtitleNewTab: 'Үргэлжлүүлэхийн тулд шинээр нээгдсэн таб руу буцна уу', titleNewTab: 'Өөр таб дээр нэвтэрсэн', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Имэйлдээ илгээсэн баталгаажуулах холбоосыг ашиглана уу', diff --git a/packages/localizations/src/ms-MY.ts b/packages/localizations/src/ms-MY.ts index a5080bba09c..28f3b8fa362 100644 --- a/packages/localizations/src/ms-MY.ts +++ b/packages/localizations/src/ms-MY.ts @@ -1400,6 +1400,10 @@ export const msMY: LocalizationResource = { subtitleNewTab: 'Kembali ke tab yang baru dibuka untuk meneruskan', titleNewTab: 'Didaftarkan masuk pada tab lain', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Gunakan pautan pengesahan yang dihantar ke e-mel anda', diff --git a/packages/localizations/src/nb-NO.ts b/packages/localizations/src/nb-NO.ts index f6ca9bd2a0d..634d14772cf 100644 --- a/packages/localizations/src/nb-NO.ts +++ b/packages/localizations/src/nb-NO.ts @@ -1398,6 +1398,10 @@ export const nbNO: LocalizationResource = { subtitleNewTab: 'Gå tilbake til den nyåpnede fanen for å fortsette', titleNewTab: 'Logget inn på en annen fane', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Bruk bekreftelseslenken som ble sendt til din e-post', diff --git a/packages/localizations/src/nl-BE.ts b/packages/localizations/src/nl-BE.ts index 679be3735fe..7584091ffb9 100644 --- a/packages/localizations/src/nl-BE.ts +++ b/packages/localizations/src/nl-BE.ts @@ -1388,6 +1388,10 @@ export const nlBE: LocalizationResource = { subtitleNewTab: 'Ga naar de pasgeopende tab om verder te gaan', titleNewTab: 'Ingelogd in andere tab', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Gebruik de verificatielink die naar je e-mail is verzonden', diff --git a/packages/localizations/src/nl-NL.ts b/packages/localizations/src/nl-NL.ts index 7475561c530..3bc6bff7ac1 100644 --- a/packages/localizations/src/nl-NL.ts +++ b/packages/localizations/src/nl-NL.ts @@ -1388,6 +1388,10 @@ export const nlNL: LocalizationResource = { subtitleNewTab: 'Ga naar de pasgeopende tab om verder te gaan', titleNewTab: 'Ingelogd in andere tab', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Gebruik de verificatielink die naar je e-mail is verzonden', diff --git a/packages/localizations/src/pl-PL.ts b/packages/localizations/src/pl-PL.ts index c752dae2933..be54a14ff4e 100644 --- a/packages/localizations/src/pl-PL.ts +++ b/packages/localizations/src/pl-PL.ts @@ -1388,6 +1388,10 @@ export const plPL: LocalizationResource = { subtitleNewTab: 'Powróć do nowo otwartej karty, aby kontynuować', titleNewTab: 'Zalogowano na innej karcie', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Użyj linku weryfikacyjnego wysłanego na Twój e-mail', diff --git a/packages/localizations/src/pt-BR.ts b/packages/localizations/src/pt-BR.ts index ee6e3fb2846..42e0c5b08f7 100644 --- a/packages/localizations/src/pt-BR.ts +++ b/packages/localizations/src/pt-BR.ts @@ -1397,6 +1397,10 @@ export const ptBR: LocalizationResource = { subtitleNewTab: 'Retorne para a nova aba que foi aberta para continuar', titleNewTab: 'Conectado em outra aba', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Use o link de verificação enviado para o seu e-mail', diff --git a/packages/localizations/src/pt-PT.ts b/packages/localizations/src/pt-PT.ts index d0398db77a2..d136f7b6fca 100644 --- a/packages/localizations/src/pt-PT.ts +++ b/packages/localizations/src/pt-PT.ts @@ -1398,6 +1398,10 @@ export const ptPT: LocalizationResource = { subtitleNewTab: 'Regresse ao novo separador que foi aberto para continuar', titleNewTab: 'Sessão iniciada noutro separador', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Utilize a ligação de verificação enviada para o seu e-mail', diff --git a/packages/localizations/src/ro-RO.ts b/packages/localizations/src/ro-RO.ts index f04ca27fc6c..ffb22de2fc2 100644 --- a/packages/localizations/src/ro-RO.ts +++ b/packages/localizations/src/ro-RO.ts @@ -1399,6 +1399,10 @@ export const roRO: LocalizationResource = { subtitleNewTab: 'Revino în noua filă deschisă pentru a continua', titleNewTab: 'Autentificat în altă filă', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Folosiți linkul de verificare trimis la adresa dvs. de e-mail', diff --git a/packages/localizations/src/ru-RU.ts b/packages/localizations/src/ru-RU.ts index 97005a6fbb0..a8552a376d7 100644 --- a/packages/localizations/src/ru-RU.ts +++ b/packages/localizations/src/ru-RU.ts @@ -1395,6 +1395,10 @@ export const ruRU: LocalizationResource = { subtitleNewTab: 'Вернитесь на только что открытую вкладку, чтобы продолжить', titleNewTab: 'Залогиньтесь на другой вкладке', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Используйте ссылку для подтверждения, отправленную на вашу электронную почту', diff --git a/packages/localizations/src/sk-SK.ts b/packages/localizations/src/sk-SK.ts index 36814980eca..386d4b8e6a2 100644 --- a/packages/localizations/src/sk-SK.ts +++ b/packages/localizations/src/sk-SK.ts @@ -1388,6 +1388,10 @@ export const skSK: LocalizationResource = { subtitleNewTab: 'Vráťte sa do novootvoreného okna pre pokračovanie', titleNewTab: 'Prihlásené v inom okne', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Použite overovací odkaz odoslaný na váš e-mail', diff --git a/packages/localizations/src/sr-RS.ts b/packages/localizations/src/sr-RS.ts index c1b209b00f7..729042ee9f9 100644 --- a/packages/localizations/src/sr-RS.ts +++ b/packages/localizations/src/sr-RS.ts @@ -1385,6 +1385,10 @@ export const srRS: LocalizationResource = { subtitleNewTab: 'Vrati se na novootvoreni tab da nastaviš', titleNewTab: 'Prijavljen na drugom tabu', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Користите везу за верификацију послату на вашу е-пошту', diff --git a/packages/localizations/src/sv-SE.ts b/packages/localizations/src/sv-SE.ts index a94c14b64d4..12cea3490e5 100644 --- a/packages/localizations/src/sv-SE.ts +++ b/packages/localizations/src/sv-SE.ts @@ -1388,6 +1388,10 @@ export const svSE: LocalizationResource = { subtitleNewTab: 'Återgå till den nyligen öppnade fliken för att fortsätta', titleNewTab: 'Loggade in på annan flik', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Använd verifieringslänken som skickades till din e-post', diff --git a/packages/localizations/src/ta-IN.ts b/packages/localizations/src/ta-IN.ts index e7025ec6989..c4c30019d93 100644 --- a/packages/localizations/src/ta-IN.ts +++ b/packages/localizations/src/ta-IN.ts @@ -1401,6 +1401,10 @@ export const taIN: LocalizationResource = { subtitleNewTab: 'தொடர புதிதாகத் திறக்கப்பட்ட தாவலுக்குத் திரும்பவும்', titleNewTab: 'மற்ற தாவலில் உள்நுழைந்தது', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'உங்கள் மின்னஞ்சலுக்கு அனுப்பப்பட்ட சரிபார்ப்பு இணைப்பைப் பயன்படுத்தவும்', diff --git a/packages/localizations/src/te-IN.ts b/packages/localizations/src/te-IN.ts index b7761190f76..3e523d8d58d 100644 --- a/packages/localizations/src/te-IN.ts +++ b/packages/localizations/src/te-IN.ts @@ -1398,6 +1398,10 @@ export const teIN: LocalizationResource = { subtitleNewTab: 'కొనసాగించడానికి కొత్తగా తెరిచిన ట్యాబ్‌కి తిరిగి వెళ్ళండి', titleNewTab: 'ఇతర ట్యాబ్‌లో సైన్ ఇన్ చేశారు', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'మీ ఇమెయిల్‌కు పంపబడిన ధృవీకరణ లింక్‌ను ఉపయోగించండి', diff --git a/packages/localizations/src/th-TH.ts b/packages/localizations/src/th-TH.ts index d65b08fc356..b86fd886f32 100644 --- a/packages/localizations/src/th-TH.ts +++ b/packages/localizations/src/th-TH.ts @@ -1387,6 +1387,10 @@ export const thTH: LocalizationResource = { subtitleNewTab: 'กลับไปยังแท็บที่เปิดใหม่เพื่อดำเนินการต่อ', titleNewTab: 'เข้าสู่ระบบในแท็บอื่น', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'ใช้ลิงก์ยืนยันที่ส่งไปยังอีเมลของคุณ', diff --git a/packages/localizations/src/tr-TR.ts b/packages/localizations/src/tr-TR.ts index ad2223c7909..34ae01d2949 100644 --- a/packages/localizations/src/tr-TR.ts +++ b/packages/localizations/src/tr-TR.ts @@ -1387,6 +1387,10 @@ export const trTR: LocalizationResource = { subtitleNewTab: 'Devam etmek için yeni açılmış sekmeye dönün', titleNewTab: 'Farklı bir sekmede giriş yapıldı', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'E-postanıza gönderilen doğrulama bağlantısını kullanın', diff --git a/packages/localizations/src/uk-UA.ts b/packages/localizations/src/uk-UA.ts index f9fd91af731..d30dea0a59c 100644 --- a/packages/localizations/src/uk-UA.ts +++ b/packages/localizations/src/uk-UA.ts @@ -1385,6 +1385,10 @@ export const ukUA: LocalizationResource = { subtitleNewTab: 'Поверніться до щойно відкритої вкладки, щоб продовжити', titleNewTab: 'Ви ввійшли на іншій вкладці', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Використовуйте посилання для підтвердження, надіслане на вашу електронну пошту', diff --git a/packages/localizations/src/vi-VN.ts b/packages/localizations/src/vi-VN.ts index c21e80d0184..266cc6d915e 100644 --- a/packages/localizations/src/vi-VN.ts +++ b/packages/localizations/src/vi-VN.ts @@ -1395,6 +1395,10 @@ export const viVN: LocalizationResource = { subtitleNewTab: 'Quay lại tab mới được mở để tiếp tục', titleNewTab: 'Đăng nhập trên tab khác', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Sử dụng liên kết xác minh được gửi đến email của bạn', diff --git a/packages/localizations/src/zh-CN.ts b/packages/localizations/src/zh-CN.ts index 5d02a382dd5..56a9b1b385a 100644 --- a/packages/localizations/src/zh-CN.ts +++ b/packages/localizations/src/zh-CN.ts @@ -1375,6 +1375,10 @@ export const zhCN: LocalizationResource = { subtitleNewTab: '返回新打开的标签页继续', titleNewTab: '在其他标签页上登录', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: '使用发送到您电子邮件的验证链接', diff --git a/packages/localizations/src/zh-TW.ts b/packages/localizations/src/zh-TW.ts index 713b1080c45..3b8462c96ea 100644 --- a/packages/localizations/src/zh-TW.ts +++ b/packages/localizations/src/zh-TW.ts @@ -1378,6 +1378,10 @@ export const zhTW: LocalizationResource = { subtitleNewTab: '返回新開啟的分頁以繼續', titleNewTab: '已在其他分頁登入', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: '使用發送到您電子郵件的驗證連結', diff --git a/packages/msw/CHANGELOG.md b/packages/msw/CHANGELOG.md index bd938fb99bf..58f7d95033b 100644 --- a/packages/msw/CHANGELOG.md +++ b/packages/msw/CHANGELOG.md @@ -1,5 +1,26 @@ # @clerk/msw +## 0.0.56 + +### Patch Changes + +- Updated dependencies [[`1ef84c3`](https://github.com/clerk/javascript/commit/1ef84c3592cee8a7d3ec5f40a9826862afe125e7), [`d639048`](https://github.com/clerk/javascript/commit/d639048e0e48ff3a120435134f9e01221697b6bc), [`a66cbbf`](https://github.com/clerk/javascript/commit/a66cbbf549477cf8afc155ad17d29e48078e60df)]: + - @clerk/shared@4.27.0 + +## 0.0.55 + +### Patch Changes + +- Updated dependencies [[`5c81479`](https://github.com/clerk/javascript/commit/5c81479d303fc6146dc81309d0b58564aa96706e)]: + - @clerk/shared@4.26.0 + +## 0.0.54 + +### Patch Changes + +- Updated dependencies [[`aaea141`](https://github.com/clerk/javascript/commit/aaea141d62804624cd8cd73036b4afe6f482184f)]: + - @clerk/shared@4.25.10 + ## 0.0.53 ### Patch Changes diff --git a/packages/msw/package.json b/packages/msw/package.json index 0c02252f89e..48736210188 100644 --- a/packages/msw/package.json +++ b/packages/msw/package.json @@ -1,6 +1,6 @@ { "name": "@clerk/msw", - "version": "0.0.53", + "version": "0.0.56", "private": true, "sideEffects": false, "type": "module", diff --git a/packages/nextjs/CHANGELOG.md b/packages/nextjs/CHANGELOG.md index 5c632b11c1b..e7837e959e9 100644 --- a/packages/nextjs/CHANGELOG.md +++ b/packages/nextjs/CHANGELOG.md @@ -1,5 +1,48 @@ # Change Log +## 7.7.0 + +### Minor Changes + +- Add ``, a control component that opens the organization invite-members form in a modal when clicked, working like ``. ([#9124](https://github.com/clerk/javascript/pull/9124)) by [@alexcarpenter](https://github.com/alexcarpenter) + + Wrap your own button (or omit children for a default one). The button requires an active organization and should be rendered for members who can manage memberships (`org:sys_memberships:manage`). Opening it without an active organization or that permission is a no-op in production, and throws a descriptive error in development. + + ```tsx + import { InviteMembersButton } from '@clerk/nextjs'; + + + + ; + ``` + + This also adds `Clerk.openInviteMembers()` and `Clerk.closeInviteMembers()` for opening and closing the modal programmatically. + +### Patch Changes + +- Updated dependencies [[`1ef84c3`](https://github.com/clerk/javascript/commit/1ef84c3592cee8a7d3ec5f40a9826862afe125e7), [`d639048`](https://github.com/clerk/javascript/commit/d639048e0e48ff3a120435134f9e01221697b6bc), [`f38cf02`](https://github.com/clerk/javascript/commit/f38cf02fd55a551fcf1d43c89371cf2132c2ba92), [`a66cbbf`](https://github.com/clerk/javascript/commit/a66cbbf549477cf8afc155ad17d29e48078e60df), [`58d8ff5`](https://github.com/clerk/javascript/commit/58d8ff50b121ebf42744ba32302da6b22e90b704)]: + - @clerk/backend@3.16.0 + - @clerk/shared@4.27.0 + - @clerk/react@6.13.0 + +## 7.6.5 + +### Patch Changes + +- Updated dependencies [[`a601cd7`](https://github.com/clerk/javascript/commit/a601cd7f45095fdbf8b0a23b01d9f559feeda347), [`bbe51ff`](https://github.com/clerk/javascript/commit/bbe51ffc343a878022c5863796450d6d97069ea0), [`5c81479`](https://github.com/clerk/javascript/commit/5c81479d303fc6146dc81309d0b58564aa96706e)]: + - @clerk/backend@3.15.1 + - @clerk/react@6.12.11 + - @clerk/shared@4.26.0 + +## 7.6.4 + +### Patch Changes + +- Updated dependencies [[`9c51d74`](https://github.com/clerk/javascript/commit/9c51d74ac36391888367e4da44912c92999a7ac2), [`aaea141`](https://github.com/clerk/javascript/commit/aaea141d62804624cd8cd73036b4afe6f482184f), [`fe6ee54`](https://github.com/clerk/javascript/commit/fe6ee5489d9efcdc5aec53b1ba74b0260e539f80)]: + - @clerk/backend@3.15.0 + - @clerk/shared@4.25.10 + - @clerk/react@6.12.10 + ## 7.6.3 ### Patch Changes diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index e300a5646a1..700a78d22b0 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -1,6 +1,6 @@ { "name": "@clerk/nextjs", - "version": "7.6.3", + "version": "7.7.0", "description": "Clerk SDK for NextJS", "keywords": [ "clerk", diff --git a/packages/nextjs/src/client-boundary/uiComponents.tsx b/packages/nextjs/src/client-boundary/uiComponents.tsx index f6f65fad650..1c2982bd933 100644 --- a/packages/nextjs/src/client-boundary/uiComponents.tsx +++ b/packages/nextjs/src/client-boundary/uiComponents.tsx @@ -16,6 +16,7 @@ export { CreateOrganization, GoogleOneTap, HandleSSOCallback, + InviteMembersButton, OAuthConsent, OrganizationList, OrganizationSwitcher, diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index 283a7935cfc..f824d89e0f2 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -25,6 +25,7 @@ export { APIKeys, CreateOrganization, GoogleOneTap, + InviteMembersButton, OAuthConsent, OrganizationList, OrganizationProfile, diff --git a/packages/nuxt/CHANGELOG.md b/packages/nuxt/CHANGELOG.md index 50bc33adf5b..92c8df45d1e 100644 --- a/packages/nuxt/CHANGELOG.md +++ b/packages/nuxt/CHANGELOG.md @@ -1,5 +1,32 @@ # @clerk/nuxt +## 3.0.3 + +### Patch Changes + +- Updated dependencies [[`1ef84c3`](https://github.com/clerk/javascript/commit/1ef84c3592cee8a7d3ec5f40a9826862afe125e7), [`d639048`](https://github.com/clerk/javascript/commit/d639048e0e48ff3a120435134f9e01221697b6bc), [`f38cf02`](https://github.com/clerk/javascript/commit/f38cf02fd55a551fcf1d43c89371cf2132c2ba92), [`a66cbbf`](https://github.com/clerk/javascript/commit/a66cbbf549477cf8afc155ad17d29e48078e60df)]: + - @clerk/backend@3.16.0 + - @clerk/shared@4.27.0 + - @clerk/vue@2.4.24 + +## 3.0.2 + +### Patch Changes + +- Updated dependencies [[`a601cd7`](https://github.com/clerk/javascript/commit/a601cd7f45095fdbf8b0a23b01d9f559feeda347), [`5c81479`](https://github.com/clerk/javascript/commit/5c81479d303fc6146dc81309d0b58564aa96706e)]: + - @clerk/backend@3.15.1 + - @clerk/shared@4.26.0 + - @clerk/vue@2.4.23 + +## 3.0.1 + +### Patch Changes + +- Updated dependencies [[`9c51d74`](https://github.com/clerk/javascript/commit/9c51d74ac36391888367e4da44912c92999a7ac2), [`aaea141`](https://github.com/clerk/javascript/commit/aaea141d62804624cd8cd73036b4afe6f482184f), [`fe6ee54`](https://github.com/clerk/javascript/commit/fe6ee5489d9efcdc5aec53b1ba74b0260e539f80), [`850051d`](https://github.com/clerk/javascript/commit/850051df0e6d81046d7a5536ceaba3622f8fe7c1)]: + - @clerk/backend@3.15.0 + - @clerk/shared@4.25.10 + - @clerk/vue@2.4.22 + ## 3.0.0 ### Major Changes diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index f0dca104eed..df8c4d1b805 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -1,6 +1,6 @@ { "name": "@clerk/nuxt", - "version": "3.0.0", + "version": "3.0.3", "description": "Clerk SDK for Nuxt", "keywords": [ "clerk", diff --git a/packages/react-router/CHANGELOG.md b/packages/react-router/CHANGELOG.md index b5daecbd0b1..9e2b0c44be5 100644 --- a/packages/react-router/CHANGELOG.md +++ b/packages/react-router/CHANGELOG.md @@ -1,5 +1,32 @@ # Change Log +## 3.6.5 + +### Patch Changes + +- Updated dependencies [[`1ef84c3`](https://github.com/clerk/javascript/commit/1ef84c3592cee8a7d3ec5f40a9826862afe125e7), [`d639048`](https://github.com/clerk/javascript/commit/d639048e0e48ff3a120435134f9e01221697b6bc), [`f38cf02`](https://github.com/clerk/javascript/commit/f38cf02fd55a551fcf1d43c89371cf2132c2ba92), [`a66cbbf`](https://github.com/clerk/javascript/commit/a66cbbf549477cf8afc155ad17d29e48078e60df), [`58d8ff5`](https://github.com/clerk/javascript/commit/58d8ff50b121ebf42744ba32302da6b22e90b704)]: + - @clerk/backend@3.16.0 + - @clerk/shared@4.27.0 + - @clerk/react@6.13.0 + +## 3.6.4 + +### Patch Changes + +- Updated dependencies [[`a601cd7`](https://github.com/clerk/javascript/commit/a601cd7f45095fdbf8b0a23b01d9f559feeda347), [`bbe51ff`](https://github.com/clerk/javascript/commit/bbe51ffc343a878022c5863796450d6d97069ea0), [`5c81479`](https://github.com/clerk/javascript/commit/5c81479d303fc6146dc81309d0b58564aa96706e)]: + - @clerk/backend@3.15.1 + - @clerk/react@6.12.11 + - @clerk/shared@4.26.0 + +## 3.6.3 + +### Patch Changes + +- Updated dependencies [[`9c51d74`](https://github.com/clerk/javascript/commit/9c51d74ac36391888367e4da44912c92999a7ac2), [`aaea141`](https://github.com/clerk/javascript/commit/aaea141d62804624cd8cd73036b4afe6f482184f), [`fe6ee54`](https://github.com/clerk/javascript/commit/fe6ee5489d9efcdc5aec53b1ba74b0260e539f80)]: + - @clerk/backend@3.15.0 + - @clerk/shared@4.25.10 + - @clerk/react@6.12.10 + ## 3.6.2 ### Patch Changes diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 2f593865327..6f62a71f423 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -1,6 +1,6 @@ { "name": "@clerk/react-router", - "version": "3.6.2", + "version": "3.6.5", "description": "Clerk SDK for React Router", "keywords": [ "clerk", diff --git a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap index 27525d4ce63..fb0e0ea41c9 100644 --- a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap @@ -26,6 +26,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "CreateOrganization", "GoogleOneTap", "HandleSSOCallback", + "InviteMembersButton", "OAuthConsent", "OrganizationList", "OrganizationProfile", diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 26d253a9334..2613052426c 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -1,5 +1,46 @@ # Change Log +## 6.13.0 + +### Minor Changes + +- Add ``, a control component that opens the organization invite-members form in a modal when clicked, working like ``. ([#9124](https://github.com/clerk/javascript/pull/9124)) by [@alexcarpenter](https://github.com/alexcarpenter) + + Wrap your own button (or omit children for a default one). The button requires an active organization and should be rendered for members who can manage memberships (`org:sys_memberships:manage`). Opening it without an active organization or that permission is a no-op in production, and throws a descriptive error in development. + + ```tsx + import { InviteMembersButton } from '@clerk/nextjs'; + + + + ; + ``` + + This also adds `Clerk.openInviteMembers()` and `Clerk.closeInviteMembers()` for opening and closing the modal programmatically. + +### Patch Changes + +- Fix a false-positive "multiple ``" crash in apps that run more than one React root in a single JavaScript runtime, most commonly React Native Android apps during activity recreation. `` now throws this error only when it is genuinely nested inside another ``. ([#9335](https://github.com/clerk/javascript/pull/9335)) by [@wobsoriano](https://github.com/wobsoriano) + +- Updated dependencies [[`1ef84c3`](https://github.com/clerk/javascript/commit/1ef84c3592cee8a7d3ec5f40a9826862afe125e7), [`d639048`](https://github.com/clerk/javascript/commit/d639048e0e48ff3a120435134f9e01221697b6bc), [`a66cbbf`](https://github.com/clerk/javascript/commit/a66cbbf549477cf8afc155ad17d29e48078e60df)]: + - @clerk/shared@4.27.0 + +## 6.12.11 + +### Patch Changes + +- Allow `ClerkProvider` to omit `publishableKey` when it is supplied through `VITE_CLERK_PUBLISHABLE_KEY` or `CLERK_PUBLISHABLE_KEY`. ([#9314](https://github.com/clerk/javascript/pull/9314)) by [@SarahSoutoul](https://github.com/SarahSoutoul) + +- Updated dependencies [[`5c81479`](https://github.com/clerk/javascript/commit/5c81479d303fc6146dc81309d0b58564aa96706e)]: + - @clerk/shared@4.26.0 + +## 6.12.10 + +### Patch Changes + +- Updated dependencies [[`aaea141`](https://github.com/clerk/javascript/commit/aaea141d62804624cd8cd73036b4afe6f482184f)]: + - @clerk/shared@4.25.10 + ## 6.12.9 ### Patch Changes diff --git a/packages/react/package.json b/packages/react/package.json index faf9973aab6..210204c6b49 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@clerk/react", - "version": "6.12.9", + "version": "6.13.0", "description": "Clerk React library", "keywords": [ "clerk", diff --git a/packages/react/src/components/InviteMembersButton.tsx b/packages/react/src/components/InviteMembersButton.tsx new file mode 100644 index 00000000000..a3f076402f2 --- /dev/null +++ b/packages/react/src/components/InviteMembersButton.tsx @@ -0,0 +1,52 @@ +import type { InviteMembersButtonProps } from '@clerk/shared/types'; +import React from 'react'; + +import type { WithClerkProp } from '../types'; +import { assertSingleChild, normalizeWithDefaultValue, safeExecute } from '../utils'; +import { withClerk } from './withClerk'; + +/** + * A button component that opens a modal containing the organization invite-members form when + * clicked. Wrap your own button, or omit children to render a default one. + * + * Requires an active organization, and should only be rendered for members who can manage memberships + * (the `org:sys_memberships:manage` permission). Guard with `` or `useAuth()` if unsure. + * Clicking it when there is no active organization or the current user lacks that permission is a + * no-op in production, and throws a descriptive error in development. + * + * @example + * ```tsx + * import { InviteMembersButton } from '@clerk/react'; + * + * function InviteButton() { + * return ( + * + * + * + * ); + * } + * ``` + */ +export const InviteMembersButton = withClerk( + ({ clerk, children, ...props }: WithClerkProp>) => { + const { appearance, getContainer, component, ...rest } = props; + + children = normalizeWithDefaultValue(children, 'Invite members'); + const child = assertSingleChild(children)('InviteMembersButton'); + + const clickHandler = () => { + return clerk.openInviteMembers({ appearance, getContainer }); + }; + + const wrappedChildClickHandler: React.MouseEventHandler = async e => { + if (child && typeof child === 'object' && 'props' in child) { + await safeExecute(child.props.onClick)(e); + } + return clickHandler(); + }; + + const childProps = { ...rest, onClick: wrappedChildClickHandler }; + return React.cloneElement(child as React.ReactElement, childProps); + }, + { component: 'InviteMembersButton', renderWhileLoading: true }, +); diff --git a/packages/react/src/components/__tests__/InviteMembersButton.test.tsx b/packages/react/src/components/__tests__/InviteMembersButton.test.tsx new file mode 100644 index 00000000000..ff0e3fb2a7a --- /dev/null +++ b/packages/react/src/components/__tests__/InviteMembersButton.test.tsx @@ -0,0 +1,109 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import React from 'react'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { InviteMembersButton } from '../InviteMembersButton'; + +const mockOpenInviteMembers = vi.fn(); +const originalError = console.error; + +const mockClerk = { + openInviteMembers: mockOpenInviteMembers, +} as any; + +vi.mock('../withClerk', () => { + return { + withClerk: (Component: any) => (props: any) => { + return ( + + ); + }, + }; +}); + +describe('', () => { + beforeAll(() => { + console.error = vi.fn(); + }); + + afterAll(() => { + console.error = originalError; + }); + + beforeEach(() => { + mockOpenInviteMembers.mockReset(); + }); + + it('calls clerk.openInviteMembers when clicked', async () => { + render(); + const btn = screen.getByText('Invite members'); + + await userEvent.click(btn); + await waitFor(() => { + expect(mockOpenInviteMembers).toHaveBeenCalled(); + }); + }); + + it('forwards appearance to clerk.openInviteMembers', async () => { + const appearance = { elements: { rootBox: 'test' } }; + render(); + const btn = screen.getByText('Invite members'); + + await userEvent.click(btn); + await waitFor(() => { + expect(mockOpenInviteMembers).toHaveBeenCalledWith(expect.objectContaining({ appearance })); + }); + }); + + it('renders passed button and calls both click handlers', async () => { + const handler = vi.fn(); + render( + + + , + ); + const btn = screen.getByText('custom button'); + + await userEvent.click(btn); + await waitFor(() => { + expect(handler).toHaveBeenCalled(); + expect(mockOpenInviteMembers).toHaveBeenCalled(); + }); + }); + + it('uses text passed as children', async () => { + render(text); + screen.getByText('text'); + }); + + it('throws if multiple children provided', async () => { + expect(() => { + render( + + + + , + ); + }).toThrow(); + }); + + it('does not pass appearance prop to child element', () => { + const { container } = render( + + + , + ); + + const button = container.querySelector('button'); + expect(button?.hasAttribute('appearance')).toBe(false); + }); +}); diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index 0cec6374f29..8baf38bf307 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -35,6 +35,7 @@ export { export type { ShowProps } from './controlComponents'; +export { InviteMembersButton } from './InviteMembersButton'; export { SignInButton } from './SignInButton'; export { SignInWithMetamaskButton } from './SignInWithMetamaskButton'; export { SignOutButton } from './SignOutButton'; diff --git a/packages/react/src/contexts/ClerkProvider.tsx b/packages/react/src/contexts/ClerkProvider.tsx index a2ac25bcd8c..fb0274520d6 100644 --- a/packages/react/src/contexts/ClerkProvider.tsx +++ b/packages/react/src/contexts/ClerkProvider.tsx @@ -1,10 +1,11 @@ -import { ClerkContextProvider } from '@clerk/shared/react'; +import { ClerkContextProvider, ClerkInstanceContext } from '@clerk/shared/react'; import React from 'react'; +import { errorThrower } from '../errors/errorThrower'; import { multipleClerkProvidersError } from '../errors/messages'; import { IsomorphicClerk } from '../isomorphicClerk'; import type { ClerkProviderProps, IsomorphicClerkOptions, Ui } from '../types'; -import { mergeWithEnv, withMaxAllowedInstancesGuard } from '../utils'; +import { mergeWithEnv } from '../utils'; import { IS_REACT_SHARED_VARIANT_COMPATIBLE } from '../utils/versionCheck'; function ClerkProviderBase(props: ClerkProviderProps) { @@ -26,7 +27,17 @@ function ClerkProviderBase(props: ClerkProviderProps) { ); } -const ClerkProvider = withMaxAllowedInstancesGuard(ClerkProviderBase, 'ClerkProvider', multipleClerkProvidersError); +function ClerkProviderGuard(props: ClerkProviderProps) { + // Context is per React tree, so a second root or React Native surface sharing + // the JS runtime can never false-positive as a nested provider. + if (React.useContext(ClerkInstanceContext)) { + errorThrower.throw(multipleClerkProvidersError); + } + return ; +} + +// Cast preserves the pre-existing public type of ClerkProvider so the export is not a breaking change. +const ClerkProvider = ClerkProviderGuard as typeof ClerkProviderBase & { displayName: string }; ClerkProvider.displayName = 'ClerkProvider'; diff --git a/packages/react/src/contexts/__tests__/ClerkProvider.test.tsx b/packages/react/src/contexts/__tests__/ClerkProvider.test.tsx index cb13e2483c1..5fd2191b08e 100644 --- a/packages/react/src/contexts/__tests__/ClerkProvider.test.tsx +++ b/packages/react/src/contexts/__tests__/ClerkProvider.test.tsx @@ -16,10 +16,30 @@ import { ukUA, } from '@clerk/localizations'; import { dark } from '@clerk/ui/themes'; -import { describe, expectTypeOf, it } from 'vitest'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { afterAll, beforeAll, describe, expect, expectTypeOf, it, vi } from 'vitest'; import type { ClerkProviderProps as GenericClerkProviderProps, Ui } from '../../types'; -import type { ClerkProvider } from '../ClerkProvider'; +import { ClerkProvider } from '../ClerkProvider'; + +vi.mock('../../isomorphicClerk', () => { + let instance: any; + class IsomorphicClerk { + status = 'loading'; + on = vi.fn(); + off = vi.fn(); + __internal_updateProps = vi.fn().mockResolvedValue(undefined); + static getOrCreateInstance() { + instance ??= new IsomorphicClerk(); + return instance; + } + static clearInstance() { + instance = undefined; + } + } + return { IsomorphicClerk }; +}); type ClerkProviderProps = Parameters[0]; type CustomAppearance = { @@ -32,12 +52,12 @@ const customUi = {} as Ui; describe('ClerkProvider', () => { describe('Type tests', () => { describe('publishableKey', () => { - it('expects a publishableKey and children as the minimum accepted case', () => { + it('accepts an explicit publishableKey', () => { expectTypeOf({ publishableKey: 'test', children: '' }).toMatchTypeOf(); }); - it('errors if no publishableKey', () => { - expectTypeOf({ children: '' }).not.toMatchTypeOf(); + it('accepts no explicit publishableKey', () => { + expectTypeOf({ children: '' }).toMatchTypeOf(); }); }); }); @@ -232,4 +252,45 @@ describe('ClerkProvider', () => { }).toMatchTypeOf(); }); }); + + describe('duplicate detection', () => { + const pk = 'pk_test_Y2xlcmsuY2xlcmsuZGV2JA'; + const originalError = console.error; + + beforeAll(() => { + console.error = vi.fn(); + }); + + afterAll(() => { + console.error = originalError; + }); + + it('throws when a ClerkProvider is nested inside another ClerkProvider', () => { + expect(() => + render( + + +

+ + , + ), + ).toThrow(/multiple /); + }); + + it('does not throw when a second React root mounts while the first is still mounted', () => { + const first = render( + +
+ , + ); + expect(() => + render( + +
+ , + ), + ).not.toThrow(); + first.unmount(); + }); + }); }); diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index e017ab2ddcd..cfb09a473aa 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -34,6 +34,7 @@ import type { GoogleOneTapProps, HandleEmailLinkVerificationParams, HandleOAuthCallbackParams, + InviteMembersModalProps, JoinWaitlistParams, ListenerCallback, ListenerOptions, @@ -145,6 +146,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { private preopenSignUp?: null | SignUpProps = null; private preopenUserProfile?: null | UserProfileProps = null; private preopenOrganizationProfile?: null | OrganizationProfileProps = null; + private preopenInviteMembers?: null | InviteMembersModalProps = null; private preopenCreateOrganization?: null | CreateOrganizationProps = null; private preOpenWaitlist?: null | WaitlistProps = null; private premountSignInNodes = new Map(); @@ -726,6 +728,10 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { clerkjs.openOrganizationProfile(this.preopenOrganizationProfile); } + if (this.preopenInviteMembers !== null) { + clerkjs.openInviteMembers(this.preopenInviteMembers); + } + if (this.preopenCreateOrganization !== null) { clerkjs.openCreateOrganization(this.preopenCreateOrganization); } @@ -1077,6 +1083,22 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; + openInviteMembers = (props?: InviteMembersModalProps) => { + if (this.clerkjs && this.loaded) { + this.clerkjs.openInviteMembers(props); + } else { + this.preopenInviteMembers = props; + } + }; + + closeInviteMembers = () => { + if (this.clerkjs && this.loaded) { + this.clerkjs.closeInviteMembers(); + } else { + this.preopenInviteMembers = null; + } + }; + openCreateOrganization = (props?: CreateOrganizationProps) => { if (this.clerkjs && this.loaded) { this.clerkjs.openCreateOrganization(props); diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index e1397073042..204c05a1dd9 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -48,9 +48,14 @@ export interface Ui { */ export type ClerkProviderProps = Omit< IsomorphicClerkOptions, - 'appearance' | keyof InternalClerkScriptProps + 'appearance' | 'publishableKey' | keyof InternalClerkScriptProps > & { children: React.ReactNode; + /** + * The Clerk Publishable Key for your instance. When omitted, `@clerk/react` reads the key from + * `VITE_CLERK_PUBLISHABLE_KEY` or `CLERK_PUBLISHABLE_KEY`. + */ + publishableKey?: string; /** * Provide an initial state of the Clerk client during server-side rendering. You don't need to set this value yourself unless you're [developing an SDK](https://clerk.com/docs/guides/development/sdk-development/overview). */ diff --git a/packages/react/src/utils/childrenUtils.tsx b/packages/react/src/utils/childrenUtils.tsx index 73b26c8c4af..7a6be1f0e58 100644 --- a/packages/react/src/utils/childrenUtils.tsx +++ b/packages/react/src/utils/childrenUtils.tsx @@ -13,7 +13,8 @@ export const assertSingleChild = | 'SignInWithMetamaskButton' | 'CheckoutButton' | 'SubscriptionDetailsButton' - | 'PlanDetailsButton', + | 'PlanDetailsButton' + | 'InviteMembersButton', ) => { try { return React.Children.only(children); diff --git a/packages/shared/CHANGELOG.md b/packages/shared/CHANGELOG.md index 461783ec8cc..07fb4c5cac6 100644 --- a/packages/shared/CHANGELOG.md +++ b/packages/shared/CHANGELOG.md @@ -1,5 +1,48 @@ # Change Log +## 4.27.0 + +### Minor Changes + +- Add ``, a control component that opens the organization invite-members form in a modal when clicked, working like ``. ([#9124](https://github.com/clerk/javascript/pull/9124)) by [@alexcarpenter](https://github.com/alexcarpenter) + + Wrap your own button (or omit children for a default one). The button requires an active organization and should be rendered for members who can manage memberships (`org:sys_memberships:manage`). Opening it without an active organization or that permission is a no-op in production, and throws a descriptive error in development. + + ```tsx + import { InviteMembersButton } from '@clerk/nextjs'; + + + + ; + ``` + + This also adds `Clerk.openInviteMembers()` and `Clerk.closeInviteMembers()` for opening and closing the modal programmatically. + +### Patch Changes + +- Improve generated API reference links, expose `BillingSubscriptionItemStatus`, and clarify the `createUser()` identification status documentation. ([#9340](https://github.com/clerk/javascript/pull/9340)) by [@SarahSoutoul](https://github.com/SarahSoutoul) + +- Rename "Client Trust" to "Device Trust" in documentation strings and links. This is a naming change only — the `needs_client_trust` sign-in status, the `clientTrustState` property, and every other API value keep their existing names, so no integration changes are required. ([#9266](https://github.com/clerk/javascript/pull/9266)) by [@mwickett](https://github.com/mwickett) + +## 4.26.0 + +### Minor Changes + +- Support sign-in-or-sign-up combined flow with Clerk component ([#7928](https://github.com/clerk/javascript/pull/7928)) by [@dmoerner](https://github.com/dmoerner) + + when strict enumeration protection is enabled. + + On development instances, `` now logs a warning when the sign-in-or-up flow is rendered on an + instance that has both password and strict enumeration protection enabled. In that configuration + visitors without an account are routed to the password screen and cannot complete a sign-up, so the + warning names both settings and how to resolve them. + +## 4.25.10 + +### Patch Changes + +- Enable self-serve OIDC configuration for every application. Organization admins can now select an OIDC provider in the `` Security tab without the `experimental.oidcSelfServe` option, and existing OIDC connections open their configuration steps instead of the unsupported-provider state. The `experimental.oidcSelfServe` option no longer does anything and can be removed from `` and `Clerk.load()`. ([#9288](https://github.com/clerk/javascript/pull/9288)) by [@NicolasLopes7](https://github.com/NicolasLopes7) + ## 4.25.9 ### Patch Changes diff --git a/packages/shared/package.json b/packages/shared/package.json index 409d0c72eea..11af8737653 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@clerk/shared", - "version": "4.25.9", + "version": "4.27.0", "description": "Internal package utils used by the Clerk SDKs", "repository": { "type": "git", diff --git a/packages/shared/src/internal/clerk-js/__tests__/navigateToNextStepSignUp.test.ts b/packages/shared/src/internal/clerk-js/__tests__/navigateToNextStepSignUp.test.ts new file mode 100644 index 00000000000..99f560a5389 --- /dev/null +++ b/packages/shared/src/internal/clerk-js/__tests__/navigateToNextStepSignUp.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { SignUpField, SignUpResource } from '@/types'; + +import { navigateToNextStepSignUp } from '../navigateToNextStepSignUp'; + +const mockNavigate = vi.fn(); + +const URLS = { + continueSignUpUrl: 'https://app.test/sign-up/continue', + verifyEmailAddressUrl: 'https://app.test/sign-up/verify-email-address', + verifyPhoneNumberUrl: 'https://app.test/sign-up/verify-phone-number', + signUpProtectCheckUrl: 'https://app.test/sign-up/protect-check', +}; + +describe('navigateToNextStepSignUp', () => { + beforeEach(() => { + mockNavigate.mockReset(); + Object.defineProperty(window, 'location', { + value: { search: '' }, + writable: true, + }); + }); + + it('navigates to the continue page when there are missing fields', async () => { + const signUp = { + status: 'missing_requirements', + missingFields: ['first_name'] as SignUpField[], + unverifiedFields: [], + } as unknown as SignUpResource; + + await navigateToNextStepSignUp({ + signUp, + ...URLS, + navigate: mockNavigate, + }); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(URLS.continueSignUpUrl); + }); + + it('navigates to the protect-check page when the sign-up is protect-gated, before checking missing fields', async () => { + const signUp = { + status: 'missing_requirements', + missingFields: ['protect_check', 'first_name'] as SignUpField[], + unverifiedFields: [], + } as unknown as SignUpResource; + + await navigateToNextStepSignUp({ + signUp, + ...URLS, + navigate: mockNavigate, + }); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(URLS.signUpProtectCheckUrl); + }); + + it('navigates to verify-email-address when email is unverified and there are no missing fields', async () => { + const signUp = { + status: 'missing_requirements', + missingFields: [] as SignUpField[], + unverifiedFields: ['email_address'], + } as unknown as SignUpResource; + + await navigateToNextStepSignUp({ + signUp, + ...URLS, + navigate: mockNavigate, + }); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(URLS.verifyEmailAddressUrl, { searchParams: new URLSearchParams() }); + }); + + it('navigates to verify-phone-number when phone is unverified and there are no missing fields', async () => { + const signUp = { + status: 'missing_requirements', + missingFields: [] as SignUpField[], + unverifiedFields: ['phone_number'], + } as unknown as SignUpResource; + + await navigateToNextStepSignUp({ + signUp, + ...URLS, + navigate: mockNavigate, + }); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(URLS.verifyPhoneNumberUrl, { searchParams: new URLSearchParams() }); + }); + + it('prefers email verification over phone verification when both are unverified', async () => { + const signUp = { + status: 'missing_requirements', + missingFields: [] as SignUpField[], + unverifiedFields: ['email_address', 'phone_number'], + } as unknown as SignUpResource; + + await navigateToNextStepSignUp({ + signUp, + ...URLS, + navigate: mockNavigate, + }); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(URLS.verifyEmailAddressUrl, { searchParams: new URLSearchParams() }); + }); + + it('prefers the continue page when there are both missing fields and unverified fields', async () => { + const signUp = { + status: 'missing_requirements', + missingFields: ['first_name'] as SignUpField[], + unverifiedFields: ['email_address'], + } as unknown as SignUpResource; + + await navigateToNextStepSignUp({ + signUp, + ...URLS, + navigate: mockNavigate, + }); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(URLS.continueSignUpUrl); + }); + + it('does nothing when sign-up has no missing fields and no unverified fields', async () => { + const signUp = { + status: 'missing_requirements', + missingFields: [] as SignUpField[], + unverifiedFields: [], + } as unknown as SignUpResource; + + await navigateToNextStepSignUp({ + signUp, + ...URLS, + navigate: mockNavigate, + }); + + expect(mockNavigate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/src/internal/clerk-js/__tests__/warnings.test.ts b/packages/shared/src/internal/clerk-js/__tests__/warnings.test.ts index ce9fcd298f3..92f2750cdff 100644 --- a/packages/shared/src/internal/clerk-js/__tests__/warnings.test.ts +++ b/packages/shared/src/internal/clerk-js/__tests__/warnings.test.ts @@ -20,4 +20,25 @@ describe('warnings', () => { expect(warnings.cannotOpenSignInOrSignUp).toContain('This notice only appears in development'); }); }); + + describe('createCannotRenderComponentWhenPermissionIsMissing', () => { + const message = warnings.createCannotRenderComponentWhenPermissionIsMissing( + 'InviteMembers', + 'org:sys_memberships:manage', + ); + + it('names the component and the missing permission', () => { + expect(message).toContain(''); + expect(message).toContain('org:sys_memberships:manage'); + }); + + it('explains it is a no-op and how to gate the component', () => { + expect(message).toContain('this is no-op'); + expect(message).toContain(' { + expect(message).toContain('This notice only appears in development'); + }); + }); }); diff --git a/packages/shared/src/internal/clerk-js/navigateToNextStepSignUp.ts b/packages/shared/src/internal/clerk-js/navigateToNextStepSignUp.ts new file mode 100644 index 00000000000..277b8ede011 --- /dev/null +++ b/packages/shared/src/internal/clerk-js/navigateToNextStepSignUp.ts @@ -0,0 +1,54 @@ +import type { SignUpResource } from '../../types'; +import { completeSignUpFlow } from './completeSignUpFlow'; + +type NavigateToNextStepSignUpProps = { + signUp: SignUpResource; + continueSignUpUrl: string; + verifyEmailAddressUrl: string; + verifyPhoneNumberUrl: string; + signUpProtectCheckUrl: string; + navigate: (to: string, options?: { searchParams?: URLSearchParams }) => Promise; +}; + +/** + * Routes a sign-up that's still in `missing_requirements` to the appropriate + * next step: + * + * - If the sign-up is protect-gated, go to the protect-check challenge. + * - Otherwise, if there are missing fields, go straight to the continue page so + * the user can fill them in. + * - Otherwise, hand off to `completeSignUpFlow` which routes unverified email + * or phone identifications to their respective verify pages. + * + * Used by both the OAuth callback handler and the sign-in `signUpIfMissing` + * transfer flow so they stay in lockstep. + * + * @internal + */ +export const navigateToNextStepSignUp = ({ + signUp, + continueSignUpUrl, + verifyEmailAddressUrl, + verifyPhoneNumberUrl, + signUpProtectCheckUrl, + navigate, +}: NavigateToNextStepSignUpProps): Promise | undefined => { + // A protect-gated sign-up always carries 'protect_check' in missing_fields, so this gate + // check must run BEFORE the generic missing-fields short-circuit below — otherwise the + // callback would land on /continue instead of the challenge. + if (signUp.protectCheck || signUp.missingFields.includes('protect_check')) { + return navigate(signUpProtectCheckUrl); + } + + if (signUp.missingFields.length) { + return navigate(continueSignUpUrl); + } + + return completeSignUpFlow({ + signUp, + verifyEmailPath: verifyEmailAddressUrl, + verifyPhonePath: verifyPhoneNumberUrl, + protectCheckPath: signUpProtectCheckUrl, + navigate, + }); +}; diff --git a/packages/shared/src/internal/clerk-js/queryParams.ts b/packages/shared/src/internal/clerk-js/queryParams.ts index 8bea94a7c9b..1a0fcd44dd3 100644 --- a/packages/shared/src/internal/clerk-js/queryParams.ts +++ b/packages/shared/src/internal/clerk-js/queryParams.ts @@ -20,9 +20,16 @@ const _ClerkQueryParams = [ type ClerkQueryParam = (typeof _ClerkQueryParams)[number]; /** - * Used for email link verification + * Possible values of `__clerk_status` returned from the email link verify + * endpoint. `transferable` belongs to the `signUpIfMissing` flow - the + * verification succeeded but the user does not exist, so a sign-up transfer is + * banked on the client that owns the sign-in and whichever tab shares that + * client performs it; see `EmailLinkUIStatus`. */ -export type VerifyTokenStatus = 'verified' | (typeof EmailLinkErrorCodeStatus)[keyof typeof EmailLinkErrorCodeStatus]; +export type VerifyTokenStatus = + | 'verified' + | 'transferable' + | (typeof EmailLinkErrorCodeStatus)[keyof typeof EmailLinkErrorCodeStatus]; /** * Used for instance invitations and organization invitations diff --git a/packages/shared/src/internal/clerk-js/warnings.ts b/packages/shared/src/internal/clerk-js/warnings.ts index 385f0363c07..4081f830339 100644 --- a/packages/shared/src/internal/clerk-js/warnings.ts +++ b/packages/shared/src/internal/clerk-js/warnings.ts @@ -7,6 +7,7 @@ const formatWarning = (msg: string) => { const createMessageForDisabledOrganizations = ( componentName: | 'OrganizationProfile' + | 'InviteMembers' | 'OrganizationSwitcher' | 'OrganizationList' | 'CreateOrganization' @@ -18,12 +19,20 @@ const createMessageForDisabledOrganizations = ( ); }; -const createCannotRenderComponentWhenOrgDoesNotExist = (componentName: 'OrganizationProfile' | 'ConfigureSSO') => { +const createCannotRenderComponentWhenOrgDoesNotExist = ( + componentName: 'OrganizationProfile' | 'InviteMembers' | 'ConfigureSSO', +) => { return formatWarning( `<${componentName}/> cannot render unless an organization is active. Since no organization is currently active, this is no-op.`, ); }; +const createCannotRenderComponentWhenPermissionIsMissing = (componentName: 'InviteMembers', permission: string) => { + return formatWarning( + `<${componentName}/> cannot render unless the current user has the \`${permission}\` permission. Since the current user is missing this permission, this is no-op. Render it only for members who can manage memberships, for example by wrapping it in .`, + ); +}; + const createMessageForDisabledBilling = (componentName: 'PricingTable' | 'Checkout' | 'PlanDetails') => { return formatWarning( `The <${componentName}/> component cannot be rendered when billing is disabled. Visit 'https://dashboard.clerk.com/last-active?path=billing/settings' to follow the necessary steps to enable billing. Since billing is disabled, this is no-op.`, @@ -54,6 +63,7 @@ const warnings = { cannotRenderComponentWhenUserDoesNotExist: ' cannot render unless a user is signed in. Since no user is signed in, this is no-op.', createCannotRenderComponentWhenOrgDoesNotExist, + createCannotRenderComponentWhenPermissionIsMissing, cannotRenderAnyOrganizationComponent: createMessageForDisabledOrganizations, cannotRenderAnyBillingComponent: createMessageForDisabledBilling, cannotOpenUserProfile: diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 77ce5435aae..1a5a42de11b 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -541,6 +541,18 @@ export interface Clerk { */ closeOrganizationProfile: () => void; + /** + * Opens a modal containing the organization invite-members form. + * + * @param props - Optional props that will be passed to the invite-members modal. + */ + openInviteMembers: (props?: InviteMembersModalProps) => void; + + /** + * Closes the invite-members modal. + */ + closeInviteMembers: () => void; + /** * Opens the Clerk CreateOrganization modal. * @@ -1433,7 +1445,7 @@ export type ClerkOptions = ClerkOptionsNavigation & */ supportEmail?: string; /** - * By default, the [Clerk Frontend API `touch` endpoint](https://clerk.com/docs/reference/frontend-api/tag/Sessions#operation/touchSession){{ target: '_blank' }} is called during page focus to keep the last active session alive. This option allows you to disable this behavior. + * By default, the [Clerk Frontend API `touch` endpoint](https://clerk.com/docs/reference/frontend-api/tag/sessions/POST/v1/client/sessions/%7Bsession_id%7D/touch){{ target: '_blank' }} is called during page focus to keep the last active session alive. This option allows you to disable this behavior. */ touchSession?: boolean; /** @@ -1513,10 +1525,6 @@ export type ClerkOptions = ClerkOptionsNavigation & * directly with the provided Clerk instance. Used by React Native / Expo. */ runtimeEnvironment: 'headless'; - /** - * Temporary flag that gates the self-serve OIDC flow in ``. Remove once the self-serve OIDC flow reaches GA. - */ - oidcSelfServe: boolean; }, Record >; @@ -1857,6 +1865,7 @@ export type __internal_EnableOrganizationsPromptProps = { caller: | 'OrganizationSwitcher' | 'OrganizationProfile' + | 'InviteMembers' | 'OrganizationList' | 'useOrganizationList' | 'useOrganization'; @@ -1867,6 +1876,7 @@ export type __internal_AttemptToEnableEnvironmentSettingParams = { caller: | 'OrganizationSwitcher' | 'OrganizationProfile' + | 'InviteMembers' | 'OrganizationList' | 'CreateOrganization' | 'TaskChooseOrganization' @@ -2073,6 +2083,23 @@ export type OrganizationProfileModalProps = WithoutRouting HTMLElement | null; }; +/** @generateWithEmptyComment */ +export type InviteMembersProps = { + /** + * Customization options to fully match the Clerk components to your own brand. These options serve as overrides and will be merged with the global `appearance` configuration (if one is provided). See the [`Appearance`](https://clerk.com/docs/guides/customizing-clerk/appearance-prop/overview) docs for more information. + */ + appearance?: OrganizationProfileProps['appearance']; +}; + +export type InviteMembersModalProps = InviteMembersProps & { + /** + * Function that returns the container element where portals should be rendered. + * This allows Clerk components to render inside external dialogs/popovers + * (e.g., Radix Dialog, React Aria Components) instead of document.body. + */ + getContainer?: () => HTMLElement | null; +}; + /** @generateWithEmptyComment */ export type CreateOrganizationProps = RoutingOptions & { /** @@ -2715,6 +2742,11 @@ export type SignUpButtonProps = (SignUpButtonPropsModal | ButtonPropsRedirect) & | 'oauthFlow' >; +/** + * The invite-members form is only available as a modal, so there is no `mode` prop. + */ +export type InviteMembersButtonProps = InviteMembersProps; + /** @generateWithEmptyComment */ export type TaskChooseOrganizationProps = { /** diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index d73ed179a04..efb96c465cc 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -511,6 +511,14 @@ export type __internal_LocalizationResource = { titleNewTab: LocalizationValue; subtitleNewTab: LocalizationValue; }; + /** + * Shown when the verified email has no matching user and the flow transfers to sign-up + * (`signUpIfMissing`), in whichever tab is not the one carrying that transfer. + */ + verifiedTransferable: { + title: LocalizationValue; + subtitle: LocalizationValue; + }; loading: { title: LocalizationValue; subtitle: LocalizationValue; diff --git a/packages/shared/src/types/signInFuture.ts b/packages/shared/src/types/signInFuture.ts index d46ce7e0f20..59941ee856a 100644 --- a/packages/shared/src/types/signInFuture.ts +++ b/packages/shared/src/types/signInFuture.ts @@ -342,7 +342,7 @@ export interface SignInFutureResource { * The current status of the sign-in. *
    *
  • `'complete'` - The sign-in process has been completed successfully.
  • - *
  • `'needs_client_trust'` - The user is signing in from a new device and must complete a [second factor verification](!second-factor-verification) to establish [Client Trust](https://clerk.com/docs/guides/secure/client-trust). See the [Client Trust custom flow guide](https://clerk.com/docs/guides/development/custom-flows/authentication/client-trust) for more information.
  • + *
  • `'needs_client_trust'` - The user is signing in from a new device and must complete a [second factor verification](!second-factor-verification) to establish [Device Trust](https://clerk.com/docs/guides/secure/device-trust). See the [Device Trust custom flow guide](https://clerk.com/docs/guides/development/custom-flows/authentication/device-trust) for more information.
  • *
  • `'needs_identifier'` - The user's identifier (e.g., email address, phone number, username) hasn't been provided.
  • *
  • `'needs_first_factor'` - One of the following [first factor verification](!first-factor-verification) strategies is missing: `'email_link'`, `'email_code'`, `passkey`, `password`, `'phone_code'`, `'web3_base_signature'`, `'web3_metamask_signature'`, `'web3_coinbase_wallet_signature'`, `'web3_okx_wallet_signature'`, `'web3_solana_signature'`, [`OAuthStrategy`](https://clerk.com/docs/reference/types/sso#o-auth-strategy), or `'enterprise_sso'`.
  • *
  • `'needs_second_factor'` - One of the following [second factor verification](!second-factor-verification) strategies is missing: `'phone_code'`, `'totp'`, `'backup_code'`, `'email_code'`, or `'email_link'`.
  • diff --git a/packages/shared/src/types/userSettings.ts b/packages/shared/src/types/userSettings.ts index dafa0190251..ec5a599a2f6 100644 --- a/packages/shared/src/types/userSettings.ts +++ b/packages/shared/src/types/userSettings.ts @@ -82,6 +82,12 @@ export type UsernameSettingsData = { max_length: number; }; +export type AttackProtectionData = { + enumeration_protection: { + enabled: boolean; + }; +}; + export type PasskeySettingsData = { allow_autofill: boolean; show_sign_in_button: boolean; @@ -122,6 +128,11 @@ export interface UserSettingsJSON extends ClerkResourceJSON { password_settings: PasswordSettingsData; passkey_settings: PasskeySettingsData; username_settings: UsernameSettingsData; + /** + * Optional because older environment payloads (and existing mocks) predate the field. + * `UserSettings.fromJSON` falls back to enumeration protection disabled. + */ + attack_protection?: AttackProtectionData; } export interface UserSettingsResource extends ClerkResource { @@ -136,6 +147,7 @@ export interface UserSettingsResource extends ClerkResource { signUp: SignUpData; passwordSettings: PasswordSettingsData; usernameSettings: UsernameSettingsData; + attackProtection: AttackProtectionData; passkeySettings: PasskeySettingsData; socialProviderStrategies: OAuthStrategy[]; authenticatableSocialStrategies: OAuthStrategy[]; diff --git a/packages/swingset/CHANGELOG.md b/packages/swingset/CHANGELOG.md index be0fb732e25..e2e42aa3c29 100644 --- a/packages/swingset/CHANGELOG.md +++ b/packages/swingset/CHANGELOG.md @@ -1,5 +1,29 @@ # @clerk/swingset +## 0.0.30 + +### Patch Changes + +- Updated dependencies [[`d639048`](https://github.com/clerk/javascript/commit/d639048e0e48ff3a120435134f9e01221697b6bc), [`d639048`](https://github.com/clerk/javascript/commit/d639048e0e48ff3a120435134f9e01221697b6bc), [`d639048`](https://github.com/clerk/javascript/commit/d639048e0e48ff3a120435134f9e01221697b6bc)]: + - @clerk/ui@1.29.0 + - @clerk/headless@0.0.20 + +## 0.0.29 + +### Patch Changes + +- Updated dependencies [[`5c81479`](https://github.com/clerk/javascript/commit/5c81479d303fc6146dc81309d0b58564aa96706e), [`83a8fc5`](https://github.com/clerk/javascript/commit/83a8fc57d7bb3c2aa9c4dad1bdc34901fee0fd10)]: + - @clerk/ui@1.28.0 + - @clerk/headless@0.0.19 + +## 0.0.28 + +### Patch Changes + +- Updated dependencies [[`aaea141`](https://github.com/clerk/javascript/commit/aaea141d62804624cd8cd73036b4afe6f482184f)]: + - @clerk/ui@1.27.2 + - @clerk/headless@0.0.18 + ## 0.0.27 ### Patch Changes diff --git a/packages/swingset/CLAUDE.md b/packages/swingset/CLAUDE.md index fc6aba1bfc0..177d3f235ce 100644 --- a/packages/swingset/CLAUDE.md +++ b/packages/swingset/CLAUDE.md @@ -57,7 +57,7 @@ Pick the archetype below by the component's **layer** (its `meta.group`), then f ### Layers -`meta.group` places a component in one of six layers. Sidebar order follows the `registry` array; group order follows first appearance there. Use these exact group strings: +`meta.group` places an entry in one of these layers. Sidebar order follows the `registry` array; group order follows first appearance there. Use these exact group strings: | Group | What lives here | Archetype | | ------------ | -------------------------------------------------------------- | --------- | @@ -67,9 +67,18 @@ Pick the archetype below by the component's **layer** (its `meta.group`), then f | `Blocks` | Reusable composite UI (e.g. `Destructive`) | C | | `Components` | Styled Mosaic components — simple CVA recipe (`Button`, `Input`) or compound/slot-based (`Dialog`, `Tabs`) | A | | `Primitives` | Headless `@clerk/headless` primitives (`Accordion`) | B | +| `Styles` | Atomic styles that ship as StyleX atoms, not components (`Scroll Area`) | B (adapted) | +| `Hooks` | Headless hooks (`useDataTable`) | B (adapted) | `AIO` → `Panels` → `Sections` → `Blocks` → `Components` → `Primitives` runs roughly high-level-composition → low-level-primitive. Composed layers (AIO/Panels/Sections/Blocks) are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B). +`Styles` and `Hooks` are the non-component layers: there is no element to knob, so they follow +archetype B's shape (Example → Usage → Parts → Styling) with `Props` replaced by whatever the export +actually surfaces — an argument table for a style function, a return-value table for a hook. A +`Styles` entry documents the theme tokens its atoms read, since those tokens _are_ its API; the +`Hooks` entry (`use-data-table.stories.tsx`) is `meta` alone, with no story exports at all, which is +the minimum a section entry needs. + Archetype A has two forms, chosen by whether the component exposes a single flat CVA recipe: **simple** components (`Button`, `Input`) are knob-driven; **compound** components built from slot recipes (`Dialog`, `Tabs`) have no flat variant props to knob, so they're documented like a primitive but themed. Both are detailed under Archetype A below. ### `meta` conventions (all archetypes) diff --git a/packages/swingset/package.json b/packages/swingset/package.json index e2035328035..20d71cb2025 100644 --- a/packages/swingset/package.json +++ b/packages/swingset/package.json @@ -1,6 +1,6 @@ { "name": "@clerk/swingset", - "version": "0.0.27", + "version": "0.0.30", "private": true, "type": "module", "scripts": { diff --git a/packages/swingset/src/components/ClientRoot.tsx b/packages/swingset/src/components/ClientRoot.tsx index 8725a699975..08a85129e6b 100644 --- a/packages/swingset/src/components/ClientRoot.tsx +++ b/packages/swingset/src/components/ClientRoot.tsx @@ -13,6 +13,7 @@ import { } from '@/components/ui/breadcrumb'; import { Separator } from '@/components/ui/separator'; import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'; +import { getModule } from '@/lib/registry'; import { AppSidebar } from './app-sidebar'; import { ThemeToggle } from './ThemeToggle'; @@ -20,10 +21,17 @@ import { ThemeToggle } from './ThemeToggle'; function useBreadcrumb() { const pathname = usePathname(); // /components/button → ["Button"] - // /primitives/dialog → ["Dialog"] - // The first segment is the group; drop it and surface the component (plus any sub-path). - const parts = pathname.split('/').filter(Boolean).slice(1); - return parts.map(p => p.charAt(0).toUpperCase() + p.slice(1).replace(/-/g, ' ')); + // /styles/scroll-area → ["Scroll Area"] + // The first segment is the group; drop it and surface the entry (plus any sub-path). + const [groupSlug, ...parts] = pathname.split('/').filter(Boolean); + + // Prefer the registry's own `meta.title`, which is the only source that round-trips a slug back + // to how the entry is actually written — `scroll-area` → `Scroll Area`, `use-data-table` → + // `useDataTable`. Fall back to title-casing the slug for any path the registry doesn't cover. + return parts.map((part, index) => { + const title = index === 0 ? getModule(groupSlug, part)?.meta.title : undefined; + return title ?? part.replace(/(^|-)([a-z])/g, (_, sep: string, ch: string) => (sep ? ' ' : '') + ch.toUpperCase()); + }); } export function ClientRoot({ children }: { children: React.ReactNode }) { diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 7bbe0bbacb8..9a19812fbf1 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -37,6 +37,8 @@ const docModules: Record> = { dialog: dynamic(() => import('../stories/dialog.component.mdx')), heading: dynamic(() => import('../stories/heading.mdx')), icon: dynamic(() => import('../stories/icon.mdx')), + menu: dynamic(() => import('../stories/menu.component.mdx')), + popover: dynamic(() => import('../stories/popover.component.mdx')), tabs: dynamic(() => import('../stories/tabs.component.mdx')), text: dynamic(() => import('../stories/text.mdx')), }, @@ -55,6 +57,10 @@ const docModules: Record> = { tabs: dynamic(() => import('../stories/tabs.mdx')), tooltip: dynamic(() => import('../stories/tooltip.mdx')), }, + styles: { + // Atomic styles — shipped as StyleX atoms rather than components. + 'scroll-area': dynamic(() => import('../stories/scroll-area.mdx')), + }, hooks: { // Headless hooks — alphabetical. 'use-data-table': dynamic(() => import('../stories/use-data-table.mdx')), diff --git a/packages/swingset/src/components/app-sidebar.tsx b/packages/swingset/src/components/app-sidebar.tsx index 35cd5ef3ad8..eba4369ff05 100644 --- a/packages/swingset/src/components/app-sidebar.tsx +++ b/packages/swingset/src/components/app-sidebar.tsx @@ -71,10 +71,16 @@ export function AppSidebar({ ...props }: React.ComponentProps) { {components.map(({ mod, componentSlug }) => { const href = `/${groupSlug}/${componentSlug}`; - // Hooks (e.g. `useDataTable`) are called, not rendered — show `useX()` rather - // than JSX ``. Everything else is a component. - const isHook = /^use[A-Z]/.test(mod.meta.title); - const usage = isHook ? `${mod.meta.title}()` : `<${mod.meta.title} />`; + // How an entry is USED differs by layer, so the label follows the layer rather + // than a guess at the title: hooks are called, atomic styles are a set of + // exports with no single call form worth privileging, and everything else is a + // component rendered as JSX. + const usage = + mod.meta.group === 'Hooks' + ? `${mod.meta.title}()` + : mod.meta.group === 'Styles' + ? mod.meta.title + : `<${mod.meta.title} />`; return ( + × + +``` diff --git a/packages/swingset/src/stories/button.stories.tsx b/packages/swingset/src/stories/button.stories.tsx index 03fc14af232..e2e6fefc3a2 100644 --- a/packages/swingset/src/stories/button.stories.tsx +++ b/packages/swingset/src/stories/button.stories.tsx @@ -22,6 +22,7 @@ export const meta: StoryMeta = { size: { sm: {}, md: {}, lg: {} }, shape: { default: {}, square: {}, circle: {} }, fullWidth: { true: {}, false: {} }, + touchTarget: { true: {}, false: {} }, }, _defaultVariants: { color: 'primary', @@ -29,6 +30,7 @@ export const meta: StoryMeta = { size: 'md', shape: 'default', fullWidth: false, + touchTarget: true, }, }, }; @@ -143,19 +145,10 @@ export function Shapes(props: Record) { size='sm' aria-label='Add' > - - - +
); diff --git a/packages/swingset/src/stories/card.component.mdx b/packages/swingset/src/stories/card.component.mdx index 5f282a30ea2..38444cd4842 100644 --- a/packages/swingset/src/stories/card.component.mdx +++ b/packages/swingset/src/stories/card.component.mdx @@ -20,11 +20,11 @@ A styled surface container that groups related content into Header, Content, and ```tsx import { Card } from '@clerk/ui/mosaic/components/card'; - + Heading Body content. Footer actions - + ``` --- diff --git a/packages/swingset/src/stories/card.component.stories.tsx b/packages/swingset/src/stories/card.component.stories.tsx index ae72fe95628..201fd081626 100644 --- a/packages/swingset/src/stories/card.component.stories.tsx +++ b/packages/swingset/src/stories/card.component.stories.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource @emotion/react */ import { Button } from '@clerk/ui/mosaic/components/button'; import type { CardProps } from '@clerk/ui/mosaic/components/card'; -import { Card, cardRecipe } from '@clerk/ui/mosaic/components/card'; +import { Card } from '@clerk/ui/mosaic/components/card'; import { Heading } from '@clerk/ui/mosaic/components/heading'; import { Text } from '@clerk/ui/mosaic/components/text'; @@ -14,8 +14,18 @@ export { default as __source } from './card.component.stories?raw'; export const meta: StoryMeta = { group: 'Components', title: 'Card', - source: 'packages/ui/src/mosaic/components/card.tsx', - styles: cardRecipe, + source: 'packages/ui/src/mosaic/components/card/card.tsx', + styleEngine: 'stylex', + styles: { + _variants: { + alignment: { start: {}, center: {} }, + elevation: { card: {}, flush: {}, overlay: {} }, + }, + _defaultVariants: { + alignment: 'start', + elevation: 'card', + }, + }, }; function knobsAsProps(props: Record) { @@ -24,7 +34,7 @@ function knobsAsProps(props: Record) { export function Default(props: Record) { return ( - @@ -36,13 +46,13 @@ export function Default(props: Record) { - + ); } export function Centered() { return ( - @@ -54,6 +64,6 @@ export function Centered() { - + ); } diff --git a/packages/swingset/src/stories/dialog.component.stories.tsx b/packages/swingset/src/stories/dialog.component.stories.tsx index afe4b77b335..051d810540c 100644 --- a/packages/swingset/src/stories/dialog.component.stories.tsx +++ b/packages/swingset/src/stories/dialog.component.stories.tsx @@ -1,4 +1,5 @@ /** @jsxImportSource @emotion/react */ +import type { RenderProps } from '@clerk/headless/utils'; import { Button } from '@clerk/ui/mosaic/components/button'; import { Dialog, dialogRecipe } from '@clerk/ui/mosaic/components/dialog'; @@ -15,9 +16,7 @@ export const meta: StoryMeta = { styles: dialogRecipe, }; -const dialogTrigger = ({ color: _nativeColor, ...props }: React.HTMLAttributes) => ( - -); +const dialogTrigger = (props: RenderProps) => ; export function Default(args: Record) { const { size } = args as { size?: 'md' | 'lg' }; diff --git a/packages/swingset/src/stories/input.stories.tsx b/packages/swingset/src/stories/input.stories.tsx index 4220c7e6e10..be98937592e 100644 --- a/packages/swingset/src/stories/input.stories.tsx +++ b/packages/swingset/src/stories/input.stories.tsx @@ -1,6 +1,5 @@ -/** @jsxImportSource @emotion/react */ import type { InputProps } from '@clerk/ui/mosaic/components/input'; -import { Input, inputRecipe } from '@clerk/ui/mosaic/components/input'; +import { Input } from '@clerk/ui/mosaic/components/input'; import type { StoryMeta } from '@/lib/types'; @@ -11,8 +10,16 @@ export { default as __source } from './input.stories?raw'; export const meta: StoryMeta = { group: 'Components', title: 'Input', - source: 'packages/ui/src/mosaic/components/input.tsx', - styles: inputRecipe, + source: 'packages/ui/src/mosaic/components/input/input.tsx', + styleEngine: 'stylex', + styles: { + _variants: { + size: { sm: {}, md: {}, lg: {} }, + }, + _defaultVariants: { + size: 'md', + }, + }, }; function knobsAsProps(props: Record) { @@ -41,6 +48,11 @@ export function Sizes(props: Record) { size='md' placeholder='Medium' /> +
); } diff --git a/packages/swingset/src/stories/item.mdx b/packages/swingset/src/stories/item.mdx index dd5aea2f3f8..b7555624052 100644 --- a/packages/swingset/src/stories/item.mdx +++ b/packages/swingset/src/stories/item.mdx @@ -4,6 +4,8 @@ import * as ItemStories from './item.stories'; Item is a flexible row for lists of accounts, organizations, and settings in Mosaic. It's composed from parts via dot syntax (`Item.Root`, `Item.Media`, `Item.Content`, `Item.Title`, …). `Item.Root` renders as a `
` by default; pass it a `render` prop to make a row an interactive link or button, which adds hover and cursor affordances. +Set `size` once on `Item.Root` and the row scales as a unit: it fixes the row's height and gap, and `Item.Media` picks the matching column width up from context rather than taking a size of its own. + ## Example +### Sizes + + + ### Group +### Scrolling + +`Item.Group` is the canonical scroll surface in Mosaic: cap its height, spread the scroll-area atoms +onto it, and it fades its content at whichever edge still has something to reveal. + + + +```tsx +import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; +import * as stylex from '@stylexjs/stylex'; + +
+ {organizations} +
; +``` + +The atoms aren't specific to `Item` — they go on anything that scrolls, and they carry the edge +fades, the scrollbar, the gutter, and the theming tokens with them. See +[Scroll Area](/styles/scroll-area) for the full surface: the gutter argument, what happens when +there is nothing to scroll, the token table, and how to replace the fade entirely. + ## Usage ```tsx @@ -34,7 +70,7 @@ import { Item } from '@clerk/ui/mosaic/components/item';
{children}}> - + {org.name[0]} @@ -44,27 +80,38 @@ import { Item } from '@clerk/ui/mosaic/components/item'; Member - + ; ``` +Media sizes itself from the row, so give it a child that fills its column — an `Avatar.Root` with `size='fit'`, or an icon at `width='100%'`. An action row that has no secondary text uses `Item.Label` in place of `Item.Title`: + +```tsx + }> + + + + + Sign out of all accounts + +; +``` + ## Parts -| Part | Class | Description | -| -------------------- | ------------------------ | -------------------------------------------------------------------- | -| `Item.Root` | `cl-item` | Root row. Renders a `
`, or a custom element via `render`. | -| `Item.Media` | `cl-item-media` | Leading (or trailing) media: icon, image, or avatar. | -| `Item.Content` | `cl-item-content` | Vertical stack that grows to fill the row between media and actions. | -| `Item.Title` | `cl-item-title` | Primary label. | -| `Item.Description` | `cl-item-description` | Secondary text. Renders a `

`. | -| `Item.Actions` | `cl-item-actions` | Trailing controls (buttons, badges). | -| `Item.Header` | `cl-item-header` | Header row above a group: a label with optional actions. | -| `Item.HeaderTitle` | `cl-item-header-title` | Label text within an `Item.Header`. | -| `Item.HeaderActions` | `cl-item-header-actions` | Trailing controls within an `Item.Header`. | -| `Item.Group` | `cl-item-group` | Vertical wrapper around a set of rows (layout only, no role). | -| `Item.Separator` | `cl-item-separator` | Thin divider (`


`) between rows. | +| Part | Class | Description | +| ------------------ | --------------------- | -------------------------------------------------------------------------- | +| `Item.Root` | `cl-item` | Root row. Renders a `
`, or a custom element via `render`. | +| `Item.Media` | `cl-item-media` | Square leading column: icon, image, or avatar. Sized by the root's `size`. | +| `Item.Content` | `cl-item-content` | Vertical stack that grows to fill the row between media and actions. | +| `Item.Title` | `cl-item-title` | Primary label. Truncates to a single line. | +| `Item.Description` | `cl-item-description` | Secondary text beneath the title. Truncates to a single line. | +| `Item.Label` | `cl-item-label` | Sole label on an action row, in place of a title. Dimmed until hovered. | +| `Item.Actions` | `cl-item-actions` | Trailing controls (buttons, badges). | +| `Item.Group` | `cl-item-group` | Vertical wrapper around a set of rows (layout only, no role). | +| `Item.Separator` | `cl-item-separator` | Thin divider (`
`) between rows. | Every part accepts a `render` prop for element polymorphism and forwards a ref. @@ -72,18 +119,23 @@ Every part accepts a `render` prop for element polymorphism and forwards a ref. The root reflects its state as `data-*` attributes on `.cl-item`, so consumers can scope overrides without touching StyleX's hashed atoms: -| Prop | Attribute | Values | Default | -| --------- | ------------------ | ----------------------------------- | -------- | -| `variant` | `data-variant` | `entity` \| `action` | `entity` | -| `render` | `data-interactive` | present when a `render` is provided | — | +| Prop | Attribute | Values | Default | +| -------- | ------------------ | ----------------------------------- | ------- | +| `size` | `data-size` | `xs` \| `md` | `md` | +| `render` | `data-interactive` | present when a `render` is provided | — | -`variant` sets the row's vertical density (`entity` is standard, `action` is denser) and, on interactive rows, promotes the title color. +`size` fixes the row's height and gap. `Item.Media` reflects the same value as `data-size` and takes its width from it, so the two stay in step without being set twice: ```css /* Re-theme interactive rows */ .cl-item[data-interactive] { background-color: var(--cl-color-card); } + +/* Widen the media column on compact rows */ +.cl-item-media[data-size='xs'] { + width: 1.5rem; +} ``` -`Item.Media` sizes to its child and centers it; its height follows the row. Bring your own icon, avatar, or image at whatever dimensions you need, then size the slot by sizing that child. Colors, radii, and spacing all resolve from the Mosaic tokens (`--cl-color-*`, `--cl-radius-*`, `--cl-spacing`). +`Item.Media` is a square that centers its child. Because the column is sized by the row, give it a child that fills it — an `Avatar.Root` with `size='fit'`, or an icon at `width='100%'` — rather than a fixed pixel size that won't track `size`. Colors, radii, and spacing all resolve from the Mosaic tokens (`--cl-color-*`, `--cl-radius-*`, `--cl-spacing`). diff --git a/packages/swingset/src/stories/item.stories.tsx b/packages/swingset/src/stories/item.stories.tsx index 7dd6c1fc288..cbf66563c04 100644 --- a/packages/swingset/src/stories/item.stories.tsx +++ b/packages/swingset/src/stories/item.stories.tsx @@ -2,6 +2,9 @@ import { Avatar } from '@clerk/ui/mosaic/components/avatar'; import { Button } from '@clerk/ui/mosaic/components/button'; import { Item } from '@clerk/ui/mosaic/components/item'; +import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; +import { radiusVars } from '@clerk/ui/mosaic/styles'; +import * as stylex from '@stylexjs/stylex'; import * as React from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -35,36 +38,6 @@ function CheckMarkIcon(props: React.ComponentPropsWithoutRef<'svg'>) { ); } -function EllipsisIcon(props: React.ComponentPropsWithoutRef<'svg'>) { - return ( - - - - - - - ); -} - function PlusIcon(props: React.ComponentPropsWithoutRef<'svg'>) { return ( Member - + ); @@ -158,6 +136,31 @@ export function Interactive() { ); } +export function Sizes() { + return ( +
+ {(['md', 'xs'] as const).map(size => ( + + + + T + + + + Test Organization + + + ))} +
+ ); +} + export function Group() { return (
@@ -173,25 +176,52 @@ export function Group() { Cameron Walker + cameron@clerk.com - + - - cameron@clerk.com - - - + + + cameron.walker@gmail.com + + + + + + Clerk - 24 members • Basic ( Clerk - 24 members • Basic ( DesignCloud - 12 members • Pro ( - + - Add account + Add account - - - (
); } + +const organizations = [ + 'Clerk', + 'Acme Corporation', + 'Globex', + 'Initech', + 'Umbrella Health', + 'DesignCloud', + 'Stark Industries', + 'Wayne Enterprises', + 'Cyberdyne Systems', + 'Soylent Industries', + 'Tyrell Corporation', + 'Weyland-Yutani', +]; + +// `Item.Group` is the canonical scroll surface, so this shows the atoms doing the minimum: cap a +// height, spread them on, and the group fades its own edges. The Scroll Area page under Styles +// carries the full surface — the gutter argument, the resting state, and the theming tokens. +export function Scrolling() { + // `stylex.props()` returns a `className`, so it has to be MERGED with any class of your own + // rather than spread beside one — whichever comes last in JSX wins outright. + const root = stylex.props(scrollAreaRoot); + + return ( +
+ + {organizations.map(name => ( + ( + + )} + > + + + + {name[0]} + + + + {name} + + + ))} + +
+ ); +} diff --git a/packages/swingset/src/stories/menu.component.mdx b/packages/swingset/src/stories/menu.component.mdx new file mode 100644 index 00000000000..b1a749eaeea --- /dev/null +++ b/packages/swingset/src/stories/menu.component.mdx @@ -0,0 +1,138 @@ +import * as MenuStories from './menu.component.stories'; + +# Menu + +The Mosaic `Menu` — the styled Mosaic component composed from the `@clerk/headless` menu primitive +and themed with StyleX. It inherits the primitive's positioning, typeahead, roving keyboard +navigation, and ARIA wiring, and adds the trigger, popup surface, and item styling. + +## Example + +Click the trigger, then use the arrow keys or type to move between items. + + + +## Usage + +```tsx +import { Icon } from '@clerk/ui/mosaic/components/icon'; +import { Menu } from '@clerk/ui/mosaic/components/menu'; + + + + + + + Add workspace + + + + Sign out + + + + Delete user + + +; +``` + +`Menu.Content` composes the portal, positioner, and popup, so items are the only children you write. + +### Trigger + +With no children, `Menu.Trigger` renders a square ghost `Button` holding an ellipsis glyph. Pass +children for a labelled trigger, or `render` to supply your own element — it receives the computed +props (ARIA attributes, click and keyboard handlers) to spread. + +```tsx +Actions + + } /> +``` + +### Items + +`label` drives typeahead and is used as the visible text when `children` is omitted. Render an icon +and text together as children. Use `color='negative'` for destructive actions; the color is +inherited by the children. `disabled` items are skipped by keyboard navigation and their `onClick` +never fires. Activating an item closes the menu; pass `closeOnClick={false}` to keep it open. + +```tsx + + + Delete + +``` + +### Placement + +`Menu.Root` takes `placement` and `sideOffset`; the popup flips and shifts automatically to stay in +view, and its `max-height` tracks the available space so long menus scroll rather than overflow. + +```tsx + + … +; +``` + +### Controlled + +```tsx +const [open, setOpen] = useState(false); + + + … +; +``` + +## Parts + +| Part | Slot | Description | +| ---------------- | -------------------------------- | --------------------------------------------------------------------- | +| `Menu.Root` | — | State provider; owns open/close, placement, and keyboard navigation. | +| `Menu.Trigger` | `menu-trigger` | Opens the menu. Defaults to a square ghost `Button` with an ellipsis. | +| `Menu.Content` | `menu-positioner` / `menu-popup` | Portals, positions, and renders the popup surface. | +| `Menu.Item` | `menu-item` | A single action whose content is composed through children. | +| `Menu.Separator` | `menu-separator` | Full-bleed divider between groups of items. | + +## Styling + +Unlike the slot-recipe components, the Mosaic menu is themed with **StyleX**. Each styled part +carries a stable `.cl-` class (the slots above) alongside the StyleX atoms. Consumers never +target the hashed atomic classes — override by targeting the `.cl-*` slot from a CSS layer that wins +over `@clerk/ui/styles.css`: + +```css +@import '@clerk/ui/styles.css' layer(components); + +@layer overrides { + .cl-menu-popup { + border-radius: 20px; + } +} +``` + +The popup's enter/exit transition is driven off its own `data-starting-style` /`data-ending-style` +attributes and is disabled under `prefers-reduced-motion: reduce`. Item hover state is gated behind +`@media (hover: hover)`, and the keyboard-active item is styled off `data-active`, so pointer and +keyboard highlighting stay in sync. diff --git a/packages/swingset/src/stories/menu.component.stories.tsx b/packages/swingset/src/stories/menu.component.stories.tsx new file mode 100644 index 00000000000..82ee976fdac --- /dev/null +++ b/packages/swingset/src/stories/menu.component.stories.tsx @@ -0,0 +1,40 @@ +/** @jsxImportSource @emotion/react */ +import { Icon } from '@clerk/ui/mosaic/components/icon'; +import { Menu } from '@clerk/ui/mosaic/components/menu'; + +import type { StoryMeta } from '@/lib/types'; + +// Exposes this file's own source (via the `?raw` webpack rule) so each `` example +// renders a code footer with its function's source. See `StoryModule.__source`. +export { default as __source } from './menu.component.stories?raw'; + +export const meta: StoryMeta = { + group: 'Components', + title: 'Menu', + source: 'packages/ui/src/mosaic/components/menu/menu.tsx', +}; + +export function Default() { + return ( + + + + + + Add workspace + + + + Sign out + + + + Delete user + + + + ); +} diff --git a/packages/swingset/src/stories/popover.component.mdx b/packages/swingset/src/stories/popover.component.mdx new file mode 100644 index 00000000000..9ce80bcbbb2 --- /dev/null +++ b/packages/swingset/src/stories/popover.component.mdx @@ -0,0 +1,203 @@ +import * as PopoverStories from './popover.component.stories'; + +# Popover + +The Mosaic `Popover` — the styled Mosaic component composed from the `@clerk/headless` popover +primitive and themed with StyleX. It owns only what it means to float: trigger wiring, ARIA, focus +management, positioning, stacking, viewport clamps, and the enter/exit transition. It paints **no +surface of its own** — background, border, radius, shadow and padding come from whatever you render +inside it, usually a `Card`. Keeping the two apart means only one element ever draws the border. + +## Example + + + +## Usage + +Compose the parts: `Popover.Root` owns the open state and placement, `Popover.Trigger` is the +anchor, and `Popover.Popup` is the floating box. Put the surface inside the popup — everything you +see comes from the `Card`. + +```tsx +import { Button } from '@clerk/ui/mosaic/components/button'; +import { Card } from '@clerk/ui/mosaic/components/card'; +import { Popover } from '@clerk/ui/mosaic/components/popover'; + + + } /> + + + Flexible inner content. + + + + + +; +``` + +`Popover.Trigger` renders a `} /> + + + + Ada Lovelace + ada@example.com + + + + + + + + ); +} + +// Each placement demo shares the same trigger and panel so the example reads as the +// placement it sets. The wrapper reserves vertical room — without it the `flip` +// middleware bounces a `top` popover back to the bottom inside a short preview. +const labelledTrigger = (label: string) => ; + +const panel = (label: string) => ( + + + {label} + + +); + +export function Placement() { + return ( +
+ + + + {panel('Placed above the trigger.')} + + + + + + {panel('Placed below the trigger.')} + + + + + + {panel('Placed to the inline start.')} + + + + + + {panel('Placed to the inline end.')} + + +
+ ); +} + +export function Alignment() { + return ( +
+ + + + {panel('Aligned to the trigger’s start edge.')} + + + + + + {panel('Centered on the trigger.')} + + + + + + {panel('Aligned to the trigger’s end edge.')} + + +
+ ); +} diff --git a/packages/swingset/src/stories/scroll-area.mdx b/packages/swingset/src/stories/scroll-area.mdx new file mode 100644 index 00000000000..4056873218d --- /dev/null +++ b/packages/swingset/src/stories/scroll-area.mdx @@ -0,0 +1,256 @@ +import * as ScrollAreaStories from './scroll-area.stories'; + +# Scroll Area + +A scrolling surface that fades its content at whichever edge still has something to reveal, and +paints a scrollbar to match. It ships as **StyleX atoms rather than a component**: everything it +does is CSS, so a component would only add a DOM node and an API to version. Put the atoms on +whatever already scrolls — an `Item.Group`, a list, a panel body — and that element keeps its own +slot class, which stays the hook a theme targets. + +## Example + + + +## Usage + +`scrollAreaViewport()` returns an array, hence the `...` spread. + +```tsx +import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; +import * as stylex from '@stylexjs/stylex'; + +
+ {organizations} +
; +``` + +`stylex.props()` returns a `className`, so a class of your own has to be **merged** with it rather +than written beside it — whichever comes last in JSX wins outright and silently drops the other: + +```tsx +const root = stylex.props(scrollAreaRoot); + +
; +``` + +## Parts + +| Export | Goes on | Description | +| ----------------------------- | --------------------- | --------------------------------------------------------------------------------- | +| `scrollAreaViewport(gutter?)` | the scrolling element | The scroll box itself: overflow, the edge fades, the scrollbar, and a focus ring. | +| `scrollAreaRoot` | a positioned ancestor | Only needed when something anchors against the scroll box. | + +`gutter` is the one argument — `auto` (the default) or `stable`, see [Gutter](#gutter). It is an +author-time decision rather than a theme one, since whether a region needs it depends on whether its +content can resize in place. + +## Examples + +### Nothing to scroll + + + +The atoms are unconditional: no "is it scrollable" branch to write, no measurement at runtime. A +scroll timeline with no scrollable overflow is inactive, so both progress vars hold at their +registered `initial-value: 0` and the mask resolves to fully opaque. Browsers without scroll-driven +animation get the same plain scrolling box rather than a broken one. + +### Gutter + + + +`auto` takes the scrollbar's space only while the content overflows; `stable` reserves it either +way. Add the rows and watch the trailing rules: `auto` jumps its content left by the lane's width as +the list starts overflowing, `stable` doesn't move. + +Two conditions must **both** hold for the two to differ at all: space-consuming scrollbars, and +content that can stop overflowing. Where the platform overlays its scrollbars they render +identically, which is why `auto` is the default. + +### Reveal on hover + + + +The far end of what `--cl-scrollbar-thumb-idle` is for — one declaration, no rules of your own: + +```css +.my-scroller { + --cl-scrollbar-thumb-idle: oklch(from var(--cl-scrollbar-thumb) l c h / 0); +} +``` + +Mosaic already dims the bar while the pointer is elsewhere; this takes that state to zero alpha. It +fades because idle → base is the one step set on the scroller, which owns the transition — see +[Styling](#styling). The lane stays reserved throughout, so nothing reflows on the way in or out. + +Reach for a zero-alpha colour rather than `transparent` in any fade like this. `transparent` is +defined as `rgba(0, 0, 0, 0)` — transparent **black** — so interpolating out of it drags the thumb +through dark, half-transparent greys and reads as dirty. Relative colour syntax +(`oklch(from … l c h / 0)`) keeps the colour's own channels and moves only the alpha. + +### Theming the scrollbar + + + +A colour per state, far louder than anything you'd ship but told apart at a glance. Move the pointer +into the region, then onto the bar, then drag it. + +Only the first of those moves animates, and the reason is structural rather than chromatic: amber → +teal changes the region's own rest colour, so it happens on the scroller, where the transition +lives. Pink and violet are the thumb's own states and switch instantly however they're written (see +[Styling](#styling)). The example stretches the transition to `0.6s` because at Mosaic's real +`0.15s` the fade is over before you've finished moving the pointer in. + +Every value is an `oklch()` literal, which is what keeps the animated step well defined — the +registered property interpolates between two colours in one space rather than guessing across +notations. + +### Shadows instead of the fade + + + +Retire the mask with `mask-image: none` and read the two per-element vars the animations write — +`--cl-scroll-area-progress-start` and `--cl-scroll-area-progress-end`, each how much that edge still +has to reveal. They live on the element carrying the atoms and inherit downward, so a pseudo-element +can drive itself from them. + +```css +.cl-item-group { + mask-image: none; +} +.cl-item-group::before, +.cl-item-group::after { + content: ''; + position: absolute; + inset-inline: 0; + height: var(--cl-scroll-fade-size); + pointer-events: none; +} +.cl-item-group::before { + top: 0; + background: linear-gradient(to bottom, color-mix(in oklab, var(--cl-color-card-foreground) 22%, transparent), transparent); + opacity: var(--cl-scroll-area-progress-start); + transform: translateY(calc((var(--cl-scroll-area-progress-start) - 1) * var(--cl-scroll-fade-size))); +} +.cl-item-group::after { + bottom: 0; + background: linear-gradient(to top, color-mix(in oklab, var(--cl-color-card-foreground) 22%, transparent), transparent); + opacity: var(--cl-scroll-area-progress-end); + transform: translateY(calc((1 - var(--cl-scroll-area-progress-end)) * var(--cl-scroll-fade-size))); +} +``` + +The vars are registered as ``, so they drive position as readily as opacity: each scrim +slides out from behind its own edge as it fades in. Clip the root — `overflow: hidden` — so the half +that is still offscreen stays there. + +Mix the scrim from a theme colour rather than hardcoding black: `--cl-color-card-foreground` inverts +with the theme, so one declaration reads as a shadow on light and a soft glow on dark, where black +would vanish. + +Position such overlays absolutely against `scrollAreaRoot` — this is the case the root exists for — +rather than with `position: sticky`, which takes space in the scroll flow and reintroduces the +layout shift the mask avoids. + +## Styling + +The fade is driven by two scroll-driven animations — no scroll listener, no measurement, nothing at +runtime. It is a mask rather than a sticky overlay element, so it is paint-only and cannot shift the +content. + +These tokens are global, so setting them once retunes every scrolling surface in Mosaic: + +| Token | Default | Description | +| ----------------------------- | ------------------------ | --------------------------------------------------------- | +| `--cl-scroll-fade-size` | `1.5rem` | Height of the fade band. | +| `--cl-scroll-fade-range` | `1.5rem` | How far you scroll before the fade reaches full strength. | +| `--cl-scrollbar-width` | `8px` | Width of the scrollbar lane. `0px` hides it. | +| `--cl-scrollbar-thumb-inset` | `2px` | How far the thumb's paint is held inside that lane. | +| `--cl-scrollbar-thumb` | derived from the palette | Thumb colour once the pointer reaches the region. | +| `--cl-scrollbar-thumb-idle` | derived from the above | Thumb colour while the pointer is elsewhere. | +| `--cl-scrollbar-thumb-hover` | derived from the above | Thumb colour while the pointer is over the thumb. | +| `--cl-scrollbar-thumb-active` | derived from the above | Thumb colour while the thumb is being dragged. | + +The two lane sizes are in pixels rather than on the `rem` scale, deliberately: a scrollbar is chrome +rather than content, so it should stay the same hairline whether or not the surrounding text scales. +The default is a 4px pill in an 8px lane. + +The colours are four states running quietest to loudest: `idle` while the pointer is elsewhere, the +base once it reaches the region — or the content takes keyboard focus — then `hover` and `active` +for the thumb's own two. Each of the three derives from `--cl-scrollbar-thumb` rather than baking +its value in, so setting the base re-derives all of them, and any one can still be pinned on its +own. + +Only the idle → base step can animate. Blink doesn't run transitions declared on +`::-webkit-scrollbar-thumb`, so the transition lives on the scroller and the thumb inherits the +animating value: a change made **on the scroller** fades, a change made on the thumb itself can only +snap. `-hover` and `-active` are the thumb's own states, so they are instant by construction. + +There is no knob for nudging the thumb sideways within its lane, and it isn't an oversight. The lane +can't move — the browser places it at the inline end of the padding box, and it takes no margin, +offset, or transform — so the only lever is making the thumb's insets asymmetric. That shifts the +pill, but it also deforms it: the paint is clipped to the content box using the **inner** radius, +which CSS derives per corner as the outer radius minus that side's own border width, so unequal +insets draw the two halves of each cap with different curvature. On a 4px pill the caps stop being +round. Position the surrounding padding instead. + +Mosaic paints the scrollbar through `::-webkit-scrollbar`, which is what buys a real width and a +thumb colour per interaction state; the standard `scrollbar-color` can express neither, and setting +it would make the engines that _do_ implement the pseudo-elements ignore them. Firefox implements +neither and keeps its platform scrollbar. Everything here is gated on `@media (pointer: fine)`, so +touch platforms keep the native overlay bar they already draw. The gutter is not gated: it is a +layout decision rather than an appearance one. + +One consequence worth knowing before you theme: styling the scrollbar takes macOS out of overlay +mode, so the bar is always visible and always occupies its lane rather than auto-hiding. That is the +cross-platform consistency the tokens exist for, but it is a change from the platform default. To +keep the lane without the bar, take both resting colours transparent: + +```css +:root { + --cl-scrollbar-thumb: transparent; + --cl-scrollbar-thumb-idle: transparent; +} +``` + +The thumb then paints only while the pointer is on it, and faintly — `-hover` and `-active` still +derive from the base, so pin them too if you want more. It is a precise target to find, so this +works best where the fades are already carrying the signal that the region scrolls. + +One layout note: the scrollbar takes its lane **inside** a scroller's own padding, so a padded +surface reads as padding plus lane at the inline end. Trimming the scroller's inline-end padding to +compensate is only safe alongside `gutter: 'stable'` — with `auto` the lane is there only while the +content overflows, so the trimmed padding collapses the moment it doesn't and the rows sit flush +against the edge. Left alone, the extra lane is the safer asymmetry. + +## Accessibility + +Chrome and Firefox make an overflowing scroll container keyboard-focusable on their own; **Safari +does not** (WCAG 2.1.1). `tabindex` isn't a style, so the atoms can't close that gap — set +`tabIndex={0}` yourself on a scroll surface that holds nothing focusable. A group of interactive +rows, like the examples above, needs nothing: tabbing into the content already scrolls it. diff --git a/packages/swingset/src/stories/scroll-area.stories.tsx b/packages/swingset/src/stories/scroll-area.stories.tsx new file mode 100644 index 00000000000..4199aed5e9e --- /dev/null +++ b/packages/swingset/src/stories/scroll-area.stories.tsx @@ -0,0 +1,325 @@ +/** @jsxImportSource @emotion/react */ +import { Avatar } from '@clerk/ui/mosaic/components/avatar'; +import { Button } from '@clerk/ui/mosaic/components/button'; +import { Item } from '@clerk/ui/mosaic/components/item'; +import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; +import { radiusVars, space } from '@clerk/ui/mosaic/styles'; +import * as stylex from '@stylexjs/stylex'; +import * as React from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +// Exposes this file's own source (via the `?raw` webpack rule) so each `` example +// renders a code footer with its function's source. See `StoryModule.__source`. +export { default as __source } from './scroll-area.stories?raw'; + +export const meta: StoryMeta = { + group: 'Styles', + title: 'Scroll Area', + source: 'packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts', +}; + +const accounts = [ + { email: 'cameron.walker@gmail.com', organizations: ['Clerk', 'Acme Corporation', 'Globex'] }, + { email: 'cameron@clerk.com', organizations: ['Clerk', 'Initech', 'Umbrella Health'] }, + { email: 'cam@designcloud.io', organizations: ['Clerk', 'DesignCloud'] }, +]; + +function OrganizationRow({ name }: { name: string }) { + return ( + ( + + )} + > + + + + {name[0]} + + + + {name} + + + ); +} + +/** + * The border is on the ROOT, not the viewport: a mask applies to the element's whole rendering, + * borders included, so a border on the viewport would fade out at the same edges its content does. + */ +export function Default() { + const root = stylex.props(scrollAreaRoot); + + return ( +
+ + {accounts.map(({ email, organizations }, index) => ( + + {index > 0 ? : null} + + + {email} + + + {organizations.map(name => ( + + ))} + + ))} + +
+ ); +} + +/** The same atoms on a surface whose content fits. Nothing in the markup is conditional. */ +export function NotScrollable() { + const root = stylex.props(scrollAreaRoot); + + return ( +
+ + {accounts[0].organizations.map(name => ( + + ))} + +
+ ); +} + +const gutterRows = ['Clerk', 'DesignCloud', 'Acme Corporation', 'Globex', 'Initech', 'Umbrella Health']; + +/** + * Toggling the row count is the whole demonstration: `auto` jumps its content left by the lane's + * width as the list starts overflowing, `stable` does not move. + */ +export function Gutter() { + const [scrollable, setScrollable] = React.useState(false); + const root = stylex.props(scrollAreaRoot); + const names = scrollable ? gutterRows : gutterRows.slice(0, 2); + + return ( +
+ + +
+ {(['stable', 'auto'] as const).map(gutter => ( +
+

{gutter}

+
+ + {names.map(name => ( + + + + + {name[0]} + + + + {name} + + {/* The shift is only legible against something reaching the content's right edge. */} +
+ + ))} + +
+
+ ))} +
+
+ ); +} + +const manyRows = [ + 'Clerk', + 'Acme Corporation', + 'Globex', + 'Initech', + 'Umbrella Health', + 'DesignCloud', + 'Stark Industries', + 'Wayne Enterprises', + 'Cyberdyne Systems', + 'Soylent Industries', + 'Tyrell Corporation', + 'Weyland-Yutani', + 'Massive Dynamic', + 'Aperture Science', + 'Black Mesa', + 'Oscorp', +]; + +/** + * Taking `--cl-scrollbar-thumb-idle` to zero alpha removes the bar entirely until the pointer + * reaches the region. `oklch(from … / 0)` rather than `transparent`, which is transparent BLACK and + * drags the fade through dark greys. + */ +export function HoverReveal() { + const root = stylex.props(scrollAreaRoot); + + return ( +
+ + {manyRows.map(name => ( + + ))} + +
+ ); +} + +/** + * A colour per state, deliberately louder than anything you'd ship. Only amber → teal animates: + * it is a change on the SCROLLER, which owns the transition. The duration is stretched well past + * Mosaic's own `base` step purely so that one step is impossible to miss. + */ +export function ThemedScrollbar() { + const root = stylex.props(scrollAreaRoot); + + return ( +
+ + {manyRows.map(name => ( + + ))} + +
+ ); +} + +/** + * The mask retired for overlay scrims, each reading the progress var for its edge. The scrim mixes + * from `--cl-color-card-foreground`, so it reads as a shadow on light and a glow on dark; + * hardcoded black would vanish on a dark surface. `overflow: hidden` on the root keeps the scrims + * inside its rounded corners. + * + * Each scrim slides in from behind its edge as well as fading, so the two vars drive position and + * opacity together. `overflow: hidden` on the root both rounds their corners and hides the + * offscreen half. + * + * `mask-image` is retired inline rather than from the stylesheet: swingset's dev server injects + * StyleX atoms with a specificity bump no selector of ours can outrank. The extracted production + * sheet puts them in a cascade layer, where the plain CSS below would win on its own. + */ +export function ShadowIndicators() { + const root = stylex.props(scrollAreaRoot); + + return ( + <> + +
+ + {manyRows.map(name => ( + + ))} + +
+ + ); +} diff --git a/packages/tanstack-react-start/CHANGELOG.md b/packages/tanstack-react-start/CHANGELOG.md index e076b669dda..ac4620b779d 100644 --- a/packages/tanstack-react-start/CHANGELOG.md +++ b/packages/tanstack-react-start/CHANGELOG.md @@ -1,5 +1,32 @@ # @clerk/tanstack-react-start +## 1.4.28 + +### Patch Changes + +- Updated dependencies [[`1ef84c3`](https://github.com/clerk/javascript/commit/1ef84c3592cee8a7d3ec5f40a9826862afe125e7), [`d639048`](https://github.com/clerk/javascript/commit/d639048e0e48ff3a120435134f9e01221697b6bc), [`f38cf02`](https://github.com/clerk/javascript/commit/f38cf02fd55a551fcf1d43c89371cf2132c2ba92), [`a66cbbf`](https://github.com/clerk/javascript/commit/a66cbbf549477cf8afc155ad17d29e48078e60df), [`58d8ff5`](https://github.com/clerk/javascript/commit/58d8ff50b121ebf42744ba32302da6b22e90b704)]: + - @clerk/backend@3.16.0 + - @clerk/shared@4.27.0 + - @clerk/react@6.13.0 + +## 1.4.27 + +### Patch Changes + +- Updated dependencies [[`a601cd7`](https://github.com/clerk/javascript/commit/a601cd7f45095fdbf8b0a23b01d9f559feeda347), [`bbe51ff`](https://github.com/clerk/javascript/commit/bbe51ffc343a878022c5863796450d6d97069ea0), [`5c81479`](https://github.com/clerk/javascript/commit/5c81479d303fc6146dc81309d0b58564aa96706e)]: + - @clerk/backend@3.15.1 + - @clerk/react@6.12.11 + - @clerk/shared@4.26.0 + +## 1.4.26 + +### Patch Changes + +- Updated dependencies [[`9c51d74`](https://github.com/clerk/javascript/commit/9c51d74ac36391888367e4da44912c92999a7ac2), [`aaea141`](https://github.com/clerk/javascript/commit/aaea141d62804624cd8cd73036b4afe6f482184f), [`fe6ee54`](https://github.com/clerk/javascript/commit/fe6ee5489d9efcdc5aec53b1ba74b0260e539f80)]: + - @clerk/backend@3.15.0 + - @clerk/shared@4.25.10 + - @clerk/react@6.12.10 + ## 1.4.25 ### Patch Changes diff --git a/packages/tanstack-react-start/package.json b/packages/tanstack-react-start/package.json index a0a0458e43f..f707556c7bd 100644 --- a/packages/tanstack-react-start/package.json +++ b/packages/tanstack-react-start/package.json @@ -1,6 +1,6 @@ { "name": "@clerk/tanstack-react-start", - "version": "1.4.25", + "version": "1.4.28", "description": "Clerk SDK for TanStack React Start", "keywords": [ "clerk", diff --git a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap index 4a5318392ee..ee11ac03e0f 100644 --- a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap @@ -32,6 +32,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "CreateOrganization", "GoogleOneTap", "HandleSSOCallback", + "InviteMembersButton", "OAuthConsent", "OrganizationList", "OrganizationProfile", diff --git a/packages/testing/CHANGELOG.md b/packages/testing/CHANGELOG.md index 3e6f81add7c..bd28b7b5ebd 100644 --- a/packages/testing/CHANGELOG.md +++ b/packages/testing/CHANGELOG.md @@ -1,5 +1,29 @@ # @clerk/testing +## 2.2.18 + +### Patch Changes + +- Updated dependencies [[`1ef84c3`](https://github.com/clerk/javascript/commit/1ef84c3592cee8a7d3ec5f40a9826862afe125e7), [`d639048`](https://github.com/clerk/javascript/commit/d639048e0e48ff3a120435134f9e01221697b6bc), [`f38cf02`](https://github.com/clerk/javascript/commit/f38cf02fd55a551fcf1d43c89371cf2132c2ba92), [`a66cbbf`](https://github.com/clerk/javascript/commit/a66cbbf549477cf8afc155ad17d29e48078e60df)]: + - @clerk/backend@3.16.0 + - @clerk/shared@4.27.0 + +## 2.2.17 + +### Patch Changes + +- Updated dependencies [[`a601cd7`](https://github.com/clerk/javascript/commit/a601cd7f45095fdbf8b0a23b01d9f559feeda347), [`5c81479`](https://github.com/clerk/javascript/commit/5c81479d303fc6146dc81309d0b58564aa96706e)]: + - @clerk/backend@3.15.1 + - @clerk/shared@4.26.0 + +## 2.2.16 + +### Patch Changes + +- Updated dependencies [[`9c51d74`](https://github.com/clerk/javascript/commit/9c51d74ac36391888367e4da44912c92999a7ac2), [`aaea141`](https://github.com/clerk/javascript/commit/aaea141d62804624cd8cd73036b4afe6f482184f), [`fe6ee54`](https://github.com/clerk/javascript/commit/fe6ee5489d9efcdc5aec53b1ba74b0260e539f80)]: + - @clerk/backend@3.15.0 + - @clerk/shared@4.25.10 + ## 2.2.15 ### Patch Changes diff --git a/packages/testing/package.json b/packages/testing/package.json index fec4b3b0c53..791147f0eb7 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -1,6 +1,6 @@ { "name": "@clerk/testing", - "version": "2.2.15", + "version": "2.2.18", "description": "Utilities to help you create E2E test suites for apps using Clerk", "keywords": [ "auth", diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 78c40eacaf8..69ef17c36d4 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -1,5 +1,64 @@ # @clerk/ui +## 1.29.0 + +### Minor Changes + +- Add ``, a control component that opens the organization invite-members form in a modal when clicked, working like ``. ([#9124](https://github.com/clerk/javascript/pull/9124)) by [@alexcarpenter](https://github.com/alexcarpenter) + + Wrap your own button (or omit children for a default one). The button requires an active organization and should be rendered for members who can manage memberships (`org:sys_memberships:manage`). Opening it without an active organization or that permission is a no-op in production, and throws a descriptive error in development. + + ```tsx + import { InviteMembersButton } from '@clerk/nextjs'; + + + + ; + ``` + + This also adds `Clerk.openInviteMembers()` and `Clerk.closeInviteMembers()` for opening and closing the modal programmatically. + +### Patch Changes + +- Ensure drawers opened from modals render above the modal and its backdrop. ([#9124](https://github.com/clerk/javascript/pull/9124)) by [@alexcarpenter](https://github.com/alexcarpenter) + +- Keep invitation email pills consistently spaced when they wrap across multiple rows. ([#9124](https://github.com/clerk/javascript/pull/9124)) by [@alexcarpenter](https://github.com/alexcarpenter) + +- Updated dependencies [[`1ef84c3`](https://github.com/clerk/javascript/commit/1ef84c3592cee8a7d3ec5f40a9826862afe125e7), [`d639048`](https://github.com/clerk/javascript/commit/d639048e0e48ff3a120435134f9e01221697b6bc), [`a66cbbf`](https://github.com/clerk/javascript/commit/a66cbbf549477cf8afc155ad17d29e48078e60df)]: + - @clerk/shared@4.27.0 + - @clerk/localizations@4.14.1 + +## 1.28.0 + +### Minor Changes + +- Support sign-in-or-sign-up combined flow with Clerk component ([#7928](https://github.com/clerk/javascript/pull/7928)) by [@dmoerner](https://github.com/dmoerner) + + when strict enumeration protection is enabled. + + On development instances, `` now logs a warning when the sign-in-or-up flow is rendered on an + instance that has both password and strict enumeration protection enabled. In that configuration + visitors without an account are routed to the password screen and cannot complete a sign-up, so the + warning names both settings and how to resolve them. + +### Patch Changes + +- fix(ui): Avoid races between email link tabs when using sign up if missing ([#9328](https://github.com/clerk/javascript/pull/9328)) by [@dmoerner](https://github.com/dmoerner) + +- Updated dependencies [[`5c81479`](https://github.com/clerk/javascript/commit/5c81479d303fc6146dc81309d0b58564aa96706e)]: + - @clerk/shared@4.26.0 + - @clerk/localizations@4.14.0 + +## 1.27.2 + +### Patch Changes + +- Enable self-serve OIDC configuration for every application. Organization admins can now select an OIDC provider in the `` Security tab without the `experimental.oidcSelfServe` option, and existing OIDC connections open their configuration steps instead of the unsupported-provider state. The `experimental.oidcSelfServe` option no longer does anything and can be removed from `` and `Clerk.load()`. ([#9288](https://github.com/clerk/javascript/pull/9288)) by [@NicolasLopes7](https://github.com/NicolasLopes7) + +- Updated dependencies [[`aaea141`](https://github.com/clerk/javascript/commit/aaea141d62804624cd8cd73036b4afe6f482184f)]: + - @clerk/shared@4.25.10 + - @clerk/localizations@4.13.10 + ## 1.27.1 ### Patch Changes diff --git a/packages/ui/bundlewatch.config.json b/packages/ui/bundlewatch.config.json index 6b07013cfbe..3b818b7738c 100644 --- a/packages/ui/bundlewatch.config.json +++ b/packages/ui/bundlewatch.config.json @@ -2,7 +2,7 @@ "files": [ { "path": "./dist/ui.browser.js", "maxSize": "44KB" }, { "path": "./dist/ui.legacy.browser.js", "maxSize": "84KB" }, - { "path": "./dist/ui.shared.browser.js", "maxSize": "40KB" }, + { "path": "./dist/ui.shared.browser.js", "maxSize": "42KB" }, { "path": "./dist/framework*.js", "maxSize": "44KB" }, { "path": "./dist/vendors*.js", "maxSize": "73KB" }, { "path": "./dist/ui-common*.js", "maxSize": "132KB" }, diff --git a/packages/ui/package.json b/packages/ui/package.json index 7a4b88defb9..cd7d5172288 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@clerk/ui", - "version": "1.27.1", + "version": "1.29.0", "description": "Internal package that contains the UI components for the Clerk frontend SDKs", "repository": { "type": "git", diff --git a/packages/ui/src/Components.tsx b/packages/ui/src/Components.tsx index d28d93d83ea..ef830af901b 100644 --- a/packages/ui/src/Components.tsx +++ b/packages/ui/src/Components.tsx @@ -11,6 +11,7 @@ import type { CreateOrganizationModalProps, EnvironmentResource, GoogleOneTapProps, + InviteMembersModalProps, OrganizationProfileModalProps, SignInModalProps, SignInProps, @@ -34,6 +35,7 @@ import { CreateOrganizationModal, EnableOrganizationsPrompt, ImpersonationFab, + InviteMembersModal, KeylessPrompt, OrganizationProfileModal, preloadComponent, @@ -90,6 +92,7 @@ export type ComponentControls = { | 'signUp' | 'userProfile' | 'organizationProfile' + | 'inviteMembers' | 'createOrganization' | 'userVerification' | 'waitlist' @@ -105,9 +108,11 @@ export type ComponentControls = { ? __internal_UserVerificationProps : T extends 'waitlist' ? WaitlistProps - : T extends 'enableOrganizationsPrompt' - ? __internal_EnableOrganizationsPromptProps - : UserProfileProps, + : T extends 'inviteMembers' + ? InviteMembersModalProps + : T extends 'enableOrganizationsPrompt' + ? __internal_EnableOrganizationsPromptProps + : UserProfileProps, ) => void; closeModal: ( modal: @@ -116,6 +121,7 @@ export type ComponentControls = { | 'signUp' | 'userProfile' | 'organizationProfile' + | 'inviteMembers' | 'createOrganization' | 'userVerification' | 'waitlist' @@ -170,6 +176,7 @@ interface ComponentsState { userProfileModal: null | UserProfileModalProps; userVerificationModal: null | __internal_UserVerificationProps; organizationProfileModal: null | OrganizationProfileModalProps; + inviteMembersModal: null | InviteMembersModalProps; createOrganizationModal: null | CreateOrganizationModalProps; enableOrganizationsPromptModal: null | __internal_EnableOrganizationsPromptProps; blankCaptchaModal: null; @@ -300,6 +307,7 @@ const Components = (props: ComponentsProps) => { userProfileModal: null, userVerificationModal: null, organizationProfileModal: null, + inviteMembersModal: null, createOrganizationModal: null, enableOrganizationsPromptModal: null, organizationSwitcherPrefetch: false, @@ -327,6 +335,7 @@ const Components = (props: ComponentsProps) => { userProfileModal, userVerificationModal, organizationProfileModal, + inviteMembersModal, createOrganizationModal, waitlistModal, blankCaptchaModal, @@ -604,6 +613,23 @@ const Components = (props: ComponentsProps) => { ); + const mountedInviteMembersModal = ( + componentsControls.closeModal('inviteMembers')} + onExternalNavigate={() => componentsControls.closeModal('inviteMembers')} + startPath={buildVirtualRouterUrl({ base: '/inviteMembers', path: urlStateParam?.path })} + getContainer={inviteMembersModal?.getContainer ?? (() => null)} + componentName={'InviteMembersModal'} + modalContainerSx={{ alignItems: 'center' }} + > + + + ); + const mountedCreateOrganizationModal = ( { {userProfileModal && mountedUserProfileModal} {userVerificationModal && mountedUserVerificationModal} {organizationProfileModal && mountedOrganizationProfileModal} + {inviteMembersModal && mountedInviteMembersModal} {createOrganizationModal && mountedCreateOrganizationModal} {waitlistModal && mountedWaitlistModal} {blankCaptchaModal && mountedBlankCaptchaModal} diff --git a/packages/ui/src/common/EmailLinkCompleteFlowCard.tsx b/packages/ui/src/common/EmailLinkCompleteFlowCard.tsx index dbede6a4b5f..d904dbc4f05 100644 --- a/packages/ui/src/common/EmailLinkCompleteFlowCard.tsx +++ b/packages/ui/src/common/EmailLinkCompleteFlowCard.tsx @@ -12,6 +12,12 @@ const signInLocalizationKeys = { title: localizationKeys('signIn.emailLink.verified.title'), subtitle: localizationKeys('signIn.emailLink.verifiedSwitchTab.subtitle'), }, + // signUpIfMissing transfer: the email verified but no user exists yet, so + // "Successfully signed in" would be wrong - the flow continues as a sign-up. + transferable: { + title: localizationKeys('signIn.emailLink.verifiedTransferable.title'), + subtitle: localizationKeys('signIn.emailLink.verifiedTransferable.subtitle'), + }, loading: { title: localizationKeys('signIn.emailLink.loading.title'), subtitle: localizationKeys('signIn.emailLink.loading.subtitle'), diff --git a/packages/ui/src/common/EmailLinkStatusCard.tsx b/packages/ui/src/common/EmailLinkStatusCard.tsx index a6350be2731..bfdfac1d6d2 100644 --- a/packages/ui/src/common/EmailLinkStatusCard.tsx +++ b/packages/ui/src/common/EmailLinkStatusCard.tsx @@ -10,6 +10,9 @@ import { ArrowLeftRight, ExclamationTriangle, ShieldCheck } from '../icons'; import type { InternalTheme } from '../styledSystem'; import { animations } from '../styledSystem'; +// `transferable` renders in a `signUpIfMissing` flow: the email was verified but no user +// exists, so the tab holding the sign-in's client carries the flow forward as a sign-up +// and this card tells the user to switch to whichever tab that is. export type EmailLinkUIStatus = VerifyTokenStatus | 'verified_switch_tab' | 'loading'; type EmailLinkStatusCardProps = React.PropsWithChildren<{ @@ -21,6 +24,7 @@ type EmailLinkStatusCardProps = React.PropsWithChildren<{ const StatusToIcon: Record, React.ComponentType> = { verified: ShieldCheck, verified_switch_tab: ArrowLeftRight, + transferable: ArrowLeftRight, expired: ExclamationTriangle, failed: ExclamationTriangle, client_mismatch: ExclamationTriangle, @@ -30,6 +34,7 @@ const statusToColor = (theme: InternalTheme, status: Exclude { // Avoid loading flickering await sleep(750); await handleEmailLinkVerification({ redirectUrlComplete, redirectUrl }, navigate); + + // `transferable` = the email was verified but no user exists (`signUpIfMissing`). + // The originating tab's poll performs the sign-up transfer, so this tab has no + // session and nothing to complete - it only points the user back there. + if (getClerkQueryParam('__clerk_status') === 'transferable') { + setVerificationStatus('transferable'); + return; + } + setVerificationStatus('verified_switch_tab'); await sleep(750); await completeSignUpFlow({ diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx index 156fc15eeed..cb6f963ea62 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx @@ -1,8 +1,6 @@ import type { EnterpriseConnectionResource, OrganizationDomainResource } from '@clerk/shared/types'; import React, { type PropsWithChildren } from 'react'; -import { useOptions } from '@/contexts'; - import type { OrganizationEnterpriseConnection } from './domain/organizationEnterpriseConnection'; import type { EnterpriseConnectionMutations, @@ -28,8 +26,6 @@ export interface ConfigureSSOData { testRuns: TestRunsView; organizationDomains: OrganizationDomainResource[] | undefined; onExit?: () => void; - /** Temporary gate for the self-serve OIDC flow; remove at OIDC GA. */ - isOIDCFlowEnabled: boolean; } interface ConfigureSSOProviderProps { @@ -57,8 +53,6 @@ export const ConfigureSSOProvider = ({ onExit, children, }: PropsWithChildren): JSX.Element => { - const isOIDCFlowEnabled = useOptions().experimental?.oidcSelfServe ?? false; - const value = React.useMemo( () => ({ contentRef, @@ -69,7 +63,6 @@ export const ConfigureSSOProvider = ({ enterpriseConnectionMutations, organizationDomainMutations, onExit, - isOIDCFlowEnabled, }), [ contentRef, @@ -80,7 +73,6 @@ export const ConfigureSSOProvider = ({ organizationDomains, enterpriseConnection, onExit, - isOIDCFlowEnabled, ], ); diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx index f4511cc458e..60d4af2e957 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx @@ -14,7 +14,6 @@ import type { EnterpriseConnectionProviderType } from '../../../types'; // left undefined so that footer self-hides in this isolated render. const contextState = vi.hoisted(() => ({ provider: undefined as string | undefined, - isOIDCFlowEnabled: true, enterpriseConnection: undefined as | { id: string; @@ -45,7 +44,6 @@ vi.mock('../../../ConfigureSSOContext', () => ({ provider: contextState.provider, hasConnection: true, }, - isOIDCFlowEnabled: contextState.isOIDCFlowEnabled, }), })); @@ -63,34 +61,28 @@ const { createFixtures } = bindCreateFixtures('ConfigureSSO'); describe('resolveConfigureSteps', () => { it('dispatches custom and legacy OIDC provider keys to the OIDC sub-flow', () => { - expect(resolveConfigureSteps('oauth_custom_clerk_dev', true)).toBe(OidcCustomConfigureSteps); - expect(resolveConfigureSteps('oidc_clerk_dev', true)).toBe(OidcCustomConfigureSteps); - expect(resolveConfigureSteps('oidc_ghe_acme', true)).toBe(OidcCustomConfigureSteps); - expect(resolveConfigureSteps('oidc_gitlab_ent_acme', true)).toBe(OidcCustomConfigureSteps); - expect(resolveConfigureSteps('oidc_custom', true)).toBe(OidcCustomConfigureSteps); - }); - - it('does not dispatch OIDC providers while the experimental flow is disabled', () => { - expect(resolveConfigureSteps('oauth_custom_clerk_dev', false)).toBeUndefined(); - expect(resolveConfigureSteps('oidc_clerk_dev', false)).toBeUndefined(); + expect(resolveConfigureSteps('oauth_custom_clerk_dev')).toBe(OidcCustomConfigureSteps); + expect(resolveConfigureSteps('oidc_clerk_dev')).toBe(OidcCustomConfigureSteps); + expect(resolveConfigureSteps('oidc_ghe_acme')).toBe(OidcCustomConfigureSteps); + expect(resolveConfigureSteps('oidc_gitlab_ent_acme')).toBe(OidcCustomConfigureSteps); + expect(resolveConfigureSteps('oidc_custom')).toBe(OidcCustomConfigureSteps); }); it('dispatches SAML providers by exact literal', () => { - expect(resolveConfigureSteps('saml_okta', false)).toBe(SamlOktaConfigureSteps); - expect(resolveConfigureSteps('saml_custom', false)).toBe(SamlCustomConfigureSteps); - expect(resolveConfigureSteps('saml_google', false)).toBe(SamlGoogleConfigureSteps); - expect(resolveConfigureSteps('saml_microsoft', false)).toBe(SamlMicrosoftConfigureSteps); + expect(resolveConfigureSteps('saml_okta')).toBe(SamlOktaConfigureSteps); + expect(resolveConfigureSteps('saml_custom')).toBe(SamlCustomConfigureSteps); + expect(resolveConfigureSteps('saml_google')).toBe(SamlGoogleConfigureSteps); + expect(resolveConfigureSteps('saml_microsoft')).toBe(SamlMicrosoftConfigureSteps); }); it('returns undefined for an unrecognized provider so the caller can degrade', () => { - expect(resolveConfigureSteps('ldap_enterprise' as EnterpriseConnectionProviderType, true)).toBeUndefined(); + expect(resolveConfigureSteps('ldap_enterprise' as EnterpriseConnectionProviderType)).toBeUndefined(); }); }); describe('ConfigureProviderStep', () => { beforeEach(() => { contextState.provider = undefined; - contextState.isOIDCFlowEnabled = true; contextState.enterpriseConnection = undefined; updateConnection.mockReset(); }); @@ -469,15 +461,4 @@ describe('ConfigureProviderStep', () => { expect(await screen.findByText(/unsupported provider/i)).toBeInTheDocument(); }); - - it('degrades to the unsupported-provider state for an existing OIDC connection when the flag is off', async () => { - contextState.provider = 'oauth_custom_clerk_dev'; - contextState.isOIDCFlowEnabled = false; - const { wrapper } = await createFixtures(); - - renderStep(wrapper); - - expect(await screen.findByText(/unsupported provider/i)).toBeInTheDocument(); - expect(screen.queryByText(/create a new oidc application/i)).not.toBeInTheDocument(); - }); }); diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/index.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/index.tsx index 6abf3e61005..c687f4c7d3b 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/index.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/index.tsx @@ -28,13 +28,8 @@ const STEPS_BY_SAML_PROVIDER: Record export const resolveConfigureSteps = ( provider: EnterpriseConnectionProviderType, - isOIDCFlowEnabled: boolean, -): ConfigureStepsComponent | undefined => { - if (isOidcProvider(provider)) { - return isOIDCFlowEnabled ? OidcCustomConfigureSteps : undefined; - } - return STEPS_BY_SAML_PROVIDER[provider]; -}; +): ConfigureStepsComponent | undefined => + isOidcProvider(provider) ? OidcCustomConfigureSteps : STEPS_BY_SAML_PROVIDER[provider]; export const ConfigureStep = (): JSX.Element => { const { organizationEnterpriseConnection: c } = useConfigureSSO(); @@ -68,13 +63,13 @@ export const ConfigureStep = (): JSX.Element => { }; export const ConfigureProviderStep = (): JSX.Element | null => { - const { organizationEnterpriseConnection: c, isOIDCFlowEnabled } = useConfigureSSO(); + const { organizationEnterpriseConnection: c } = useConfigureSSO(); if (!c.provider) { return null; } - const ConfigureSteps = resolveConfigureSteps(c.provider, isOIDCFlowEnabled); + const ConfigureSteps = resolveConfigureSteps(c.provider); return ( diff --git a/packages/ui/src/components/ConfigureSSO/steps/SelectProviderStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/SelectProviderStep.tsx index 7fde28ee673..c939cd88784 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/SelectProviderStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/SelectProviderStep.tsx @@ -78,16 +78,10 @@ export const SelectProviderStep = (): JSX.Element => { organizationEnterpriseConnection: c, enterpriseConnectionMutations: { createConnection, changeProvider }, contentRef, - isOIDCFlowEnabled, } = useConfigureSSO(); const { goNext, goPrev, isFirstStep } = useWizard(); const { t } = useLocalizations(); - const providerGroups = React.useMemo( - () => PROVIDER_GROUPS.filter(group => group.id !== 'oidc' || isOIDCFlowEnabled), - [isOIDCFlowEnabled], - ); - const currentCard = c.provider ? toProviderCard(c.provider) : null; const [selected, setSelected] = React.useState(currentCard); @@ -166,7 +160,7 @@ export const SelectProviderStep = (): JSX.Element => { ({ gap: theme.space.$5 })}> - {providerGroups.map(group => ( + {PROVIDER_GROUPS.map(group => ( ({ provider: undefined as 'saml_okta' | 'saml_custom' | 'saml_google' | undefined, hasConnection: false, - isOIDCFlowEnabled: false, })); vi.mock('../../ConfigureSSOContext', () => ({ @@ -40,7 +39,6 @@ vi.mock('../../ConfigureSSOContext', () => ({ provider: contextState.provider, hasConnection: contextState.hasConnection, }, - isOIDCFlowEnabled: contextState.isOIDCFlowEnabled, }), })); @@ -64,7 +62,6 @@ const resetMocks = () => { changeProvider.mockResolvedValue(undefined); contextState.provider = undefined; contextState.hasConnection = false; - contextState.isOIDCFlowEnabled = false; }; describe('SelectProviderStep', () => { @@ -96,7 +93,7 @@ describe('SelectProviderStep', () => { // Each provider card is a
], + ['Card', ], + ['Card.Header', ], + ['Card.Content', ], + ['Card.Footer', ], + ['Heading', Title], + [ + 'Icon', + , + ], + ['Input', ], + ['Item', ], + ['Item.Group', ], + ['Item.Separator', ], + ['Text', Body copy], +]; + +describe('Mosaic reset', () => { + it('derives the classes it asserts on from the reset itself', () => { + expect(borderBoxAtom).toHaveLength(1); + expect(resetMarker).toHaveLength(1); + expect(classes(reset.base)).toEqual(expect.arrayContaining([...borderBoxAtom, ...resetMarker])); + }); + + it.each(cases)('%s carries the reset on its root element', (_name, ui) => { + const { container } = render(ui); + const element = container.firstElementChild; + + expect(element).not.toBeNull(); + expect(element).toHaveClass(...resetMarker, ...borderBoxAtom); + }); + + it('lets a component win over the reset it composes first', () => { + const { container } = render(Title); + + // `reset.base` sets `fontWeight: inherit`; `heading.styles` sets semibold after it, so StyleX + // must have dropped the reset's atom. Order-dependent, which is why the reset always goes first. + expect(container.firstElementChild).not.toHaveClass(...atoms(probe.inheritedWeight)); + }); +}); diff --git a/packages/ui/src/mosaic/components/scroll-area/index.ts b/packages/ui/src/mosaic/components/scroll-area/index.ts new file mode 100644 index 00000000000..5c72aeb1986 --- /dev/null +++ b/packages/ui/src/mosaic/components/scroll-area/index.ts @@ -0,0 +1,3 @@ +export { scrollAreaRoot, scrollAreaViewport } from './scroll-area.styles'; +export type { ScrollAreaGutter } from './scroll-area.styles'; +export { scrollAreaVars } from './scroll-area.vars.stylex'; diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts b/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts new file mode 100644 index 00000000000..1dc99a1a148 --- /dev/null +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.styles.ts @@ -0,0 +1,310 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, durationVars, radiusVars, scrollbarVars, scrollFadeVars, space } from '../../tokens.stylex'; +import { scrollAreaVars, scrollbarThumbVars } from './scroll-area.vars.stylex'; + +// Same-file locals so the `var()` references read as names rather than as a wall of +// bracket lookups inside the gradient. StyleX inlines them at build; an imported helper +// would fail static evaluation. +const progressStart = scrollAreaVars['--cl-scroll-area-progress-start']; +const progressEnd = scrollAreaVars['--cl-scroll-area-progress-end']; +const fadeSize = scrollFadeVars['--cl-scroll-fade-size']; +const fadeRange = scrollFadeVars['--cl-scroll-fade-range']; +const scrollbarWidth = scrollbarVars['--cl-scrollbar-width']; +const thumbColor = scrollbarThumbVars['--_cl-scrollbar-thumb-color']; + +// One animation per edge, each writing its own progress var. The end fade counts DOWN +// rather than running `animation-direction: reverse`: with `fill-mode: both` the two are +// equivalent (the backwards fill holds the `from` frame, so the fade reads 1 for the whole +// scroll and only drops across the final `fade-range`), and writing it into the keyframes +// keeps `animation-direction` off the element entirely. +// The suppressions work around a gap in StyleX's own types, not a problem with the CSS: +// `Keyframes` declares each frame as `CSSProperties`, which carries no index signature for +// `--*` keys, so a custom property the compiler accepts and emits correctly still fails to +// typecheck. It has to be suppressed rather than cast — the babel plugin requires a bare +// object literal, and wrapping the argument in an `as` expression fails the build with +// "keyframes() can only accept an object". A computed key is out for the same reason. +const revealStart = stylex.keyframes({ + // @ts-expect-error -- StyleX's `Keyframes` type omits custom properties; see above. + from: { '--cl-scroll-area-progress-start': 0 }, + // @ts-expect-error -- StyleX's `Keyframes` type omits custom properties; see above. + to: { '--cl-scroll-area-progress-start': 1 }, +}); + +const revealEnd = stylex.keyframes({ + // @ts-expect-error -- StyleX's `Keyframes` type omits custom properties; see above. + from: { '--cl-scroll-area-progress-end': 1 }, + // @ts-expect-error -- StyleX's `Keyframes` type omits custom properties; see above. + to: { '--cl-scroll-area-progress-end': 0 }, +}); + +// A single four-stop gradient covers both edges, because the animated quantity is a number +// the stops are computed from rather than the mask's own geometry. At progress 0 the stop +// collapses onto the edge it starts from, leaving a hard boundary that reads as fully +// opaque — so "no scroll yet" and "not scrollable at all" render identically, for free. +// +// The second layer is the scrollbar strip, held opaque so the fade never touches it. Its width +// comes from `--cl-scrollbar-width` — the lane we specify ourselves — rather than a knob of its +// own, since the two can never legitimately differ. Where we do NOT paint the scrollbar the +// layer is zero-wide and contributes nothing. Layers composite with `add` by default, so no +// `mask-composite` declaration is needed. +const maskImage = `linear-gradient(to bottom, transparent 0, #000 calc(${progressStart} * ${fadeSize}), #000 calc(100% - ${progressEnd} * ${fadeSize}), transparent 100%), linear-gradient(#000, #000)`; + +// Split by concern rather than one object per slot: the sort-keys rule reorders within an +// object, so a large one ends up interleaving unrelated properties and stranding the comments +// that explain them. `scrollAreaViewport()` recomposes them, so callers spread one thing. +const styles = stylex.create({ + root: { + display: 'flex', + flexDirection: 'column', + // Only load-bearing for a future scrollbar part; the viewport needs no positioning. + position: 'relative', + // A scroll container nested in a column flex parent overflows its track without this. + minHeight: 0, + }, + + /** The scroll container itself. */ + viewport: { + overscrollBehavior: 'contain', + flexBasis: 'auto', + flexGrow: 1, + flexShrink: 1, + minHeight: 0, + overflowX: 'hidden', + overflowY: 'auto', + }, + + /** + * The thumb's colour, produced HERE on the scroller rather than on the pseudo-element that + * paints it, because Blink does not run transitions declared on `::-webkit-scrollbar-thumb` — + * verified by hand, and the reason Polaris declares its own on the scroller too. A registered + * custom property set here animates and inherits into the pseudo-element, which only reads it. + * + * The consequence is worth stating plainly, because it decides which states can move: a change + * made ON THE SCROLLER animates, and a change made on the thumb itself can only snap. So the + * thumb's own `:hover` / `:active` below are instant by construction, while anything driven from + * the scroller — including a consumer retargeting `--cl-scrollbar-thumb` on the region's + * `:hover` to fade the bar in — transitions through this declaration. + * + * `linear` because this is a colour: an ease on top of an already perceptually non-uniform + * interpolation only makes the midpoint drag. + */ + thumbColor: { + '--_cl-scrollbar-thumb-color': { + default: null, + '@media (pointer: fine)': { + // Quietest while the pointer is elsewhere; the region itself is the first thing that lifts + // it. `:focus-within` comes along so a keyboard user arrowing through the content gets the + // same bar a pointer user does. + default: scrollbarVars['--cl-scrollbar-thumb-idle'], + ':is(:hover, :focus-within)': scrollbarVars['--cl-scrollbar-thumb'], + }, + }, + // Longer leaving than arriving, per the duration tokens: reaching the region is direct pointer + // feedback, its decay is not. + transitionDuration: { + default: null, + '@media (pointer: fine)': { + default: durationVars['--cl-duration-base'], + ':is(:hover, :focus-within)': durationVars['--cl-duration-fast'], + }, + }, + transitionProperty: { default: null, '@media (pointer: fine)': '--_cl-scrollbar-thumb-color' }, + transitionTimingFunction: { default: null, '@media (pointer: fine)': 'linear' }, + }, + + /** + * The scrollbar's own paint. Only the lane's size and the thumb are styled — the track is left + * alone, so the thumb reads as floating over the content rather than riding in a rail. + * + * Every declaration here repeats `{ default: null, '@media (pointer: fine)': … }`. A touch + * platform draws an overlay bar there is no width or colour to apply to, and — the reason the + * gate has to reach the SHAPE properties too, not just the visible ones — Blink switches an + * element to a custom scrollbar the moment ANY `::-webkit-scrollbar*` rule matches it, which + * would trade that overlay bar for a permanent one. `null` emits no declaration at all, so + * under a coarse pointer the pseudo-elements carry no rules and the platform keeps its own. + * Written out each time rather than wrapped in a local helper: the compiler evaluates a helper + * fine, but `@stylexjs/valid-styles` can't see through the call and rejects every value it + * wraps, trading this repetition for a wall of suppressions. + * + * Deliberately no `scrollbar-color` / `scrollbar-width` on the scroller: a non-`auto` value for + * either makes a UA ignore the `::-webkit-scrollbar*` family entirely, so keeping them would + * leave every rule here as dead code in exactly the engines that implement it. Firefox + * implements the pseudo-elements not at all and keeps its platform scrollbar. That is the whole + * cost of the trade, and it buys per-state thumb colours and a real pixel width, neither of + * which the standard properties can express. + * + * The thumb's states are COMBINED keys rather than a `:hover` nested inside the + * `::-webkit-scrollbar-thumb` block: StyleX emits a nested pseudo-class BEFORE the + * pseudo-element (`:hover::-webkit-scrollbar-thumb`), which asks whether the SCROLLER is + * hovered — a much larger target that lights the thumb up whenever the pointer is anywhere over + * the region. These are the thumb's own states, and a combined key is the only way to reach + * them. Their source order is the sort-keys rule's and doesn't matter: StyleX prices `:active` + * above `:hover` either way. + */ + scrollbar: { + '::-webkit-scrollbar': { + width: { default: null, '@media (pointer: fine)': scrollbarWidth }, + }, + '::-webkit-scrollbar-thumb': { + // A transparent border clipped away is how you inset a pill thumb: the lane keeps its full + // width for hit-testing while the paint shrinks to the middle of it. Both Polaris and + // `references/stylex-ui` arrive at this independently — a scrollbar pseudo-element has no + // padding to do it with. (Key order here is the sort-keys rule's, not ours.) + borderColor: { default: null, '@media (pointer: fine)': 'transparent' }, + borderRadius: { default: null, '@media (pointer: fine)': radiusVars['--cl-radius-full'] }, + borderStyle: { default: null, '@media (pointer: fine)': 'solid' }, + // Uniform, and it has to stay uniform. Nudging the pill sideways by making these asymmetric + // works geometrically but wrecks the caps: `background-clip: content-box` clips to the + // content box using the INNER radius, which CSS derives per corner as the outer radius minus + // that side's border width, so unequal borders give the two halves of each cap different + // curvature. Measured on a 4px pill, the cap goes from a mirrored `35 76 76 35` to a lopsided + // `24 60 78 54`. There is no offsetting the thumb within its lane without paying that. + borderWidth: { default: null, '@media (pointer: fine)': scrollbarVars['--cl-scrollbar-thumb-inset'] }, + backgroundClip: { default: null, '@media (pointer: fine)': 'content-box' }, + backgroundColor: { + default: null, + '@media (pointer: fine)': { + // eslint-disable-next-line @stylexjs/valid-styles -- valid-styles doesn't resolve a `stylex.types.color()` var to a colour; the compiler does. + default: thumbColor, + // No `scrollbar-color: auto` lever survives on this path, so forced colors need their + // own answer: pin the thumb to a system colour rather than let a themed one lose its + // contrast guarantee against a palette we no longer control. Declared on + // `background-color` rather than on the var so it holds across all four states at once. + '@media (forced-colors: active)': 'ButtonBorder', + }, + }, + }, + // eslint-disable-next-line @stylexjs/valid-styles -- StyleX's pseudo-element allowlist holds the bare selectors only; it compiles the combined form correctly. See the note above. + '::-webkit-scrollbar-thumb:active': { + '--_cl-scrollbar-thumb-color': { + default: null, + '@media (pointer: fine)': scrollbarVars['--cl-scrollbar-thumb-active'], + }, + }, + // eslint-disable-next-line @stylexjs/valid-styles -- see above. + '::-webkit-scrollbar-thumb:hover': { + '--_cl-scrollbar-thumb-color': { + default: null, + '@media (pointer: fine)': scrollbarVars['--cl-scrollbar-thumb-hover'], + }, + }, + // Declared transparent rather than left alone. Opting into a custom scrollbar at all means the + // track is OURS, and an undeclared one falls back to the UA's own painting for the part — + // which shows through the moment the thumb is anything less than opaque, and reads as a dark + // rail behind a thumb that was supposed to be invisible. "Unstyled" has to be said out loud. + '::-webkit-scrollbar-track': { + backgroundColor: { default: null, '@media (pointer: fine)': 'transparent' }, + }, + }, + + /** Paint-only, so it can never shift the content the way a sticky shadow element does. */ + mask: { + maskImage, + maskPosition: 'left top, right top', + maskRepeat: 'no-repeat', + // Held back from the scrollbar only where we actually paint one, which is the same pair of + // conditions the rules above run under: a fine pointer, and an engine that implements + // `::-webkit-scrollbar`. This is not a fallback branch for the scrollbar styling — there + // isn't one — it is the mask asking whether there is a lane to keep clear. Gecko answers no + // and gets the fade edge to edge, rather than an unfaded strip beside a bar we never styled + // and whose width we don't know. + // + // `not (-moz-appearance: none)` stands in for the question we actually want to ask, + // `selector(::-webkit-scrollbar)`, because StyleX 0.19 rewrites the argument of + // `@supports selector(…)` with the same `:not(#\#)` specificity bump it applies to real + // selectors. That turns the query into `selector(:not(#\#):not(#\#):not(#\#)::-webkit-scrollbar)`, + // which every engine reports as false — verified in Chrome, where the honest form returns + // true and the rewritten one returns false. Any property-based condition is left alone. + maskSize: { + default: '100% 100%, 0px 100%', + '@media (pointer: fine)': { + '@supports not (-moz-appearance: none)': `calc(100% - ${scrollbarWidth}) 100%, ${scrollbarWidth} 100%`, + }, + }, + }, + + // Only the name is gated on timeline support. A browser that ignores `animation-timeline` + // would otherwise run these on the document timeline at the default `0s` duration, land + // on the end frame immediately, and paint both fades permanently. With no name the + // remaining animation properties are inert, the vars hold at their registered + // `initial-value: 0`, and the mask resolves to fully opaque — so an unsupported browser + // gets a plain scroll area rather than a broken one. + indicators: { + // eslint-disable-next-line @stylexjs/valid-styles -- `animation-range` postdates StyleX's property allowlist; it compiles and emits correctly. + animationRange: `0px ${fadeRange}, calc(100% - ${fadeRange}) 100%`, + animationFillMode: 'both', + animationName: { + default: null, + '@supports (animation-timeline: scroll())': `${revealStart}, ${revealEnd}`, + }, + animationTimeline: 'scroll(self block), scroll(self block)', + animationTimingFunction: 'linear', + }, + + // Not focusable by default — see the `tabIndex` note on the component. Styled anyway so it + // looks right the moment a consumer opts in. + focusRing: { + outline: { default: null, ':focus-visible': `2px solid ${colorVars['--cl-color-primary']}` }, + outlineOffset: { default: null, ':focus-visible': space['0.5'] }, + }, +}); + +// Gutter only — the scrollbar's own size is a theme token (`--cl-scrollbar-width`), since +// Mosaic has no reason to size scrollbars differently between components. What varies per +// instance is whether the space is held open, which is a layout decision about the +// surrounding content rather than an appearance one. +const gutters = stylex.create({ + // The default, and CSS's own. Nothing is reserved until a scrollbar actually appears, which + // is right whenever the content can't change height while mounted — no shift is possible, + // so holding space open would only cost width. + auto: { + scrollbarGutter: 'auto', + }, + // Opt in where the content CAN change height in place — a filterable or paginated + // collection — so crossing the overflow threshold doesn't shift the rows sideways. + stable: { + scrollbarGutter: 'stable', + }, +}); + +export type ScrollAreaGutter = keyof typeof gutters; + +/** + * The scroll surface, as StyleX atoms to spread onto an element you already render. + * + * There is no `` component: everything here is CSS, so a component would only add + * a DOM node and an API to version. Put these on whatever already scrolls — an `Item.Group`, + * a list, a panel body — and it keeps its own slot class, which stays the hook a theme + * targets. + * + * ```tsx + *
+ * {rows} + *
+ * ``` + * + * @param gutter - Whether the scrollbar's space is held open. `auto` (the default, and CSS's + * own) takes it only while the content overflows. `stable` reserves it either way, which is + * worth it when the content can change height **in place** — a filterable or paginated + * collection — so crossing the overflow threshold doesn't shift the rows sideways. Neither + * does anything on platforms that overlay their scrollbars. + */ +export function scrollAreaViewport(gutter: ScrollAreaGutter = 'auto') { + return [ + styles.viewport, + styles.thumbColor, + styles.scrollbar, + styles.mask, + styles.indicators, + styles.focusRing, + gutters[gutter], + ] as const; +} + +/** + * The positioned ancestor. Only needed when something has to anchor against the scroll box — + * an overlay replacing the default mask, or a future scrollbar. A scroll surface whose parent + * is already positioned doesn't need it. + */ +export const scrollAreaRoot = styles.root; diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts b/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts new file mode 100644 index 00000000000..3acd569ae2d --- /dev/null +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; + +import { scrollbarVars, scrollFadeVars } from '../../tokens.stylex'; +import { scrollAreaRoot, scrollAreaViewport } from './scroll-area.styles'; +import { scrollAreaVars, scrollbarThumbVars } from './scroll-area.vars.stylex'; + +describe('Mosaic scroll area styles', () => { + it('composes the viewport atoms into one spreadable set', () => { + expect(scrollAreaViewport()).toHaveLength(7); + expect(scrollAreaRoot).toBeDefined(); + }); + + // The atoms carry the gutter, so the two have to be distinguishable — an accidental + // collapse would silently give every scroll surface the same overflow behaviour. + it('varies the gutter atom by argument', () => { + expect(scrollAreaViewport('stable')).not.toEqual(scrollAreaViewport('auto')); + }); + + it('defaults the gutter to auto', () => { + expect(scrollAreaViewport()).toEqual(scrollAreaViewport('auto')); + }); + + // The `--cl-*` names are the public API — a consumer's stylesheet references them by hand, + // and `clerk-js` ships to apps pinned to older SDKs, so renaming one breaks themes already + // in the wild. Assert the exact strings so a rename has to be a deliberate act. + // + // `toMatchObject`, not `toEqual`: StyleX adds an internal `__varGroupHash__` key, and adding + // a var is not itself breaking — removing or renaming one is. + it('emits the documented per-element progress properties', () => { + expect(scrollAreaVars).toMatchObject({ + '--cl-scroll-area-progress-start': 'var(--cl-scroll-area-progress-start)', + '--cl-scroll-area-progress-end': 'var(--cl-scroll-area-progress-end)', + }); + }); + + it('reads the shared scroll tokens', () => { + expect(scrollbarVars).toMatchObject({ + '--cl-scrollbar-width': 'var(--cl-scrollbar-width)', + '--cl-scrollbar-thumb-inset': 'var(--cl-scrollbar-thumb-inset)', + '--cl-scrollbar-thumb': 'var(--cl-scrollbar-thumb)', + '--cl-scrollbar-thumb-idle': 'var(--cl-scrollbar-thumb-idle)', + '--cl-scrollbar-thumb-hover': 'var(--cl-scrollbar-thumb-hover)', + '--cl-scrollbar-thumb-active': 'var(--cl-scrollbar-thumb-active)', + }); + expect(scrollFadeVars).toMatchObject({ + '--cl-scroll-fade-size': 'var(--cl-scroll-fade-size)', + '--cl-scroll-fade-range': 'var(--cl-scroll-fade-range)', + }); + }); + + // The counterpart to the assertion above: `--_cl-` is the marker for plumbing, so it must not + // drift into the themable `--cl-` namespace the way a rename easily could. + it('keeps the thumb colour carrier out of the public token namespace', () => { + expect(scrollbarThumbVars).toMatchObject({ + '--_cl-scrollbar-thumb-color': 'var(--_cl-scrollbar-thumb-color)', + }); + }); + + it('keeps the fade inset off the public token namespace', () => { + expect(Object.keys(scrollFadeVars)).not.toContain('--cl-scroll-fade-inset'); + }); +}); diff --git a/packages/ui/src/mosaic/components/scroll-area/scroll-area.vars.stylex.ts b/packages/ui/src/mosaic/components/scroll-area/scroll-area.vars.stylex.ts new file mode 100644 index 00000000000..ecd4f398dee --- /dev/null +++ b/packages/ui/src/mosaic/components/scroll-area/scroll-area.vars.stylex.ts @@ -0,0 +1,39 @@ +import * as stylex from '@stylexjs/stylex'; + +// ScrollArea's per-element runtime output: vars a consumer READS, never sets. The scroll-driven +// animations write them on every scrolling element, so a `:root` value would simply be +// overwritten. That is why these stay component-named while the fade's actual knobs live in +// `tokens.stylex.ts` as the global `--cl-scroll-fade-*` family — those are set, these are read. +// +// They are `stylex.types.number` rather than plain strings so StyleX emits an `@property` +// registration for each. That registration is load-bearing twice over: +// +// 1. An unregistered custom property animates DISCRETELY — it would flip at 50% of the +// scroll range instead of tracking it. Registering `syntax: ""` is what makes +// the value interpolate. +// 2. `initial-value: 0` is what hides both indicators when the viewport isn't scrollable. +// A scroll timeline with no scrollable overflow is inactive, so neither animation +// applies and both vars fall back to 0 — which the mask reads as "no fade". The +// `--can-scroll` space-toggle hack the well-known demos use is unnecessary here, +// because our resting state is already the hidden one. +export const scrollAreaVars = stylex.defineVars({ + '--cl-scroll-area-progress-start': stylex.types.number(0), + '--cl-scroll-area-progress-end': stylex.types.number(0), +}); + +// The animated carrier for the scrollbar thumb's colour. `::-webkit-scrollbar-thumb` cannot +// transition properties of its own, so the transition is declared on the SCROLLER against this +// property and the pseudo-element only ever reads it — `inherits: true`, which StyleX hardcodes +// for typed vars, is what carries the animating value down into it. Same primitive as the +// progress vars above, for the same reason: unregistered, it would snap at the halfway point +// instead of interpolating. +// +// The initial value has to be a literal. `@property`'s `initial-value` must be computationally +// independent, so it cannot be the `var(--cl-scrollbar-thumb)` reference the scroller actually +// assigns — an invalid one would drop the whole registration and take the transition with it. +// +// `--_cl-` rather than `--cl-`: this is plumbing between an element and its pseudo-element, not +// a themable contract. The knobs are the `--cl-scrollbar-thumb*` tokens this resolves to. +export const scrollbarThumbVars = stylex.defineVars({ + '--_cl-scrollbar-thumb-color': stylex.types.color('transparent'), +}); diff --git a/packages/ui/src/mosaic/components/tabs.tsx b/packages/ui/src/mosaic/components/tabs.tsx index bf9a76728a5..6c4d428cd65 100644 --- a/packages/ui/src/mosaic/components/tabs.tsx +++ b/packages/ui/src/mosaic/components/tabs.tsx @@ -98,70 +98,66 @@ declare module '../registry' { } } -const List = React.forwardRef>( - function TabsList(props, ref) { - const { list } = useRecipe(tabsRecipe); - return ( - - ); - }, -); +export type TabsListProps = React.ComponentPropsWithoutRef; +export type TabsTabProps = React.ComponentPropsWithoutRef; +export type TabsTriggerProps = React.ComponentPropsWithoutRef; +export type TabsPanelProps = React.ComponentPropsWithoutRef; +export type TabsIndicatorProps = React.ComponentPropsWithoutRef; -const Tab = React.forwardRef>( - function TabsTab(props, ref) { - const { tab } = useRecipe(tabsRecipe); - return ( - - ); - }, -); +const List = React.forwardRef(function TabsList(props, ref) { + const { list } = useRecipe(tabsRecipe); + return ( + + ); +}); -const Trigger = React.forwardRef>( - function TabsTrigger(props, ref) { - const { trigger } = useRecipe(tabsRecipe); - return ( - - ); - }, -); +const Tab = React.forwardRef(function TabsTab(props, ref) { + const { tab } = useRecipe(tabsRecipe); + return ( + + ); +}); -const Panel = React.forwardRef>( - function TabsPanel(props, ref) { - const { panel } = useRecipe(tabsRecipe); - return ( - - ); - }, -); +const Trigger = React.forwardRef(function TabsTrigger(props, ref) { + const { trigger } = useRecipe(tabsRecipe); + return ( + + ); +}); -const Indicator = React.forwardRef>( - function TabsIndicator(props, ref) { - const { indicator } = useRecipe(tabsRecipe); - return ( - - ); - }, -); +const Panel = React.forwardRef(function TabsPanel(props, ref) { + const { panel } = useRecipe(tabsRecipe); + return ( + + ); +}); + +const Indicator = React.forwardRef(function TabsIndicator(props, ref) { + const { indicator } = useRecipe(tabsRecipe); + return ( + + ); +}); /** Styled mosaic Tabs components built on headless Tabs primitives. */ export const Tabs: { diff --git a/packages/ui/src/mosaic/components/text/text.tsx b/packages/ui/src/mosaic/components/text/text.tsx index e43bb7204ec..a8418cf0a88 100644 --- a/packages/ui/src/mosaic/components/text/text.tsx +++ b/packages/ui/src/mosaic/components/text/text.tsx @@ -5,8 +5,9 @@ import React from 'react'; import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { useContextProps } from '../../utils/context'; +import { reset } from '../reset.styles'; import type { TypographyColor, TypographySize } from '../typography.styles'; -import { colors, sizes } from '../typography.styles'; +import { colors, sizes, styles } from '../typography.styles'; export interface TextProps extends MosaicComponentProps<'p'> { size?: TypographySize; @@ -23,7 +24,12 @@ export const Text = React.forwardRef(function M const { size = 'sm', color = 'primary', render, className, style, ...rest } = useContextProps(rawProps, TextContext); const props = { - ...mergeStyleProps(themeProps('text', { size, color }), stylex.props(sizes[size], colors[color]), className, style), + ...mergeStyleProps( + themeProps('text', { size, color }), + stylex.props(reset.base, styles.base, sizes[size], colors[color]), + className, + style, + ), ...rest, }; diff --git a/packages/ui/src/mosaic/components/typography.styles.ts b/packages/ui/src/mosaic/components/typography.styles.ts index a267425e655..463c4deb187 100644 --- a/packages/ui/src/mosaic/components/typography.styles.ts +++ b/packages/ui/src/mosaic/components/typography.styles.ts @@ -1,11 +1,17 @@ import * as stylex from '@stylexjs/stylex'; -import { colorVars, typeScaleVars } from '../tokens.stylex'; +import { colorVars, fontFamilyVars, typeScaleVars } from '../tokens.stylex'; export type TypographySize = 'xs' | 'sm' | 'base' | 'lg' | 'xl' | '2xl'; export type TypographyColor = 'primary' | 'neutral' | 'warning' | 'negative' | 'positive'; +export const styles = stylex.create({ + base: { + fontFamily: fontFamilyVars['--cl-font-family-sans'], + }, +}); + export const sizes = stylex.create({ xs: { fontSize: typeScaleVars['--cl-text-xs-size'], diff --git a/packages/ui/src/mosaic/icons/registry.tsx b/packages/ui/src/mosaic/icons/registry.tsx index 9f108740fda..057dbe0237a 100644 --- a/packages/ui/src/mosaic/icons/registry.tsx +++ b/packages/ui/src/mosaic/icons/registry.tsx @@ -65,6 +65,28 @@ const Close = glyph( />, ); +const Ellipsis = glyph( + , +); + +const Plus = glyph( + , +); + +const LogOut = glyph( + , +); + /** Runtime name → glyph map. `Icon`'s `name` prop is typed from these keys. */ export const iconRegistry = { 'chevron-right': ChevronRight, @@ -72,6 +94,9 @@ export const iconRegistry = { 'chevron-down': ChevronDown, check: Check, close: Close, + ellipsis: Ellipsis, + plus: Plus, + 'log-out': LogOut, } satisfies Record; export type IconName = keyof typeof iconRegistry; diff --git a/packages/ui/src/mosaic/organization/organization-profile-domains-section-add-verify.view.tsx b/packages/ui/src/mosaic/organization/organization-profile-domains-section-add-verify.view.tsx index 7fc7e67eb6b..e043f91d91f 100644 --- a/packages/ui/src/mosaic/organization/organization-profile-domains-section-add-verify.view.tsx +++ b/packages/ui/src/mosaic/organization/organization-profile-domains-section-add-verify.view.tsx @@ -88,7 +88,7 @@ export function OrganizationProfileDomainsSectionAddVerifyView({ disabled={isBusy} placeholder='example.com' onChange={e => send({ type: 'TYPE_NAME', value: e.target.value })} - sx={t => ({ marginBlockStart: t.spacing(1) })} + style={{ marginBlockStart: 'var(--cl-spacing)' }} /> send({ type: 'TYPE_CODE', value: e.target.value })} - sx={t => ({ marginBlockStart: t.spacing(1) })} + style={{ marginBlockStart: 'var(--cl-spacing)' }} /> ({ marginBlockStart: t.spacing(2), display: 'flex', columnGap: t.spacing(2) })}> diff --git a/packages/ui/src/mosaic/organization/organization-profile-profile-section.view.tsx b/packages/ui/src/mosaic/organization/organization-profile-profile-section.view.tsx index b07ca69503f..e63d442cb02 100644 --- a/packages/ui/src/mosaic/organization/organization-profile-profile-section.view.tsx +++ b/packages/ui/src/mosaic/organization/organization-profile-profile-section.view.tsx @@ -82,7 +82,7 @@ export function OrganizationProfileProfileSectionView({ send({ type: open ? 'OPEN' : 'CANCEL' })} - trigger={({ color: _nativeColor, ...props }) => ( + trigger={props => (