Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f6cec3315 | ||
|
|
4d9aabb7bd | ||
|
|
30acbc52d0 | ||
|
|
c1a8c8495d | ||
|
|
a6bc25943f | ||
|
|
70b99204ac | ||
|
|
b88389e91c | ||
|
|
d78a71e50d | ||
|
|
f8d83250f7 | ||
|
|
a7a7fc9ef6 | ||
|
|
7c6305c455 | ||
|
|
15da85d0ac | ||
|
|
d20f0859f2 | ||
|
|
53375e44ed | ||
|
|
87abc48add | ||
|
|
8153606ac8 | ||
|
|
c4ac4eff3f | ||
|
|
922ba7d676 | ||
|
|
15b8cff948 | ||
|
|
bbff6b3a82 | ||
|
|
83e6eb4667 | ||
|
|
32c88caa12 | ||
|
|
b4c71e1aac | ||
|
|
c8295794fa | ||
|
|
40da2fc17d | ||
|
|
d09d3cae1b | ||
|
|
0207df0759 | ||
|
|
a0a489986d | ||
|
|
b6e35ee042 | ||
|
|
06e416365b | ||
|
|
da268976b8 | ||
|
|
2271c29297 | ||
|
|
9da398459e | ||
|
|
b9d31aa305 | ||
|
|
1b50c31a96 | ||
|
|
846069c98b | ||
|
|
06b7f5416a | ||
|
|
80cbfcbbb1 | ||
|
|
4656c316f4 | ||
|
|
d9bccc9e8d | ||
|
|
ffd291fedf | ||
|
|
4da48f9274 |
+1
-1
@@ -1,4 +1,4 @@
|
||||
# console2 — Hanzo Cloud Console (Next.js 15 + @hanzo/gui). BSD-3-Clause.
|
||||
# console2 — Hanzo Cloud Console (Next.js 15 + @hanzo/gui). MIT OR Apache-2.0.
|
||||
# NEXT_PUBLIC_* are inlined at build time (browser config), so they are build args.
|
||||
FROM public.ecr.aws/docker/library/node:24-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
@@ -1,42 +1,14 @@
|
||||
BSD 3-Clause License
|
||||
Licensed under either of
|
||||
|
||||
Copyright (c) 2026-present, Hanzo AI, Inc.
|
||||
* Apache License, Version 2.0 (LICENSE-APACHE or
|
||||
https://www.apache.org/licenses/LICENSE-2.0)
|
||||
* MIT license (LICENSE-MIT or https://opensource.org/licenses/MIT)
|
||||
|
||||
Portions of this software are derived from upstream code originally licensed under
|
||||
the MIT License, with the following copyright notices retained per its terms:
|
||||
at your option.
|
||||
|
||||
Copyright (c) 2020 Nate Wienert
|
||||
Copyright (c) 2015-present, Nicolas Gallagher.
|
||||
Copyright (c) 2015-present, Facebook, Inc.
|
||||
Copyright (c) 2021 Radix
|
||||
Copyright (c) 2017 Carmelo Pullara
|
||||
Copyright (c) 2018 Framer B.V.
|
||||
Copyright (c) 2022 WorkOS
|
||||
Unless you explicitly state otherwise, any contribution intentionally
|
||||
submitted for inclusion in the work by you, as defined in the Apache-2.0
|
||||
license, shall be dual licensed as above, without any additional terms or
|
||||
conditions.
|
||||
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software
|
||||
without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
||||
See HIP-0137 (hanzoai/hips) for the standard this follows.
|
||||
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026-present, Hanzo AI, Inc.
|
||||
|
||||
Portions of this software are derived from upstream code originally licensed
|
||||
under the MIT License, with the following copyright notices retained per its
|
||||
terms:
|
||||
|
||||
Copyright (c) 2020 Nate Wienert
|
||||
Copyright (c) 2015-present, Nicolas Gallagher.
|
||||
Copyright (c) 2015-present, Facebook, Inc.
|
||||
Copyright (c) 2021 Radix
|
||||
Copyright (c) 2017 Carmelo Pullara
|
||||
Copyright (c) 2018 Framer B.V.
|
||||
Copyright (c) 2022 WorkOS
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,7 +1,7 @@
|
||||
# console2 — Hanzo Cloud Console
|
||||
|
||||
Unified admin console for **Hanzo Cloud** and all cloud products. Our code,
|
||||
BSD-3-Clause, built on **@hanzo/gui** (the Tamagui-based cross-platform UI).
|
||||
`MIT OR Apache-2.0` (HIP-0137), built on **@hanzo/gui** (the Tamagui-based cross-platform UI).
|
||||
NOT an observability-console fork, NOT casibase — it is a clean client over the unified `/v1`
|
||||
backend (`hanzoai/cloud`), reached at the ONE Hanzo API endpoint https://api.hanzo.ai/v1/*.
|
||||
|
||||
@@ -326,14 +326,14 @@ keep all credentials server-side (the browser only ever sends its session cookie
|
||||
embeddings|rerank` (not a general tunnel). `playground.ts` now points at this
|
||||
proxy (`<origin>/ai`), so Models/Playground/Chat/cmd+K all work with no key in
|
||||
the browser and no rotation on a chat turn.
|
||||
- **`app/keys/route.ts`** — per-user `hk-` Cloud API key. POST mint/rotate, DELETE
|
||||
- **`app/keys/route.ts`** — per-user `sk-` Cloud API key. POST mint/rotate, DELETE
|
||||
revoke, GET status (no secret). Same app-on-behalf pattern via
|
||||
`/v1/iam/mint-user-keys` + `/v1/iam/revoke-user-keys`. The `hk-` secret is shown
|
||||
`/v1/iam/mint-user-keys` + `/v1/iam/revoke-user-keys`. The `sk-` secret is shown
|
||||
ONCE (POST). `ApiKeysModule` is now create/copy/rotate/revoke.
|
||||
- Shared trust boundary: `src/lib/server/identity.ts` (server-only) — `resolveUser`
|
||||
+ `mintUserKey`/`revokeUserKey`/`issueUserToken`. The `hanzo-console` client is
|
||||
allow-listed in IAM `IAM_KEY_MINT_ALLOWED_APPS`; verified end-to-end that a
|
||||
minted `hk-` key and an issued user JWT both 200 on `api.hanzo.ai/v1/chat/
|
||||
minted `sk-` key and an issued user JWT both 200 on `api.hanzo.ai/v1/chat/
|
||||
completions`.
|
||||
- **Chat is interactive** (`chat/ChatConversation.tsx`): a real multi-turn
|
||||
conversation over `AiApi.chat` (→ the `/ai` proxy), with a Zen default model,
|
||||
@@ -3702,3 +3702,134 @@ and I will not type a password; one SuperAdmin session re-running this spec clos
|
||||
that. Untouched and flagged for the caps pass: `MarketplaceModule`'s "CATEGORIES"
|
||||
and the palette's own uppercased section labels are `textTransform` sites that
|
||||
belong to that lane, not this one.
|
||||
|
||||
## Billing calls the route names the server actually registers
|
||||
|
||||
Commerce dropped the compound prefixes from its billing routes — the `/v1/billing/`
|
||||
namespace already says "billing", so `billing/payment-methods` stuttered. Both servers
|
||||
register only the short names, measured against the live edge: `/v1/billing/methods`
|
||||
401, `/v1/billing/settings` 403, `/v1/billing/alerts` 403, while `payment-methods`,
|
||||
`payment-config` and `spend-alerts` are all 404. The console never followed. Its card
|
||||
reads, its card writes and its Square-config read were all addressed at routes that no
|
||||
longer exist, so a new user could not add a card — the revenue path was broken in
|
||||
production.
|
||||
|
||||
The client had already been repointed for alerts, so `payment-methods` (list, save,
|
||||
detach) and `payment-config` were the ones still dead. They now build `methods` and
|
||||
`settings`. There is deliberately no alias and no fallback: one name per concept.
|
||||
|
||||
The tests were part of the defect, not the safety net. Every suite around payment
|
||||
methods stubbed a response body and asserted the normalization, so a client pointed at
|
||||
a 404 stayed green — the exact reason this survived. The URL is now pinned where the
|
||||
request is actually made, including the two reads nothing had ever asserted
|
||||
(`methods`, `settings`) and `alerts` beside them. Reverting any of the four short names
|
||||
turns the suite red, which was checked rather than assumed.
|
||||
|
||||
`POST /v1/billing/me/welcome` and everything feeding it is deleted, not repointed.
|
||||
Commerce removed that route on purpose: it was a self-service mint, a browser could
|
||||
grant its own org $5, and commerce's own `api/billing/mint_gates_test.go` names it the
|
||||
TOCTOU double-mint. Credit is minted only through the mint-gated `POST
|
||||
/v1/billing/credit`. The call had been failing silently, so restoring it would have
|
||||
re-opened a closed money hole to fix nothing. The trial credit still lands — commerce
|
||||
grants it server-side when a card is vaulted, and signup grants it server-side — and
|
||||
that path is untouched. `src/lib/billing/welcome.ts` had no callers left at all.
|
||||
|
||||
Scope, checked rather than assumed: `/v1/finance/payment-methods` is still 401 (alive)
|
||||
and `/v1/finance/methods` is 404, so the finance ledger keeps the compound name — a
|
||||
blanket repo-wide rename would have broken it. The Billing Center's tab slugs
|
||||
(`/billing/payment-methods`, `/billing/credits`) are console page URLs, not server
|
||||
routes, and are unchanged.
|
||||
|
||||
Two headlines were lying about which layer failed. "Card top-up isn't available on this
|
||||
deployment yet" and "Adding a card isn't available on this deployment yet" both fire
|
||||
when the ORG has no Square `applicationId`/`locationId` — a per-organization
|
||||
configuration, not a property of the deployment. Both now name the organization, as
|
||||
does the onboarding step's "Payments aren't set up", which had the same defect. The
|
||||
stale `GET /v1/billing/payment-config` endpoint hints under those cards now read
|
||||
`settings`.
|
||||
|
||||
## The assistant has one home, and the app directory is one you can walk
|
||||
|
||||
Three fixes to the console's own chrome. Each root cause was measured in a browser
|
||||
on computed style, geometry or where the browser lands — never inferred from source.
|
||||
|
||||
**The assistant lived in a third place.** `FloatingChat` owned every shape the
|
||||
assistant can take (the sheet, the docked column, the dock state) except the way
|
||||
in, which was two small buttons in the TOPBAR — a brand-H "Chat with Hanzo" and a
|
||||
"Talk to Hanzo" mic — wedged between the search box and the org/theme/alert
|
||||
cluster. On a 390px phone that put five controls in the header and squeezed the
|
||||
search field to "Search or jump…". Both controls moved into `AssistantFab`, one
|
||||
floating cluster fixed bottom-right, in the corner the assistant actually appears
|
||||
in. Chat and voice are the same surface opened two ways, so they sit together.
|
||||
Nothing about the assistant was rewritten: the FAB calls the same `openChat` /
|
||||
`startVoice` the topbar called, and `open`/`toggle`/`ask` still drive it
|
||||
programmatically (the Code hub's "Ask AI").
|
||||
|
||||
It is suppressed exactly where the assistant is already on screen — while the
|
||||
sheet is open, on the pages that ARE a composer, and, at `lg+` only, while it is
|
||||
docked as a column. That last half is a CSS media prop rather than a JS branch so
|
||||
SSR and first paint agree, and it sits above the Developers dock (whose collapsed
|
||||
bar is 44px and exists only at `lg+`).
|
||||
|
||||
**[BUG, measured] All products was a directory you could not walk.** The pane that
|
||||
lists every Hanzo app — the sidebar's "All products", the one place the whole
|
||||
catalog is browsable — rendered each app as an inert `XStack`: a plain `DIV` with
|
||||
`role=null` and `cursor: auto`, no handler, no pointer affordance. Measured, not
|
||||
read. The only live control in the row was the pin, so a user could curate the
|
||||
sidebar but could not open anything from the list. The row now opens its app
|
||||
through the shared `openProduct` — the ONE opener the sidebar, ⌘K and the category
|
||||
pages already route through — and closes the pane behind it, because a directory is
|
||||
not a destination. Pin stays a separate control on the same row and stops the press
|
||||
from bubbling: curating never navigates, navigating never curates.
|
||||
|
||||
**[BUG] A pin made after sign-in was thrown away on the next reload.** Preferences
|
||||
are read off `properties['hanzo.preferences']` in the IAM access token's claims — a
|
||||
SNAPSHOT taken when that token was minted. An earlier lane fixed the case where the
|
||||
snapshot is SILENT about a key. The other half was never closed: once a user has
|
||||
saved anything, the next token CARRIES a snapshot, and the merge let it win over a
|
||||
newer local write. So the second pin onward read as pinned and was gone after F5.
|
||||
|
||||
The merge is now told the ordering it was missing. `Account` carries the token's
|
||||
own `iat`; the provider stamps `…prefs.<user>.writtenAt` when — and only when — the
|
||||
SERVER acknowledges a write; `mergePrefs(cached, fromAccount, order)` lets the cache
|
||||
win only when a confirmed write is newer than the snapshot. Last writer wins, and
|
||||
both writers are now identifiable. A fresh device (no cache, no stamp) and a fresh
|
||||
sign-in (token minted after the write) both still take the account wholesale, so
|
||||
cross-device is preserved. Stamping only server-confirmed writes is what keeps this
|
||||
from being localStorage impersonating a backend: a save that never landed earns
|
||||
nothing and the account stays authoritative.
|
||||
|
||||
**Backend gap, named rather than papered over.** There is no READ for this
|
||||
document. `PATCH /v1/ai/preferences` (hanzoai/ai `UpdatePreferences`) writes it to
|
||||
the IAM user's `properties['hanzo.preferences']` and returns the merged result;
|
||||
nothing serves a GET, so the token's snapshot is the only read the console has. The
|
||||
smallest seam that removes the ordering problem entirely is `GET
|
||||
/v1/ai/preferences` returning that property after the handler's existing
|
||||
`refreshSessionUser` — the write path already does every part of it. Better still
|
||||
is `GET/PATCH /v1/prefs` (hanzoai/cloud `apps/prefs`), the canonical cross-surface
|
||||
plane, which answers 503 on api.hanzo.ai today.
|
||||
|
||||
**House rule: one filled CTA.** Counted by computed background luminance on the
|
||||
rendered home, not by reading JSX: FIVE white-filled buttons competed — "Take the
|
||||
tour", the getting-started card's active step, and all three `PrimaryActionTile`
|
||||
CTAs (two of them saying "Get API key"). The same measurement now returns ONE: the
|
||||
checklist's ACTIVE step, the thing to do next. The tour is a neutral aside beside its
|
||||
dismiss, and the three tiles are neutral because they are PEERS — a menu of things
|
||||
you can do, not a call to action, and three primaries are none.
|
||||
|
||||
**Verification.** `tsc --noEmit` clean; `vitest` 3175 passed / 8 skipped (256 files,
|
||||
+6 ordering tests). RED→GREEN proven both ways: disabling `cacheIsNewer` turns the
|
||||
two new ordering tests red and the browser test with it. `e2e/assistant-fab-and-apps.spec.ts`
|
||||
(4 tests, 1440 and 390) asserts the control's BOX is in the bottom-right quadrant and
|
||||
≥44px, that `.hz-topbar` carries no assistant control, that clicking an app in All
|
||||
products LANDS on `/agents`, and that a pin survives a reload under a token whose
|
||||
snapshot is an hour old. `e2e/chrome-brand-voice.spec.ts` was retargeted, not
|
||||
deleted — every claim it made still holds, only the location moved.
|
||||
|
||||
Two spec gotchas worth keeping. `_session.ts`'s `b64` emitted plain base64; that is
|
||||
fine while a forged payload is tiny, but `+`/`/` appear as soon as one grows (a
|
||||
`properties` bag is enough) and a strict decoder rejects the token outright — the SDK
|
||||
reports signed out and the app sits on its loader forever. It emits base64URL now,
|
||||
which is what a JWT segment actually is. And the assistant's composer carries its own
|
||||
mic with the same `Talk to Hanzo` label, mounted-but-hidden until the panel opens, so
|
||||
a bare attribute locator matches that one first: scope to `getByTestId('assistant-fab')`.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Hanzo Cloud Console (console2)
|
||||
Copyright (c) Hanzo AI, Inc. Licensed BSD-3-Clause (see LICENSE).
|
||||
Copyright (c) Hanzo AI, Inc. Licensed MIT OR Apache-2.0 (see LICENSE) per HIP-0137.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
Third-party attribution
|
||||
@@ -39,3 +39,38 @@ Langfuse EE / commercial ("ee") code is neither used nor referenced.
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
Vendored MIT-licensed code
|
||||
------------------------------------------------------------------------
|
||||
|
||||
Portions of this software are derived from upstream MIT-licensed code. Those
|
||||
copyright notices are retained here per the MIT License's terms; they were
|
||||
previously carried in LICENSE, which is reserved for this project's own
|
||||
BSD-3-Clause grant.
|
||||
|
||||
Copyright (c) 2020 Nate Wienert (Tamagui)
|
||||
Copyright (c) 2015-present, Nicolas Gallagher. (react-native-web)
|
||||
Copyright (c) 2015-present, Facebook, Inc. (react-native-web)
|
||||
Copyright (c) 2021 Radix (Radix UI)
|
||||
Copyright (c) 2017 Carmelo Pullara
|
||||
Copyright (c) 2018 Framer B.V. (Framer Motion)
|
||||
Copyright (c) 2022 WorkOS
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -48,4 +48,6 @@ the product-module registry, and the Providers surface). Endpoint reference in
|
||||
|
||||
## License
|
||||
|
||||
BSD-3-Clause. Copyright (c) 2026-present, Hanzo AI, Inc.
|
||||
`MIT OR Apache-2.0` at your option — see [LICENSE](./LICENSE),
|
||||
[LICENSE-MIT](./LICENSE-MIT), [LICENSE-APACHE](./LICENSE-APACHE).
|
||||
Copyright (c) 2026-present, Hanzo AI, Inc. Estate-wide licensing standard: HIP-0137 (`hanzoai/hips`).
|
||||
|
||||
@@ -3,11 +3,14 @@ import type { ReactNode } from 'react'
|
||||
import { Preferences } from '~/lib/products/preferences'
|
||||
import { Toast } from '~/components/ui/Toast'
|
||||
import { Entry } from '~/entry/entry'
|
||||
import { Host } from '~/entry/host'
|
||||
|
||||
/**
|
||||
* The console entry, decomplected (see src/entry/). `Preferences` + `Toast` are the
|
||||
* session-tier context: the stage RESOLVER reads the onboarding preference, and the
|
||||
* onboard wizard + every module report through Toast — so they sit above the switch.
|
||||
* `Host` answers the two effects `@hanzo/ui/product`'s state cards ask for (sign in,
|
||||
* add credits), so every card below renders its affordance without being handed one.
|
||||
* `Entry` computes ONE stage value from the session and renders EXACTLY one surface
|
||||
* (sign-in · waitlist · org · onboard · dashboard).
|
||||
*/
|
||||
@@ -15,7 +18,9 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Preferences>
|
||||
<Toast>
|
||||
<Entry>{children}</Entry>
|
||||
<Host>
|
||||
<Entry>{children}</Entry>
|
||||
</Host>
|
||||
</Toast>
|
||||
</Preferences>
|
||||
)
|
||||
|
||||
@@ -21,15 +21,12 @@ import { ProductRoute } from '~/components/ProductRoute'
|
||||
import { openProduct } from '~/lib/products/open'
|
||||
import { useFavorites } from '~/lib/products/favorites'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { ProductIcon } from '~/components/ui/ProductIcon'
|
||||
import type { IconLike } from '~/components/ui/color'
|
||||
import { useProductColors } from '~/lib/products/pins'
|
||||
import { FadeIn } from '~/components/ui/FadeIn'
|
||||
import { livingOverviewModule } from '~/components/products/overview/living/LivingOverviewModule'
|
||||
import { ResourceOverview } from '~/components/products/overview/ResourceOverview'
|
||||
import { ProductObservability } from '~/components/products/observability/ProductObservability'
|
||||
import { FadeIn, PageHeader, type IconLike } from '@hanzo/ui/product'
|
||||
|
||||
// The home centerpiece is the reusable LivingOverview (count-up KPIs, live
|
||||
// sparklines, streaming activity) — the SAME component every product overview uses.
|
||||
@@ -148,13 +145,19 @@ function PrimaryActionTile({
|
||||
{description}
|
||||
</Text>
|
||||
<XStack>
|
||||
<PrimaryButton
|
||||
{/* Neutral, not filled. These three tiles are PEERS — a menu of things you can
|
||||
do, not a call to action — so three white buttons side by side gave the
|
||||
screen three primaries and therefore none. The one filled action on this
|
||||
page is the getting-started card's active step: the thing to do NEXT. */}
|
||||
<Button
|
||||
size="$3"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
iconAfter={external ? <ExternalLink size={15} /> : <ArrowRight size={15} />}
|
||||
onPress={onPress}
|
||||
>
|
||||
{ctaLabel}
|
||||
</PrimaryButton>
|
||||
</Button>
|
||||
</XStack>
|
||||
</Card>
|
||||
)
|
||||
@@ -234,7 +237,7 @@ export default function DashboardHome() {
|
||||
icon={HandCoins}
|
||||
color={colorOf('authors')}
|
||||
title="Earn from your OSS"
|
||||
description="Earn 20% of the compute margin your open-source project drives when teams run it on Hanzo Cloud — paid to your Hanzo wallet."
|
||||
description="Earn 20% of the compute margin your open-source project drives when organizations run it on Hanzo Cloud — paid to your Hanzo wallet."
|
||||
ctaLabel="Start earning"
|
||||
onPress={() => push('/authors')}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* `/v1/chat/completions` (and friends) REQUIRE an `Authorization: Bearer` token; a
|
||||
* browser session cookie alone is rejected. Rather than ship the user's durable
|
||||
* `hk-` key to the browser, the console calls its OWN origin at the canonical, prefix-free
|
||||
* `sk-` key to the browser, the console calls its OWN origin at the canonical, prefix-free
|
||||
* `/v1/<aihead>` (the /v1-first law); `next.config.mjs` dispatches those heads to THIS `/ai`
|
||||
* proxy (re-rooting the upstream at `v1/` — invisible to the client). `forwardWithUserBearer`
|
||||
* resolves the user, mints a SHORT-LIVED, user-bound IAM token (shared per-user cache in
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
/* Hanzo is monochrome. One hue rendered through an opacity ladder.
|
||||
Base ladder = Tailwind neutral (tailwind.config.ts). Semantic names match
|
||||
hanzo.ai's CSS variables exactly, so code copies over 1:1.
|
||||
DARK IS THE DEFAULT THEME (hanzo.ai mounts ThemeProvider defaultTheme="dark"). */
|
||||
|
||||
:root{
|
||||
/* ——— base neutral ladder ——— */
|
||||
--neutral-50:#FAFAFA;
|
||||
--neutral-100:#F5F5F5;
|
||||
--neutral-200:#E5E5E5;
|
||||
--neutral-300:#D4D4D4;
|
||||
--neutral-400:#A3A3A3;
|
||||
--neutral-500:#737373;
|
||||
--neutral-600:#525252;
|
||||
--neutral-700:#404040;
|
||||
--neutral-800:#262626;
|
||||
--neutral-900:#171717;
|
||||
--neutral-950:#0A0A0A;
|
||||
--pure-black:#000000;
|
||||
--pure-white:#FFFFFF;
|
||||
/* Press-kit brand constants (public/press/hanzo/README.md). */
|
||||
--hanzo-black:#0A0A0B;
|
||||
--hanzo-white:#FFFFFF;
|
||||
|
||||
/* ——— the opacity ladder: the real palette ——— */
|
||||
--white-05:rgb(255 255 255 / .05);
|
||||
--white-10:rgb(255 255 255 / .10);
|
||||
--white-15:rgb(255 255 255 / .15);
|
||||
--white-20:rgb(255 255 255 / .20);
|
||||
--white-30:rgb(255 255 255 / .30);
|
||||
--white-40:rgb(255 255 255 / .40);
|
||||
--white-60:rgb(255 255 255 / .60);
|
||||
--white-80:rgb(255 255 255 / .80);
|
||||
|
||||
/* ——— semantic aliases (dark, the default) ——— */
|
||||
--background:#000000;
|
||||
--foreground:#ededed;
|
||||
--card:#0a0a0a;
|
||||
--card-foreground:#f5f5f5;
|
||||
--popover:#0a0a0a;
|
||||
--popover-foreground:#f5f5f5;
|
||||
--primary:#ffffff;
|
||||
--primary-foreground:#000000;
|
||||
--secondary:#1a1a1a;
|
||||
--secondary-foreground:#f5f5f5;
|
||||
--muted:#101010;
|
||||
--muted-foreground:#888888;
|
||||
--accent:#1a1a1a;
|
||||
--accent-foreground:#f5f5f5;
|
||||
--destructive:#666666;
|
||||
--destructive-foreground:#f5f5f5;
|
||||
--border:#1f1f1f;
|
||||
--input:#1f1f1f;
|
||||
--ring:#333333;
|
||||
--brand:#e4e4e7;
|
||||
--brand-foreground:#09090b;
|
||||
--brand-muted:#a3a3a3;
|
||||
--black:#000000;
|
||||
--white:#f5f5f5;
|
||||
|
||||
/* ——— surface recipes (card fills used across hanzo.ai) ——— */
|
||||
--surface-page:var(--background);
|
||||
--surface-card:rgb(23 23 23 / .5); /* bg-neutral-900/50 — grid tiles */
|
||||
--surface-card-emphasis:rgb(23 23 23 / .8);/* bg-neutral-900/80 — featured */
|
||||
--surface-card-quiet:rgb(23 23 23 / .4); /* bg-neutral-900/40 — story cards */
|
||||
--surface-overlay:rgb(10 10 10 / .95); /* dropdown / popover panels */
|
||||
--surface-header:rgb(0 0 0 / .7); /* fixed nav, with backdrop blur */
|
||||
--border-hairline:var(--neutral-800);
|
||||
--border-card:var(--white-10);
|
||||
--border-strong:var(--neutral-700);
|
||||
|
||||
/* ——— text ranks ——— */
|
||||
--text-primary:var(--pure-white);
|
||||
--text-secondary:var(--white-80);
|
||||
--text-tertiary:var(--white-60);
|
||||
--text-helper:var(--muted-foreground);
|
||||
--text-disabled:var(--white-30);
|
||||
|
||||
/* ——— the ONLY permitted hues (DESIGN.md §2.4) ——— */
|
||||
--state-error:#ef4444; /* red-500 — destructive / blocking error */
|
||||
--state-error-text:#fca5a5; /* red-300 */
|
||||
--state-error-bg:rgb(239 68 68 / .1);
|
||||
--state-online:#4ade80; /* green-400 — live status dot */
|
||||
--state-success:#22c55e; /* green-500 — "Free" / "Save N%" callouts */
|
||||
--chrome-dot-red:rgb(239 68 68 / .6);
|
||||
--chrome-dot-yellow:rgb(234 179 8 / .6);
|
||||
--chrome-dot-green:rgb(34 197 94 / .6);
|
||||
}
|
||||
|
||||
/* Light theme — the same tokens, inverted. Rare: only /brand-style docs pages. */
|
||||
.light{
|
||||
--background:#ffffff;
|
||||
--foreground:#0a0a0a;
|
||||
--card:#f5f5f5;
|
||||
--card-foreground:#0a0a0a;
|
||||
--popover:#ffffff;
|
||||
--popover-foreground:#0a0a0a;
|
||||
--primary:#0a0a0a;
|
||||
--primary-foreground:#ffffff;
|
||||
--secondary:#f5f5f5;
|
||||
--secondary-foreground:#0a0a0a;
|
||||
--muted:#f5f5f5;
|
||||
--muted-foreground:#525252;
|
||||
--accent:#f5f5f5;
|
||||
--accent-foreground:#0a0a0a;
|
||||
--destructive:#999999;
|
||||
--destructive-foreground:#ffffff;
|
||||
--border:#e5e5e5;
|
||||
--input:#e5e5e5;
|
||||
--ring:#d4d4d4;
|
||||
--black:#0a0a0a;
|
||||
--white:#ffffff;
|
||||
--surface-card:#f5f5f5;
|
||||
--surface-card-emphasis:#ffffff;
|
||||
--surface-card-quiet:#fafafa;
|
||||
--surface-overlay:rgb(255 255 255 / .95);
|
||||
--surface-header:rgb(255 255 255 / .8);
|
||||
--border-hairline:var(--neutral-200);
|
||||
--border-card:rgb(0 0 0 / .1);
|
||||
--border-strong:var(--neutral-300);
|
||||
--text-primary:var(--neutral-950);
|
||||
--text-secondary:rgb(10 10 10 / .8);
|
||||
--text-tertiary:rgb(10 10 10 / .6);
|
||||
--text-disabled:rgb(10 10 10 / .3);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
/* Hanzo barely uses shadow: on black, elevation reads as a hairline border plus
|
||||
a wide, very dark drop. Only two levels ship (Tailwind's shadow-2xl for
|
||||
floating surfaces) plus the ambient radial glow used behind heroes. */
|
||||
:root{
|
||||
--shadow-none:none;
|
||||
--shadow-floating:0 25px 50px -12px rgb(0 0 0 / .25); /* shadow-2xl: composer, dropdowns, mega panel */
|
||||
--shadow-inset-hairline:inset 0 0 0 1px var(--white-10);
|
||||
--ring-focus:0 0 0 2px var(--ring);
|
||||
/* Ambient hero glow — a single white radial, blurred 120px, low opacity. */
|
||||
--glow-hero:radial-gradient(circle,rgb(255 255 255 / .12) 0%,transparent 68%); /* @kind color */
|
||||
--glow-hero-blur:120px;
|
||||
/* Card top-corner sheen used on the story cards. */
|
||||
--sheen-card:radial-gradient(120% 120% at 80% 0%,rgb(255 255 255 / .08) 0%,transparent 55%); /* @kind color */
|
||||
/* Chrome text: the canonical headline gradient. Never a saturated rainbow. */
|
||||
--gradient-chrome:linear-gradient(to right,#ffffff,var(--white-80),var(--white-60));
|
||||
--gradient-chrome-2:linear-gradient(to right,#ffffff,var(--neutral-500));
|
||||
/* Section-top protection gradient (hero overlays). */
|
||||
--gradient-protect:linear-gradient(to bottom,var(--white-10),transparent);
|
||||
}
|
||||
+19
-21
@@ -1,30 +1,28 @@
|
||||
/* ─────────────────────────────────────────────────────────────────────────────
|
||||
Hanzo Design System tokens — VENDORED from hanzoai/design (@hanzo/design).
|
||||
Hanzo Design System tokens — the PUBLISHED @hanzo/design package.
|
||||
|
||||
This directory is the canonical Hanzo design language expressed as CSS custom
|
||||
properties: the monochrome neutral ladder + opacity ladder, the ONLY permitted
|
||||
semantic hues (live/success/warning/error), type scale, 4px spacing ramp,
|
||||
radius, elevation and motion. It is the SINGLE SOURCE OF TRUTH for how every
|
||||
Hanzo surface looks.
|
||||
These were vendored under app/design/ (synced 2026-07-24) only because the
|
||||
package was not yet on npm. It is now (@hanzo/design ≥ 0.4.6), so the console
|
||||
reads the real dependency and can no longer drift a border rework behind the
|
||||
rest of the fleet. The token subpaths are named one by one rather than pulling
|
||||
`@hanzo/design/styles.css`: that entry chains relative `@import url(...)`s that
|
||||
Next's CSS pipeline resolves as modules, not sibling files, so the explicit
|
||||
published subpaths are the resolvable form of the same import.
|
||||
|
||||
@hanzo/design is not yet published to npm, so its token layer is vendored here
|
||||
verbatim (the README's stated contract: "code copies over 1:1"). Do NOT edit
|
||||
these files in the console — edit them in hanzoai/design and re-sync. Synced
|
||||
from hanzoai/design tokens/ on 2026-07-24.
|
||||
|
||||
The console's Tamagui theme (app/globals.css) DERIVES its --colorN ladder and
|
||||
borders from this neutral ladder, so the whole product reads from one palette.
|
||||
Fonts are deliberately NOT imported from the package: the console loads the
|
||||
Geist faces via app/fonts.css, and the `:root` shim below lets the vendored
|
||||
typography roles resolve without a second copy.
|
||||
───────────────────────────────────────────────────────────────────────────── */
|
||||
@import './colors.css';
|
||||
@import './typography.css';
|
||||
@import './spacing.css';
|
||||
@import './radius.css';
|
||||
@import './elevation.css';
|
||||
@import './motion.css';
|
||||
@import './z.css';
|
||||
@import '@hanzo/design/tokens/colors.css';
|
||||
@import '@hanzo/design/tokens/typography.css';
|
||||
@import '@hanzo/design/tokens/spacing.css';
|
||||
@import '@hanzo/design/tokens/radius.css';
|
||||
@import '@hanzo/design/tokens/elevation.css';
|
||||
@import '@hanzo/design/tokens/motion.css';
|
||||
@import '@hanzo/design/tokens/z.css';
|
||||
|
||||
/* Font families — the console loads the Geist faces via app/fonts.css; these vars
|
||||
let the vendored typography roles (--type-*) resolve without re-importing fonts. */
|
||||
let the typography roles (--type-*) resolve without re-importing fonts. */
|
||||
:root {
|
||||
--font-sans: 'Geist', 'Geist Sans', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display: var(--font-sans);
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
/* Motion is restrained: fade + small rise, CSS-only hovers, one breathing glow.
|
||||
No springs, no bounce, no parallax, no autoplay carousels. */
|
||||
:root{
|
||||
--duration-fast:150ms; /* @kind other */ /* dropdown / panel open */
|
||||
--duration-base:300ms; /* @kind other */ /* slide-up-fade */
|
||||
--duration-slow:400ms; /* @kind other */ /* hero element entry */
|
||||
--duration-slower:500ms; /* @kind other */ /* section entry */
|
||||
--duration-glow:9s; /* @kind other */ /* ambient radial breathe */
|
||||
--ease-out:cubic-bezier(0,0,0.2,1); /* @kind other */
|
||||
--ease-in-out:cubic-bezier(0.4,0,0.2,1); /* @kind other */
|
||||
--stagger:60ms; /* @kind other */ /* per-element delay in a group */
|
||||
--entry-rise:16px; /* hero y-offset */
|
||||
--entry-rise-lg:24px; /* card y-offset */
|
||||
}
|
||||
|
||||
@keyframes hanzo-fade-up{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
|
||||
@keyframes hanzo-fade-down{from{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}}
|
||||
@keyframes hanzo-slide-up-fade{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
|
||||
@keyframes hanzo-glow{0%,100%{transform:scale(1);opacity:.45}50%{transform:scale(1.08);opacity:.65}}
|
||||
@keyframes hanzo-pulse-dot{0%,100%{opacity:1}50%{opacity:.35}}
|
||||
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
:root{
|
||||
--radius:0.5rem; /* the base token (globals.css) */
|
||||
--radius-sm:0.375rem; /* rounded-md — buttons, inputs */
|
||||
--radius-md:0.5rem;
|
||||
--radius-lg:0.75rem; /* rounded-xl — cards */
|
||||
--radius-xl:1rem; /* rounded-2xl — dropdown panels */
|
||||
--radius-2xl:1.5rem; /* rounded-3xl — story / hero cards */
|
||||
--radius-composer:28px; /* the chat composer, exactly 28px */
|
||||
--radius-full:9999px; /* pills, CTAs, avatars, badges */
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
/* Spacing: the 4px Tailwind ramp is what ships. The golden-ratio ramp below is
|
||||
declared in hanzo.ai's tailwind.config.ts (legacy v3 config, kept for
|
||||
reference) — use it for editorial layouts, not for component padding. */
|
||||
:root{
|
||||
--space-0:0;
|
||||
--space-1:0.25rem;
|
||||
--space-2:0.5rem;
|
||||
--space-3:0.75rem;
|
||||
--space-4:1rem;
|
||||
--space-5:1.25rem;
|
||||
--space-6:1.5rem;
|
||||
--space-8:2rem;
|
||||
--space-10:2.5rem;
|
||||
--space-12:3rem;
|
||||
--space-14:3.5rem;
|
||||
--space-16:4rem;
|
||||
--space-20:5rem;
|
||||
--space-24:6rem;
|
||||
--space-32:8rem;
|
||||
|
||||
/* golden ramp (φ) — hanzo.ai tailwind.config.ts */
|
||||
--golden-1:0.25rem;
|
||||
--golden-2:0.405rem;
|
||||
--golden-3:0.654rem;
|
||||
--golden-4:1.059rem;
|
||||
--golden-5:1.713rem;
|
||||
--golden-6:2.772rem;
|
||||
--golden-7:4.487rem;
|
||||
--golden-8:7.26rem;
|
||||
--golden-9:11.749rem;
|
||||
--golden-split:38.2% 61.8%; /* @kind other */
|
||||
|
||||
/* layout rules (DESIGN.md §1.3) */
|
||||
--container-max:80rem; /* max-w-7xl — grids */
|
||||
--container-prose:48rem; /* max-w-3xl — centered text */
|
||||
--container-wide:72rem; /* max-w-6xl — landing sections */
|
||||
--gutter:1rem; /* px-4 */
|
||||
--gutter-sm:1.5rem; /* sm:px-6 */
|
||||
--gutter-lg:2rem; /* lg:px-8 */
|
||||
--section-y:4rem; /* py-16 — content sections */
|
||||
--section-y-lg:6rem; /* py-24 — landing sections */
|
||||
--hero-y:5rem; /* py-20 … */
|
||||
--hero-y-lg:8rem; /* … lg:py-32 */
|
||||
--header-height:4rem;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/* TIGHT app-first type scale — the compact developer-app register (linear.app /
|
||||
vercel.com / the Codex desktop look), the Hanzo default across chat / app /
|
||||
desktop. Base is 14px, nav 13px, labels 11px; display sizes tightened. Kept in
|
||||
lockstep with @hanzo/brand (styles/variables.css --font-size-* + typography.ts)
|
||||
— the two are the SAME scale, mirrored. A surface/tenant overrides any --text-*
|
||||
on :root to retune density on demand. */
|
||||
:root{
|
||||
--text-xs:0.6875rem; --leading-xs:1rem; /* 11px — eyebrows / section labels */
|
||||
--text-sm:0.8125rem; --leading-sm:1.15rem; /* 13px — nav labels, dense body */
|
||||
--text-base:0.875rem; --leading-base:1.35rem; /* 14px — base app text (was 16px) */
|
||||
--text-lg:0.9375rem; --leading-lg:1.4rem; /* 15px */
|
||||
--text-xl:1.0625rem; --leading-xl:1.55rem; /* 17px */
|
||||
--text-2xl:1.3125rem; --leading-2xl:1.7rem; /* 21px */
|
||||
--text-3xl:1.625rem; --leading-3xl:1.95rem; /* 26px */
|
||||
--text-4xl:2rem; --leading-4xl:2.25rem; /* 32px */
|
||||
--text-5xl:2.5rem; --leading-5xl:1.05; /* 40px */
|
||||
--text-6xl:3.25rem; --leading-6xl:1; /* 52px */
|
||||
--text-7xl:4rem; --leading-7xl:1; /* 64px */
|
||||
|
||||
--weight-normal:400;
|
||||
--weight-medium:500;
|
||||
--weight-semibold:600;
|
||||
--weight-bold:700;
|
||||
|
||||
--tracking-tight:-0.025em;
|
||||
--tracking-normal:0em;
|
||||
--tracking-wide:0.025em;
|
||||
--tracking-widest:0.1em; /* eyebrows / uppercase category labels */
|
||||
|
||||
--leading-none:1;
|
||||
--leading-tight:1.25;
|
||||
--leading-snug:1.375;
|
||||
--leading-normal:1.5;
|
||||
--leading-relaxed:1.625;
|
||||
--leading-golden:1.618;
|
||||
|
||||
/* named roles */
|
||||
--type-hero:600 var(--text-5xl)/1.05 var(--font-display);
|
||||
--type-h2:700 var(--text-4xl)/var(--leading-4xl) var(--font-display);
|
||||
--type-h3:600 var(--text-xl)/var(--leading-xl) var(--font-display);
|
||||
--type-lead:400 var(--text-lg)/var(--leading-relaxed) var(--font-sans);
|
||||
--type-body:400 var(--text-sm)/var(--leading-sm) var(--font-sans);
|
||||
--type-caption:400 var(--text-xs)/var(--leading-xs) var(--font-sans);
|
||||
--type-code:400 var(--text-sm)/var(--leading-relaxed) var(--font-mono);
|
||||
--type-eyebrow:600 0.625rem/1 var(--font-sans);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
/* Stacking order — the one z-index ladder. Layers are named by role, never by a
|
||||
magic number, so a dropdown opened from the fixed header always sits above it
|
||||
and nothing ever reaches for 9999. Below --z-raised is ordinary document flow. */
|
||||
:root{
|
||||
--z-base:0;
|
||||
--z-raised:10; /* hover-lifted cards, sticky table headers */
|
||||
--z-sticky:200; /* pinned section rails */
|
||||
--z-header:300; /* the fixed site header */
|
||||
--z-dropdown:400; /* menus, selects, comboboxes */
|
||||
--z-overlay:500; /* dialog / sheet scrim */
|
||||
--z-modal:600; /* dialogs, sheets, command palette */
|
||||
--z-popover:700; /* popovers, tooltips (also when anchored in modals)*/
|
||||
--z-toast:800; /* toasts / notifications — always on top */
|
||||
}
|
||||
+5
-5
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Per-user `hk-` Cloud API key — the SAME-ORIGIN console route (the fix for the
|
||||
* Per-user `sk-` Cloud API key — the SAME-ORIGIN console route (the fix for the
|
||||
* API-keys "sign in to manage API keys" / CORS crack).
|
||||
*
|
||||
* The browser calls this OWN-origin route (`/keys`) with just its first-party
|
||||
@@ -7,19 +7,19 @@
|
||||
* (`resolveUser`) and mints/reads/revokes the key through IAM as the confidential
|
||||
* `hanzo-console` client (`identity.ts` `mintUserKey`/`getUserKey`/`revokeUserKey`,
|
||||
* over IAM `mint-user-keys`/`get-user`/`revoke-user-keys` — the WORKING key path,
|
||||
* verified live). No credential ever reaches the browser; the `hk-` secret is
|
||||
* verified live). No credential ever reaches the browser; the `sk-` secret is
|
||||
* returned ONLY by POST (show once).
|
||||
*
|
||||
* Why not `cloud.hanzo.ai/v1/iam/keys` (the old path): that is a DIFFERENT
|
||||
* ORIGIN than console.hanzo.ai, so a browser `fetch` is blocked by CORS ("Failed to
|
||||
* fetch") — and cloud-api's own keys handler 501s ("IAM client unset") on this
|
||||
* deployment anyway. The IAM confidential-client mint the console already uses for
|
||||
* `hk-` keys elsewhere (`app/ai` chat) is the ONE authoritative, same-origin,
|
||||
* `sk-` keys elsewhere (`app/ai` chat) is the ONE authoritative, same-origin,
|
||||
* always-working path — so the Org-Settings API-keys surface uses it too (DRY: the
|
||||
* exact primitives from `identity.ts`, no new IAM plumbing).
|
||||
*
|
||||
* GET → { hasKey, keyPrefix, createdAt } (no secret)
|
||||
* POST → { accessKey } (mint/rotate; full hk- shown ONCE)
|
||||
* POST → { accessKey } (mint/rotate; full sk- shown ONCE)
|
||||
* DELETE → { ok: true } (revoke; the old key stops working)
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
@@ -57,7 +57,7 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
/** POST — mint (or rotate) the key. Returns the full `hk-` secret ONCE. */
|
||||
/** POST — mint (or rotate) the key. Returns the full `sk-` secret ONCE. */
|
||||
export async function POST(req: NextRequest) {
|
||||
// CSRF: minting mutates (and is billable-adjacent) from the auto-sent cookie —
|
||||
// refuse a cross-origin request before any work.
|
||||
|
||||
@@ -4,6 +4,11 @@ import '@hanzogui/core/reset.css'
|
||||
// source of truth. Imported BEFORE globals.css so the console's Tamagui theme can
|
||||
// derive its ladder from the design neutral/semantic tokens.
|
||||
import './design/index.css'
|
||||
// The motion/skeleton classes `@hanzo/ui/product` components emit (`skeleton`,
|
||||
// `row`, `tnum`, `fade-up`, `drag`). Console's own markup still names the `hz-`
|
||||
// prefixed twins in globals.css below; these are the package's, and without this
|
||||
// import a DataTable's skeleton, row hover and tabular figures render unstyled.
|
||||
import '@hanzo/ui/styles/motion.css'
|
||||
import './globals.css'
|
||||
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Same-origin proxy to the cloud ML/training surface on hanzoai/ai (`/v1/train/*`,
|
||||
* `/v1/ml/models`, and the fine-tuning broker `/v1/finetune/*`).
|
||||
* Same-origin proxy to the cloud ML/training surface (`/v1/ml/models` and the
|
||||
* fine-tuning broker `/v1/finetune/*`).
|
||||
*
|
||||
* The console's Training page calls its OWN origin (`/training/...`) with just the
|
||||
* first-party session cookie; this server handler resolves the signed-in user from
|
||||
@@ -11,16 +11,16 @@
|
||||
* this is user-scoped (resolveUser), NOT the control-plane admin gate the `/paas`
|
||||
* proxy uses. The cloud backend resolves the org from the token's `owner` claim (and
|
||||
* the X-Org-Id the plain-REST train sub-service reads), so a caller can only ever
|
||||
* touch their own org's jobs. `POST /v1/train/jobs` is billing-gated by the live
|
||||
* ResourceMeter and returns 402 on an unfunded org — that status flows straight back
|
||||
* so the UI can surface it honestly.
|
||||
* touch their own org's jobs. `POST /v1/finetune/jobs` is billing-gated upstream and
|
||||
* returns 402 on an unfunded org — that status flows straight back so the UI can
|
||||
* surface it honestly.
|
||||
*
|
||||
* Why a Bearer and NOT the cookie (the fix for the "Not enabled" 403): cloud-api's
|
||||
* `/v1/train/*` authorizes on a VALIDATED JWT principal and returns 403 "no validated
|
||||
* `/v1/*` authorizes on a VALIDATED JWT principal and returns 403 "no validated
|
||||
* principal" for a cookie-only call — the raw casibase session cookie is NOT a
|
||||
* principal it accepts (only the sanitizer's cookie-token names or a Bearer). Minting
|
||||
* the same user-bound token the `/v1` proxy uses is the ONE way a signed-in tenant
|
||||
* reaches the train surface; the cookie is deliberately dropped upstream (it can't
|
||||
* reaches this surface; the cookie is deliberately dropped upstream (it can't
|
||||
* authenticate, and a cookie + JWT together risks the public-gateway 431).
|
||||
*
|
||||
* Least privilege: only the explicit ML/training sub-paths are forwarded; anything
|
||||
@@ -44,14 +44,9 @@ const CLOUD_API_URL = trim(process.env.CLOUD_API_URL ?? 'http://cloud.hanzo.svc.
|
||||
|
||||
/** The exact `/v1/<...>` ML/training sub-paths the console is allowed to reach. */
|
||||
const ALLOWED = new Set([
|
||||
// mlsvc — the canonical training surface (task #40 ResourceMeter gates POST jobs).
|
||||
'train/jobs',
|
||||
'train/experiments',
|
||||
// Model serving — the org's deployed kserve InferenceServices.
|
||||
'ml/models',
|
||||
// Real Kubeflow control-plane probe (which operators/CRDs are actually served).
|
||||
// Read-only; 503 + body flows through so the UI can report a degraded plane.
|
||||
'train/health',
|
||||
// fine-tuning broker (custom-data runs, HF search) — sibling surface.
|
||||
// fine-tuning broker (custom-data runs, HF search) — the ONE training door.
|
||||
'finetune/jobs',
|
||||
'finetune/job',
|
||||
'finetune/cancel',
|
||||
@@ -68,7 +63,7 @@ async function forward(req: NextRequest, path: string[]): Promise<NextResponse>
|
||||
return NextResponse.json({ status: 'error', msg: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// CSRF: `POST /train/jobs` mutates (and bills) from the auto-sent cookie — refuse a
|
||||
// CSRF: `POST /finetune/jobs` mutates (and bills) from the auto-sent cookie — refuse a
|
||||
// cross-origin one before any work (safe reads pass).
|
||||
const csrf = csrfRefusal(req, 'casibase')
|
||||
if (csrf) return csrf
|
||||
@@ -82,7 +77,7 @@ async function forward(req: NextRequest, path: string[]): Promise<NextResponse>
|
||||
}
|
||||
|
||||
// Mint a short-lived, user-bound Bearer (the SAME per-user cache the `/v1`
|
||||
// proxy uses). cloud-api's `/v1/train/*` 403s a cookie-only call ("no validated
|
||||
// proxy uses). cloud-api's `/v1/*` 403s a cookie-only call ("no validated
|
||||
// principal"); a Bearer is the one credential it accepts. Fail CLOSED with 502 if
|
||||
// the token can't be minted — never fall through to an unauthenticated forward.
|
||||
let bearer: string
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* billing UI tab URLs (`/billing/reports`, `/billing/invoices`, …) — they differ at
|
||||
* the FIRST path segment, so the tab slugs fall through to the SPA.
|
||||
*
|
||||
* Verbs: GET (reads: balance/usage/invoices/subscriptions/payment-methods, and the
|
||||
* Verbs: GET (reads: balance/usage/invoices/subscriptions/methods, and the
|
||||
* per-invoice PDF), POST (writes: top-up, alerts, save-a-method, cancel/
|
||||
* reactivate a subscription), PATCH (edit a budget/spend-alert), DELETE (detach a
|
||||
* saved payment method, remove a budget). Each is scoped to the caller's OWN org
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// Side-effect CSS imports (`import './globals.css'`, `import '@hanzogui/core/reset.css'`).
|
||||
// The bundler owns them; TypeScript only needs to know the specifier resolves.
|
||||
// TS7 (tsgo) errors on an unresolvable side-effect import (TS2882) where tsc stayed
|
||||
// silent, so the declaration lives here — one place, every stylesheet.
|
||||
declare module '*.css'
|
||||
+24
-8
@@ -27,12 +27,24 @@ export type SessionClaims = {
|
||||
email?: string
|
||||
displayName?: string
|
||||
isAdmin?: boolean
|
||||
/** The IAM user's property bag — where `hanzo.preferences` rides as a SNAPSHOT. */
|
||||
properties?: Record<string, string>
|
||||
/** When the token was minted (`iat`, seconds). Defaults to now; set it in the past
|
||||
* to reproduce the production case where the snapshot predates a later write. */
|
||||
issuedAt?: number
|
||||
}
|
||||
|
||||
const b64 = (o: object): string => Buffer.from(JSON.stringify(o)).toString('base64')
|
||||
/**
|
||||
* base64URL — what a JWT segment actually is. Plain base64 was close enough while the
|
||||
* payloads were tiny, but `+` and `/` appear as soon as one grows (a `properties` bag
|
||||
* is enough), and a strict decoder rejects the token outright: the SDK reports signed
|
||||
* out and the app sits on its loader forever.
|
||||
*/
|
||||
const b64 = (o: object): string =>
|
||||
Buffer.from(JSON.stringify(o)).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
|
||||
/** The default identity render specs run as — a hanzo-org admin. */
|
||||
export const DEFAULT_CLAIMS: Required<SessionClaims> = {
|
||||
export const DEFAULT_CLAIMS: Required<Omit<SessionClaims, 'properties' | 'issuedAt'>> = {
|
||||
owner: 'hanzo',
|
||||
name: 'z',
|
||||
email: 'z@hanzo.ai',
|
||||
@@ -40,20 +52,24 @@ export const DEFAULT_CLAIMS: Required<SessionClaims> = {
|
||||
isAdmin: true,
|
||||
}
|
||||
|
||||
/** An unsigned JWT whose payload carries the claims + a far-future `exp`. */
|
||||
export function forgeToken(claims: Required<SessionClaims>): string {
|
||||
const payload = { ...claims, sub: `${claims.owner}/${claims.name}`, exp: Math.floor(Date.now() / 1000) + 3600 }
|
||||
/** An unsigned JWT whose payload carries the claims, an `iat` and a far-future `exp`. */
|
||||
export function forgeToken(claims: SessionClaims): string {
|
||||
const iat = claims.issuedAt ?? Math.floor(Date.now() / 1000)
|
||||
const payload = { ...claims, sub: `${claims.owner}/${claims.name}`, iat, exp: iat + 86_400 }
|
||||
return `${b64({ alg: 'none' })}.${b64(payload)}.x`
|
||||
}
|
||||
|
||||
/** Seed tokens + gate keys and register the IAM endpoint mocks. */
|
||||
export async function primeSession(page: Page, overrides: Partial<SessionClaims> = {}): Promise<void> {
|
||||
const claims: Required<SessionClaims> = { ...DEFAULT_CLAIMS, ...overrides }
|
||||
const claims: SessionClaims = { ...DEFAULT_CLAIMS, ...overrides }
|
||||
await page.addInitScript(
|
||||
({ org, token }: { org: string; token: string }) => {
|
||||
try {
|
||||
sessionStorage.setItem('hanzo_iam_access_token', token)
|
||||
sessionStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3600_000))
|
||||
// localStorage, not sessionStorage: the `@hanzo/iam` token store is shared
|
||||
// across tabs (that IS the session), so seeding a per-tab area would leave
|
||||
// the SDK reading an empty store and every primed spec signed out.
|
||||
localStorage.setItem('hanzo_iam_access_token', token)
|
||||
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3600_000))
|
||||
localStorage.setItem('hanzo.console.org', org)
|
||||
localStorage.setItem('hanzo.console.org.selected', '1')
|
||||
localStorage.setItem(`hz_onboarding_done:${org}`, '1')
|
||||
|
||||
+25
-10
@@ -1,10 +1,17 @@
|
||||
/**
|
||||
* The ONE account control, at the foot of the rail.
|
||||
* The ONE account control, at the foot of the rail — and the ONE org switch.
|
||||
*
|
||||
* There used to be three: an org switcher at the top of the sidebar, an account
|
||||
* popover at the bottom, and a third menu in the mobile drawer — with four ways
|
||||
* to sign out between them. This spec pins the replacement: one control, both
|
||||
* switchers, at the bottom, in a shell that ships no Tailwind.
|
||||
* There used to be three account-ish menus: an org switcher at the top of the
|
||||
* sidebar, an account popover at the bottom, and a third in the mobile drawer,
|
||||
* with four ways to sign out between them. They became one control that answered
|
||||
* BOTH "who am I" and "where am I".
|
||||
*
|
||||
* They have now been split again, but by QUESTION rather than by accident: the
|
||||
* account control at the foot answers who you are (identity, team, personal
|
||||
* settings, balance, the way out), and `ContextSwitcher` at the TOP-LEFT answers
|
||||
* where you are (organization + project, together, beside the tenant's mark).
|
||||
* So the cross-tenant reach is asserted against the context switcher below, and
|
||||
* the account menu is asserted to no longer offer a tenant at all.
|
||||
*
|
||||
* Everything is asserted on computed style and geometry. The failure this guards
|
||||
* against is a menu that is present in the DOM and unreadable — a library that
|
||||
@@ -40,6 +47,9 @@ const accountTrigger = (page: Page) => page.getByTestId('nav-user').first()
|
||||
/** The trigger inside the phone's account sheet — the last mount in the document. */
|
||||
const drawerTrigger = (page: Page) => page.getByTestId('nav-user').last()
|
||||
|
||||
/** The org + project control at the top-left — the only thing that switches tenant. */
|
||||
const contextTrigger = (page: Page) => page.getByTestId('switcher-context').first()
|
||||
|
||||
async function mountConsole(page: Page, seen: Scoped[]) {
|
||||
// The standalone console reaches the cross-tenant list through its own gated
|
||||
// `/admin/iam` proxy; the go:embed build reaches cloud's `/v1/iam` directly.
|
||||
@@ -182,18 +192,23 @@ test.describe('account control', () => {
|
||||
expect(typedInCaps).toEqual([])
|
||||
})
|
||||
|
||||
test('the org switcher reaches a tenant the caller is not a member of', async ({ page }) => {
|
||||
test('the context switcher reaches a tenant the caller is not a member of', async ({ page }) => {
|
||||
const seen: Scoped[] = []
|
||||
await mountConsole(page, seen)
|
||||
await accountTrigger(page).click()
|
||||
await page.locator('[role=menu]').waitFor()
|
||||
// Tenancy is the TOP-LEFT control's job now, not the account menu's.
|
||||
await contextTrigger(page).click()
|
||||
|
||||
// Acme is nobody's membership — it exists only in the cross-tenant list an
|
||||
// admin may search. A memberships-only switcher could not offer it at all.
|
||||
await page.getByLabel('Find an organization').fill('acme')
|
||||
const acme = page.getByRole('option', { name: 'Acme Industrial' })
|
||||
// `radiogroup`/`radio`, not `listbox`/`option`: @hanzo/gui's `role` union is
|
||||
// React Native's a11y set, which carries `option` but NOT `listbox`.
|
||||
const orgList = page.getByRole('radiogroup', { name: 'Organizations' })
|
||||
const acme = orgList.getByRole('radio', { name: 'Acme Industrial' })
|
||||
await acme.waitFor()
|
||||
await expect(page.getByRole('option')).toHaveCount(1)
|
||||
// Scoped to the ORG group — the same popover also lists projects, and a bare
|
||||
// getByRole('radio') would silently count those too.
|
||||
await expect(orgList.getByRole('radio')).toHaveCount(1)
|
||||
|
||||
await page.screenshot({ path: 'e2e-shots/account-menu-find-org.png', animations: 'disabled' })
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* e2e: the assistant's ONE entry point, and the All-products directory you can act in.
|
||||
*
|
||||
* Three claims, each measured in a real browser rather than inferred from source:
|
||||
*
|
||||
* 1. The assistant opens from a FLOATING bottom-right control, not from the header —
|
||||
* asserted on GEOMETRY (the control's box is in the bottom-right quadrant of the
|
||||
* viewport) and on the header carrying no assistant control at all.
|
||||
* 2. Clicking an app in the All-products directory NAVIGATES to that app. This is the
|
||||
* regression that matters: the rows rendered, hovered, and did nothing, so the
|
||||
* directory looked interactive and was not. Asserted on where the browser LANDS.
|
||||
* 3. A pin made in the directory survives a reload EVEN WHEN the identity token
|
||||
* carries an older preferences snapshot — the exact production condition (the
|
||||
* token is minted at sign-in; a pin made after it is not in it).
|
||||
*
|
||||
* Local dev server + mocked network; `primeSession` supplies the IAM-PKCE identity.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test assistant-fab-and-apps
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
|
||||
requireFixtureServer()
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations)(\/|$|\?)/
|
||||
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(url.pathname)) return route.continue()
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Sign in and land on `path`, waiting for the signed-in shell to have mounted. */
|
||||
async function boot(page: Page, path = '/', claims?: Parameters<typeof primeSession>[1]) {
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, claims)
|
||||
await page.goto(`${BASE_URL}${path}`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.getByRole('button', { name: 'Ask Hanzo' })).toBeVisible({ timeout: 60_000 })
|
||||
}
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
test('the assistant opens from the bottom-right, and the header carries no AI control', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await boot(page)
|
||||
|
||||
const fab = page.getByRole('button', { name: 'Ask Hanzo' })
|
||||
const box = await fab.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
// Bottom-right quadrant: the whole point of the relocation.
|
||||
expect(box!.x).toBeGreaterThan(1440 / 2)
|
||||
expect(box!.y).toBeGreaterThan(900 / 2)
|
||||
// A comfortable target, not a hairline.
|
||||
expect(box!.width).toBeGreaterThanOrEqual(44)
|
||||
expect(box!.height).toBeGreaterThanOrEqual(44)
|
||||
|
||||
// The topbar itself holds no assistant control any more — it used to carry two
|
||||
// (a brand-H "Chat with Hanzo" and a "Talk to Hanzo" mic) beside the search box.
|
||||
const inTopbar = await page.evaluate(() =>
|
||||
Array.from(document.querySelectorAll('.hz-topbar [aria-label]')).map((n) => n.getAttribute('aria-label') ?? ''),
|
||||
)
|
||||
expect(inTopbar).not.toHaveLength(0) // the topbar was found at all
|
||||
expect(inTopbar.filter((l) => /Hanzo/i.test(l))).toHaveLength(0)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'assistant-fab-desktop.png') })
|
||||
|
||||
// It opens the SAME assistant surface.
|
||||
await fab.click()
|
||||
await expect(page.getByText('Assistant').first()).toBeVisible({ timeout: 15_000 })
|
||||
await page.screenshot({ path: join(SHOTS, 'assistant-open-desktop.png') })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('the assistant control is reachable on a phone and never scrolls the body sideways', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
|
||||
const page = await ctx.newPage()
|
||||
await boot(page)
|
||||
|
||||
const fab = page.getByRole('button', { name: 'Ask Hanzo' })
|
||||
const box = await fab.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(390)
|
||||
expect(box!.y).toBeGreaterThan(844 / 2)
|
||||
|
||||
const [scrollW, clientW] = await page.evaluate(() => [
|
||||
document.documentElement.scrollWidth,
|
||||
document.documentElement.clientWidth,
|
||||
])
|
||||
expect(scrollW).toBe(clientW)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'assistant-fab-mobile.png') })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('clicking an app in All products opens that app', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await boot(page)
|
||||
|
||||
await page.getByRole('button', { name: 'All products' }).first().click()
|
||||
const row = page.getByRole('button', { name: 'Open Agents' })
|
||||
await expect(row).toBeVisible({ timeout: 15_000 })
|
||||
await page.screenshot({ path: join(SHOTS, 'all-products-desktop.png') })
|
||||
|
||||
await row.click()
|
||||
// Where the browser LANDS is the claim — not that a handler fired.
|
||||
await expect(page).toHaveURL(/\/agents$/, { timeout: 15_000 })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('a pin made in All products survives a reload under a STALE token snapshot', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
|
||||
// The production condition: the identity token was minted an hour ago and carries a
|
||||
// preferences SNAPSHOT from then. Treating that snapshot as authoritative is what
|
||||
// silently threw away every pin made since — the pin reads as pinned, and is gone
|
||||
// after a reload.
|
||||
const snapshot = { pins: [{ id: 'models', group: '' }], pinGroups: [] }
|
||||
await boot(page, '/', {
|
||||
properties: { 'hanzo.preferences': JSON.stringify(snapshot) },
|
||||
issuedAt: Math.floor(Date.now() / 1000) - 3600,
|
||||
})
|
||||
|
||||
const openDirectory = async () => {
|
||||
await page.getByRole('button', { name: 'All products' }).first().click()
|
||||
// "…to sidebar" / "…from sidebar" are the directory's own labels — the home page's
|
||||
// Apps map carries a plain "Pin Agents", so the short form is ambiguous.
|
||||
await expect(page.getByRole('button', { name: /Agents (to|from) sidebar/ })).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
// The snapshot the token carries is what the sidebar starts from.
|
||||
await openDirectory()
|
||||
await page.getByRole('button', { name: 'Pin Agents to sidebar' }).click()
|
||||
await expect(page.getByRole('button', { name: 'Remove Agents from sidebar' })).toBeVisible()
|
||||
|
||||
// Only a write the SERVER acknowledged earns the stamp that out-ranks the snapshot.
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => localStorage.getItem('hanzo.console2.prefs.z.writtenAt')), { timeout: 10_000 })
|
||||
.not.toBeNull()
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' })
|
||||
await expect(page.getByRole('button', { name: 'Ask Hanzo' })).toBeVisible({ timeout: 60_000 })
|
||||
|
||||
// Still pinned — the hour-old snapshot did not win. Asserted on what the user sees…
|
||||
await openDirectory()
|
||||
await expect(page.getByRole('button', { name: 'Remove Agents from sidebar' })).toBeVisible({ timeout: 15_000 })
|
||||
// …and on what was actually kept (models from the snapshot, agents from the write).
|
||||
const pins = await page.evaluate(() => {
|
||||
const raw = JSON.parse(localStorage.getItem('hanzo.console2.prefs.z') ?? '{}')
|
||||
return (raw.pins ?? []).map((p: { id: string }) => p.id)
|
||||
})
|
||||
expect(pins).toContain('agents')
|
||||
expect(pins).toContain('models')
|
||||
await ctx.close()
|
||||
})
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* e2e: two-tenant BILLING ISOLATION through the `/billing/*` proxy.
|
||||
* e2e: two-tenant BILLING ISOLATION through the `/v1/billing/*` proxy.
|
||||
*
|
||||
* The proxy (app/billing/v1/[...path]/route.ts) resolves the billing subject from the
|
||||
* The proxy (app/v1/billing/[...path]/route.ts) resolves the billing subject from the
|
||||
* session server-side and pins the full subject-key set (user/userId/customerId) +
|
||||
* the X-Org-Id header, so a tenant can only ever read its OWN commerce ledger. This
|
||||
* spec proves that end-to-end against the LIVE proxy: two accounts in DIFFERENT orgs
|
||||
* each fetch `/billing/subscriptions` (and `/payment-methods`), and we assert the
|
||||
* two result sets are disjoint — neither tenant can see the other's rows.
|
||||
* each fetch `/v1/billing/subscriptions` (and `/v1/billing/methods`), and we assert
|
||||
* the two result sets are disjoint — neither tenant can see the other's rows.
|
||||
*
|
||||
* This is the regression guard for the IDOR RED found (the proxy previously pinned
|
||||
* only `?user=` while commerce filters subscriptions on `?userId=`, so subscriptions
|
||||
@@ -36,11 +36,12 @@ async function signIn(page: Page, email: string, password: string) {
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
}
|
||||
|
||||
/** Fetch a billing path through the same-origin DATA proxy (`/billing/v1/*`), as the
|
||||
* signed-in browser. (`/billing/<slug>` without `v1/` is a UI tab, served by the SPA.) */
|
||||
/** Fetch a billing path through the same-origin DATA proxy (`/v1/billing/*`), as the
|
||||
* signed-in browser. (`/billing/<slug>` is a UI tab, served by the SPA — it differs at
|
||||
* the FIRST path segment, so the two never collide.) */
|
||||
async function billing(page: Page, path: string): Promise<{ status: number; ids: string[] }> {
|
||||
return page.evaluate(async (p) => {
|
||||
const res = await fetch(`/billing/v1/${p}`, { credentials: 'include', headers: { Accept: 'application/json' } })
|
||||
const res = await fetch(`/v1/billing/${p}`, { credentials: 'include', headers: { Accept: 'application/json' } })
|
||||
let ids: string[] = []
|
||||
try {
|
||||
const body = await res.json()
|
||||
@@ -73,9 +74,9 @@ test.describe('billing is isolated per tenant through the proxy', () => {
|
||||
await signIn(pageB, B.email, B.password)
|
||||
|
||||
// `invoices` is included because its row ids drive the per-invoice PDF URL
|
||||
// (`/billing/v1/invoices/:id/pdf`) — proving the invoice list is tenant-isolated
|
||||
// (`/v1/billing/invoices/:id/pdf`) — proving the invoice list is tenant-isolated
|
||||
// proves a user can only ever build a PDF URL for their OWN org's invoices.
|
||||
for (const path of ['subscriptions', 'payment-methods', 'invoices']) {
|
||||
for (const path of ['subscriptions', 'methods', 'invoices']) {
|
||||
const a = await billing(pageA, path)
|
||||
const b = await billing(pageB, path)
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ test.describe('Money/usage/o11y surface is fail-closed for anonymous (unauthenti
|
||||
'/v1/billing/balance',
|
||||
'/v1/billing/invoices',
|
||||
'/v1/billing/usage',
|
||||
'/v1/billing/payment-methods',
|
||||
'/v1/billing/methods',
|
||||
'/v1/billing/alerts',
|
||||
'/v1/usage/summary',
|
||||
'/v1/get-cloud-usages',
|
||||
|
||||
@@ -40,7 +40,7 @@ const CANONICAL_IDS: string[] = JSON.parse(readFileSync(join(process.cwd(), 'e2e
|
||||
* are NOT registry ids — they must resolve via SLUG_ALIASES to a real module (never
|
||||
* a 404 blank). Auditing them here proves the alias map end-to-end against the real app.
|
||||
*/
|
||||
const ALIAS_SLUGS = ['traces', 'deploy', 'plans-pricing', 'wallets', 'model-catalog', 'fine-tuning', 'web-search', 'mlpipelines', 'kubeflow']
|
||||
const ALIAS_SLUGS = ['traces', 'deploy', 'plans-pricing', 'wallets', 'model-catalog', 'fine-tuning', 'web-search']
|
||||
const IDS: string[] = [...CANONICAL_IDS, ...ALIAS_SLUGS]
|
||||
|
||||
/** A global-admin (sees every surface) or a tenant customer (Dave/maxpower shape). */
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* e2e: brand-forward chrome + voice — mocked-network render proof.
|
||||
*
|
||||
* The Chrome wave: the big floating chat CIRCLE was removed; the assistant now opens
|
||||
* from the TOPBAR (a small brand-H "Chat with Hanzo" + a "Talk to Hanzo" mic), the
|
||||
* top-left SidebarBrand renders the org's own logo (white-label), and the Developers
|
||||
* dock is drag-resizable with a live "Create key". This spec proves all of it in a
|
||||
* browser.
|
||||
* The Chrome wave: the big floating chat CIRCLE was removed; the assistant opens from
|
||||
* ONE control — a floating bottom-right cluster (a brand-H "Ask Hanzo" + a "Talk to
|
||||
* Hanzo" mic, `AssistantFab`), NOT the topbar, which carries navigation and account
|
||||
* chrome only. The top-left SidebarBrand renders the org's own logo (white-label), and
|
||||
* the Developers dock is drag-resizable with a live "Create key". This spec proves all
|
||||
* of it in a browser.
|
||||
*
|
||||
* Same harness as workbench.spec (the closest sibling): a LOCAL server with the
|
||||
* network mocked. `primeSession` seeds the IAM-PKCE identity AND the first-run gates
|
||||
@@ -13,7 +14,7 @@
|
||||
* real-shaped ledger rows for the dock's Overview, `/v1/models` → a small catalog for
|
||||
* the assistant's model list; everything else → an empty-ok envelope.
|
||||
*
|
||||
* Voice gotcha: headless chromium ships NO webkitSpeechRecognition, so the topbar mic
|
||||
* Voice gotcha: headless chromium ships NO webkitSpeechRecognition, so the mic
|
||||
* (rendered only when `voiceSupported()`) would be absent for an environment reason,
|
||||
* not a code one. A tiny, inert Web Speech stub is injected BEFORE load
|
||||
* (`installVoiceStub`) so `voiceSupported()` is deterministically true and the mic
|
||||
@@ -107,20 +108,20 @@ function installVoiceStub(page: Page) {
|
||||
})
|
||||
}
|
||||
|
||||
/** Prime + navigate; the topbar brand-H is on EVERY viewport, so it is the mount signal. */
|
||||
/** Prime + navigate; the floating brand-H is on EVERY viewport, so it is the mount signal. */
|
||||
async function openHome(page: Page, waitForMount = true) {
|
||||
await installVoiceStub(page)
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page)
|
||||
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' })
|
||||
if (waitForMount) {
|
||||
await expect(page.locator('[aria-label="Chat with Hanzo"]').first()).toBeVisible({ timeout: 20_000 })
|
||||
await expect(page.locator('[aria-label="Ask Hanzo"]').first()).toBeVisible({ timeout: 20_000 })
|
||||
}
|
||||
}
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
test('the floating circle is gone; the topbar carries chat + voice, the sidebar brand + docked assistant + Developers dock work', async ({ browser }) => {
|
||||
test('one floating control carries chat + voice; the topbar carries neither; sidebar brand + docked assistant + Developers dock work', async ({ browser }) => {
|
||||
// laptop (≥ lg 1024): the persistent sidebar, the Developers dock, and the docked
|
||||
// assistant column are all present (they are desktop-only concerns).
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } })
|
||||
@@ -130,10 +131,18 @@ test('the floating circle is gone; the topbar carries chat + voice, the sidebar
|
||||
// 1. The OLD floating circle is GONE — the bubble that covered page content.
|
||||
await expect(page.locator('[aria-label="Open AI assistant"]')).toHaveCount(0)
|
||||
|
||||
// 2. Topbar: the small brand-H "Chat with Hanzo" AND the "Talk to Hanzo" mic, both
|
||||
// visible (the mic renders because the Web Speech stub makes voiceSupported() true).
|
||||
await expect(page.locator('[aria-label="Chat with Hanzo"]').first()).toBeVisible()
|
||||
await expect(page.locator('[aria-label="Talk to Hanzo"]').first()).toBeVisible()
|
||||
// 2. ONE floating control, bottom-right: the brand-H "Ask Hanzo" AND the "Talk to
|
||||
// Hanzo" mic (the mic renders because the Web Speech stub makes voiceSupported()
|
||||
// true) — and the topbar carries no assistant control at all. The two used to live
|
||||
// up there beside the search box, which put the assistant in a third place.
|
||||
// Scoped to the control itself: the assistant's own composer carries a mic with
|
||||
// the same label, mounted-but-hidden until the panel opens, so a bare
|
||||
// `[aria-label="Talk to Hanzo"]` matches that one first and reads "hidden".
|
||||
const fab = page.getByTestId('assistant-fab')
|
||||
await expect(fab.locator('[aria-label="Ask Hanzo"]')).toBeVisible()
|
||||
await expect(fab.locator('[aria-label="Talk to Hanzo"]')).toBeVisible()
|
||||
await expect(page.locator('.hz-topbar [aria-label="Ask Hanzo"]')).toHaveCount(0)
|
||||
await expect(page.locator('.hz-topbar [aria-label="Talk to Hanzo"]')).toHaveCount(0)
|
||||
|
||||
// 3. The top-left SidebarBrand renders the org logo / BrandMark (an <img> or <svg>).
|
||||
const brand = page.locator('[aria-label*="right-click for brand menu"]').first()
|
||||
@@ -147,15 +156,18 @@ test('the floating circle is gone; the topbar carries chat + voice, the sidebar
|
||||
await expect(page.locator('[title="Drag to resize"]').first()).toBeVisible()
|
||||
await expect(page.locator('text=Create key').first()).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// 5. Clicking "Chat with Hanzo" opens the DOCKED assistant surface — the "Assistant"
|
||||
// header + its Undock control appear (uniquely the docked panel at lg+).
|
||||
await page.locator('[aria-label="Chat with Hanzo"]').first().click()
|
||||
// 5. Clicking "Ask Hanzo" opens the DOCKED assistant surface — the "Assistant"
|
||||
// header + its Undock control appear (uniquely the docked panel at lg+). The
|
||||
// floating control then steps aside: at lg+ the docked column IS the assistant,
|
||||
// so keeping a button to open it on top of itself would be a second way in.
|
||||
await fab.locator('[aria-label="Ask Hanzo"]').click()
|
||||
await expect(page.locator('[aria-label^="Undock"]').first()).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByText('Assistant', { exact: true }).filter({ visible: true }).first()).toBeVisible()
|
||||
await expect(page.locator('[aria-label="Ask Hanzo"]')).toHaveCount(0)
|
||||
|
||||
// 6. The mic is wired: "Talk to Hanzo" → startVoice → the conversation opens the
|
||||
// recognition (voiceSignal effect → voice.start() → the stub records the call).
|
||||
await page.locator('[aria-label="Talk to Hanzo"]').first().click()
|
||||
// 6. The mic is wired: "Talk to Hanzo" (now the open conversation's own) → the
|
||||
// recognition opens (voice.start() → the stub records the call).
|
||||
await page.locator('[aria-label="Talk to Hanzo"]').filter({ visible: true }).first().click()
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => (window as unknown as { __voiceStarted?: number }).__voiceStarted ?? 0), { timeout: 15_000 })
|
||||
.toBeGreaterThan(0)
|
||||
@@ -179,7 +191,7 @@ test('renders across breakpoints with no horizontal body scroll on a phone; scre
|
||||
// (real render, or an honest blank shell if the sandbox can't paint the SPA).
|
||||
await openHome(page, false)
|
||||
await page
|
||||
.locator('[aria-label="Chat with Hanzo"]')
|
||||
.locator('[aria-label="Ask Hanzo"]')
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 20_000 })
|
||||
.catch(() => {})
|
||||
|
||||
+10
-10
@@ -14,7 +14,7 @@
|
||||
*
|
||||
* Run:
|
||||
* HANZO_PASSWORD=xxx pnpm e2e
|
||||
* HANZO_PASSWORD=xxx HANZO_API_KEY=hk-xxx pnpm e2e
|
||||
* HANZO_PASSWORD=xxx HANZO_API_KEY=sk-xxx pnpm e2e
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
@@ -151,15 +151,15 @@ test.describe('Hanzo Cloud Console e2e', () => {
|
||||
|
||||
if (needsCreate) {
|
||||
await createBtn.click()
|
||||
// One-time reveal card with the hk- key
|
||||
await expect(page.locator('text=/hk-/')).toBeVisible({ timeout: 25_000 })
|
||||
// One-time reveal card with the sk- key
|
||||
await expect(page.locator('text=/sk-/')).toBeVisible({ timeout: 25_000 })
|
||||
await expect(page.locator('text=/shown only once/i')).toBeVisible()
|
||||
await expect(page.locator('button:has-text("Copy")')).toBeVisible()
|
||||
console.log('✓ API key created (hk- one-time reveal shown)')
|
||||
console.log('✓ API key created (sk- one-time reveal shown)')
|
||||
} else {
|
||||
// Key already exists
|
||||
await expect(hasKey).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.locator('text=/hk-…|hk-[A-Za-z0-9]{3,}/i')).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.locator('text=/sk-…|sk-[A-Za-z0-9]{3,}/i')).toBeVisible({ timeout: 5_000 })
|
||||
console.log('✓ API key already exists (prefix shown)')
|
||||
}
|
||||
})
|
||||
@@ -183,19 +183,19 @@ test.describe('Hanzo Cloud Console e2e', () => {
|
||||
} else if (await createBtn.isVisible({ timeout: 1_000 }).catch(() => false)) {
|
||||
await createBtn.click()
|
||||
}
|
||||
await expect(page.locator('text=/hk-/')).toBeVisible({ timeout: 25_000 })
|
||||
await expect(page.locator('text=/sk-/')).toBeVisible({ timeout: 25_000 })
|
||||
|
||||
// Grab the FULL key from the one-time reveal — never the masked display
|
||||
// (the account card shows `hk-2f18…` with an ellipsis, which is not a
|
||||
// usable credential). Match only a full hk- token (no `…`/`...`).
|
||||
const fullKey = /hk-[A-Za-z0-9._-]{16,}/
|
||||
// (the account card shows `sk-2f18…` with an ellipsis, which is not a
|
||||
// usable credential). Match only a full sk- token (no `…`/`...`).
|
||||
const fullKey = /sk-[A-Za-z0-9._-]{16,}/
|
||||
const keyEl = page.locator('[style*="monospace"]').filter({ hasText: fullKey }).first()
|
||||
apiKey = (((await keyEl.textContent().catch(() => '')) ?? '').match(fullKey) ?? [''])[0]
|
||||
if (!apiKey) {
|
||||
const m = ((await page.textContent('body')) ?? '').match(fullKey)
|
||||
apiKey = m ? m[0] : ''
|
||||
}
|
||||
expect(apiKey, 'Could not extract hk- key from page').toMatch(/^hk-/)
|
||||
expect(apiKey, 'Could not extract sk- key from page').toMatch(/^sk-/)
|
||||
console.log(`✓ Extracted key prefix: ${apiKey.slice(0, 11)}…`)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* The OAuth return raises EXACTLY ONE toast.
|
||||
*
|
||||
* A unit test cannot see this bug. It is a render loop: the toast provider built
|
||||
* its context value fresh on every render and used it as the value, so every
|
||||
* useToast() consumer got a new identity whenever a toast was added — and the
|
||||
* integrations effect both DEPENDS on the toast api and RAISES a toast. Raising
|
||||
* one re-rendered the provider, which handed the effect a new api, which raised
|
||||
* another. Live this stacked ~15 identical "Connected slack" cards down the
|
||||
* viewport. Stripping the query params could not stop it: router.replace is
|
||||
* asynchronous, so the params are still readable on the renders in between.
|
||||
*
|
||||
* So the assertion is a COUNT after the loop has had time to run, on the real
|
||||
* rendered DOM — the only place the defect exists.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { primeSession } from './_session'
|
||||
|
||||
const PROVIDERS = [
|
||||
{
|
||||
id: 'slack',
|
||||
name: 'Slack',
|
||||
description: 'Post messages and receive events in your Slack workspace.',
|
||||
category: 'Communication',
|
||||
available: true,
|
||||
connected: true,
|
||||
connection: { account: 'The Foundation', connectedAt: '2026-08-05T00:16:49Z' },
|
||||
},
|
||||
]
|
||||
|
||||
test.describe('integrations OAuth return', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Everything else answers empty so the module mounts standalone.
|
||||
await page.route('**/v1/**', async (route) => {
|
||||
const url = route.request().url()
|
||||
if (url.includes('/v1/integrations')) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PROVIDERS) })
|
||||
}
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: '{"data":[]}' })
|
||||
})
|
||||
await primeSession(page)
|
||||
})
|
||||
|
||||
test('a connected= return raises exactly one toast', async ({ page }) => {
|
||||
await page.goto('/integrations?connected=slack&account=The+Foundation')
|
||||
|
||||
const toasts = page.getByText('Connected slack')
|
||||
await expect(toasts.first()).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// Give the loop every chance to run: the effect re-fires on each provider
|
||||
// re-render, and the pre-fix build had stacked well past a dozen by now.
|
||||
await page.waitForTimeout(3_000)
|
||||
expect(await toasts.count()).toBe(1)
|
||||
|
||||
await page.screenshot({ path: 'e2e-shots/integrations-one-toast.png', fullPage: false })
|
||||
})
|
||||
|
||||
test('the callback params are stripped so a reload cannot replay it', async ({ page }) => {
|
||||
await page.goto('/integrations?connected=slack&account=The+Foundation')
|
||||
await expect(page.getByText('Connected slack').first()).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
await expect.poll(() => new URL(page.url()).search, { timeout: 10_000 }).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -134,7 +134,7 @@ test.describe('LIVE v8.4.15 — (a) business board + (c) billing dimension', ()
|
||||
|
||||
test('(c) billing Reports renders the cost-dimension surface', async ({ page }) => {
|
||||
await signIn(page, CONSOLE)
|
||||
// v8.4.16: the data proxy moved to /billing/v1/*, so /billing/reports now falls
|
||||
// The data proxy lives at /v1/billing/*, so /billing/reports now falls
|
||||
// through to the SPA (was shadowed by the /billing/[...path] proxy → raw JSON).
|
||||
// A hard deep-link must render the Reports UI, not a proxy "not found".
|
||||
await page.goto(`${CONSOLE}/billing/reports`, { waitUntil: 'domcontentloaded' })
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Onboarding — Continue never moves, and Skip is always reachable.
|
||||
*
|
||||
* The complaint this pins: the Continue button landed at a different height on
|
||||
* every step, so a user clicking through had to re-aim each time. StepActions was
|
||||
* the LAST CHILD of a flex column, so its y was whatever the step's content
|
||||
* happened to add up to. It is now a SLOT on StepShell above a content area with
|
||||
* a reserved height — one placement, decided in one place.
|
||||
*
|
||||
* This is a GEOMETRY assertion on purpose. The JSX move is invisible to a unit
|
||||
* test (both shapes render the same button with the same label); only the painted
|
||||
* box says whether the thing the user complained about is fixed.
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { primeSession } from './_session'
|
||||
|
||||
/** Every step whose footer must line up, in flow order. */
|
||||
const STEPS = ['Secure your account', 'Data & consent', 'Your organization', 'Free trial credits', 'AI access']
|
||||
|
||||
/** The y of the actions row, in page coordinates. */
|
||||
async function actionsY(page: Page): Promise<number> {
|
||||
const row = page.getByTestId('onboarding-actions')
|
||||
await expect(row).toBeVisible()
|
||||
const box = await row.boundingBox()
|
||||
if (!box) throw new Error('actions row has no box')
|
||||
return Math.round(box.y)
|
||||
}
|
||||
|
||||
/** Advance past the current step, preferring Skip so the flow stays clickable. */
|
||||
async function advance(page: Page): Promise<void> {
|
||||
const row = page.getByTestId('onboarding-actions')
|
||||
const skip = row.getByRole('button', { name: /^(Skip|Keep the default)/ })
|
||||
if (await skip.count()) {
|
||||
await skip.first().click()
|
||||
return
|
||||
}
|
||||
// Consent has no Skip by design (accepting Terms is not optional), so tick the
|
||||
// agreement and use Continue. Tick only if Continue is still disabled — a caller
|
||||
// may already have ticked it, and toggling twice turns it back OFF.
|
||||
const cont = row.getByRole('button', { name: /Continue/ })
|
||||
if (await cont.isDisabled()) {
|
||||
const agree = page.locator('[role="switch"], input[type="checkbox"]').first()
|
||||
if (await agree.count()) await agree.click()
|
||||
}
|
||||
await cont.click()
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Anything the steps reach for answers empty — they are best-effort and must
|
||||
// still render. Registered BEFORE primeSession so its handlers win.
|
||||
await page.route('**/v1/**', (r) => r.fulfill({ status: 200, contentType: 'application/json', body: '{}' }))
|
||||
await primeSession(page)
|
||||
// primeSession marks onboarding DONE so other specs can reach the app. This
|
||||
// spec is about the wizard, so un-mark it (the tour gate stays seeded).
|
||||
await page.addInitScript(() => {
|
||||
for (const k of Object.keys(localStorage)) if (k.startsWith('hz_onboarding_done:')) localStorage.removeItem(k)
|
||||
})
|
||||
})
|
||||
|
||||
test('Continue lands at the same height on every step', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
const seen: { step: string; y: number }[] = []
|
||||
for (const step of STEPS) {
|
||||
await expect(page.getByTestId('onboarding-step-title')).toHaveText(step, { timeout: 15_000 })
|
||||
seen.push({ step, y: await actionsY(page) })
|
||||
await advance(page)
|
||||
}
|
||||
|
||||
const ys = seen.map((s) => s.y)
|
||||
const spread = Math.max(...ys) - Math.min(...ys)
|
||||
expect(
|
||||
spread,
|
||||
`Continue moved ${spread}px across steps — ${seen.map((s) => `${s.step}:${s.y}`).join(' ')}`,
|
||||
).toBeLessThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('every step always offers an enabled way forward', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
// The real invariant behind "skip so easy to click through": on every step there
|
||||
// is ALWAYS at least one enabled control that advances you — Skip where there is
|
||||
// something to decline, Continue where there is not. Credits swaps between the
|
||||
// two on purpose (Skip appears only when a card could be added; otherwise
|
||||
// Continue carries you), so asserting a literal "Skip" everywhere would be
|
||||
// asserting the wrong thing. Being STUCK is the defect.
|
||||
for (const step of STEPS) {
|
||||
await expect(page.getByTestId('onboarding-step-title')).toHaveText(step, { timeout: 15_000 })
|
||||
|
||||
// Consent gates Continue on accepting the Terms — not optional, so tick it
|
||||
// first and then assert the way forward exists.
|
||||
if (step === 'Data & consent') {
|
||||
const agree = page.locator('[role="switch"], input[type="checkbox"]').first()
|
||||
if (await agree.count()) await agree.click()
|
||||
}
|
||||
|
||||
const row = page.getByTestId('onboarding-actions')
|
||||
const forward = row.getByRole('button', { name: /^(Skip|Keep the default|Continue)/ })
|
||||
const n = await forward.count()
|
||||
expect(n, `${step} renders no forward control`).toBeGreaterThan(0)
|
||||
|
||||
let usable = 0
|
||||
for (let i = 0; i < n; i++) {
|
||||
const b = forward.nth(i)
|
||||
if (await b.isDisabled()) continue
|
||||
const box = await b.boundingBox()
|
||||
if (!box || box.height < 24) continue
|
||||
const hit = await b.evaluate((el) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
return el.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2))
|
||||
})
|
||||
if (hit) usable++
|
||||
}
|
||||
expect(usable, `${step} has no enabled, clickable way forward`).toBeGreaterThan(0)
|
||||
|
||||
await advance(page)
|
||||
}
|
||||
})
|
||||
@@ -18,7 +18,6 @@
|
||||
"agents",
|
||||
"inference",
|
||||
"finetuning",
|
||||
"ml-pipelines",
|
||||
"embeddings",
|
||||
"evals",
|
||||
"gpus",
|
||||
|
||||
@@ -128,11 +128,11 @@ async function mock(route: Route) {
|
||||
async function openPolicy(page: Page, marker = 'Enabled models') {
|
||||
await page.addInitScript((org) => {
|
||||
try {
|
||||
// A valid @hanzo/iam session: a non-expired access token in sessionStorage so the
|
||||
// A valid @hanzo/iam session: a non-expired access token in localStorage so the
|
||||
// SDK's getValidAccessToken() returns it (userinfo is network-mocked to CLAIMS).
|
||||
// Without a future `expires_at` the SDK treats the token as expired → anonymous.
|
||||
sessionStorage.setItem('hanzo_iam_access_token', 'mock-access-token')
|
||||
sessionStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3600000))
|
||||
localStorage.setItem('hanzo_iam_access_token', 'mock-access-token')
|
||||
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3600000))
|
||||
|
||||
localStorage.setItem('hanzo.console.org', org)
|
||||
// Scope shows the org PICKER until an org is explicitly entered — the scope
|
||||
|
||||
+48
-8
@@ -1,4 +1,4 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
@@ -7,12 +7,16 @@ import { resolveBuildId, readGitSha } from './src/config/build-id.mjs'
|
||||
/**
|
||||
* Hanzo Cloud Console — Next.js config.
|
||||
*
|
||||
* Hanzo GUI is consumed at runtime (no optimizing compiler): the published
|
||||
* `@hanzogui/next-plugin` has a broken npm dependency (`hanzogui-loader@7.3.0`
|
||||
* is unpublished; the available fork renames its exports), so we transpile the
|
||||
* Gui ESM packages with Next's built-in `transpilePackages` and let
|
||||
* `GuiProvider` inject CSS at runtime. Gui is designed to work this way — the
|
||||
* compiler is an optimization, not a requirement.
|
||||
* Hanzo GUI is consumed at runtime (no optimizing compiler): we transpile the Gui
|
||||
* ESM packages with Next's built-in `transpilePackages` and let `GuiProvider`
|
||||
* inject CSS at runtime. Gui is designed to work this way — the compiler is an
|
||||
* optimization, not a requirement.
|
||||
*
|
||||
* (The original reason to avoid `@hanzogui/next-plugin` no longer holds: the loader
|
||||
* it depends on was unpublished at 7.3.0, but 8.x renamed it to `@hanzogui/loader`
|
||||
* and both now ship. Adopting the compiler is therefore a live option — as an
|
||||
* optimization to measure, not a correctness fix, so it is deliberately not bundled
|
||||
* into the 8.x convergence.)
|
||||
*
|
||||
* `react-native` is aliased to `react-native-web` for the browser.
|
||||
*
|
||||
@@ -46,6 +50,9 @@ function guiPackages() {
|
||||
return ['@hanzo/gui', '@hanzo/iam-js-sdk', '@hanzo/dash', '@hanzo/data', '@hanzo/canvas', '@hanzo/finance-ui', '@hanzo/usage', '@hanzo/ui', 'react-native-web', ...scoped]
|
||||
}
|
||||
|
||||
/** A `@hanzogui/<pkg>/<subpath>/index.{js,cjs}` metro-compat shim (see `webpack()`). */
|
||||
const GUI_SUBPATH_SHIM = /\/@hanzogui\/([^/]+)\/([^/]+)\/index\.c?js$/
|
||||
|
||||
/**
|
||||
* Same-origin `/v1/*` — ZERO client-visible prefix (the CTO contract: "no prefix
|
||||
* before /v1/ in any API call"). The browser ALWAYS calls its OWN origin at a clean
|
||||
@@ -206,7 +213,40 @@ const nextConfig = {
|
||||
experimental: {
|
||||
esmExternals: true,
|
||||
},
|
||||
webpack(config) {
|
||||
webpack(config, { webpack }) {
|
||||
// `@hanzogui/*` 8.x ships legacy metro-compat subpath DIRECTORIES (`config/v5/`,
|
||||
// `themes/v5/`, `shorthands/v5/`, …) beside the `exports` map that already names
|
||||
// the real entry. Each holds a CommonJS `index.js` — inside a `"type": "module"`
|
||||
// package. Whatever resolves the directory therefore parses that file as ESM: the
|
||||
// `export *` chain goes opaque ("'defaultConfig' is not exported from
|
||||
// '@hanzogui/config/v5'") and its bare `require('../dist/cjs/v5.cjs')` survives
|
||||
// into the server chunk, where it MODULE_NOT_FOUNDs at prerender (the require is
|
||||
// relative to `.next/server/chunks/`, not to the package).
|
||||
//
|
||||
// So redirect any such shim to the ESM build sitting beside it. Pattern-based, on
|
||||
// the RESOLVED file, so it holds however the request got there — and costs nothing
|
||||
// the day the shims stop shipping.
|
||||
config.plugins.push(
|
||||
new webpack.NormalModuleReplacementPlugin(GUI_SUBPATH_SHIM, (data) => {
|
||||
const resource = data.createData?.resource
|
||||
if (!resource) return
|
||||
const shim = GUI_SUBPATH_SHIM.exec(resource)
|
||||
if (!shim) return
|
||||
const esm = `${resource.slice(0, shim.index)}/@hanzogui/${shim[1]}/dist/esm/${shim[2]}.mjs`
|
||||
if (!existsSync(esm)) return
|
||||
// The module's CONTEXT must move with it, or its own relative imports
|
||||
// (`./v5-base.mjs`) keep resolving against the shim directory.
|
||||
data.createData.resource = esm
|
||||
data.createData.userRequest = esm
|
||||
data.createData.context = dirname(esm)
|
||||
data.context = dirname(esm)
|
||||
}),
|
||||
)
|
||||
// @hanzo/ui is consumed from SOURCE via a workspace link. Keep the symlinked path
|
||||
// so its own imports (@hanzo/gui, @hanzogui/*) walk up into the CONSOLE's
|
||||
// node_modules — one Tamagui instance, as the tsconfig `paths` already pin for
|
||||
// types. Resolving the realpath would load a second copy and break theme context.
|
||||
config.resolve.symlinks = false
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
'react-native$': 'react-native-web',
|
||||
|
||||
Generated
+12019
File diff suppressed because it is too large
Load Diff
+10
-9
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "@hanzo/console",
|
||||
"version": "8.5.35",
|
||||
"version": "8.5.50",
|
||||
"packageManager": "pnpm@11.17.0",
|
||||
"private": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"author": "Hanzo AI <dev@hanzo.ai>",
|
||||
"description": "Hanzo Cloud Console — unified admin console for Hanzo Cloud and all cloud products.",
|
||||
"description": "Hanzo Cloud Console \u2014 unified admin console for Hanzo Cloud and all cloud products.",
|
||||
"scripts": {
|
||||
"dev": "next dev -p 4000",
|
||||
"build": "next build",
|
||||
@@ -14,26 +14,27 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"e2e": "playwright test",
|
||||
"e2e:headed": "playwright test --headed",
|
||||
"postinstall": "patch-package"
|
||||
"e2e:headed": "playwright test --headed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/brand": "^1.4.5",
|
||||
"@hanzo/canvas": "^0.2.1",
|
||||
"@hanzo/dash": "^0.3.0",
|
||||
"@hanzo/data": "^1.2.2",
|
||||
"@hanzo/design": "^0.4.6",
|
||||
"@hanzo/event": "^0.3.8",
|
||||
"@hanzo/finance-ui": "~0.1.1",
|
||||
"@hanzo/gui": "^8.0.0",
|
||||
"@hanzo/iam": "^0.21.2",
|
||||
"@hanzo/iam": "^0.21.6",
|
||||
"@hanzo/logo": "^1.0.14",
|
||||
"@hanzo/ui": "^8.0.38",
|
||||
"@hanzo/ui": "^8.0.56",
|
||||
"@hanzo/usage": "^0.1.6",
|
||||
"@hanzogui/config": "^8.0.0",
|
||||
"@hanzogui/core": "^8.0.0",
|
||||
"@hanzogui/lucide-icons-2": "^8.0.0",
|
||||
"@hanzogui/next-theme": "^8.0.0",
|
||||
"@hanzogui/shell": "^8.0.1",
|
||||
"@hanzogui/telemetry": "^8.0.0",
|
||||
"@hanzogui/shell": "^8.1.1",
|
||||
"@lexical/html": "0.46.0",
|
||||
"@lexical/link": "0.46.0",
|
||||
"@lexical/list": "0.46.0",
|
||||
@@ -53,6 +54,7 @@
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"react-native-svg": "15.15.5",
|
||||
"react-native-web": "0.21.2",
|
||||
"superjson": "2.2.2"
|
||||
},
|
||||
@@ -61,7 +63,6 @@
|
||||
"@types/node": "22.20.0",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"patch-package": "^8.0.0",
|
||||
"react-native": "0.83.9",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "3.2.4"
|
||||
|
||||
Generated
+618
-264
File diff suppressed because it is too large
Load Diff
@@ -195,8 +195,8 @@ try {
|
||||
stdio: 'inherit',
|
||||
// CONSOLE_EMBED gates the server-side build transforms; NEXT_PUBLIC_CONSOLE_EMBED
|
||||
// is inlined into the CLIENT bundle so runtime code (lib/embed.ts → IS_EMBED) can
|
||||
// skip the BFF-only session probes (/auth/refresh|session, /billing welcome) that
|
||||
// don't exist in this static, server-less deployment.
|
||||
// skip the BFF-only session probes (/auth/refresh|session) that don't exist in
|
||||
// this static, server-less deployment.
|
||||
env: { ...process.env, CONSOLE_EMBED: '1', NEXT_PUBLIC_CONSOLE_EMBED: '1' },
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Asks the RENDERER which props @hanzo/gui 8 actually honors.
|
||||
*
|
||||
* gui accepts any prop and drops the ones it does not know, so a gui-7 `tag="a"`
|
||||
* type-checks, builds, and ships a <div>: the link is inert and nothing anywhere says
|
||||
* so. A green build cannot answer this; only the rendered markup can. Every rule in
|
||||
* `src/lib/gui8-props.ts` was verified here before it was written down.
|
||||
*
|
||||
* gui injects its stylesheet as a leading <style>, so read the host element from the
|
||||
* marked child — never from the first tag in the string.
|
||||
*
|
||||
* node scripts/gui-prop-probe.mjs (slow: it resolves ~184 unbundled ESM packages)
|
||||
*/
|
||||
import React from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { defaultConfig } from '@hanzogui/config/v5'
|
||||
import { XStack, Text, createGui, GuiProvider } from '@hanzo/gui'
|
||||
|
||||
const config = createGui(defaultConfig)
|
||||
const render = (el) =>
|
||||
renderToStaticMarkup(React.createElement(GuiProvider, { config, defaultTheme: 'dark' }, el))
|
||||
|
||||
/** The element carrying our marker id — not gui's injected <style>. */
|
||||
const marked = (markup) => markup.match(/<([a-z]+)[^>]*\bid="probe"[^>]*>/)?.[0] ?? ''
|
||||
const hostOf = (markup) => marked(markup).match(/^<([a-z]+)/)?.[1] ?? '(not found)'
|
||||
|
||||
const probe = (label, Comp, props) => {
|
||||
const markup = render(React.createElement(Comp, { id: 'probe', ...props }, 'x'))
|
||||
console.log(`${label.padEnd(30)} -> ${marked(markup) || '(not found)'}`)
|
||||
return { host: hostOf(markup), markup }
|
||||
}
|
||||
|
||||
console.log('--- host element: tag (gui 7) vs render (gui 8) ---')
|
||||
const withTag = probe('tag="a"', XStack, { tag: 'a', href: 'https://hanzo.ai' })
|
||||
const withRender = probe('render="a"', XStack, { render: 'a', href: 'https://hanzo.ai' })
|
||||
|
||||
console.log('\n--- style props that silently drop or mis-unit ---')
|
||||
probe('lineHeight={1.1} (prop)', Text, { lineHeight: 1.1 })
|
||||
probe('style lineHeight: 1.1 (ratio)', Text, { style: { lineHeight: 1.1 } })
|
||||
probe("style lineHeight: '1.1' (string)", Text, { style: { lineHeight: '1.1' } })
|
||||
probe('letterSpacing="-0.02em"', Text, { letterSpacing: '-0.02em' })
|
||||
probe('animation="quick"', XStack, { animation: 'quick' })
|
||||
probe('$sm={{...}}', XStack, { $sm: { bg: '$red10' } })
|
||||
probe('$gtSm={{...}}', XStack, { $gtSm: { bg: '$red10' } })
|
||||
|
||||
console.log()
|
||||
console.log(withTag.host === 'a' ? 'tag WORKS' : 'tag IS SILENTLY DROPPED')
|
||||
console.log(withRender.host === 'a' ? 'render WORKS' : 'render IS SILENTLY DROPPED')
|
||||
@@ -1,29 +1,22 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* The account control — who you are, which organization you are acting in, what
|
||||
* you have left to spend, and the way out. ONE control, at the foot of the rail.
|
||||
* The account control — WHO you are: identity, your team, your personal
|
||||
* settings, what you have left to spend, and the way out. ONE control, at the
|
||||
* foot of the rail.
|
||||
*
|
||||
* It deliberately does NOT switch tenant. Org and project are one question —
|
||||
* WHERE you are — and they are answered together by `ContextSwitcher` at the
|
||||
* top-left, beside the tenant's own mark. Handing this menu an `orgState` too
|
||||
* would put the org in two corners again, which is the exact confusion the
|
||||
* condensed switcher removes. The cross-tenant reach, the admin-gated org list
|
||||
* and the single `org-scope.switchOrg` money seam all moved there intact; there
|
||||
* is still exactly one org switch in the app.
|
||||
*
|
||||
* It is `@hanzo/iam`'s `UserMenu`, the same component hanzo.chat mounts, so the
|
||||
* identity, the switcher and the behaviour (click-away, Escape, close-before-
|
||||
* navigate, never a raw uuid) are shared rather than rebuilt. This file is the
|
||||
* ADAPTER — everything the console knows that the SDK does not:
|
||||
*
|
||||
* - ORG REACH. `useOrganizations()` reads the caller's memberships off the token
|
||||
* and cannot express what an admin console does: enter ANY tenant. So the
|
||||
* switcher is handed `findOrgs`, backed by the console's existing lazy,
|
||||
* server-paged cross-tenant list (`IamAdminApi.organizations` through the
|
||||
* gated `/admin/iam` proxy) — the same source the full-page org picker uses.
|
||||
* A regular user never fires it: they see their own org, synthesized from the
|
||||
* session, exactly as before. Nothing is fabricated, and nobody's reach widens.
|
||||
*
|
||||
* - MONEY. Switching goes through `org-scope.switchOrg` — the console's existing
|
||||
* switch, passed by reference, not reimplemented. It persists the scope and
|
||||
* reloads so every module refetches under the new `X-Org-Id`. That one seam is
|
||||
* where tenant scoping and its billing attribution already live; this file adds
|
||||
* no second switch, no header of its own, and no billing call, so the rule about
|
||||
* which ledger a masquerading admin's spend lands on is exactly where it was.
|
||||
* `org-state.test.ts` pins the identity so a second switch cannot creep in.
|
||||
* identity and the behaviour (click-away, Escape, close-before-navigate, never a
|
||||
* raw uuid) are shared rather than rebuilt. This file is the ADAPTER —
|
||||
* everything the console knows that the SDK does not:
|
||||
*
|
||||
* - THEME. The console themes through `@hanzogui/next-theme` (which drives the
|
||||
* Gui tree). That is adapted into the menu's shape rather than mounting IAM's
|
||||
@@ -32,45 +25,19 @@
|
||||
* - BRAND. The strip at the foot wears THIS host's brand. Passing nothing would
|
||||
* paint a Hanzo mark on a Lux or Zoo console.
|
||||
*/
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { UserMenu, type OrgState, type UserTheme } from '@hanzo/iam/react'
|
||||
import { useMemo } from 'react'
|
||||
import { UserMenu, type UserTheme } from '@hanzo/iam/react'
|
||||
import { useThemeSetting } from '@hanzogui/next-theme'
|
||||
|
||||
import { config } from '~/config'
|
||||
import { adminOrgState, scopedOrgRow } from '~/lib/account/org-state'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { IamAdminApi, type Organization } from '~/lib/api'
|
||||
import { ORG_PAGE_SIZE, orgQuery } from '~/lib/org-list'
|
||||
import { currentOrg, leaveOrg, switchOrg } from '~/lib/org-scope'
|
||||
import { useCloudBalance, spendableCents } from '~/lib/billing/live-balance'
|
||||
|
||||
export function AccountMenu() {
|
||||
const { account, signOut } = useSession()
|
||||
// The cross-tenant list is admin-gated at the proxy; a regular user would 403 it,
|
||||
// so they are never asked to. Their own org is the honest answer.
|
||||
const isSuperAdmin = useIsSuperAdmin()
|
||||
const { balance } = useCloudBalance()
|
||||
const { current, resolvedTheme, set } = useThemeSetting()
|
||||
|
||||
const scoped = currentOrg()
|
||||
|
||||
const findOrgs = useCallback(
|
||||
async (query: string): Promise<Organization[]> => {
|
||||
if (!isSuperAdmin) return scopedOrgRow(scoped)
|
||||
const res = await IamAdminApi.organizations(orgQuery(0, query, ORG_PAGE_SIZE))
|
||||
return res.rows ?? []
|
||||
},
|
||||
[isSuperAdmin, scoped],
|
||||
)
|
||||
|
||||
// The console's own switch, by reference: persist the scope, reload, refetch
|
||||
// under the new X-Org-Id. Pinned in `org-state.test.ts`.
|
||||
const orgState: OrgState = useMemo(
|
||||
() => adminOrgState({ scoped, findOrgs, switchOrg }),
|
||||
[scoped, findOrgs],
|
||||
)
|
||||
|
||||
// `system` is a real choice, and the console's provider already understands it.
|
||||
const theme: UserTheme = useMemo(
|
||||
() => ({
|
||||
@@ -98,7 +65,6 @@ export function AccountMenu() {
|
||||
isAuthenticated
|
||||
isLoading={false}
|
||||
onSignOut={() => void signOut()}
|
||||
orgState={orgState}
|
||||
theme={theme}
|
||||
settingsUrl="/profile"
|
||||
usageUrl="/billing"
|
||||
@@ -106,12 +72,10 @@ export function AccountMenu() {
|
||||
// Only shown when the backend actually reported a balance — never a fabricated $0.
|
||||
balance={cents === null ? undefined : { amountUsd: cents / 100, topUpUrl: config.payUrl }}
|
||||
items={[
|
||||
{
|
||||
label: 'All organizations',
|
||||
// De-scope back to the full-page picker, where an org is entered — and
|
||||
// where a new one is created. The dropdown never grew its own form.
|
||||
onSelect: () => leaveOrg(),
|
||||
},
|
||||
// Your people, beside your own settings — the other half of "who am I".
|
||||
// Choosing a DIFFERENT tenant is a different question and lives in the
|
||||
// top-left context switcher, so this menu never re-scopes the console.
|
||||
{ label: 'Members', href: '/team' },
|
||||
{ label: 'Documentation', href: config.docsUrl, external: true, separatorBefore: true },
|
||||
]}
|
||||
brand={{ name: config.brandName }}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* All products — the directory where you curate your sidebar. Every Hanzo product
|
||||
* is always available on demand; this panel lists the FULL catalog the viewer may
|
||||
* see (brand-scoped, admin surfaces gated), grouped by category, each row with a
|
||||
* PIN toggle that promotes/removes it from the sidebar's Pinned quick-access section
|
||||
* (via `usePins`). Rendered in the shared DetailPane (opened from the sidebar's
|
||||
* "All products" row).
|
||||
* All products — the directory of every Hanzo app, where you OPEN one and where you
|
||||
* curate your sidebar. Every product is always available on demand; this panel lists
|
||||
* the FULL catalog the viewer may see (brand-scoped, admin surfaces gated), grouped
|
||||
* by category. A row OPENS its app; the pin toggle beside it promotes/removes it from
|
||||
* the sidebar's Pinned quick-access section (via `usePins`). Rendered in the shared
|
||||
* DetailPane (opened from the sidebar's "All products" row).
|
||||
*
|
||||
* Honest by construction: pinning is instant + optimistic (persisted through the
|
||||
* account preferences store — no async error state). Products with REAL org usage
|
||||
@@ -15,16 +15,18 @@
|
||||
* the badges/filter degrade away — never a fabricated "in use".
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button, Input, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Activity, Plus, Search, Star } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { visibleCatalogByCategory, type CatalogEntry, type ProductIcon } from '~/lib/products/registry'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { usePins, useProductColors } from '~/lib/products/pins'
|
||||
import { openProduct } from '~/lib/products/open'
|
||||
import { useDetailPane } from '~/components/DetailPane'
|
||||
import { fetchUsageRecords } from '~/lib/api/aimetrics'
|
||||
import { inUseProductIds } from '~/lib/products/product-usage'
|
||||
import { asColor } from '~/components/ui/color'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { EmptyState, asColor } from '@hanzo/ui/product'
|
||||
|
||||
/** The list narrowing controls at the top. */
|
||||
type Filter = 'all' | 'inuse' | 'pinned'
|
||||
@@ -41,23 +43,51 @@ function InUseBadge() {
|
||||
)
|
||||
}
|
||||
|
||||
/** One catalog row: icon · label/description (+ In-use badge) · pin toggle. */
|
||||
/**
|
||||
* One catalog row: icon · label/description (+ In-use badge) · pin toggle.
|
||||
*
|
||||
* The row itself OPENS the product. It used to be an inert `XStack` — a plain div
|
||||
* with `cursor: auto`, no role and no handler — so the one place in the console that
|
||||
* lists every app was a directory you could not walk: the only live control in the
|
||||
* row was the pin. Opening is delegated to the shared `openProduct`, the ONE opener
|
||||
* every other surface (sidebar, ⌘K, category page) already routes through.
|
||||
*
|
||||
* Pin stays a SEPARATE control on the same row, so curating never navigates and
|
||||
* navigating never curates. It stops the press from bubbling into the row for the
|
||||
* same reason.
|
||||
*/
|
||||
function ProductRow({
|
||||
entry,
|
||||
color,
|
||||
pinned,
|
||||
inUse,
|
||||
onOpen,
|
||||
onToggle,
|
||||
}: {
|
||||
entry: CatalogEntry
|
||||
color: string
|
||||
pinned: boolean
|
||||
inUse: boolean
|
||||
onOpen: () => void
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const Icon = entry.icon
|
||||
return (
|
||||
<XStack items="center" gap="$3" py="$2" px="$2" rounded="$3" minH={44} hoverStyle={{ bg: '$color2' }}>
|
||||
<XStack
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onPress={onOpen}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
gap="$3"
|
||||
py="$2"
|
||||
px="$2"
|
||||
rounded="$3"
|
||||
minH={44}
|
||||
hoverStyle={{ bg: '$color2' }}
|
||||
focusStyle={{ bg: '$color2' }}
|
||||
aria-label={`Open ${entry.label}`}
|
||||
>
|
||||
<YStack width={32} height={32} rounded="$3" bg="$color3" items="center" justify="center">
|
||||
<Icon size={16} color={asColor(color)} />
|
||||
</YStack>
|
||||
@@ -73,7 +103,10 @@ function ProductRow({
|
||||
<Button
|
||||
size="$2"
|
||||
icon={pinned ? <Star size={15} /> : <Plus size={15} />}
|
||||
onPress={onToggle}
|
||||
onPress={(e?: { stopPropagation?: () => void }) => {
|
||||
e?.stopPropagation?.()
|
||||
onToggle()
|
||||
}}
|
||||
bg={pinned ? '$color5' : 'transparent'}
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
@@ -121,6 +154,15 @@ export function AddProductPanel() {
|
||||
const showAdmin = useIsSuperAdmin()
|
||||
const { isPinned, toggle } = usePins()
|
||||
const { colorOf } = useProductColors()
|
||||
const router = useRouter()
|
||||
const detail = useDetailPane()
|
||||
// Opening an app takes you to it: navigate, then close the pane you launched from
|
||||
// (it is a directory, not a destination — leaving it open would cover the page it
|
||||
// just opened).
|
||||
const openEntry = (entry: CatalogEntry) => {
|
||||
openProduct(entry, (path) => router.push(path))
|
||||
detail.close()
|
||||
}
|
||||
const [query, setQuery] = useState('')
|
||||
const [filter, setFilter] = useState<Filter>('all')
|
||||
// Real org usage signal. `null` = not (yet) known; a Set (even empty) = a real
|
||||
@@ -236,6 +278,7 @@ export function AddProductPanel() {
|
||||
color={colorOf(entry.id)}
|
||||
pinned={isPinned(entry.id)}
|
||||
inUse={inUse?.has(entry.id) ?? false}
|
||||
onOpen={() => openEntry(entry)}
|
||||
onToggle={() => toggle(entry.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,40 +1,38 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Bridges the console session + App-Router navigation into the shared analytics
|
||||
* client (`@hanzo/event`). Rendered once, inside both `SessionProvider` and
|
||||
* `AnalyticsProvider` (see `Provider.tsx`), it renders nothing.
|
||||
* The console's telemetry surface — the ONE Hanzo telemetry provider, plus the one
|
||||
* thing it deliberately leaves to the app.
|
||||
*
|
||||
* - `usePageview` emits a pageview on every path change (the provider fires the
|
||||
* FIRST pageview itself, so this only covers subsequent client navigations).
|
||||
* - `identify` binds the user to their Hanzo IAM user id (`account.userId`, the
|
||||
* OIDC `sub`) once the session resolves, AND carries the attributes that make
|
||||
* that id legible. Anonymous placeholder sessions are skipped.
|
||||
* `TelemetrySurface` mounts `@hanzogui/telemetry`, which owns the whole plane for
|
||||
* this subtree: pageviews (including SPA route changes), `window.onerror` +
|
||||
* `unhandledrejection`, React render errors (its internal boundary REPORTS and
|
||||
* re-throws, so the app's own error UI still decides what the user sees), lazily
|
||||
* imported interaction capture, and DNT/GPC consent. It replaces the hand-rolled
|
||||
* `<AnalyticsProvider>` + bridge + boundary combo; it mounts @hanzo/event's
|
||||
* `AnalyticsProvider` internally with its own client, so every existing
|
||||
* `useAnalytics()` call site keeps working against that ONE client and one stream.
|
||||
* `product="console"` is all the configuration there is — @hanzo/event's DSN
|
||||
* registry resolves the hanzo-console Sentry project from it, so the error plane
|
||||
* needs no `dsn` prop and no env var.
|
||||
*
|
||||
* WHY NOT `owner/name`. This used to identify by the `${owner}/${name}` actor ref.
|
||||
* That is an org-relative REFERENCE, not a user id: it is a different id space
|
||||
* from the one hanzo.ai and hanzo.chat identify by (the IAM `sub`), so the same
|
||||
* user counted twice the moment they used two Hanzo surfaces — and every
|
||||
* cross-property funnel, retention curve and path silently measured nothing. It
|
||||
* also moves when an org or a login handle is renamed, which rewrites history.
|
||||
* It reads `usePathname()` ITSELF rather than taking a `path` prop from `Provider`:
|
||||
* `Provider` memoizes its tree on `children`, so a path read up there would be
|
||||
* baked into the cached element and go stale on the first client navigation.
|
||||
*
|
||||
* WHY TRAITS. A bare `identify(id)` writes a user id and nothing else, so the
|
||||
* warehouse held a population of opaque subjects: every funnel could count users
|
||||
* but no one could say WHICH user, and answering "who hit this error" meant a
|
||||
* manual IAM lookup per row. Email and name are FIRST-PARTY facts about our own
|
||||
* users — they arrive in the same IAM claims this file already decodes to get the
|
||||
* id, and were simply dropped on the floor. Sent as traits they are exactly as
|
||||
* sensitive as they were in the token, and the id stays the join key.
|
||||
*
|
||||
* WHAT IS STILL NOT SENT: the org. The tenant is stamped SERVER-SIDE from the
|
||||
* validated bearer, so org-level cohorts are already queryable — and a tenant the
|
||||
* client can name is a tenant the client can get wrong. Traits describe the user,
|
||||
* never the scope they are trusted with.
|
||||
* `AnalyticsBridge` is the one thing TelemetryProvider does NOT do — `identify`.
|
||||
* It binds the person to the STABLE `owner/name` actor id (the same id the API
|
||||
* client uses via `setCurrentActor`), never the email, once the session resolves.
|
||||
* The org tenant is stamped server-side from the session, so we send the user id
|
||||
* only, and anonymous placeholder sessions are skipped. It renders nothing and
|
||||
* emits NO pageview — the provider owns those, and a second emitter would
|
||||
* double-count every route.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, type ReactNode } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useAnalytics, usePageview } from '@hanzo/event/react'
|
||||
import { TelemetryProvider, useTelemetry } from '@hanzogui/telemetry'
|
||||
|
||||
import { iamAccessToken } from '~/lib/auth/iam'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { type Account } from '~/lib/api/types'
|
||||
|
||||
@@ -49,21 +47,30 @@ export function identityTraits(account: Account): Record<string, unknown> {
|
||||
return traits
|
||||
}
|
||||
|
||||
export function TelemetrySurface({ children }: { children: ReactNode }) {
|
||||
const path = usePathname()
|
||||
// @hanzo/iam (PKCE) is the console's ONE credential and it is a BEARER — the same
|
||||
// token `lib/api/client.ts` puts on every call — so telemetry authenticates the
|
||||
// same way rather than relying on a cookie the ingest host would never receive.
|
||||
return (
|
||||
<TelemetryProvider product="console" path={path} getToken={iamAccessToken}>
|
||||
{children}
|
||||
</TelemetryProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function AnalyticsBridge() {
|
||||
const analytics = useAnalytics()
|
||||
const telemetry = useTelemetry()
|
||||
const { account } = useSession()
|
||||
usePageview(usePathname())
|
||||
|
||||
const identified = useRef('')
|
||||
useEffect(() => {
|
||||
if (!account || account.type === 'anonymous-user') return
|
||||
// No IAM subject means no IAM user — leave the visitor anonymous rather than
|
||||
// inventing an id for them. Identity flows from the token's own claims.
|
||||
const userId = account.userId
|
||||
if (!userId || identified.current === userId) return
|
||||
identified.current = userId
|
||||
analytics.identify(userId, identityTraits(account))
|
||||
}, [account, analytics])
|
||||
if (!account?.owner || !account?.name || account.type === 'anonymous-user') return
|
||||
const personId = `${account.owner}/${account.name}`
|
||||
if (identified.current === personId) return
|
||||
identified.current = personId
|
||||
telemetry.identify(personId)
|
||||
}, [account, telemetry])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -22,12 +22,13 @@ import { useIam } from '@hanzo/iam/react'
|
||||
import { Button, Text, YStack } from '@hanzo/gui'
|
||||
|
||||
import { Loader } from '~/components/ui/Loader'
|
||||
import { takeReturnTo } from '~/lib/auth/iam'
|
||||
import { takeReturnTo, startReauth } from '~/lib/auth/iam'
|
||||
import { classifyCallback, type CallbackVerdict } from '~/lib/auth/callback-error'
|
||||
|
||||
export function AuthCallback() {
|
||||
const router = useRouter()
|
||||
const { handleCallback } = useIam()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [verdict, setVerdict] = useState<CallbackVerdict | null>(null)
|
||||
// The OAuth `code` is SINGLE-USE and the SDK removes the PKCE verifier BEFORE the
|
||||
// token fetch, so the exchange must fire EXACTLY ONCE. Without this guard, React
|
||||
// StrictMode's double-invoke (or any `handleCallback` identity change re-running the
|
||||
@@ -47,20 +48,37 @@ export function AuthCallback() {
|
||||
window.location.assign(takeReturnTo())
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError('Sign-in failed.')
|
||||
if (cancelled) return
|
||||
// The SDK is the AUTHORITY on whether a callback is a sign-in — it validates
|
||||
// `state` before honouring an error branch, so an attacker-supplied
|
||||
// /callback?error=… cannot mint a session. It reports every refusal the same
|
||||
// way, though, so this screen used to say "Sign-in failed." to someone who had
|
||||
// merely cancelled a consent screen. Read the code for WORDING only; the
|
||||
// decision was already made above.
|
||||
setVerdict(
|
||||
classifyCallback(typeof window === 'undefined' ? '' : window.location.search) ?? {
|
||||
kind: 'failed',
|
||||
message: 'Sign-in failed.',
|
||||
},
|
||||
)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [handleCallback])
|
||||
|
||||
if (error) {
|
||||
if (verdict) {
|
||||
// A refusal is not always a fault. Only a genuine failure is worded as one, and
|
||||
// the two benign outcomes lead with the action that actually resolves them.
|
||||
const retry = verdict.kind === 'failed' ? 'Back to sign in' : 'Sign in'
|
||||
return (
|
||||
<YStack flex={1} minH="100vh" items="center" justify="center" gap="$3">
|
||||
<Text color="$color12" fontWeight="600">
|
||||
{error}
|
||||
{verdict.message}
|
||||
</Text>
|
||||
<Button onPress={() => router.replace('/signin')}>Back to sign in</Button>
|
||||
<Button onPress={() => (verdict.kind === 'failed' ? router.replace('/signin') : startReauth())}>
|
||||
{retry}
|
||||
</Button>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -73,17 +73,16 @@ import {
|
||||
|
||||
import { AiApi, IamAdminApi, type Organization } from '~/lib/api'
|
||||
import { findEntry, type CatalogEntry } from '~/lib/products/registry'
|
||||
import { commandBarSystemPrompt, hanzoAssistantSystemPrompt } from '~/lib/assistant'
|
||||
import { assistantState, commandBarSystemPrompt, hanzoAssistantSystemPrompt } from '~/lib/assistant'
|
||||
import { searchDestinations, type Destination } from '~/lib/products/search'
|
||||
import { DEFAULT_GROUP_LABEL, pinnedFirst } from '~/lib/products/pins-core'
|
||||
import { usePins, useProductColors } from '~/lib/products/pins'
|
||||
import { asColor } from '~/components/ui/color'
|
||||
import { ProductIcon } from '~/components/ui/ProductIcon'
|
||||
import { openProduct } from '~/lib/products/open'
|
||||
import { currentOrg, switchOrg } from '~/lib/org-scope'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { BackendStateCard, asColor, type BackendState } from '@hanzo/ui/product'
|
||||
|
||||
const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
|
||||
|
||||
@@ -542,7 +541,7 @@ function PaletteDialog({
|
||||
setRun({ status: 'text', text: ans })
|
||||
}
|
||||
} catch (e) {
|
||||
setRun({ status: 'error', state: classifyBackend(e) })
|
||||
setRun({ status: 'error', state: assistantState(e) })
|
||||
}
|
||||
}, [mode, sub, showAdmin])
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Context switcher — WHERE you are: the organization and the project, in ONE
|
||||
* control, at the TOP-LEFT where the tenant's mark already sits.
|
||||
*
|
||||
* The console used to answer "who and where am I" from three different corners:
|
||||
* the org at the top of the rail, the account (which also switched org) at its
|
||||
* foot, and the project chip in the top-right beside the network. Org and
|
||||
* project are one question — which tenant, and which slice of it — so they are
|
||||
* one control, and it sits with the org mark that already anchors the top-left.
|
||||
*
|
||||
* The ACCOUNT keeps the other question ("who am I": identity, team, personal
|
||||
* settings, the way out) at the foot of the rail. The NETWORK stays its own
|
||||
* control in the top-right, because it is a global MODE rather than a place —
|
||||
* and its tier dot is a destructive-environment guard, not decoration.
|
||||
*
|
||||
* There is still exactly ONE org switch. `switchOrg` is passed by reference from
|
||||
* `~/lib/org-scope` (the seam that persists the scope and reloads so every
|
||||
* module refetches under the new `X-Org-Id`, which is where tenant scoping and
|
||||
* its billing attribution already live). This control does not mint a second
|
||||
* one, add a header of its own, or make a billing call — `org-state.test.ts`
|
||||
* pins that identity. Cross-tenant reach is the SAME admin-gated, server-paged
|
||||
* list the full-page picker uses; a regular user never fires it and sees only
|
||||
* their own org.
|
||||
*/
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button, Popover, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { ChevronsUpDown, FolderGit2, Plus } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { useScope } from '~/lib/scope-context'
|
||||
import { useOrgIdentity } from '~/components/ui/BrandLogo'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { IamAdminApi, type Organization } from '~/lib/api'
|
||||
import { ORG_PAGE_SIZE, orgQuery } from '~/lib/org-list'
|
||||
import { currentOrg, leaveOrg, switchOrg } from '~/lib/org-scope'
|
||||
import { contextLabel, scopedOrgRow, titleCase } from '~/lib/account/org-state'
|
||||
import { MenuRow } from '~/components/ui/MenuRow'
|
||||
import { paper } from '~/components/ui/paper'
|
||||
import { SearchInput } from '@hanzo/ui/product'
|
||||
|
||||
export function ContextSwitcher() {
|
||||
const router = useRouter()
|
||||
const org = useOrgIdentity()
|
||||
const scoped = currentOrg()
|
||||
const isSuperAdmin = useIsSuperAdmin()
|
||||
const { scope, projects, loadingProjects, selectProject } = useScope()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [orgs, setOrgs] = useState<Organization[] | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
// IAM's display name when it has one; otherwise the slug, titled the same way
|
||||
// `scopedOrgRow` titles it — one rule, so the trigger and the list agree.
|
||||
const orgLabel = org.displayName || titleCase(org.name || scoped)
|
||||
|
||||
// The cross-tenant list is admin-gated at the proxy; a regular user would 403
|
||||
// it, so they are never asked to — their own org is the honest answer. An admin
|
||||
// searches the SERVER (the list is paged and far longer than one page), which is
|
||||
// the only way to reach a tenant nobody is a member of.
|
||||
const loadOrgs = useCallback(
|
||||
async (q: string) => {
|
||||
if (!isSuperAdmin) return setOrgs(scopedOrgRow(scoped) as Organization[])
|
||||
const res = await IamAdminApi.organizations(orgQuery(0, q, ORG_PAGE_SIZE))
|
||||
setOrgs(res.rows ?? [])
|
||||
},
|
||||
[isSuperAdmin, scoped],
|
||||
)
|
||||
|
||||
const onOpenChange = useCallback(
|
||||
(next: boolean) => {
|
||||
setOpen(next)
|
||||
if (next && orgs === null) void loadOrgs('')
|
||||
},
|
||||
[orgs, loadOrgs],
|
||||
)
|
||||
|
||||
const search = useCallback(
|
||||
(q: string) => {
|
||||
setQuery(q)
|
||||
void loadOrgs(q)
|
||||
},
|
||||
[loadOrgs],
|
||||
)
|
||||
|
||||
const pick = useCallback(
|
||||
(fn: () => void) => () => {
|
||||
setOpen(false)
|
||||
fn()
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const orgRows = useMemo(() => orgs ?? [], [orgs])
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange} placement="bottom-start">
|
||||
<Popover.Trigger asChild>
|
||||
<Button
|
||||
size="$3"
|
||||
chromeless
|
||||
justify="flex-start"
|
||||
px="$2"
|
||||
data-testid="switcher-context"
|
||||
iconAfter={<ChevronsUpDown size={13} opacity={0.6} />}
|
||||
aria-label={`Organization and project — ${contextLabel(orgLabel, scope.project)}`}
|
||||
>
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
|
||||
{contextLabel(orgLabel, scope.project)}
|
||||
</Text>
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content {...paper} p="$2" width={280}>
|
||||
<YStack gap="$0.5">
|
||||
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="500">
|
||||
Organization
|
||||
</Text>
|
||||
|
||||
{/* Admins only: the cross-tenant list is server-paged and longer than
|
||||
one page, so reaching a tenant nobody is a member of means SEARCHING
|
||||
it, not scrolling. A regular user has one org and no field. */}
|
||||
{isSuperAdmin ? (
|
||||
<YStack px="$1" pb="$1">
|
||||
{/* A search landmark names the control for assistive tech — the shared
|
||||
SearchInput has no accessible-name prop of its own. */}
|
||||
<div role="search" aria-label="Find an organization">
|
||||
<SearchInput value={query} onChange={search} placeholder="Find an organization" name="org" />
|
||||
</div>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
<YStack role="radiogroup" aria-label="Organizations" gap="$0.5">
|
||||
{orgRows.map((o) => (
|
||||
<MenuRow
|
||||
key={o.name}
|
||||
label={o.displayName || o.name}
|
||||
active={scoped === o.name}
|
||||
onPress={pick(() => {
|
||||
if (o.name !== scoped) switchOrg(o.name)
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</YStack>
|
||||
|
||||
{orgRows.length === 0 ? (
|
||||
<Text px="$2" py="$1.5" fontSize="$2" color="$color10">
|
||||
{orgs === null ? 'Loading…' : 'No organization matches that.'}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<MenuRow label="All organizations" icon={<Plus size={14} />} onPress={pick(leaveOrg)} />
|
||||
|
||||
<XStack height={1} bg="$borderColor" my="$1" />
|
||||
|
||||
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="500">
|
||||
Project
|
||||
</Text>
|
||||
|
||||
<YStack role="radiogroup" aria-label="Projects" gap="$0.5">
|
||||
{/* Org-level scope — no X-Project-Id sent. */}
|
||||
<MenuRow
|
||||
label="All projects"
|
||||
sub="Org-level"
|
||||
active={!scope.project}
|
||||
onPress={pick(() => selectProject(undefined))}
|
||||
/>
|
||||
|
||||
{projects.map((p) => (
|
||||
<MenuRow
|
||||
key={p.name}
|
||||
label={p.displayName || p.name}
|
||||
active={scope.project === p.name}
|
||||
onPress={pick(() => selectProject(p.name))}
|
||||
/>
|
||||
))}
|
||||
</YStack>
|
||||
|
||||
{projects.length === 0 && !loadingProjects ? (
|
||||
<Text px="$2" py="$1.5" fontSize="$2" color="$color10">
|
||||
No projects yet.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<MenuRow
|
||||
label="New project"
|
||||
icon={<FolderGit2 size={14} />}
|
||||
onPress={pick(() => router.push('/projects'))}
|
||||
/>
|
||||
</YStack>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -28,8 +28,8 @@ import { Button, ScrollView, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { X } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { SlideOver } from '~/components/ui/SlideOver'
|
||||
import { asColor, type IconLike } from '~/components/ui/color'
|
||||
import { Z } from '~/lib/z'
|
||||
import { asColor, type IconLike } from '@hanzo/ui/product'
|
||||
|
||||
export type DetailDescriptor = {
|
||||
/** Pane title (the item's name). */
|
||||
|
||||
+120
-59
@@ -15,7 +15,23 @@
|
||||
*
|
||||
* Docking is a desktop concern (a phone has no room for a permanent column), so on
|
||||
* `<lg` the assistant is ALWAYS the floating bubble/sheet regardless of the dock
|
||||
* choice; `docked` only reserves the right column at `lg+`.
|
||||
* choice. That fact — the persisted CHOICE against a viewport that can honor it —
|
||||
* meets in exactly one place, `column`, and it is what every shape is chosen by.
|
||||
*
|
||||
* It is not a detail. The sheet is a MODAL dialog, so leaving it open behind a hidden
|
||||
* column put a full-viewport dialog over the page: the column painted, and every click
|
||||
* on it landed on the dialog instead — an assistant you could read and could not type
|
||||
* into. Hiding one of two open surfaces with CSS cannot fix that, because `display:
|
||||
* none` on the dialog's own content does not make the dialog stop being modal. So only
|
||||
* one is ever open, and `column` is the one fact that says which.
|
||||
*
|
||||
* The assistant has ONE entry point and it lives HERE: `AssistantFab`, a floating
|
||||
* control fixed bottom-right over every dashboard page. It used to be two small
|
||||
* buttons in the topbar (a brand-H and a mic), which put the assistant in a third
|
||||
* place — beside the search box, competing with the org/theme/alert chrome — while
|
||||
* this module owned every other shape it can take. Chat and voice are the same
|
||||
* surface opened two ways, so they sit together, in the corner the assistant
|
||||
* actually appears in.
|
||||
*
|
||||
* Every shape REUSES the one working chat surface (`ChatConversation` → `AiApi.chat`
|
||||
* → the keyless `/ai` proxy → /v1/chat/completions). Nothing about AI is rebuilt
|
||||
@@ -25,27 +41,33 @@
|
||||
*/
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { Button, Dialog, Text, VisuallyHidden, XStack, YStack } from '@hanzo/gui'
|
||||
import { PanelRight, PanelRightClose, Sparkles, X } from '@hanzogui/lucide-icons-2'
|
||||
import { Button, Dialog, Text, VisuallyHidden, XStack, YStack, useMedia } from '@hanzo/gui'
|
||||
import { Mic, PanelRight, PanelRightClose, Sparkles, X } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { ChatConversation } from '~/components/products/chat/ChatConversation'
|
||||
// NB: the old `BrandMark` bubble import was removed with the floating circle — the
|
||||
// assistant now opens from the topbar's small brand-H + mic controls.
|
||||
import { BrandMark } from '~/components/ui/BrandLogo'
|
||||
import { usePreferences } from '~/lib/products/preferences'
|
||||
import { voiceSupported } from '~/lib/voice'
|
||||
import { Z } from '~/lib/z'
|
||||
|
||||
type FloatingChatApi = {
|
||||
isOpen: boolean
|
||||
open: () => void
|
||||
close: () => void
|
||||
toggle: () => void
|
||||
/** True when the assistant is docked as a permanent right column (persisted). */
|
||||
docked: boolean
|
||||
/**
|
||||
* True when the permanent right column IS the assistant right now: the dock choice,
|
||||
* a viewport wide enough to hold it, and a page that is not already a composer.
|
||||
* Every surface reads this rather than the raw choice, so exactly one composer is
|
||||
* on screen at any width.
|
||||
*/
|
||||
column: boolean
|
||||
setDocked: (v: boolean) => void
|
||||
/**
|
||||
* Open the assistant with a PRE-FILLED prompt (e.g. "Ask AI about this code" from the
|
||||
* Code hub). The composer is seeded and focused; the user reviews and sends (never an
|
||||
* auto-send — no surprise billing), matching the suggested-prompt UX. Opens the floating
|
||||
* sheet when floating; when docked, the permanent column receives the seed.
|
||||
* auto-send — no surprise billing), matching the suggested-prompt UX. The column takes
|
||||
* the seed when it is the assistant; otherwise the sheet opens with it.
|
||||
*/
|
||||
ask: (prompt: string) => void
|
||||
/** The current pending seed for the composer (consumed once by the active conversation). */
|
||||
@@ -114,17 +136,14 @@ function ChatSheet({
|
||||
onOpenChange,
|
||||
onHistory,
|
||||
onDock,
|
||||
docked,
|
||||
seed,
|
||||
voiceSignal,
|
||||
}: {
|
||||
/** Open ONLY when the sheet is the assistant — never alongside the column. */
|
||||
open: boolean
|
||||
onOpenChange: (o: boolean) => void
|
||||
onHistory: () => void
|
||||
onDock: () => void
|
||||
/** When docked (desktop), the permanent column is the surface — so the floating
|
||||
* sheet is suppressed at lg+ (it still serves phones, which have no column). */
|
||||
docked: boolean
|
||||
/** Pre-fill seed for the composer (from `useFloatingChat().ask`). */
|
||||
seed?: string | null
|
||||
/** Voice-start signal forwarded to the conversation ("talk to Hanzo"). */
|
||||
@@ -136,12 +155,7 @@ function ChatSheet({
|
||||
return (
|
||||
<Dialog modal open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
key="chat-overlay"
|
||||
className="hz-scrim-in"
|
||||
bg="rgba(0,0,0,0.5)"
|
||||
$lg={{ bg: 'transparent', display: docked ? 'none' : undefined }}
|
||||
/>
|
||||
<Dialog.Overlay key="chat-overlay" className="hz-scrim-in" bg="rgba(0,0,0,0.5)" $lg={{ bg: 'transparent' }} />
|
||||
<Dialog.Content
|
||||
key="chat-content"
|
||||
className="hz-paper hz-pop-in"
|
||||
@@ -158,18 +172,8 @@ function ChatSheet({
|
||||
width="100vw"
|
||||
height="100dvh"
|
||||
rounded="$0"
|
||||
// Desktop (≥lg): a compact popover bottom-right, above the bubble. Docked →
|
||||
// hidden at lg+ (the permanent right column replaces it).
|
||||
$lg={{
|
||||
t: 'auto',
|
||||
l: 'auto',
|
||||
b: 88,
|
||||
r: 24,
|
||||
width: 380,
|
||||
height: 560,
|
||||
rounded: '$6',
|
||||
display: docked ? 'none' : undefined,
|
||||
}}
|
||||
// Desktop (≥lg): a compact popover bottom-right, above the bubble.
|
||||
$lg={{ t: 'auto', l: 'auto', b: 88, r: 24, width: 380, height: 560, rounded: '$6' }}
|
||||
>
|
||||
<VisuallyHidden>
|
||||
<Dialog.Title>Assistant</Dialog.Title>
|
||||
@@ -204,6 +208,64 @@ function ChatSheet({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The floating assistant control — bottom-right, on every dashboard page.
|
||||
*
|
||||
* Two ways into one surface, side by side: the brand mark opens the assistant (the
|
||||
* docked right column on a laptop, the full sheet on a phone) and the mic opens it
|
||||
* listening. The mic renders only where the browser can actually listen, so there is
|
||||
* never a dead control.
|
||||
*
|
||||
* It sits ABOVE the Developers dock at `lg+` (that dock's collapsed bar is 44px and
|
||||
* exists only there), and its caller suppresses it exactly where the assistant is
|
||||
* already on screen: while the sheet is open, on the pages that ARE a composer
|
||||
* (`/chat`, `/playground`), and while the assistant IS the column.
|
||||
*/
|
||||
function AssistantFab({ onOpen, onVoice }: { onOpen: () => void; onVoice: () => void }) {
|
||||
const [voiceOk] = useState(() => voiceSupported())
|
||||
return (
|
||||
<XStack
|
||||
testID="assistant-fab"
|
||||
position="fixed"
|
||||
r={20}
|
||||
b={20}
|
||||
$lg={{ b: 64 }}
|
||||
items="center"
|
||||
gap="$2"
|
||||
style={{ zIndex: Z.raised }}
|
||||
>
|
||||
{voiceOk ? (
|
||||
<Button
|
||||
className="hz-paper"
|
||||
size="$3"
|
||||
circular
|
||||
width={44}
|
||||
height={44}
|
||||
bg="$color2"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
icon={<Mic size={18} />}
|
||||
onPress={onVoice}
|
||||
aria-label="Talk to Hanzo"
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
className="hz-paper"
|
||||
size="$4"
|
||||
circular
|
||||
width={52}
|
||||
height={52}
|
||||
bg="$color2"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
icon={<BrandMark size={22} />}
|
||||
onPress={onOpen}
|
||||
aria-label="Ask Hanzo"
|
||||
/>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The DOCKED assistant — a permanent right column. Rendered by `Dashboard`
|
||||
* inside the layout's reserved right rail (lg+ only), so it reserves space beside
|
||||
@@ -229,30 +291,36 @@ export function Chat({ children }: { children: ReactNode }) {
|
||||
const { get, set } = usePreferences()
|
||||
const docked = get<boolean>('chatDocked', false)
|
||||
const setDocked = useCallback((v: boolean) => set('chatDocked', v), [set])
|
||||
// The bubble is redundant — and OVERLAPS the composer's send control — on the
|
||||
// pages that ARE a full chat/composer surface. Suppress it there (the assistant
|
||||
// is still openable programmatically via `useFloatingChat`); every other page
|
||||
// keeps the one-tap bubble.
|
||||
const media = useMedia()
|
||||
// The pages that ARE a full composer already show the assistant, so no other shape
|
||||
// of it belongs on them — the bubble would overlap the page's own send control, and
|
||||
// the column would be a second composer beside the first.
|
||||
const onChatSurface =
|
||||
pathname === '/chat' ||
|
||||
pathname.startsWith('/chat/') ||
|
||||
pathname === '/playground' ||
|
||||
pathname.startsWith('/playground/')
|
||||
// Every fact about which shape the assistant takes meets here and nowhere else: the
|
||||
// persisted choice, a viewport wide enough to honor it, and whether this page is
|
||||
// already a composer. A JS fact, not a media prop, because it decides what MOUNTS —
|
||||
// a modal dialog that is merely hidden is still modal, and still eats every click.
|
||||
const column = docked && media.lg && !onChatSurface
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const open = useCallback(() => setIsOpen(true), [])
|
||||
const close = useCallback(() => setIsOpen(false), [])
|
||||
const toggle = useCallback(() => setIsOpen((v) => !v), [])
|
||||
|
||||
// Seed the composer from anywhere (e.g. the Code hub's "Ask AI about this code").
|
||||
// Open the floating sheet when floating; when docked, the permanent column is already
|
||||
// on screen and receives the seed, so opening the (hidden) sheet is skipped.
|
||||
// The column is already on screen and receives the seed; otherwise open the sheet —
|
||||
// including on a phone whose owner once docked on a laptop, where the choice is
|
||||
// remembered but no column exists to deliver the prompt.
|
||||
const [seed, setSeed] = useState<string | null>(null)
|
||||
const ask = useCallback(
|
||||
(prompt: string) => {
|
||||
setSeed(prompt)
|
||||
if (!docked) setIsOpen(true)
|
||||
if (!column) setIsOpen(true)
|
||||
},
|
||||
[docked],
|
||||
[column],
|
||||
)
|
||||
|
||||
const onHistory = useCallback(() => {
|
||||
@@ -270,10 +338,9 @@ export function Chat({ children }: { children: ReactNode }) {
|
||||
// conversation opens the mic on change.
|
||||
const [voiceSignal, setVoiceSignal] = useState(0)
|
||||
|
||||
// The topbar brand-H entry: TOGGLE the assistant. Desktop → the docked right
|
||||
// sidebar column; phones (no column) → the full sheet. Setting both in tandem is
|
||||
// correct because at lg+ the sheet is suppressed while docked, and below lg the
|
||||
// dock column is display:none — so one control opens the right surface per viewport.
|
||||
// The topbar brand-H entry: TOGGLE the assistant. Desktop → the right column;
|
||||
// phones (no column) → the full sheet. Both are set because `column` then admits
|
||||
// exactly one of them per viewport.
|
||||
const openChat = useCallback(() => {
|
||||
const next = !docked
|
||||
setDocked(next)
|
||||
@@ -289,29 +356,23 @@ export function Chat({ children }: { children: ReactNode }) {
|
||||
}, [setDocked])
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={{ isOpen, open, close, toggle, docked, setDocked, ask, seed, openChat, startVoice, voiceSignal }}>
|
||||
<Ctx.Provider value={{ isOpen, open, close, toggle, column, setDocked, ask, seed, openChat, startVoice, voiceSignal }}>
|
||||
{children}
|
||||
|
||||
{/* The bubble — fixed bottom-right over every page. Hidden while open (the
|
||||
sheet's own close is the single dismiss). Hidden on the chat/playground
|
||||
surfaces (would overlap the page composer). At lg+ it is ALSO hidden when
|
||||
docked (the permanent column is the surface); on phones it always shows,
|
||||
since docking has no room there. */}
|
||||
{/* NO floating bubble — the big circle that covered page content is gone. The
|
||||
assistant is opened from the topbar (the small brand-H "chat" control + the
|
||||
"talk to Hanzo" mic), so the user's OWN brand leads the chrome and AI help is
|
||||
one small press away. `open`/`toggle`/`ask` still drive it programmatically
|
||||
(e.g. the Code hub's "Ask AI"). */}
|
||||
{/* The assistant's ONE entry point — bottom-right, over every page. Hidden
|
||||
while the sheet is open (its own close is the single dismiss), on the pages
|
||||
that ARE a composer, and while the column is the surface. `open`/`toggle`/
|
||||
`ask` still drive the assistant programmatically (e.g. "Ask AI"). */}
|
||||
{isOpen || onChatSurface || column ? null : <AssistantFab onOpen={openChat} onVoice={startVoice} />}
|
||||
|
||||
{/* The floating sheet. Suppressed on the full chat/playground surfaces (the page
|
||||
IS the composer) and, at lg+, while docked (the right column is the surface);
|
||||
on phones it's the assistant even when docked. */}
|
||||
{/* The floating sheet — the assistant wherever the column is not: every width on
|
||||
a phone or tablet, and on a laptop until the user docks it. Never open at the
|
||||
same time as the column, so there is one composer and it takes the click. */}
|
||||
<ChatSheet
|
||||
open={isOpen && !onChatSurface}
|
||||
open={isOpen && !onChatSurface && !column}
|
||||
onOpenChange={setIsOpen}
|
||||
onHistory={onHistory}
|
||||
onDock={dock}
|
||||
docked={docked}
|
||||
seed={seed}
|
||||
voiceSignal={voiceSignal}
|
||||
/>
|
||||
|
||||
@@ -20,11 +20,27 @@ import { Button, Card, Input, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Building2, ArrowRight, Sparkles } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { slugifyOrg, validateOrgName } from '~/lib/server/onboarding'
|
||||
import { readOnboardRefusal, slugifyOrg, validateOrgName } from '~/lib/server/onboarding'
|
||||
import { v1Url } from '~/lib/api/client'
|
||||
import { FadeIn } from '~/components/ui/FadeIn'
|
||||
import { FadeIn } from '@hanzo/ui/product'
|
||||
|
||||
type Phase = 'form' | 'done'
|
||||
type Phase = 'form' | 'done' | 'exists'
|
||||
|
||||
// currentOwner asks the server which organization this account is in. IAM answers
|
||||
// the legacy envelope ({status,msg,data}) on this address, so read through `data`
|
||||
// and fall back to a bare body. Any failure returns null and the caller falls back
|
||||
// to showing the server's own message — a recovery that guesses is worse than none.
|
||||
async function currentOwner(): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(v1Url('iam/account'), { credentials: 'include' })
|
||||
if (!res.ok) return null
|
||||
const body = (await res.json()) as { data?: { owner?: string }; owner?: string } | null
|
||||
const owner = body?.data?.owner ?? body?.owner
|
||||
return owner && owner !== 'admin' ? owner : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function OrgOnboarding() {
|
||||
const { signIn, signOut } = useSession()
|
||||
@@ -32,6 +48,9 @@ export function OrgOnboarding() {
|
||||
const [busy, setBusy] = useState<false | 'create' | 'personal'>(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [phase, setPhase] = useState<Phase>('form')
|
||||
// The org the SERVER says this account already admins, discovered only after a
|
||||
// refused create. See the 409 branch in onboard().
|
||||
const [existingOrg, setExistingOrg] = useState<string | null>(null)
|
||||
|
||||
const slug = slugifyOrg(name)
|
||||
const named = validateOrgName(name)
|
||||
@@ -55,7 +74,28 @@ export function OrgOnboarding() {
|
||||
}
|
||||
const json = (await res.json().catch(() => null)) as { org?: string; error?: string } | null
|
||||
if (!res.ok || !json?.org) {
|
||||
setError(json?.error || `Could not create the organization (HTTP ${res.status}).`)
|
||||
// 409 = the FIRST-RUN GATE, not a name collision. Onboarding MOVES the caller
|
||||
// into the org it founds, so founding a second one would orphan the org this
|
||||
// account already admins — IAM refuses (provision.go: "you already have an
|
||||
// organization"). This screen is only ever rendered when the client resolved
|
||||
// an EMPTY owner, and an owner that is empty because a read failed looks
|
||||
// exactly like a brand-new account. So the refusal is the first reliable
|
||||
// signal that the session was wrong, and the only honest thing to do with it
|
||||
// is recover: ask the server which org this account is actually in and offer
|
||||
// the way in. Leaving the customer on a form that can never submit — with a
|
||||
// message about organizations when they just typed a name — is how "it said
|
||||
// the name was taken" happens.
|
||||
const refusal = readOnboardRefusal(
|
||||
res.status,
|
||||
json?.error,
|
||||
res.status === 409 ? await currentOwner() : null,
|
||||
)
|
||||
if (refusal.action === 'recover') {
|
||||
setExistingOrg(refusal.org)
|
||||
setPhase('exists')
|
||||
} else {
|
||||
setError(refusal.error)
|
||||
}
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
@@ -69,6 +109,32 @@ export function OrgOnboarding() {
|
||||
signIn()
|
||||
}
|
||||
|
||||
if (phase === 'exists' && existingOrg) {
|
||||
return (
|
||||
<Center>
|
||||
<FadeIn style={CENTER_STYLE}>
|
||||
<Card p="$5" gap="$4" width={440} borderWidth={1} borderColor="$borderColor" bg="$color1" items="center">
|
||||
<Building2 size={20} />
|
||||
<YStack gap="$1" items="center">
|
||||
<Text fontSize="$6" fontWeight="800">
|
||||
You{'\u2019'}re already in {existingOrg}
|
||||
</Text>
|
||||
<Text fontSize="$3" color="$color11" text="center">
|
||||
This account already belongs to an organization, so there was nothing to
|
||||
create. Sign in again to continue there.
|
||||
</Text>
|
||||
</YStack>
|
||||
{/* Explicit, never automatic. Re-authenticating on our own would loop
|
||||
forever against whatever left the session without an owner. */}
|
||||
<Button size="$3" onPress={() => signIn()}>
|
||||
Continue to {existingOrg}
|
||||
</Button>
|
||||
</Card>
|
||||
</FadeIn>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
if (phase === 'done') {
|
||||
return (
|
||||
<Center>
|
||||
|
||||
@@ -27,10 +27,9 @@ import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { enterOrg } from '~/lib/org-scope'
|
||||
import { IamAdminApi, type Organization } from '~/lib/api'
|
||||
import { BrandMark } from '~/components/ui/BrandLogo'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { FadeIn } from '~/components/ui/FadeIn'
|
||||
import { OrgOnboarding } from '~/components/OrgOnboarding'
|
||||
import { PAGE_SIZE, pickerView, type OrgCard, type PickerContext } from '~/components/org-picker/logic'
|
||||
import { EmptyState, FadeIn } from '@hanzo/ui/product'
|
||||
|
||||
const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
|
||||
|
||||
|
||||
@@ -8,15 +8,13 @@ import { useMemo, type ReactNode } from 'react'
|
||||
import { GuiProvider } from '@hanzo/gui'
|
||||
import { NextThemeProvider, useRootTheme } from '@hanzogui/next-theme'
|
||||
import { registerDefaultFields, registerField } from '@hanzo/data'
|
||||
import { AnalyticsProvider } from '@hanzo/event/react'
|
||||
import { IamProvider } from '@hanzo/iam/react'
|
||||
|
||||
import config from '../../gui.config'
|
||||
import { SessionProvider } from '~/lib/auth/session'
|
||||
import { iamConfig } from '~/lib/auth/iam'
|
||||
import { EntitlementsProvider } from '~/lib/entitlements-context'
|
||||
import { eventClient } from '~/lib/event'
|
||||
import { AnalyticsBridge } from './Analytics'
|
||||
import { AnalyticsBridge, TelemetrySurface } from './Analytics'
|
||||
import { OrgAccentProvider } from './OrgAccentProvider'
|
||||
import { RichTextDisplay, RichTextInput } from './fields/RichTextField'
|
||||
|
||||
@@ -64,16 +62,15 @@ export function Provider({ children }: { children: ReactNode }) {
|
||||
{/* Entitlements live inside the session (they read the signed-in account +
|
||||
active org scope) so the sidebar/palette gate from ONE fetch. */}
|
||||
<EntitlementsProvider>
|
||||
{/* Analytics lives INSIDE the session so `identify` binds the signed-in
|
||||
actor. The ONE shared `eventClient` (same-origin /v1/event; the tenant is
|
||||
stamped server-side, so the client never sends an org) is also referenced
|
||||
by the error boundaries, so every signal rides one stream. The provider
|
||||
fires the first pageview (autoPageview); `AnalyticsBridge` wires the
|
||||
per-navigation pageviews + identity. */}
|
||||
<AnalyticsProvider client={eventClient}>
|
||||
{/* Telemetry lives INSIDE the session so `identify` binds the signed-in
|
||||
actor. `TelemetrySurface` is the ONE provider — pageviews, errors,
|
||||
interaction capture, consent — and it mounts @hanzo/event internally,
|
||||
so `useAnalytics()` call sites share its client. `AnalyticsBridge`
|
||||
adds only identity; the provider owns pageviews. */}
|
||||
<TelemetrySurface>
|
||||
<AnalyticsBridge />
|
||||
{children}
|
||||
</AnalyticsProvider>
|
||||
</TelemetrySurface>
|
||||
</EntitlementsProvider>
|
||||
</SessionProvider>
|
||||
</IamProvider>
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* PublicLanding — what an UNAUTHENTICATED visitor sees at `/` (the marketing face
|
||||
* of the console). The SAME one `cloud` binary that serves the signed-in console
|
||||
* serves this to anon — one binary, one way, no separate marketing service.
|
||||
*
|
||||
* Content is DERIVED from the real product taxonomy (categoriesForBrand +
|
||||
* CATEGORY_SUMMARY), brand-scoped via getBrand — so it is honest (every tile is a
|
||||
* real category the platform ships) and white-labels for free. The primary CTA is
|
||||
* the ONE sign-in surface (/signin); "Learn more" goes to the brand's own site.
|
||||
*/
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { HanzoHeader, HANZO_PRODUCT_CATEGORIES, findSurfaceByHost, type HanzoSurface } from '@hanzogui/shell'
|
||||
|
||||
import { config } from '~/config'
|
||||
import { getBrand } from '~/lib/branding/brands'
|
||||
import { categoriesForBrand, CATEGORY_SUMMARY } from '~/lib/products/brand-scope'
|
||||
import { landingSurface } from '~/lib/products/landing-surface'
|
||||
import { ConsoleFooter } from '~/components/ConsoleFooter'
|
||||
|
||||
/** The shared header, with its CTAs re-pointed at this landing's own sign-in
|
||||
* (the canonical surface aims them AT the console — a self-link from here). */
|
||||
const LANDING_SURFACE: HanzoSurface = landingSurface(findSurfaceByHost('cloud.hanzo.ai'))
|
||||
|
||||
/**
|
||||
* Decline the shell's account control. `HanzoHeader` renders its OWN text "Sign in"
|
||||
* link whenever `account` is nullish (`account ?? <DefaultAccount/>`), so omitting it
|
||||
* put a second sign-in beside the primary CTA that IS the sign-in — the desktop
|
||||
* logged-out header read `[Get API key] [Sign in] [Sign in]`. `false` is how a caller
|
||||
* says "no account node": it is not nullish, so the default never renders, and React
|
||||
* renders nothing for it — including in the mobile sheet, which would otherwise draw
|
||||
* an empty bordered identity row around it. Exactly ONE sign-in affordance.
|
||||
*/
|
||||
const NO_ACCOUNT = false
|
||||
|
||||
/**
|
||||
* The house hero buttons — the same pill pair every Hanzo landing wears: a white
|
||||
* primary carrying the weight, and a hairline secondary that is still visibly a
|
||||
* button. Tamagui's default Button is a grey chip that reads as DISABLED next to
|
||||
* the white sign-in pill in the header, and `chromeless` has no edge at all.
|
||||
*/
|
||||
const CTA_PRIMARY = {
|
||||
rounded: 999,
|
||||
bg: '$color12',
|
||||
color: '$color1',
|
||||
borderWidth: 0,
|
||||
hoverStyle: { bg: '$color12', opacity: 0.85 },
|
||||
pressStyle: { bg: '$color12', opacity: 0.7 },
|
||||
} as const
|
||||
|
||||
const CTA_SECONDARY = {
|
||||
rounded: 999,
|
||||
bg: 'transparent',
|
||||
color: '$color12',
|
||||
borderWidth: 1,
|
||||
borderColor: '$color6',
|
||||
hoverStyle: { bg: '$color3', borderColor: '$color8' },
|
||||
pressStyle: { bg: '$color4' },
|
||||
} as const
|
||||
|
||||
export function PublicLanding() {
|
||||
const router = useRouter()
|
||||
const brand = getBrand()
|
||||
const categories = categoriesForBrand(brand.id).filter((c) => c !== 'Settings')
|
||||
|
||||
const signIn = (
|
||||
<Button size="$3" onPress={() => router.push('/signin')}>
|
||||
Sign in
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<YStack minH="100vh" bg="$color1">
|
||||
{/*
|
||||
Top bar. On the Hanzo brand this is the UNIFIED @hanzogui/shell HanzoHeader
|
||||
(Meet Hanzo + the rich ten-category Products mega-menu + brand tokens) — the
|
||||
SAME header every Hanzo surface wears. White-label brands (lux/zoo/…) keep the
|
||||
brand-neutral bar so no Hanzo ecosystem URL leaks onto their console.
|
||||
*/}
|
||||
{brand.id === 'hanzo' ? (
|
||||
// The header's own primary CTA IS the sign-in (see LANDING_SURFACE), so the
|
||||
// account control is declined explicitly (see NO_ACCOUNT) — one way in, not
|
||||
// two competing sign-ins.
|
||||
<HanzoHeader
|
||||
surface={LANDING_SURFACE}
|
||||
productsTaxonomy={HANZO_PRODUCT_CATEGORIES}
|
||||
account={NO_ACCOUNT}
|
||||
/>
|
||||
) : (
|
||||
<XStack
|
||||
items="center"
|
||||
justify="space-between"
|
||||
px="$4"
|
||||
py="$3"
|
||||
borderBottomWidth={1}
|
||||
borderColor="$borderColor"
|
||||
$md={{ px: '$6' }}
|
||||
>
|
||||
<Text fontSize="$6" fontWeight="700" color="$color12">
|
||||
{config.brandName}
|
||||
</Text>
|
||||
{signIn}
|
||||
</XStack>
|
||||
)}
|
||||
|
||||
{/* Hero */}
|
||||
<YStack items="center" gap="$4" px="$4" py="$10" $md={{ py: '$12' }}>
|
||||
<YStack items="center" gap="$3" maxW={760}>
|
||||
{/* `hz-display` gives the headline a unitless line-height (see globals.css):
|
||||
the size token's own line-height is tuned for ONE line, so on a phone —
|
||||
where this always wraps — the two lines overprint without it. */}
|
||||
<Text
|
||||
render="h1"
|
||||
className="hz-display"
|
||||
fontSize="$11"
|
||||
$md={{ fontSize: '$13' }}
|
||||
fontWeight="800"
|
||||
color="$color12"
|
||||
style={{ textAlign: 'center' }}
|
||||
>
|
||||
The AI cloud, one platform
|
||||
</Text>
|
||||
<Text fontSize="$5" color="$color10" style={{ textAlign: 'center' }}>
|
||||
Models, compute, training, data, and the tools to ship — with usage-based billing and a single API.
|
||||
</Text>
|
||||
</YStack>
|
||||
<XStack gap="$3" mt="$2" flexWrap="wrap" justify="center">
|
||||
<Button
|
||||
size="$4"
|
||||
{...CTA_PRIMARY}
|
||||
onPress={() => router.push('/signin')}
|
||||
>
|
||||
Get started
|
||||
</Button>
|
||||
<Button
|
||||
size="$4"
|
||||
{...CTA_SECONDARY}
|
||||
onPress={() => typeof window !== 'undefined' && window.open(brand.websiteUrl, '_blank', 'noopener')}
|
||||
>
|
||||
Learn more
|
||||
</Button>
|
||||
</XStack>
|
||||
</YStack>
|
||||
|
||||
{/* Real product categories — derived from the live taxonomy */}
|
||||
<XStack justify="center" px="$4" pb="$10" $md={{ px: '$6' }}>
|
||||
<YStack width="100%" maxW={1080} gap="$4">
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: 16,
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
}}
|
||||
>
|
||||
{categories.map((c) => (
|
||||
<YStack
|
||||
key={c}
|
||||
gap="$1.5"
|
||||
p="$4"
|
||||
rounded="$4"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
bg="$color2"
|
||||
>
|
||||
<Text fontSize="$5" fontWeight="700" color="$color12">
|
||||
{c}
|
||||
</Text>
|
||||
<Text fontSize="$3" color="$color10">
|
||||
{CATEGORY_SUMMARY[c]}
|
||||
</Text>
|
||||
</YStack>
|
||||
))}
|
||||
</div>
|
||||
</YStack>
|
||||
</XStack>
|
||||
|
||||
<XStack justify="center" px="$4" $md={{ px: '$6' }}>
|
||||
<YStack width="100%" maxW={1080}>
|
||||
<ConsoleFooter />
|
||||
</YStack>
|
||||
</XStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +1,35 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Scope switcher — the project + network pickers that scope every module.
|
||||
* Scope switcher — the NETWORK picker, and only that.
|
||||
*
|
||||
* Two chips next to the org switcher: the active PROJECT (or "All projects" for
|
||||
* org-level scope) and the active NETWORK. The network picker offers the stock
|
||||
* tiers (Mainnet/Testnet/Devnet — the live Hanzo networks), a Local option for a
|
||||
* self-hosted cloud binary, and any custom networks the user adds (their own
|
||||
* networkID / EVM chainID / RPC / API). Selecting a network writes through
|
||||
* `useScope`, which updates the module-level scope the API client reads — so it
|
||||
* re-scopes every module (via `X-Environment`) AND retargets chain/RPC/API at once.
|
||||
* A "New project" affordance routes to the Projects module; we never fabricate one.
|
||||
* The network is a global MODE, not a place, so it stays its own control in the
|
||||
* top-right while org and project condense into the top-left `ContextSwitcher`.
|
||||
* Its tier dot is a destructive-environment guard (mainnet live green, testnet
|
||||
* caution amber), which is why it keeps a distinct, always-visible chip rather
|
||||
* than folding into a menu you have to open to read.
|
||||
*
|
||||
* The picker offers the stock tiers (Mainnet/Testnet/Devnet — the live Hanzo
|
||||
* networks), a Local option for a self-hosted cloud binary, and any custom
|
||||
* networks the user adds (their own networkID / EVM chainID / RPC / API).
|
||||
* Selecting a network writes through `useScope`, which updates the module-level
|
||||
* scope the API client reads — so it re-scopes every module (via
|
||||
* `X-Environment`) AND retargets chain/RPC/API at once.
|
||||
*/
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Button, Popover, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Check, ChevronsUpDown, FolderGit2, Layers, Plus, Trash } from '@hanzogui/lucide-icons-2'
|
||||
import { Check, ChevronsUpDown, Layers, Plus, Trash } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { useScope } from '~/lib/scope-context'
|
||||
import { STOCK_ENVIRONMENTS } from '~/lib/scope'
|
||||
import { isStockNetwork, parseCustomNetwork, type Network } from '~/lib/network'
|
||||
import { FieldText } from '~/components/ui/Field'
|
||||
import { MenuRow, type DotColor } from '~/components/ui/MenuRow'
|
||||
import { paper } from '~/components/ui/paper'
|
||||
import { FieldText } from '@hanzo/ui/product'
|
||||
|
||||
/** A small dot keyed to the network tier. Monochrome by default; only the genuine
|
||||
* states carry a hue — mainnet is live (green), testnet is a caution (amber). Every
|
||||
* other tier is a neutral off the design ladder (Tamagui $colorN). */
|
||||
type DotColor = '$green10' | '$yellow10' | '$color10' | '$color9' | '$color8'
|
||||
const NET_DOT: Record<string, DotColor> = {
|
||||
mainnet: '$green10',
|
||||
testnet: '$yellow10',
|
||||
@@ -41,59 +44,6 @@ const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
|
||||
const idSub = (n: Network): string =>
|
||||
n.networkID === n.evmChainID ? `Chain ${n.evmChainID}` : `Net ${n.networkID} · Chain ${n.evmChainID}`
|
||||
|
||||
function ProjectPicker() {
|
||||
const router = useRouter()
|
||||
const { scope, projects, loadingProjects, selectProject } = useScope()
|
||||
const label = scope.project ? scope.project : 'All projects'
|
||||
|
||||
return (
|
||||
<Popover placement="bottom-end">
|
||||
<Popover.Trigger asChild>
|
||||
<Button size="$2" chromeless icon={<FolderGit2 size={14} />} iconAfter={<ChevronsUpDown size={13} />}>
|
||||
{label}
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content {...paper} p="$2" width={260}>
|
||||
<YStack gap="$0.5">
|
||||
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="500">
|
||||
Project
|
||||
</Text>
|
||||
|
||||
{/* Org-level scope — no X-Project-Id sent. */}
|
||||
<Row
|
||||
label="All projects"
|
||||
sub="Org-level"
|
||||
active={!scope.project}
|
||||
onPress={() => selectProject(undefined)}
|
||||
/>
|
||||
|
||||
{projects.map((p) => (
|
||||
<Row
|
||||
key={p.name}
|
||||
label={p.displayName || p.name}
|
||||
active={scope.project === p.name}
|
||||
onPress={() => selectProject(p.name)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{projects.length === 0 && !loadingProjects ? (
|
||||
<Text px="$2" py="$1.5" fontSize="$2" color="$color10">
|
||||
No projects yet.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<XStack height={1} bg="$borderColor" my="$1" />
|
||||
<Row
|
||||
label="New project"
|
||||
icon={<Plus size={14} />}
|
||||
onPress={() => router.push('/projects')}
|
||||
/>
|
||||
</YStack>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
const EMPTY_FORM = { label: '', networkID: '', evmChainID: '', rpcEndpoint: '', apiEndpoint: '' }
|
||||
|
||||
function AddNetworkForm({
|
||||
@@ -200,7 +150,7 @@ function NetworkPicker() {
|
||||
Project environments
|
||||
</Text>
|
||||
{extraEnvs.map((env) => (
|
||||
<Row
|
||||
<MenuRow
|
||||
key={env}
|
||||
label={titleCase(env)}
|
||||
sub="Custom"
|
||||
@@ -216,7 +166,7 @@ function NetworkPicker() {
|
||||
{adding ? (
|
||||
<AddNetworkForm taken={takenCustomIds} onAdd={add} onCancel={() => setAdding(false)} />
|
||||
) : (
|
||||
<Row label="Add custom network" icon={<Plus size={14} />} onPress={() => setAdding(true)} />
|
||||
<MenuRow label="Add custom network" icon={<Plus size={14} />} onPress={() => setAdding(true)} />
|
||||
)}
|
||||
</YStack>
|
||||
</Popover.Content>
|
||||
@@ -263,54 +213,10 @@ function NetworkRow({
|
||||
)
|
||||
}
|
||||
|
||||
/** One selectable row in a picker popover. */
|
||||
function Row({
|
||||
label,
|
||||
sub,
|
||||
dot,
|
||||
icon,
|
||||
active,
|
||||
onPress,
|
||||
}: {
|
||||
label: string
|
||||
sub?: string
|
||||
dot?: DotColor
|
||||
icon?: ReactNode
|
||||
active?: boolean
|
||||
onPress: () => void
|
||||
}) {
|
||||
return (
|
||||
<XStack
|
||||
onPress={onPress}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
gap="$2"
|
||||
px="$2"
|
||||
py="$2"
|
||||
rounded="$3"
|
||||
hoverStyle={{ bg: '$color4' }}
|
||||
>
|
||||
{dot ? <YStack width={8} height={8} rounded="$10" bg={dot} /> : icon}
|
||||
<YStack flex={1}>
|
||||
<Text fontSize="$2" color="$color12" numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
{sub ? (
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{sub}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
{active ? <Check size={14} /> : null}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** Project + network pickers as a unit (topbar). */
|
||||
/** The network picker (topbar). Org + project live in `ContextSwitcher`, top-left. */
|
||||
export function ScopeSwitcher() {
|
||||
return (
|
||||
<XStack items="center" gap="$1">
|
||||
<ProjectPicker />
|
||||
<XStack items="center" gap="$1" data-testid="switcher-network">
|
||||
<NetworkPicker />
|
||||
</XStack>
|
||||
)
|
||||
|
||||
@@ -26,13 +26,13 @@
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { OrgMark } from '@hanzo/ui/product'
|
||||
import { BookOpen, Globe, Info, SlidersHorizontal } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { config } from '~/config'
|
||||
import { getBrand } from '~/lib/branding/brands'
|
||||
import { useOrgIdentity } from '~/components/ui/BrandLogo'
|
||||
import { Z } from '~/lib/z'
|
||||
import { OrgMark } from '@hanzo/ui/product'
|
||||
|
||||
type MenuItem = {
|
||||
icon: typeof SlidersHorizontal
|
||||
|
||||
@@ -22,9 +22,8 @@ import { COLOR_SWATCHES } from '~/lib/products/colors'
|
||||
import { DEFAULT_GROUP, DEFAULT_GROUP_LABEL, type PinGroupView } from '~/lib/products/pins-core'
|
||||
import { usePins, useProductColors } from '~/lib/products/pins'
|
||||
import { findEntry } from '~/lib/products/registry'
|
||||
import { Reorder } from '~/components/ui/Reorder'
|
||||
import { asColor } from '~/components/ui/color'
|
||||
import { contrastText } from '~/lib/theme/accent'
|
||||
import { Reorder, asColor } from '@hanzo/ui/product'
|
||||
|
||||
/** A round color swatch button; ringed + checked when selected. */
|
||||
function SwatchButton({ hex, selected, onPress }: { hex: string; selected: boolean; onPress: () => void }) {
|
||||
|
||||
@@ -24,9 +24,9 @@ import { useIam } from '@hanzo/iam/react'
|
||||
import { Text, YStack } from '@hanzo/gui'
|
||||
|
||||
import { Loader } from '~/components/ui/Loader'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { config } from '~/config'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { PrimaryButton } from '@hanzo/ui/product'
|
||||
|
||||
export function SignIn() {
|
||||
const { account, loading } = useSession()
|
||||
|
||||
@@ -34,9 +34,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Bot, Plus, Terminal, X } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { ComboBox, type ComboOption } from '~/components/ui/ComboBox'
|
||||
import { FieldRow, FieldSelect, FieldSlider, FieldSwitch, FieldText, FieldTextArea } from '~/components/ui/Field'
|
||||
import { SelectMenu, type SelectOption } from '~/components/ui/SelectMenu'
|
||||
import {
|
||||
canSubmit,
|
||||
classifyBuilderError,
|
||||
@@ -48,6 +45,7 @@ import {
|
||||
toCreateBody,
|
||||
} from './logic'
|
||||
import type { AgentBuilderLoaders, AgentConfig, AgentSpec, BuilderOption, BuilderPrompt, ReasoningEffort } from './types'
|
||||
import { ComboBox, FieldRow, FieldSelect, FieldSlider, FieldSwitch, FieldText, FieldTextArea, SelectMenu, type ComboOption, type SelectOption } from '@hanzo/ui/product'
|
||||
|
||||
/** Async option-list state for the live pickers (model/tool). */
|
||||
type OptState =
|
||||
|
||||
@@ -20,9 +20,9 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { RefreshCw, TriangleAlert } from '@hanzogui/lucide-icons-2'
|
||||
import { RecordsView, type FieldDefinition } from '@hanzo/data'
|
||||
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { BaseDataApi, type BaseRecord } from '~/lib/base-data/api'
|
||||
import { baseCollectionToFields } from '~/lib/base-data/fields'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '@hanzo/ui/product'
|
||||
|
||||
type LoadState =
|
||||
| { phase: 'loading' }
|
||||
|
||||
@@ -21,11 +21,10 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { ArrowLeft, Pencil, Save, Trash2, TriangleAlert, X } from '@hanzogui/lucide-icons-2'
|
||||
import { RecordDetail, RecordForm, type FieldDefinition } from '@hanzo/data'
|
||||
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { BaseDataApi, type BaseRecord } from '~/lib/base-data/api'
|
||||
import { baseCollectionToFields } from '~/lib/base-data/fields'
|
||||
import { recordLabel, savePayload } from './records'
|
||||
import { BackendStateCard, PrimaryButton, classifyBackend, type BackendState } from '@hanzo/ui/product'
|
||||
|
||||
export interface RecordDetailViewProps {
|
||||
/** A configured Base client (transport already wired — e.g. the `/superbase` proxy). */
|
||||
|
||||
@@ -16,9 +16,6 @@ import { useCallback, useMemo, useState, type CSSProperties } from 'react'
|
||||
import { Button, Card, Input, Label, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { GripVertical, Plus, Trash2, TriangleAlert, ArrowUp, ArrowDown } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { FieldSwitch } from '~/components/ui/Field'
|
||||
import { classifyBackend } from '~/components/ui/BackendState'
|
||||
import type { FrameworkClient } from '~/lib/framework/client'
|
||||
import type { DocType, Fieldtype } from '~/lib/framework/types'
|
||||
import {
|
||||
@@ -32,6 +29,7 @@ import {
|
||||
type BuilderField,
|
||||
} from './builder-logic'
|
||||
import { CHEVRON } from '~/components/ui/Field'
|
||||
import { FieldSwitch, PrimaryButton, classifyBackend } from '@hanzo/ui/product'
|
||||
|
||||
// A value≠label native <select>, themed with the app CSS vars (same idiom as
|
||||
// ui/Field.tsx FieldSelect, but here options carry a distinct value + label).
|
||||
|
||||
@@ -16,15 +16,12 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Boxes, Plus, TriangleAlert } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { Loader } from '~/components/ui/Loader'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import type { FrameworkClient } from '~/lib/framework/client'
|
||||
import type { DocType } from '~/lib/framework/types'
|
||||
import { moduleDoctypes } from '~/lib/framework/fields'
|
||||
import { CollectionBuilder } from './CollectionBuilder'
|
||||
import { BackendStateCard, EmptyState, PageHeader, PrimaryButton, classifyBackend, type BackendState } from '@hanzo/ui/product'
|
||||
|
||||
export interface CollectionsBrowserProps {
|
||||
client: FrameworkClient
|
||||
|
||||
@@ -19,12 +19,11 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { ArrowLeft, Ban, Globe, Pencil, PenOff, Save, Send, Trash2, TriangleAlert, X } from '@hanzogui/lucide-icons-2'
|
||||
import { RecordDetail, RecordForm, type FieldDefinition, type SelectOption } from '@hanzo/data'
|
||||
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import type { FrameworkClient } from '~/lib/framework/client'
|
||||
import type { DocType, FrameworkDoc } from '~/lib/framework/types'
|
||||
import { docTypeToFields, toRecord, enrichLinks, savePayload, newDraft, statusField, titleOf, hasProjectField, PROJECT_FIELD } from '~/lib/framework/fields'
|
||||
import { loadLinkOptions, makeFieldOptions } from './data'
|
||||
import { BackendStateCard, PrimaryButton, classifyBackend, type BackendState } from '@hanzo/ui/product'
|
||||
|
||||
export interface DocTypeDetailProps {
|
||||
client: FrameworkClient
|
||||
|
||||
@@ -19,12 +19,12 @@ import { Button } from '@hanzo/gui'
|
||||
import { RefreshCw } from '@hanzogui/lucide-icons-2'
|
||||
import { RecordsView, type FieldDefinition, type SelectOption } from '@hanzo/data'
|
||||
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import type { FrameworkClient } from '~/lib/framework/client'
|
||||
import type { DocType, FrameworkDoc } from '~/lib/framework/types'
|
||||
import { docTypeToFields, toRecord, enrichLinks, savePayload, isMediaDoctype, hasProjectField, PROJECT_FIELD } from '~/lib/framework/fields'
|
||||
import { loadLinkOptions, makeFieldOptions } from './data'
|
||||
import { MediaGrid } from './MediaGrid'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '@hanzo/ui/product'
|
||||
|
||||
type LoadState =
|
||||
| { phase: 'loading' }
|
||||
|
||||
@@ -17,13 +17,11 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Image as ImageIcon, Trash2, TriangleAlert, Upload } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { classifyBackend } from '~/components/ui/BackendState'
|
||||
import type { FrameworkClient } from '~/lib/framework/client'
|
||||
import type { DocType, FrameworkDoc } from '~/lib/framework/types'
|
||||
import { mediaFileField, titleOf } from '~/lib/framework/fields'
|
||||
import { uploadMedia, resolveMediaUrl, deleteMediaObject } from './media-upload'
|
||||
import { EmptyState, PrimaryButton, classifyBackend } from '@hanzo/ui/product'
|
||||
|
||||
export interface MediaGridProps {
|
||||
client: FrameworkClient
|
||||
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
Zap,
|
||||
} from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { FadeIn } from '~/components/ui/FadeIn'
|
||||
import { GuidedTour } from '~/components/tour/GuidedTour'
|
||||
import { useGuideSignals } from '~/lib/guide/use-signals'
|
||||
import { stepAnchorId } from '~/lib/guide/signals'
|
||||
@@ -58,6 +57,7 @@ import {
|
||||
type ProductGuide,
|
||||
} from '~/lib/guide/spec'
|
||||
import type { ProductIcon } from '~/lib/products/registry'
|
||||
import { FadeIn } from '@hanzo/ui/product'
|
||||
|
||||
/** Pitch-point icon name → Lucide glyph (kept out of the pure data layer). */
|
||||
const PITCH_ICON: Record<PitchIcon, ProductIcon> = {
|
||||
@@ -129,8 +129,8 @@ export function PitchHero({ guide }: { guide: ProductGuide }) {
|
||||
<Text fontSize="$1" color="$color10" fontWeight="500">
|
||||
Get started
|
||||
</Text>
|
||||
{/* `hz-display` — the ONE way this app sets display leading (globals.css,
|
||||
same as PublicLanding). It was `style={{ lineHeight: 1.12 }}`, which is a
|
||||
{/* `hz-display` — the ONE way this app sets display leading (globals.css).
|
||||
It was `style={{ lineHeight: 1.12 }}`, which is a
|
||||
correct ratio in plain React but NOT under @hanzo/gui: react-native-web
|
||||
appends `px` to any numeric style value absent from its unitless list, and
|
||||
`lineHeight` is absent — so it compiled to `line-height: 1.12px`, a 1px box
|
||||
@@ -143,8 +143,12 @@ export function PitchHero({ guide }: { guide: ProductGuide }) {
|
||||
</Text>
|
||||
</YStack>
|
||||
<XStack gap="$2" items="center">
|
||||
{/* Neutral, not filled: the ONE filled action in this card is the
|
||||
checklist's ACTIVE step below — the thing to do next. A white "Take
|
||||
the tour" beside it made two buttons compete for the same emphasis,
|
||||
and the tour is the aside. */}
|
||||
{tourSteps.length ? (
|
||||
<Button size="$2" theme="light" icon={<Compass size={14} />} onPress={() => setTourOpen(true)}>
|
||||
<Button size="$2" icon={<Compass size={14} />} onPress={() => setTourOpen(true)}>
|
||||
Take the tour
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@@ -22,7 +22,6 @@ import { config } from '~/config'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { usePreferences } from '~/lib/products/preferences'
|
||||
import { BrandMark } from '~/components/ui/BrandLogo'
|
||||
import { FadeIn } from '~/components/ui/FadeIn'
|
||||
import {
|
||||
ONBOARDING_STEPS,
|
||||
LAST_INDEX,
|
||||
@@ -42,6 +41,7 @@ import { TeamStep } from '~/components/onboarding/steps/TeamStep'
|
||||
import { CreditsStep } from '~/components/onboarding/steps/CreditsStep'
|
||||
import { AiAccessStep } from '~/components/onboarding/steps/AiAccessStep'
|
||||
import { LaunchStep } from '~/components/onboarding/steps/LaunchStep'
|
||||
import { FadeIn } from '@hanzo/ui/product'
|
||||
|
||||
const STEP_COMPONENTS: Record<StepId, (p: StepProps) => ReactNode> = {
|
||||
secure: SecureStep,
|
||||
|
||||
@@ -10,30 +10,50 @@ import type { ReactNode } from 'react'
|
||||
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { ArrowLeft, ArrowRight, Check } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { ONBOARDING_STEPS, type StepId, type StepStatus } from '~/lib/onboarding/steps'
|
||||
import { PrimaryButton } from '@hanzo/ui/product'
|
||||
|
||||
/** Title + subtitle header + body for one step. */
|
||||
/**
|
||||
* The content area's reserved height. Every step's body occupies at least this
|
||||
* much, so `actions` lands at the SAME y on every step and Continue never moves
|
||||
* under the pointer between steps — the whole reason actions is a slot on the
|
||||
* shell rather than the last child a step happens to render.
|
||||
*
|
||||
* A taller step still grows (the area is flex), so this reserves space without
|
||||
* capping it.
|
||||
*/
|
||||
const CONTENT_MIN_HEIGHT = 320
|
||||
|
||||
export function StepShell({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
actions,
|
||||
}: {
|
||||
title: string
|
||||
subtitle: string
|
||||
children: ReactNode
|
||||
/**
|
||||
* The step's StepActions. It is a SLOT, not a child, so the shell decides
|
||||
* where it sits — one placement for every step, decided in one place.
|
||||
*/
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<YStack gap="$4" flex={1} minW={0}>
|
||||
<YStack gap="$1.5">
|
||||
<Text fontSize="$8" fontWeight="800" color="$color12">
|
||||
<Text testID="onboarding-step-title" fontSize="$8" fontWeight="800" color="$color12">
|
||||
{title}
|
||||
</Text>
|
||||
<Text fontSize="$4" color="$color11">
|
||||
{subtitle}
|
||||
</Text>
|
||||
</YStack>
|
||||
{children}
|
||||
<YStack gap="$4" flex={1} minH={CONTENT_MIN_HEIGHT}>
|
||||
{children}
|
||||
</YStack>
|
||||
{actions}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -61,7 +81,7 @@ export function StepActions({
|
||||
busy?: boolean
|
||||
}) {
|
||||
return (
|
||||
<XStack gap="$3" items="center" justify="space-between" flexWrap="wrap" pt="$2">
|
||||
<XStack testID="onboarding-actions" gap="$3" items="center" justify="space-between" flexWrap="wrap" pt="$2">
|
||||
<XStack>
|
||||
{onBack ? (
|
||||
<Button size="$3" chromeless disabled={busy} icon={<ArrowLeft size={16} />} onPress={onBack}>
|
||||
|
||||
@@ -24,12 +24,11 @@ import { Wand2, KeyRound, LogIn, Check, Plus } from '@hanzogui/lucide-icons-2'
|
||||
import { AiAccountsApi } from '~/lib/api/ai-accounts'
|
||||
import { AiConnectionsApi, AI_CONNECTION_PROVIDERS, type AiConnection, type AiConnectionProvider } from '~/lib/api/ai-connections'
|
||||
import { ApiError } from '~/lib/api/client'
|
||||
import { FieldSelect, FieldText } from '~/components/ui/Field'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { useToast } from '~/components/ui/Toast'
|
||||
import { ChoiceCard, StepShell, StepActions } from '~/components/onboarding/parts'
|
||||
import { withAiChoice, type AiChoice } from '~/lib/onboarding/steps'
|
||||
import type { StepProps } from '~/components/onboarding/types'
|
||||
import { FieldSelect, FieldText, PrimaryButton } from '@hanzo/ui/product'
|
||||
|
||||
const providerLabel = (id: AiConnectionProvider): string => AI_CONNECTION_PROVIDERS.find((p) => p.id === id)?.label ?? id
|
||||
const providerFromLabel = (label: string): AiConnectionProvider =>
|
||||
@@ -121,7 +120,19 @@ export function AiAccessStep({ state, patch, next, skip, back, isFirst }: StepPr
|
||||
const connectedLabels = connections.map((c) => c.provider).join(', ')
|
||||
|
||||
return (
|
||||
<StepShell title="AI access" subtitle="Choose how you want to power AI. You can change or combine these anytime in AI Accounts.">
|
||||
<StepShell
|
||||
title="AI access"
|
||||
subtitle="Choose how you want to power AI. You can change or combine these anytime in AI Accounts."
|
||||
actions={
|
||||
<StepActions
|
||||
onBack={isFirst ? undefined : back}
|
||||
onSkip={skip}
|
||||
skipLabel="Decide later"
|
||||
onContinue={next}
|
||||
continueLabel="Continue"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ChoiceCard
|
||||
icon={<Wand2 size={20} />}
|
||||
title="Let Hanzo power it"
|
||||
@@ -247,14 +258,6 @@ export function AiAccessStep({ state, patch, next, skip, back, isFirst }: StepPr
|
||||
{err}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<StepActions
|
||||
onBack={isFirst ? undefined : back}
|
||||
onSkip={skip}
|
||||
skipLabel="Decide later"
|
||||
onContinue={next}
|
||||
continueLabel="Continue"
|
||||
/>
|
||||
</StepShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,9 +12,9 @@ import { FileText, Sparkles } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { config } from '~/config'
|
||||
import { getBrand } from '~/lib/branding/brands'
|
||||
import { FieldSwitch } from '~/components/ui/Field'
|
||||
import { StepShell, StepActions, InlineLink } from '~/components/onboarding/parts'
|
||||
import type { StepProps } from '~/components/onboarding/types'
|
||||
import { FieldSwitch } from '@hanzo/ui/product'
|
||||
|
||||
export function ConsentStep({ state, patch, next, back, isFirst }: StepProps) {
|
||||
const brand = getBrand()
|
||||
@@ -33,7 +33,17 @@ export function ConsentStep({ state, patch, next, back, isFirst }: StepProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<StepShell title="Data & consent" subtitle={`A couple of choices about how ${config.brandName} handles your data.`}>
|
||||
<StepShell
|
||||
title="Data & consent"
|
||||
subtitle={`A couple of choices about how ${config.brandName} handles your data.`}
|
||||
actions={
|
||||
// No Skip here on purpose: accepting the Terms is not optional, so an
|
||||
// affordance that skips past them would be dishonest. Continue stays
|
||||
// disabled until the box is ticked. The data-sharing choice beside it IS
|
||||
// optional and defaults to off — leaving it alone is the skip.
|
||||
<StepActions onBack={isFirst ? undefined : back} onContinue={commit} continueDisabled={!agreed} />
|
||||
}
|
||||
>
|
||||
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor">
|
||||
<XStack gap="$3" items="flex-start" justify="space-between">
|
||||
<XStack gap="$3" items="center" flex={1} minW={0}>
|
||||
@@ -80,7 +90,6 @@ export function ConsentStep({ state, patch, next, back, isFirst }: StepProps) {
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<StepActions onBack={isFirst ? undefined : back} onContinue={commit} continueDisabled={!agreed} />
|
||||
</StepShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
* browser — the console never sees a PAN. REAL: `BillingApi.paymentConfig` mounts the
|
||||
* element (`useSquareCard`), `createPaymentMethod({token})` vaults the card (the
|
||||
* commerce handler grants/extends the trial credit as a side-effect, $1 verify-then-
|
||||
* void, no charge), `welcome()` claims the fixed starter grant (idempotent), and
|
||||
* `balance()` shows the granted balance. Skippable — credits can be added later.
|
||||
* void, no charge), and `balance()` shows the granted balance. Skippable — credits
|
||||
* can be added later.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
@@ -19,10 +19,10 @@ import type { CloudBalance } from '~/lib/api/wallet'
|
||||
import { useSquareCard } from '~/lib/billing/use-square-card'
|
||||
import { trialCents, spendableCents, balanceSplitLabel, invalidateBalance } from '~/lib/billing/live-balance'
|
||||
import { ApiError } from '~/lib/api/client'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { useToast } from '~/components/ui/Toast'
|
||||
import { StepShell, StepActions } from '~/components/onboarding/parts'
|
||||
import type { StepProps } from '~/components/onboarding/types'
|
||||
import { PrimaryButton } from '@hanzo/ui/product'
|
||||
|
||||
const usd = (c: number): string => `$${(c / 100).toFixed(2)}`
|
||||
|
||||
@@ -70,9 +70,9 @@ export function CreditsStep({ next, skip, back, isFirst }: StepProps) {
|
||||
try {
|
||||
const token = await card.tokenize()
|
||||
await BillingApi.createPaymentMethod({ type: 'card', token })
|
||||
// Claim the fixed starter grant too (idempotent server-side); the card-added
|
||||
// handler also extends the trial — both are safe to run.
|
||||
await BillingApi.welcome().catch(() => undefined)
|
||||
// The trial credit is granted SERVER-SIDE as a side-effect of vaulting the card.
|
||||
// The browser never mints its own credit — the only credit mint is the
|
||||
// mint-gated POST /v1/billing/credit, which a tenant session cannot call.
|
||||
const bal = await BillingApi.balance().catch(() => balance)
|
||||
if (!mounted.current) return
|
||||
setBalance(bal)
|
||||
@@ -91,7 +91,21 @@ export function CreditsStep({ next, skip, back, isFirst }: StepProps) {
|
||||
const split = balanceSplitLabel(balance)
|
||||
|
||||
return (
|
||||
<StepShell title="Free trial credits" subtitle="Add a card to unlock free trial credits. No charge now — it just keeps your account ready when the trial ends.">
|
||||
<StepShell
|
||||
title="Free trial credits"
|
||||
subtitle="Add a card to unlock free trial credits. No charge now — it just keeps your account ready when the trial ends."
|
||||
actions={
|
||||
<StepActions
|
||||
onBack={isFirst ? undefined : back}
|
||||
onSkip={phase === 'ready' && !isUnlocked ? skip : undefined}
|
||||
skipLabel="Skip for now"
|
||||
onContinue={next}
|
||||
continueLabel="Continue"
|
||||
continueDisabled={phase === 'ready' && !isUnlocked}
|
||||
busy={adding}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{phase === 'loading' ? (
|
||||
<Card p="$5" items="center" borderWidth={1} borderColor="$borderColor">
|
||||
<Spinner size="large" color="$color11" />
|
||||
@@ -121,7 +135,7 @@ export function CreditsStep({ next, skip, back, isFirst }: StepProps) {
|
||||
<XStack gap="$2" items="center">
|
||||
<CreditCard size={18} color="var(--color10)" />
|
||||
<Text fontSize="$4" fontWeight="700" color="$color12">
|
||||
Payments aren't set up on this deployment
|
||||
Payments aren't set up for this organization
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$3" color="$color11">
|
||||
@@ -195,16 +209,6 @@ export function CreditsStep({ next, skip, back, isFirst }: StepProps) {
|
||||
</XStack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<StepActions
|
||||
onBack={isFirst ? undefined : back}
|
||||
onSkip={phase === 'ready' && !isUnlocked ? skip : undefined}
|
||||
skipLabel="Skip for now"
|
||||
onContinue={next}
|
||||
continueLabel="Continue"
|
||||
continueDisabled={phase === 'ready' && !isUnlocked}
|
||||
busy={adding}
|
||||
/>
|
||||
</StepShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ import { EVENTS } from '@hanzo/event'
|
||||
|
||||
import { config } from '~/config'
|
||||
import { StepShell, ChoiceCard } from '~/components/onboarding/parts'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import type { StepProps } from '~/components/onboarding/types'
|
||||
import { PrimaryButton } from '@hanzo/ui/product'
|
||||
|
||||
const TILES: { icon: typeof MessageSquare; title: string; description: string; to: string }[] = [
|
||||
{ icon: MessageSquare, title: 'Start a chat', description: 'Talk to the latest Zen model right now.', to: '/chat' },
|
||||
@@ -30,11 +30,11 @@ export function LaunchStep({ finish, back, isFirst }: StepProps) {
|
||||
<XStack gap="$2" items="center">
|
||||
<PartyPopper size={20} color="var(--green11)" />
|
||||
<Text fontSize="$5" fontWeight="700" color="$green11">
|
||||
Your workspace is set up
|
||||
Your organization is set up
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$3" color="$color11">
|
||||
Two-factor, consent, workspace, credits, and AI access are all configured.
|
||||
Two-factor, consent, organization, credits, and AI access are all configured.
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ import { ShieldCheck, KeyRound, Copy } from '@hanzogui/lucide-icons-2'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { ApiError } from '~/lib/api/client'
|
||||
import { MfaApi, type MfaSetup } from '~/lib/api/mfa'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { StepShell, StepActions } from '~/components/onboarding/parts'
|
||||
import type { StepProps } from '~/components/onboarding/types'
|
||||
import { PrimaryButton } from '@hanzo/ui/product'
|
||||
|
||||
function copy(value: string) {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard) void navigator.clipboard.writeText(value).catch(() => {})
|
||||
@@ -62,7 +62,21 @@ export function SecureStep({ next, skip, back, isFirst }: StepProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<StepShell title="Secure your account" subtitle="Add two-factor authentication so a stolen password isn't enough to sign in.">
|
||||
<StepShell
|
||||
title="Secure your account"
|
||||
subtitle="Add two-factor authentication so a stolen password isn't enough to sign in."
|
||||
actions={
|
||||
<StepActions
|
||||
onBack={isFirst ? undefined : back}
|
||||
onSkip={enabled ? undefined : skip}
|
||||
skipLabel="Skip securing my account"
|
||||
onContinue={next}
|
||||
continueLabel="Continue"
|
||||
continueDisabled={!enabled}
|
||||
busy={busy}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{enabled ? (
|
||||
<Card p="$4" gap="$3" borderWidth={1} borderColor="$green7" bg="$green2">
|
||||
<XStack gap="$2" items="center">
|
||||
@@ -161,16 +175,6 @@ export function SecureStep({ next, skip, back, isFirst }: StepProps) {
|
||||
</XStack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<StepActions
|
||||
onBack={isFirst ? undefined : back}
|
||||
onSkip={enabled ? undefined : skip}
|
||||
skipLabel="Skip securing my account"
|
||||
onContinue={next}
|
||||
continueLabel="Continue"
|
||||
continueDisabled={!enabled}
|
||||
busy={busy}
|
||||
/>
|
||||
</StepShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Step 3 — Your workspace. Confirms the org the user is in (created at first-run
|
||||
* Step 3 — Your organization. Confirms the org the user is in (created at first-run
|
||||
* org onboarding) and lets them optionally NAME it. REAL: reads/writes the org via
|
||||
* the org-admin `TeamApi` (`get-organization` / `update-organization`, pinned to the
|
||||
* caller's own org server-side). Renaming is best-effort — a read/write failure never
|
||||
* blocks the flow (the workspace already exists).
|
||||
* blocks the flow (the organization already exists).
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, Input, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
@@ -20,7 +20,7 @@ import { useToast } from '~/components/ui/Toast'
|
||||
import { StepShell, StepActions } from '~/components/onboarding/parts'
|
||||
import type { StepProps } from '~/components/onboarding/types'
|
||||
|
||||
export function TeamStep({ next, back, isFirst }: StepProps) {
|
||||
export function TeamStep({ next, skip, back, isFirst }: StepProps) {
|
||||
const { account } = useSession()
|
||||
const toast = useToast()
|
||||
const org = account?.owner || currentOrg()
|
||||
@@ -52,9 +52,9 @@ export function TeamStep({ next, back, isFirst }: StepProps) {
|
||||
setBusy(true)
|
||||
try {
|
||||
await TeamApi.updateOrganization({ ...record, displayName: name })
|
||||
toast.success('Workspace renamed', name)
|
||||
toast.success('Organization renamed', name)
|
||||
} catch (e) {
|
||||
toast.error('Could not rename the workspace', e instanceof ApiError ? e.message : undefined)
|
||||
toast.error('Could not rename the organization', e instanceof ApiError ? e.message : undefined)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -63,7 +63,20 @@ export function TeamStep({ next, back, isFirst }: StepProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<StepShell title="Your workspace" subtitle="This is where your projects, usage, and billing live. Name it now, or keep the default.">
|
||||
<StepShell
|
||||
title="Your organization"
|
||||
subtitle="This is where your projects, usage, and billing live. Name it now, or keep the default."
|
||||
actions={
|
||||
<StepActions
|
||||
onBack={isFirst ? undefined : back}
|
||||
onSkip={skip}
|
||||
skipLabel="Keep the default"
|
||||
onContinue={() => void commit()}
|
||||
continueLabel="Continue"
|
||||
busy={busy}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor">
|
||||
<XStack gap="$3" items="center">
|
||||
<YStack width={44} height={44} rounded="$4" items="center" justify="center" bg="$color3">
|
||||
@@ -82,7 +95,7 @@ export function TeamStep({ next, back, isFirst }: StepProps) {
|
||||
{record ? (
|
||||
<YStack gap="$1.5">
|
||||
<Text fontSize="$2" color="$color11" fontWeight="600">
|
||||
Workspace name
|
||||
Organization name
|
||||
</Text>
|
||||
<Input value={displayName} onChangeText={setDisplayName} placeholder="Acme Inc" autoCapitalize="words" />
|
||||
</YStack>
|
||||
@@ -92,16 +105,9 @@ export function TeamStep({ next, back, isFirst }: StepProps) {
|
||||
<XStack gap="$2" items="center">
|
||||
<Users size={16} color="var(--color10)" />
|
||||
<Text fontSize="$2" color="$color10">
|
||||
Invite teammates and switch workspaces anytime from the top bar.
|
||||
Invite teammates and switch organizations anytime from the top bar.
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
<StepActions
|
||||
onBack={isFirst ? undefined : back}
|
||||
onContinue={() => void commit()}
|
||||
continueLabel="Continue"
|
||||
busy={busy}
|
||||
/>
|
||||
</StepShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,9 +16,8 @@ import { useCallback, useState } from 'react'
|
||||
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Accessibility, ExternalLink } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { toIssues, summarize, IMPACTS, type A11yIssue, type A11ySummary, type Impact } from '~/lib/a11y/scan'
|
||||
import { DataTable, PageHeader, type Column } from '@hanzo/ui/product'
|
||||
|
||||
type State =
|
||||
| { phase: 'idle' }
|
||||
|
||||
@@ -11,9 +11,7 @@ import { useRouter } from 'next/navigation'
|
||||
|
||||
import type { CatalogEntry, ProductSubpage } from '~/lib/products/registry'
|
||||
import { config } from '~/config'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { FadeIn } from '~/components/ui/FadeIn'
|
||||
import { EmptyState, FadeIn, PageHeader } from '@hanzo/ui/product'
|
||||
|
||||
export function AdminManagedNotice({
|
||||
entry,
|
||||
|
||||
@@ -23,10 +23,8 @@ import {
|
||||
} from '~/lib/api/admin'
|
||||
import { config } from '~/config'
|
||||
import { currentOrg } from '~/lib/org-scope'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { FieldRow, FieldText } from '~/components/ui/Field'
|
||||
import { ErrorState, asApiError, isForbidden, SuperAdminRequired, type HonestCopy } from '~/components/ui/States'
|
||||
import { DataTable, FieldRow, FieldText, PageHeader, SecretInput, type Column } from '@hanzo/ui/product'
|
||||
|
||||
/** IAM-specific guidance for the honest 404 / unauthorized states. */
|
||||
const IAM_COPY: HonestCopy = {
|
||||
@@ -221,7 +219,7 @@ function UsersAdminView({ owner }: { owner: string }) {
|
||||
<Text fontSize="$4" fontWeight="700">New user in {owner}</Text>
|
||||
<FieldRow label="Name"><FieldText value={name} onChange={setName} placeholder="jdoe" disabled={saving} /></FieldRow>
|
||||
<FieldRow label="Email"><FieldText value={email} onChange={setEmail} placeholder="jdoe@hanzo.ai" disabled={saving} /></FieldRow>
|
||||
<FieldRow label="Password"><FieldText value={password} onChange={setPassword} placeholder="initial password" secure disabled={saving} /></FieldRow>
|
||||
<FieldRow label="Password"><SecretInput value={password} onChange={setPassword} placeholder="initial password" disabled={saving} copy={false} id="initial-password" /></FieldRow>
|
||||
<XStack gap="$2" items="center">
|
||||
<Button self="flex-start" icon={<Plus size={15} />} disabled={saving} onPress={() => void create()}>
|
||||
{saving ? 'Creating…' : 'Create user'}
|
||||
|
||||
@@ -20,11 +20,11 @@ import { Activity, AlertTriangle, Boxes, Building2, Coins, Cpu, Gauge, ScrollTex
|
||||
|
||||
import { AdminO11yApi, type FleetO11y, type O11yRange } from '~/lib/api/admin-o11y'
|
||||
import { MetricCard } from '~/components/ui/Metric'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { LineChart, type ChartPoint } from '~/components/ui/Charts'
|
||||
import { asApiError, ErrorState, SuperAdminRequired } from '~/components/ui/States'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { formatMetric } from '~/components/products/overview/living/logic'
|
||||
import { DataTable, type Column } from '@hanzo/ui/product'
|
||||
|
||||
const RANGES: O11yRange[] = ['24h', '7d', '30d']
|
||||
const pct1 = (v: number): string => (Number.isFinite(v) ? `${v.toFixed(1)}%` : '—')
|
||||
|
||||
@@ -21,14 +21,8 @@ import { Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Target, Plus, RefreshCw } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { PLATFORMS, STATUSES, AdsApi, type Campaign, type Summary } from '~/lib/api/ads'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { FieldRow, FieldText, FieldSelect } from '~/components/ui/Field'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { StatusTag } from '~/components/ui/StatusTag'
|
||||
import { SlideOver } from '~/components/ui/SlideOver'
|
||||
import { BackendStateCard, DataTable, EmptyState, FieldRow, FieldSelect, FieldText, PageHeader, PrimaryButton, StatusTag, classifyBackend, type BackendState, type Column } from '@hanzo/ui/product'
|
||||
|
||||
/** cents (int64 minor units from the backend) → a $ label. */
|
||||
function money(cents: number): string {
|
||||
|
||||
@@ -15,15 +15,12 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { BadgeCheck, Ban, Coins, HandCoins, Handshake, Percent, RefreshCw, Users, Wallet, Zap } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { AdminAffiliatesApi, type AdminAffiliate, type AdminAffiliatesView, type SweepResult } from '~/lib/api/admin-affiliates'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { MetricCard } from '~/components/ui/Metric'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { FieldRow, FieldSelect, FieldText } from '~/components/ui/Field'
|
||||
import { asApiError, ErrorState, isForbidden, SuperAdminRequired } from '~/components/ui/States'
|
||||
import { ApiError } from '~/lib/api'
|
||||
import { dollarsToCents, percentToBps, ratePct, shortDate, statusLabel, statusColor, usd } from './affiliates/logic'
|
||||
import { toneColor } from '~/components/ui/tone'
|
||||
import { EmptyState, FieldRow, FieldSelect, FieldText, PageHeader, PrimaryButton } from '@hanzo/ui/product'
|
||||
|
||||
/** The backend caps the DIRECT (L1) rate at 9300 bps so the L1+L2+L3 schedule ≤ 100% of margin. */
|
||||
const MAX_RATE_BPS = 9300
|
||||
|
||||
@@ -16,14 +16,11 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { BadgeCheck, Check, Coins, Copy, HandCoins, Handshake, RefreshCw, Users, Wallet } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { AffiliatesApi, type AffiliateOverview } from '~/lib/api/affiliates'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { MetricCard } from '~/components/ui/Metric'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { FieldRow, FieldText } from '~/components/ui/Field'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { payoutMethodLabel, ratePct, shortDate, statusLabel, statusColor, usd } from './affiliates/logic'
|
||||
import { EarningsPanel, LinksPanel, LeaderboardPanel } from './affiliates/panels'
|
||||
import { toneColor } from '~/components/ui/tone'
|
||||
import { BackendStateCard, FieldRow, FieldText, PageHeader, PrimaryButton, classifyBackend, type BackendState } from '@hanzo/ui/product'
|
||||
|
||||
type Async<T> =
|
||||
| { phase: 'loading' }
|
||||
@@ -203,7 +200,7 @@ function AffiliateDashboard({
|
||||
</Text>
|
||||
) : (
|
||||
<Text fontSize="$3" color="$color11">
|
||||
Share your link. Every team that signs up through it earns you {ratePct(data.rateBps)} of their spend, every
|
||||
Share your link. Every organization that signs up through it earns you {ratePct(data.rateBps)} of their spend, every
|
||||
period.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -64,10 +64,6 @@ import {
|
||||
type MetricsRange,
|
||||
} from '~/lib/api/agents'
|
||||
import { productColorHex } from '~/lib/products/colors'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { LineChart, Sparkline, type ChartPoint } from '~/components/ui/Charts'
|
||||
import { useDetailPane } from '~/components/DetailPane'
|
||||
import { MetricCard, Delta } from './functions/parts'
|
||||
@@ -82,6 +78,7 @@ import {
|
||||
VersionBadge,
|
||||
} from './agents/parts'
|
||||
import { AgentDetailView, NewAgentForm } from './agents/forms'
|
||||
import { BackendStateCard, DataTable, EmptyState, PageHeader, classifyBackend, type BackendState, type Column } from '@hanzo/ui/product'
|
||||
|
||||
const PAGE_SIZE = 8
|
||||
const DASH = '—'
|
||||
|
||||
@@ -18,10 +18,8 @@ import { Button, Text } from '@hanzo/gui'
|
||||
import { RefreshCw } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { restGet, cloudProxyV1Url } from '~/lib/api/client'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { StatusTag } from '~/components/ui/StatusTag'
|
||||
import { interpretPlatformError, PlatformStateCard, type PlatformError } from './platform/state'
|
||||
import { DataTable, PageHeader, StatusTag, type Column } from '@hanzo/ui/product'
|
||||
|
||||
type Alert = {
|
||||
id: string
|
||||
|
||||
@@ -35,12 +35,10 @@ import {
|
||||
type Top,
|
||||
} from '~/lib/api/analytics'
|
||||
import { fmtUsd, fmtInt } from '~/lib/api/functions'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { LineChart, Donut, type ChartPoint, type Slice } from '~/components/ui/Charts'
|
||||
import { RAMP } from '~/lib/theme/ramp'
|
||||
import { classifyBackend, BackendStateCard, type BackendState } from '~/components/ui/BackendState'
|
||||
import { MetricCard } from './functions/parts'
|
||||
import { BackendStateCard, DataTable, PageHeader, classifyBackend, type BackendState, type Column } from '@hanzo/ui/product'
|
||||
|
||||
const fmtPct = (n: number): string => (Number.isFinite(n) ? `${(n * 100).toFixed(1)}%` : '—')
|
||||
|
||||
|
||||
@@ -21,11 +21,10 @@ import {
|
||||
type AnnotationQueueItem,
|
||||
type O11yPageMeta,
|
||||
} from '~/lib/api'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { RuntimeNotice } from './observability/RuntimeNotice'
|
||||
import { Pager } from './observability/Pager'
|
||||
import { fmtDate } from './observability/format'
|
||||
import { DataTable, PageHeader, type Column } from '@hanzo/ui/product'
|
||||
|
||||
const PAGE_LIMIT = 50
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* API Keys — create, copy, rotate, and revoke the per-user `hk-` Cloud API key.
|
||||
* API Keys — create, copy, rotate, and revoke the per-user `sk-` Cloud API key.
|
||||
*
|
||||
* The key is minted server-side (`/keys` route → IAM, app-on-behalf as the
|
||||
* confidential console client); the browser only sends its session cookie and
|
||||
* never holds a long-lived secret beyond the one-time reveal at creation. This
|
||||
* is the real credential the user presents as `Authorization: Bearer hk-…` to
|
||||
* is the real credential the user presents as `Authorization: Bearer sk-…` to
|
||||
* the SDKs, CLI, and the api.hanzo.ai gateway.
|
||||
*
|
||||
* `ApiKeysView` is the bare surface so Settings can embed it as a tab;
|
||||
@@ -22,8 +22,8 @@ import { EVENTS } from '@hanzo/event'
|
||||
import { ApiError, KeysApi, type KeyStatus } from '~/lib/api'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { config } from '~/config'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { ErrorState } from '~/components/ui/States'
|
||||
import { CopyButton, PageHeader, SecretInput } from '@hanzo/ui/product'
|
||||
|
||||
// The docs site serves everything under the `/docs` base path (a bare
|
||||
// `docs.hanzo.ai/<slug>` 404s), so the API reference is `/docs/api` — white-labeled
|
||||
@@ -37,25 +37,6 @@ function fmtKeyDate(iso?: string): string {
|
||||
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
/** Copy-to-clipboard button with a transient confirmed state. */
|
||||
function CopyButton({ value, label = 'Copy' }: { value: string; label?: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard?.writeText(value)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
/* clipboard blocked (insecure context) — the value is already visible */
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Button size="$2" icon={copied ? <Check size={14} /> : <Copy size={14} />} onPress={() => void copy()}>
|
||||
{copied ? 'Copied' : label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
/** The full secret, shown ONCE right after creation. */
|
||||
function NewKeyCard({ accessKey, onDone }: { accessKey: string; onDone: () => void }) {
|
||||
return (
|
||||
@@ -66,19 +47,7 @@ function NewKeyCard({ accessKey, onDone }: { accessKey: string; onDone: () => vo
|
||||
Your new API key
|
||||
</Text>
|
||||
</XStack>
|
||||
<XStack gap="$2" items="center" flexWrap="wrap">
|
||||
<Text
|
||||
fontSize="$3"
|
||||
color="$color12"
|
||||
flex={1}
|
||||
minW={260}
|
||||
selectable
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
>
|
||||
{accessKey}
|
||||
</Text>
|
||||
<CopyButton value={accessKey} />
|
||||
</XStack>
|
||||
<SecretInput value={accessKey} id="api-key" />
|
||||
<XStack gap="$2" items="center">
|
||||
<TriangleAlert size={14} color="$yellow10" />
|
||||
<Text fontSize="$2" color="$color11">
|
||||
@@ -190,7 +159,7 @@ export function ApiKeysView() {
|
||||
Cloud API key
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color10" style={{ fontFamily: 'monospace' }}>
|
||||
{status?.keyPrefix ? `${status.keyPrefix}…` : 'hk-…'}
|
||||
{status?.keyPrefix ? `${status.keyPrefix}…` : 'sk-…'}
|
||||
</Text>
|
||||
{fmtKeyDate(status?.createdAt) ? (
|
||||
<Text fontSize="$1" color="$color10">
|
||||
@@ -233,7 +202,7 @@ export function ApiKeysView() {
|
||||
</XStack>
|
||||
<Text fontSize="$3" color="$color11">
|
||||
One key for your account, scoped to your organization. Use it as{' '}
|
||||
<Text fontSize="$2" style={{ fontFamily: 'monospace' }}>Authorization: Bearer hk-…</Text> with the SDKs,
|
||||
<Text fontSize="$2" style={{ fontFamily: 'monospace' }}>Authorization: Bearer sk-…</Text> with the SDKs,
|
||||
CLI, and the api.hanzo.ai gateway.
|
||||
</Text>
|
||||
<Button
|
||||
|
||||
@@ -35,12 +35,7 @@ import { AppWindow, Cpu, ExternalLink, Globe, HardDrive, Pencil, RefreshCw, Rock
|
||||
import { config } from '~/config'
|
||||
import { AppsApi, builderEditUrl, type App, type AppDeployment } from '~/lib/api/apps'
|
||||
import { PlatformSitesApi } from '~/lib/api/platform-sites'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { StatusTag } from '~/components/ui/StatusTag'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { BackendStateCard, DataTable, EmptyState, PageHeader, PrimaryButton, StatusTag, classifyBackend, type BackendState, type Column } from '@hanzo/ui/product'
|
||||
|
||||
type Async<T> =
|
||||
| { phase: 'loading' }
|
||||
|
||||
@@ -15,10 +15,8 @@ import { Button, Text } from '@hanzo/gui'
|
||||
import { RefreshCw } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { restGet } from '~/lib/api/client'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { StatusTag } from '~/components/ui/StatusTag'
|
||||
import { interpretPlatformError, PlatformStateCard, type PlatformError } from './platform/state'
|
||||
import { DataTable, PageHeader, StatusTag, type Column } from '@hanzo/ui/product'
|
||||
|
||||
const paas = (path: string) => `/paas/${path.replace(/^\/+/, '')}`
|
||||
|
||||
|
||||
@@ -15,15 +15,12 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { BadgeCheck, Ban, Coins, Github, HandCoins, RefreshCw, Wallet, Zap } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { AdminAuthorsApi, type AdminAuthor, type AdminAuthorsView, type SweepResult } from '~/lib/api/admin-authors'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { MetricCard } from '~/components/ui/Metric'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { FieldRow, FieldSelect, FieldText } from '~/components/ui/Field'
|
||||
import { asApiError, ErrorState, isForbidden, SuperAdminRequired } from '~/components/ui/States'
|
||||
import { ApiError } from '~/lib/api'
|
||||
import { dollarsToCents, sharePct, shortDate, statusLabel, statusColor, usd } from './authors/logic'
|
||||
import { toneColor } from '~/components/ui/tone'
|
||||
import { EmptyState, FieldRow, FieldSelect, FieldText, PageHeader, PrimaryButton } from '@hanzo/ui/product'
|
||||
|
||||
type Async<T> =
|
||||
| { phase: 'loading' }
|
||||
|
||||
@@ -31,13 +31,10 @@ import {
|
||||
} from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { AuthorsApi, type AuthorOverview } from '~/lib/api/authors'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { MetricCard } from '~/components/ui/Metric'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { FieldRow, FieldText } from '~/components/ui/Field'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { payoutMethodLabel, sharePct, shortDate, statusLabel, statusColor, usd, verifyMethodLabel } from './authors/logic'
|
||||
import { toneColor } from '~/components/ui/tone'
|
||||
import { BackendStateCard, FieldRow, FieldText, PageHeader, PrimaryButton, classifyBackend, type BackendState } from '@hanzo/ui/product'
|
||||
|
||||
type Async<T> =
|
||||
| { phase: 'loading' }
|
||||
@@ -122,7 +119,7 @@ function AuthorConnect({ onConnected }: { onConnected: () => void }) {
|
||||
<Text fontSize="$3" color="$color11">
|
||||
The OSS Author program pays you an ongoing share of the platform spend of every organization that deploys your
|
||||
open-source project on Hanzo. Connect GitHub, verify the repositories you own, and earn your share every period —
|
||||
for as long as teams build on your work. New authors are reviewed by our team before earnings begin.
|
||||
for as long as organizations build on your work. New authors are reviewed by our team before earnings begin.
|
||||
</Text>
|
||||
<YStack gap="$1">
|
||||
<FieldRow label="GitHub login (optional)">
|
||||
|
||||
Binary file not shown.
@@ -26,15 +26,8 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Blocks, ListChecks, Play, Plus, Power, RefreshCw, Trash2, Workflow } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { AutomationsApi, type AutomationFlow, type FlowRun, type Piece } from '~/lib/api/automations'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { FieldSelect, FieldText } from '~/components/ui/Field'
|
||||
import { Loader } from '~/components/ui/Loader'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { SlideOver } from '~/components/ui/SlideOver'
|
||||
import { StatusTag } from '~/components/ui/StatusTag'
|
||||
import {
|
||||
authLabel,
|
||||
capabilitySummary,
|
||||
@@ -49,6 +42,7 @@ import {
|
||||
validateFlowName,
|
||||
} from './automations/logic'
|
||||
import { toneColor } from '~/components/ui/tone'
|
||||
import { BackendStateCard, DataTable, EmptyState, FieldSelect, FieldText, PageHeader, PrimaryButton, StatusTag, classifyBackend, type BackendState, type Column } from '@hanzo/ui/product'
|
||||
|
||||
/** Cap the rendered connector cards; search/category narrow the rest (honest note). */
|
||||
const CATALOG_RENDER_CAP = 240
|
||||
|
||||
@@ -14,9 +14,8 @@ import { FlaskConical, RefreshCw } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { ApiError } from '~/lib/api'
|
||||
import { EnablementApi, type UserEnablementItem } from '~/lib/api/admin-cockpit'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { ErrorState, asApiError } from '~/components/ui/States'
|
||||
import { EmptyState, PageHeader } from '@hanzo/ui/product'
|
||||
|
||||
export function BetaFeaturesModule() {
|
||||
const [view, setView] = useState<{ items: UserEnablementItem[]; betas: UserEnablementItem[] } | null>(null)
|
||||
|
||||
@@ -14,8 +14,7 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { RefreshCw } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { ApiError, BotApi } from '~/lib/api'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { StatusTag } from '~/components/ui/StatusTag'
|
||||
import { PageHeader, StatusTag } from '@hanzo/ui/product'
|
||||
|
||||
/** Operator surfaces for the bot — public product domains, opened in a tab. */
|
||||
const SURFACES: ReadonlyArray<{ label: string; href: string; hint: string }> = [
|
||||
|
||||
@@ -31,8 +31,7 @@ import { Bot, Cpu, ExternalLink, Monitor, Play, RefreshCw, Square, Terminal } fr
|
||||
|
||||
import { ApiError } from '~/lib/api'
|
||||
import { BotsApi, type BotRunRow, type BotSurface } from '~/lib/api/bots'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { StatusTag } from '~/components/ui/StatusTag'
|
||||
import { PageHeader, StatusTag } from '@hanzo/ui/product'
|
||||
|
||||
/** Statuses a run can't be stopped from — the Stop button is disabled on these. */
|
||||
const TERMINAL = new Set([
|
||||
|
||||
@@ -13,10 +13,8 @@ import { Button, Text } from '@hanzo/gui'
|
||||
import { RefreshCw } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { restGet, cloudProxyV1Url } from '~/lib/api/client'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { StatusTag } from '~/components/ui/StatusTag'
|
||||
import { interpretPlatformError, PlatformStateCard, type PlatformError } from './platform/state'
|
||||
import { DataTable, PageHeader, StatusTag, type Column } from '@hanzo/ui/product'
|
||||
|
||||
type Build = {
|
||||
id: string
|
||||
|
||||
@@ -54,21 +54,15 @@ import {
|
||||
type Safe,
|
||||
type Round,
|
||||
} from '~/lib/api/captable'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { StatusTag } from '~/components/ui/StatusTag'
|
||||
import { SlideOver } from '~/components/ui/SlideOver'
|
||||
import { FieldRow, FieldText, FieldSelect, FieldOptionSelect } from '~/components/ui/Field'
|
||||
import { FieldOptionSelect } from '~/components/ui/Field'
|
||||
import { MetricCard } from '~/components/ui/Metric'
|
||||
import { Donut } from '~/components/ui/Charts'
|
||||
import { RAMP } from '~/lib/theme/ramp'
|
||||
import { Loader } from '~/components/ui/Loader'
|
||||
import { useToast } from '~/components/ui/Toast'
|
||||
import { ConfirmDelete } from '~/components/ui/ConfirmDelete'
|
||||
import { toneColor } from '~/components/ui/tone'
|
||||
import { BackendStateCard, ConfirmDelete, DataTable, EmptyState, FieldRow, FieldSelect, FieldText, PageHeader, PrimaryButton, StatusTag, classifyBackend, type BackendState, type Column } from '@hanzo/ui/product'
|
||||
|
||||
type Tab = 'summary' | 'stakeholders' | 'shares' | 'classes' | 'fundraising'
|
||||
const tabPath = (id: Tab): string => (id === 'summary' ? '/captable' : `/captable/${id}`)
|
||||
|
||||
@@ -27,12 +27,7 @@ import { Boxes, Check, Pencil, Plus, RefreshCw, Save, Trash2 } from '@hanzogui/l
|
||||
import { ApiError } from '~/lib/api'
|
||||
import { CatalogAdminApi, type CatalogEntry, type CatalogEntryInput } from '~/lib/api/catalog-admin'
|
||||
import { config } from '~/config'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { FieldRow, FieldSelect, FieldSwitch, FieldText, FieldTextArea } from '~/components/ui/Field'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { SlideOver } from '~/components/ui/SlideOver'
|
||||
import { ConfirmDelete } from '~/components/ui/ConfirmDelete'
|
||||
import { ErrorState, asApiError, isForbidden, SuperAdminRequired, type HonestCopy } from '~/components/ui/States'
|
||||
import { MetadataEditor } from './pricing/MetadataEditor'
|
||||
import {
|
||||
@@ -49,6 +44,7 @@ import {
|
||||
specSummary,
|
||||
} from './catalog/logic'
|
||||
import { toneVar } from '~/components/ui/tone'
|
||||
import { ConfirmDelete, DataTable, FieldRow, FieldSelect, FieldSwitch, FieldText, FieldTextArea, PageHeader, PrimaryButton, type Column } from '@hanzo/ui/product'
|
||||
|
||||
const CATALOG_COPY: HonestCopy = {
|
||||
notFound:
|
||||
|
||||
@@ -40,16 +40,9 @@ import {
|
||||
type WorkerRoute,
|
||||
type WorkerScript,
|
||||
} from '~/lib/api/cloudflare'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { ConfirmDelete } from '~/components/ui/ConfirmDelete'
|
||||
import { StatusTag } from '~/components/ui/StatusTag'
|
||||
import { SlideOver } from '~/components/ui/SlideOver'
|
||||
import { FieldRow, FieldText, FieldTextArea } from '~/components/ui/Field'
|
||||
import { useToast } from '~/components/ui/Toast'
|
||||
import { BackendStateCard, ConfirmDelete, DataTable, EmptyState, FieldRow, FieldText, FieldTextArea, PageHeader, PrimaryButton, StatusTag, classifyBackend, type BackendState, type Column } from '@hanzo/ui/product'
|
||||
|
||||
/** Cloudflare brand orange — used ONLY for Cloudflare-specific affordances. */
|
||||
const CF_TONE = 'var(--color11)' // the Cloudflare lane reads by icon + label, not hue
|
||||
@@ -167,7 +160,10 @@ function LinkOut({ href, label }: { href: string; label?: string }) {
|
||||
<XStack
|
||||
items="center"
|
||||
gap="$1"
|
||||
tag="a"
|
||||
// `render`, not `tag`: gui 8 renamed the host-element prop, and gui drops a prop
|
||||
// it does not know without erroring — `tag="a"` type-checked, built, and shipped
|
||||
// a <div>, so this chip was an inert link that nothing reported.
|
||||
render="a"
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{...({ href, target: '_blank', rel: 'noopener noreferrer' } as any)}
|
||||
>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user