@tribe-nest/forge 3.19.0 → 3.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.19.0",
3
+ "version": "3.21.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -69,13 +69,26 @@ export function useUpdateAccount() {
69
69
  });
70
70
  }
71
71
 
72
- /** Change the member's password. */
72
+ /**
73
+ * Change the member's password.
74
+ *
75
+ * Tenant-scoped (`/public/sessions/password`), because that is where a fan's
76
+ * credential lives — see the note at the top of `useAuthActions.ts`. The global
77
+ * `/public/accounts/password` this used to call wrote a hash that login never
78
+ * reads: it returned 200, invalidated the session, and left the old password
79
+ * working.
80
+ *
81
+ * Requires an association-scoped session (the endpoint is behind
82
+ * `requireAssociation`). Every current login path mints one; a member still
83
+ * holding a pre-cutover account-scoped token gets a 401 and needs to sign in
84
+ * again — honest, where the old call was silently a no-op.
85
+ */
73
86
  export function useChangePassword() {
74
87
  const { client } = useForge();
75
88
 
76
89
  return useMutation<unknown, unknown, { currentPassword: string; newPassword: string }>({
77
90
  mutationFn: async (body) => {
78
- const res = await client.put("/public/accounts/password", body);
91
+ const res = await client.put("/public/sessions/password", body);
79
92
  return res.data;
80
93
  },
81
94
  });
@@ -2,26 +2,70 @@ import { useForge } from "../../provider/ForgeProvider";
2
2
  import { useMutation } from "@tanstack/react-query";
3
3
 
4
4
  // Stateless account actions (no session state) — kept out of PublicAuthContext.
5
+ //
6
+ // ## Why these hit `/public/sessions/*` and not `/public/accounts/*`
7
+ //
8
+ // A member's credential for a tenant lives on their `account_associations` row,
9
+ // not on `accounts` — that is the hash `POST /public/sessions` checks at login
10
+ // (see `resolveAssociationLogin` on the backend). `accounts.password` is the
11
+ // CREATOR/admin-dashboard credential; for a fan it is at most a lazy-heal
12
+ // fallback, gated on the account having some other relationship to the profile.
13
+ //
14
+ // These three calls used to point at the global `/public/accounts/*` endpoints,
15
+ // which write `accounts.password`. Every one of them returned 200 and changed
16
+ // nothing that login reads:
17
+ //
18
+ // - "Change password" logged the member out (a credential change invalidates
19
+ // sessions), then refused the new password and kept accepting the old one.
20
+ // - "Forgot password" was worse: it reset a hash login never consults, so the
21
+ // member — who by definition no longer knows the old password — was locked
22
+ // out with a reset link that appeared to work.
23
+ //
24
+ // The tenant-scoped endpoints below are the ones the credential actually lives
25
+ // behind, and they invalidate only this context's sessions.
5
26
 
6
- /** Request a password-reset email. */
27
+ /**
28
+ * Request a password-reset email for this tenant.
29
+ *
30
+ * `profileId` comes from the provider (one deploy = one tenant); pass it
31
+ * explicitly only when rendering outside a pinned context.
32
+ */
7
33
  export function useForgotPassword() {
8
- const { client } = useForge();
34
+ const { client, profileId } = useForge();
9
35
 
10
- return useMutation<unknown, unknown, { email: string; origin?: string }>({
11
- mutationFn: async ({ email, origin }) => {
12
- const res = await client.post("/public/accounts/forgot-password", { email, origin });
36
+ return useMutation<unknown, unknown, { email: string; origin?: string; profileId?: string }>({
37
+ mutationFn: async ({ email, origin, profileId: overrideProfileId }) => {
38
+ const contextId = overrideProfileId ?? profileId;
39
+ if (!contextId) {
40
+ // Better than posting without it: the endpoint would 400 on schema
41
+ // validation and the form would show "something went wrong".
42
+ throw new Error("useForgotPassword: no profileId — pass one or render inside a ForgeProvider with profileId");
43
+ }
44
+ const res = await client.post("/public/sessions/forgot-password", {
45
+ profileId: contextId,
46
+ email,
47
+ // The reset link is built server-side from this, and the association
48
+ // endpoint requires it (the global one treated it as optional).
49
+ origin: origin ?? (typeof window !== "undefined" ? window.location.origin : undefined),
50
+ });
13
51
  return res.data;
14
52
  },
15
53
  });
16
54
  }
17
55
 
18
- /** Complete a password reset with the emailed token. */
56
+ /**
57
+ * Complete a password reset with the emailed token.
58
+ *
59
+ * The token is looked up on the association row, so it must be redeemed against
60
+ * the same surface that issued it — a token minted by the hook above will not
61
+ * resolve on `/public/accounts/reset-password`, and vice versa.
62
+ */
19
63
  export function useResetPassword() {
20
64
  const { client } = useForge();
21
65
 
22
66
  return useMutation<unknown, unknown, { token: string; password: string }>({
23
67
  mutationFn: async ({ token, password }) => {
24
- const res = await client.post("/public/accounts/reset-password", { token, password });
68
+ const res = await client.post("/public/sessions/reset-password", { token, password });
25
69
  return res.data;
26
70
  },
27
71
  });
@@ -5,11 +5,12 @@ import { ForgeAnalytics } from "../analytics/ForgeAnalytics";
5
5
  import { PwaRegistration } from "../styled/PwaRegistration";
6
6
  import { InstallBanner } from "../styled/InstallBanner";
7
7
  import { CookieConsent } from "../styled/CookieConsent";
8
+ import { AiAgentWidget } from "../styled/AiAgentWidget";
8
9
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
9
10
  import { useInitialSiteConfig } from "../../provider/SiteConfigProvider";
10
11
  import { PoweredBy } from "./PoweredBy";
11
12
  import { PreviewDiagnostics } from "./PreviewDiagnostics";
12
- import { shellPoweredByEnabled, shellPwaEnabled } from "./shellGating";
13
+ import { shellAiAgentEnabled, shellPoweredByEnabled, shellPwaEnabled } from "./shellGating";
13
14
  import { previewDiagnosticsEnabled, resolvePreviewDiagnostics } from "./diagnosticsGating";
14
15
  import type { ForgeSsrDiagnostics } from "../../types/diagnostics";
15
16
  import { captureAttributionRefFromUrl, readAttributionRef } from "../../utils/attribution";
@@ -52,6 +53,14 @@ export interface TribeNestAppProps extends Omit<ForgeProviderProps, "children">
52
53
  analytics?: boolean;
53
54
  pwa?: boolean;
54
55
  cookieConsent?: boolean;
56
+ /**
57
+ * The website AI agent bubble. Defaults to on for a fan site and off for a
58
+ * mini-app; set it explicitly to override either default. Renders nothing
59
+ * unless the creator has also enabled the agent for the profile — the widget
60
+ * fetches its own config and self-hides. For custom chat UI, leave this off
61
+ * and build on the `useAiAgent()` hook instead.
62
+ */
63
+ aiAgent?: boolean;
55
64
  }
56
65
 
57
66
  // Ensure the PWA `<link rel="manifest">` + a `theme-color` meta exist even if the
@@ -114,6 +123,7 @@ export function TribeNestApp({
114
123
  analytics = true,
115
124
  pwa = true,
116
125
  cookieConsent = true,
126
+ aiAgent,
117
127
  ...forgeProps
118
128
  }: TribeNestAppProps) {
119
129
  // Register the SW + offer install only on the live published site — never in
@@ -154,6 +164,11 @@ export function TribeNestApp({
154
164
  {/* Consent banner — shown on first visit, re-openable from anywhere via
155
165
  useCookieConsent().reopen(). Hidden in the editor. */}
156
166
  {cookieConsent && !editable && <CookieConsent useTribeNestPrivacy />}
167
+ {/* Website AI agent — floating bubble, self-hiding when the profile has no
168
+ agent enabled. Lives here rather than in each __root so enabling the
169
+ agent in admin is enough to make it appear; before this it shipped only
170
+ on the Craft stack and no code site ever rendered it. */}
171
+ {shellAiAgentEnabled({ aiAgent, editable, appId: forgeProps.appId }) && <AiAgentWidget />}
157
172
  </ForgeProvider>
158
173
  );
159
174
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { poweredByHref, shellPoweredByEnabled, shellPwaEnabled } from "./shellGating";
2
+ import { poweredByHref, shellAiAgentEnabled, shellPoweredByEnabled, shellPwaEnabled } from "./shellGating";
3
3
 
4
4
  // The PWA (SW register + install prompt) must be ON only for the live published
5
5
  // site, and OFF everywhere else (editor, preview/draft), so review Workers never
@@ -47,6 +47,31 @@ describe("shellPoweredByEnabled", () => {
47
47
  });
48
48
  });
49
49
 
50
+ // The agent bubble defaults ON for fan sites and OFF for mini-apps, and is
51
+ // blocked in the editor because an answer there spends the creator's AI credits.
52
+ // The widget itself still renders nothing when the agent is disabled for the
53
+ // profile — this gate is about the SURFACE, not the entitlement.
54
+ describe("shellAiAgentEnabled", () => {
55
+ it("is on by default for a fan site, live or preview", () => {
56
+ expect(shellAiAgentEnabled({ editable: false })).toBe(true);
57
+ expect(shellAiAgentEnabled({})).toBe(true);
58
+ });
59
+
60
+ it("is off by default for a mini-app", () => {
61
+ expect(shellAiAgentEnabled({ editable: false, appId: "app-7" })).toBe(false);
62
+ });
63
+
64
+ it("is off in the editor even when explicitly asked for", () => {
65
+ expect(shellAiAgentEnabled({ aiAgent: true, editable: true })).toBe(false);
66
+ expect(shellAiAgentEnabled({ editable: true })).toBe(false);
67
+ });
68
+
69
+ it("lets an explicit prop override the per-surface default in both directions", () => {
70
+ expect(shellAiAgentEnabled({ aiAgent: false, editable: false })).toBe(false); // site opts out
71
+ expect(shellAiAgentEnabled({ aiAgent: true, editable: false, appId: "app-7" })).toBe(true); // app opts in
72
+ });
73
+ });
74
+
50
75
  // The badge is only worth carrying if we can tell WHICH site sent the visitor,
51
76
  // so the identifying params are the point of the link, not decoration.
52
77
  describe("poweredByHref", () => {
@@ -24,6 +24,22 @@ export function shellPoweredByEnabled(opts: {
24
24
  return !opts.hideBadge && !opts.editable && opts.state === "published";
25
25
  }
26
26
 
27
+ // The website AI agent bubble. Unlike the PWA it IS wanted on preview/draft
28
+ // deploys — that is where a creator checks the assistant before going live — so
29
+ // the only hard block is the HMR editor, where a live answer would spend the
30
+ // creator's AI credits every time they typed into their own site.
31
+ //
32
+ // The default differs by surface. A fan site gets it (the agent answers from the
33
+ // artist's content + knowledge base, which is what a visitor is there for); a
34
+ // mini-app does NOT, because an app is a utility and a floating "ask me about
35
+ // the artist" bubble is noise on top of it. `appId` is baked at build time, so
36
+ // this is decided without a runtime lookup. An explicit `aiAgent` prop wins
37
+ // either way — that is how an app opts in, or a site opts out.
38
+ export function shellAiAgentEnabled(opts: { aiAgent?: boolean; editable?: boolean; appId?: string }): boolean {
39
+ if (opts.editable) return false;
40
+ return opts.aiAgent ?? !opts.appId;
41
+ }
42
+
27
43
  /** Marketing site the badge points at. */
28
44
  const TRIBENEST_URL = "https://tribenest.co/";
29
45