Files
a7d9f8fb32 feat(web): require Spaces login + at least one Space to access the app (#7884)
* feat(web): require Spaces login + at least one Space to access the app

Gate every non-public route behind SIWE login and at least one Space,
behind a new chain feature flag REQUIRE_LOGIN_DISABLED (default OFF =
gate ON). Read the flag from chain 1 so the answer is stable on routes
with no active chain context.

Bounce unauthenticated users to /welcome/spaces?next=<original-url> and
preserve next through the full onboarding flow (create-space ->
select-safes -> invite-members) so they land on the URL they originally
tried to open. Sanitise next to reject protocol-relative / absolute URLs.

/ redirects to /welcome/spaces when the gate is on, and /spaces without
a spaceId redirects to /welcome/spaces. Hide the Accounts/Spaces tabs
(AccountsNavigation) and the Topbar on /welcome/spaces while the gate
is on so the page is a clean login/Spaces list. Adding /welcome/spaces
to NO_HEADER_ROUTES also fixes a hydration error when ?safe= is in the
URL (Topbar hooks read different values SSR vs client).

Also fix a pre-existing race in WalletProvider where the initial wallet
state could stay null if connectLastWallet populated onboard.state
before the subscription started.

Always-public routes (terms, privacy, cookie, licenses, imprint,
safe-labs-terms, 403/404/_offline, /share/*, OIDC callback) remain
reachable without a Space.

* fix(web): revert WalletProvider change; gracefully render select-safes without a wallet

Per review feedback, do not modify the global WalletProvider for this
feature. Instead, restructure /welcome/select-safes so the form always
renders: the connect-wallet prompt is now a small inline element inside
the otherwise-empty Safes list area rather than a full screen takeover.
Continue/Skip remain primary actions regardless of wallet state, so a
brief useWallet=null window during onboard restore no longer flashes
"Connect your wallet to see your safes" at the user.

* fix(web): don't store `/` as the `next` redirect target

Visiting `/` while logged out was producing /welcome/spaces?next=%2F,
which is a no-op round-trip — `/` itself just redirects back to
/welcome/spaces. Reject bare `/` in sanitizeNextUrl and apply the
sanitiser to currentUrl too, so the guard simply lands on /welcome/spaces
without a meaningless next= param.

* feat(web): auto-trigger SIWE after wallet connect on the Spaces sign-in button

After the user clicks "Connect wallet" on the Spaces sign-in flow, fire
the SIWE prompt as soon as the wallet shows up in context, instead of
making the user click a second "Continue with wallet" button.

Skip the auto-trigger if the user is already SIWE-authenticated — that's
just a wallet switch and re-signing would be noise.

* fix(web): redirect /welcome to /welcome/spaces and reject self-redirecting paths as `next`

When the require-login gate is on, the legacy /welcome page now redirects
to /welcome/spaces — both because it shouldn't render the old "Get started"
NewSafe UI in this flow, and because the route guard would otherwise stash
"/welcome" as `next` and bounce the user back to the same redirector.

Sanitise also rejects /welcome and /welcome/spaces (in addition to / from
the previous commit) so manually crafted next= values can't loop either.

* fix(web): redirect from /home (and other protected routes) before wallet is ready

The route guard's first rule allowed the page to render whenever
`isWalletReady` was false, which meant unauthenticated users landing on
/home would briefly see the dashboard (and trigger downstream hydration
issues in NewsCarousel) before the gate-on rules ever got a chance to
fire.

The gate rules only need the Redux store to be hydrated (so
isSiweAuthenticated is trustworthy) — they don't actually need the
wallet system. Swap the early-allow trigger to `!isStoreHydrated` so the
auth-based redirects fire as soon as we know who the user is, even if
onboard is still bootstrapping.

* fix(web): stop /home ↔ /welcome/spaces redirect loop and drop redundant safe=

Two things were combining to ping-pong unauthenticated users between
/home and /welcome/spaces:

1. The login-redirect target appended ?safe= alongside ?next=, even though
   the safe was already inside the encoded next= URL. Drop the duplicate.

2. The /welcome/spaces page calls useFeatureFlagRedirect, which pushes
   to /welcome/accounts whenever the SPACES chain feature is off. With
   the gate on, the route guard then bounces /welcome/accounts back to
   /welcome/spaces — instant loop. Skip the redirect when the gate is
   on; /welcome/spaces is the canonical login page in that mode and
   doesn't need to defer to /welcome/accounts.

The onboarding redirect (/welcome/create-space) is cleaned up the same
way — no duplicate safe= on the target.

* fix(web): address PR review feedback on the require-login gate

1. Don't coerce isRequireLoginEnabled === undefined to false in the route
   guard. Until the chains config resolves the flag we now allow the page
   to render instead of running the legacy redirect rules — those would
   otherwise misroute logged-out deep links to /welcome and drop the
   intended next= round-trip.

2. /spaces (no spaceId) → /welcome/spaces now preserves router.query so
   safe= / chain= / tracking params survive the hop instead of being
   silently dropped.

3. Unauthenticated users on onboarding routes (e.g. /welcome/create-space)
   now redirect to /welcome/spaces?next=<onboarding-route> instead of
   falling through to the legacy "not signed in" rule, which lands on
   /welcome and loses the round-trip target.

* fix(web): make /welcome/spaces topbar hiding conditional on the gate

NO_HEADER_ROUTES previously hid the Topbar on /welcome/spaces
unconditionally, but when the require-login gate is OFF that page is the
legacy Spaces list and the Topbar should still appear there. Hide it
only when useIsRequireLoginEnabled() === true; while the flag is
loading (undefined) we leave the Topbar in place so we don't change the
visible chrome before knowing the user's mode.

* fix(web): bypass require-login gate under Cypress (IS_TEST_E2E)

The existing Cypress smoke & regression suites rely on visiting Safe
URLs directly without going through SIWE login. Force the gate OFF when
IS_TEST_E2E is true so those flows keep working without each test having
to learn about the new redirect.

Same pattern is already used for the Safe Labs terms bypass.

* fix(ci): plumb a NEXT_PUBLIC_ flag for Cypress so IS_TEST_E2E survives the build

next build forces process.env.NODE_ENV to 'production' in client bundles,
which means the existing IS_TEST_E2E (derived from APP_ENV === 'cypress')
is always false at runtime in production-built Cypress runs. The
useSafeLabsTerms bypass happened to work via a parallel !IS_PRODUCTION
check, but our require-login gate has no such side-channel.

Add NEXT_PUBLIC_IS_TEST_E2E so the flag survives the static export, set
it to 'true' in the Cypress GitHub Action, and OR it into IS_TEST_E2E.
The require-login gate now correctly bypasses for the Cypress smoke and
regression suites.

* feat(web): classic-view escape hatch behind CLASSIC_VIEW_DISABLED flag (#7889)

* feat(web): add classic-view escape hatch behind CLASSIC_VIEW_DISABLED flag

Logged-out users on /welcome/spaces get a "View classic app" link that opts
them out of the require-login gate for the rest of the tab session. The link
honours the ?next= deep link that brought them to the gate (e.g.
/home?safe=…) and falls back to /welcome/accounts when none is present.
Closing the tab returns to the gated experience — the opt-in is sessionStorage
only, not persisted across tabs.

A non-dismissable banner is mounted at the top of PageLayout while the user
is in classic view and not signed in, warning that the view is deprecated and
linking back to /welcome/spaces (clicking the link clears the opt-in).

The whole escape hatch sits behind the inverted CLASSIC_VIEW_DISABLED chain
flag (default OFF = available). When the flag is explicitly enabled the link
is hidden and the banner stops rendering, but existing session opt-ins are
still honoured for the rest of the tab so we don't yank users mid-flow.

The opt-in itself short-circuits useIsRequireLoginEnabled even while the
chains config is still loading, so the gate doesn't briefly flicker on after
an explicit bypass.

* refactor(web): turn classic-view deprecation banner into a one-shot toast

Replaces the sticky top-of-page ClassicViewBanner with a renderless
ClassicViewToast that dispatches a warning notification once when a
signed-out user is opted into classic view, and dismisses it via
closeByGroupKey when they sign in or opt out. Message text and the
"Log in" link (disable opt-in + navigate to /welcome/spaces) are
preserved; only the presentation moves from a persistent banner to
the shared notifications surface.

* fix(web): re-engage require-login gate when an opted-in user signs in

The classic-view opt-in's docstring promised the override held only "until
the tab is closed or they sign in", but the hook short-circuited on the
opt-in regardless of auth state, so a user who opted into classic view
and then signed in within the same tab would keep the gate OFF for the
rest of the session — silently bypassing the post-login "no Spaces →
onboarding" redirect in useFlowActivationGuard.

Gate the short-circuit on !isAuthenticated so the opt-in is honoured only
while signed out, and add a unit test covering the signed-in case.

---------

* feat(web): show local Safes count on spaces sign-in screen (#7905)

* chore: drop pipefail from commit-msg hook for Linux VM compatibility

Mirrors the fix applied to .husky/pre-commit in #7890. The hook's shebang asks for bash but it runs under dash on some Linux VMs, which rejects 'set -o pipefail'. Validation logic is unchanged.

* feat(web): show local Safes count on the spaces sign-in screen

Detect locally persisted Safes (addedSafesSlice) and surface an
outlined green block above the sign-in card on /welcome/spaces.
Hidden when no Safes are stored in this browser.

---------

* tests(web): mock chain-dependent hooks in SpacesList test

SpacesList now calls useIsRequireLoginEnabled, which reaches RTK Query
via useChain and needs a Redux Provider the test doesn't supply. Mock
useIsRequireLoginEnabled and useIsClassicViewFeatureEnabled (used by
the SignedOutState branch) so the bare render keeps working.

* feat(web): move local Safes alert into the sign-in card

Place the "X Safes detected" alert under the "Sign in to view or
create a Space." description, inside the same Card, so it reads as
part of the sign-in prompt rather than a separate banner above it.

* fix(web): align local Safes alert width with sign-in buttons

* fix(web): skip /welcome/accounts hop on sidebar logo under login gate

Sidebar logo's href was computed from pathname alone, so signed-out
users clicking it would land on /welcome/accounts and then be bounced
by the route guard to /welcome/spaces. Make the href gate-aware so it
goes straight to /welcome/spaces (the canonical landing page when the
require-login gate is on), and keep the legacy toggle when the gate
is off.

* fix(web): defer require-login redirects until the flag resolves

Three callers were coercing `useIsRequireLoginEnabled()` `undefined → false`,
which let their effects fire during the ~100ms window before the chains
config loaded:

- useFeatureFlagRedirect: could ping-pong to /welcome/accounts on first
  mount when SPACES=false and the gate flag was still loading, only to be
  bounced straight back by the route guard once the gate resolved to ON.
- useSignInRedirect: a fast SIWE sign-in against a slow chains response
  could leave the user stranded on /welcome/spaces because the
  ?next= redirect branch only ran when the gate was already known to be on.
- pages/welcome: rendered the legacy NewSafe screen for one paint before
  redirecting to /welcome/spaces, flashing UI the user shouldn't see.

All three now treat undefined as "wait" instead of "off" and only act on
the explicit boolean result. Added tests covering the loading state for
each consumer.

* fix(web): use timeless wording in classic-view deprecation toast

The hardcoded "deprecated in 1 month" copy would silently become
inaccurate as time passes. Reword to "deprecated soon" so the message
stays correct without code changes, and align with the Spaces →
Workspaces rename ("create a workspace").

The existing test asserts against the exported constant, so the snapshot
keeps tracking the live copy.

* fix(web): parse next URL into a structured router object

Three new sites in this PR (sign-in redirect, classic-view escape hatch,
invite-members onboarding) pass `sanitizeNextUrl(query.next)` straight
into `router.push()`. CodeQL flags these as
js/client-side-unvalidated-url-redirection because the taint analyser
doesn't follow the sanitizer call.

Add a `parseNextUrlForRouter` helper that runs the same sanitiser then
reconstructs the URL into `{ pathname, query, hash? }` via `new URL()`.
Routing through structured router objects is the pattern CodeQL
recognises as safe, and the explicit URL parsing is a stronger
sanitisation contract than passing the raw string.

Existing call sites are updated to consume the structured form; the
old `sanitizeNextUrl` helper is kept for callers that genuinely need
the string (e.g. round-tripping `next=` through another URL).

* fix(web): blank protected UI + unsubscribe Safe hooks when gate is blocking

Reported bug: a user with a connected wallet (no SIWE session) opening a
bookmarked /home would see Dashboard flash for ~1s before being bounced
to /welcome/spaces, and toasts about pending txs / messages would pop
during the redirect and persist on the login page.

Two fixes share a new useIsAuthGateBlocking() selector (true when the
gate flag !== false AND the user is not signed in):

1. PageLayout returns null for protected pathnames while the gate is
   blocking, so page data hooks never mount. /welcome/spaces, onboarding
   routes, and always-public routes (legal, /share/*, /404) still render.

2. InitApp moves Safe-scoped subscription hooks (useTxNotifications,
   useSafeMessageNotifications, useSafeNotifications,
   useTxPendingStatuses, useSafeMessagePendingStatuses,
   usePortfolioRefetchOnTxHistory, useTxTracking, useSafeMsgTracking,
   useCounterfactualSafeSync) into a sub-component that only renders
   when the gate isn't blocking. Effect cleanups unsubscribe from
   TxEvent / SafeMsgEvent.

The hook treats isRequireLoginEnabled === undefined as 'blocking' —
that ~1s loading window is where the flash happens.

isAlwaysPublic is promoted from a file-local helper to an export so
PageLayout can reuse the same allow-list as the route guard.

* fix(web): preserve next/query on gate redirects and harden self-redirect checks

- /welcome: forward router.query on the redirect to /welcome/spaces so deep
  links carrying next/safe/chain survive the hop.
- /spaces: narrow router.query.spaceId to a non-empty string so duplicate
  ?spaceId= keys (array) or empty values redirect instead of rendering with
  a comma-joined value.
- CreateSpaceOnboarding: back arrow forwards router.query so users who
  entered onboarding via a deep link can navigate back without dropping next.
- nextUrl: strip trailing slash before the self-redirect blocklist check,
  and re-validate the resolved URL.pathname so path-traversal (`/foo/..`)
  collapsing to `/` can no longer slip through.

* feat(web): warning border around the viewport while in classic view

Persistent visual cue that pairs with the existing ClassicViewToast so users
remember they're outside the gated flow.

* fix(web): don't bounce existing users into create-space on re-login

After logout the spaces RTK Query tag is invalidated. On the render where
sign-in completes, the query has `skip` flipping from true to false but
hasn't dispatched its fetch yet (that happens in a useEffect after render),
so RTK Query returns isFetching=false and currentData=undefined. The hook
then read spacesAmount=0 + isSpacesLoading=false and treated the user as
new, pushing them into /welcome/create-space.

Pass `isFetching || isUninitialized` as `isSpacesLoading` so the
skip→unskip transition is also treated as loading.

* fix(web): stop /welcome/spaces flash on hydration for opted-in classic users

useIsClassicViewOptedIn is backed by useSyncExternalStore, whose server
snapshot is hard-coded false. On the first client render after hydration,
useIsRequireLoginEnabled therefore read isClassicViewOptedIn=false even
when sessionStorage actually said true — long enough for the route guard
to fire one redirect to /welcome/spaces before the opt-in propagated.

Gate the hook behind an isMounted flag so it returns undefined on the
first render, which the route guard already treats as "still loading"
and skips the redirect for. After the first effect tick the hook returns
the real opt-in-aware value.

---------

Co-authored-by: Hanzo Dev <[email protected]>
2026-05-22 14:51:22 +02:00

56 lines
1.5 KiB
Bash
Executable File

#!/bin/bash
set -eu
# Validate commit message follows conventional commit format
# Format: type(scope): description OR type: description
# Examples:
# feat(auth): add login functionality
# fix: resolve null pointer exception
# tests(e2e): fix regression
# chore(deps): update dependencies
commit_msg_file="$1"
commit_msg=$(cat "$commit_msg_file")
# Skip merge commits
if echo "$commit_msg" | grep -qE "^Merge "; then
exit 0
fi
# Skip revert commits
if echo "$commit_msg" | grep -qE "^Revert "; then
exit 0
fi
# Valid commit types
types="feat|fix|docs|style|refactor|perf|test|tests|build|ci|chore|revert"
# Regex pattern for conventional commits
# type(scope): description OR type: description
pattern="^($types)(\([a-zA-Z0-9_-]+\))?: .+"
if ! echo "$commit_msg" | head -1 | grep -qE "$pattern"; then
echo "======================================"
echo "ERROR: Invalid commit message format!"
echo "======================================"
echo ""
echo "Your commit message:"
echo " $(head -1 "$commit_msg_file")"
echo ""
echo "Expected format:"
echo " type(scope): description"
echo " type: description"
echo ""
echo "Valid types: feat, fix, docs, style, refactor, perf, test, tests, build, ci, chore, revert"
echo ""
echo "Examples:"
echo " feat(auth): add login functionality"
echo " fix: resolve null pointer exception"
echo " tests(e2e): fix regression"
echo " chore(deps): update dependencies"
echo ""
exit 1
fi
exit 0