@hraness/suite-accounts 0.9.20
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/LICENSE +21 -0
- package/README.md +496 -0
- package/compiler-foundation.css +1 -0
- package/dist/auth-client.js +16 -0
- package/dist/auth-proxy.js +757 -0
- package/dist/bearer-verifier.js +1119 -0
- package/dist/browser-session.js +208 -0
- package/dist/client-configuration.js +470 -0
- package/dist/convex-browser-auth-browser.js +923 -0
- package/dist/convex-browser-auth-server.js +846 -0
- package/dist/convex-browser-auth.js +597 -0
- package/dist/entitlements.js +502 -0
- package/dist/identity/functions.js +44 -0
- package/dist/identity/index.js +1716 -0
- package/dist/identity/return-targets.js +33 -0
- package/dist/index.js +1133 -0
- package/dist/oidc-device-code.js +253 -0
- package/dist/oidc-rp.js +2259 -0
- package/dist/oidc-session-policy.js +5 -0
- package/dist/oidc-surface-server.js +2332 -0
- package/dist/profile-form.js +1934 -0
- package/dist/profile.js +17 -0
- package/dist/public-config.js +462 -0
- package/dist/react.js +137 -0
- package/dist/receipt-verifier.js +619 -0
- package/dist/registry.js +327 -0
- package/dist/stylex-manifest.json +1 -0
- package/dist/stylex.css +435 -0
- package/dist/urls.js +391 -0
- package/package.json +263 -0
- package/portfolio-inventory.json +37 -0
- package/src/auth-client.ts +37 -0
- package/src/auth-proxy.ts +473 -0
- package/src/bearer-verifier.ts +478 -0
- package/src/browser-session.ts +260 -0
- package/src/client-configuration.ts +172 -0
- package/src/convex-browser-auth-browser.ts +238 -0
- package/src/convex-browser-auth-server.ts +492 -0
- package/src/convex-browser-auth.ts +208 -0
- package/src/convex-url.ts +74 -0
- package/src/entitlements.ts +317 -0
- package/src/identity/catalog.ts +227 -0
- package/src/identity/consumers.ts +53 -0
- package/src/identity/functions.ts +70 -0
- package/src/identity/identifiers.ts +82 -0
- package/src/identity/index.ts +10 -0
- package/src/identity/links.ts +280 -0
- package/src/identity/membership.ts +156 -0
- package/src/identity/principals.ts +301 -0
- package/src/identity/profiles-v2.ts +330 -0
- package/src/identity/profiles.ts +524 -0
- package/src/identity/return-targets.ts +20 -0
- package/src/identity/usernames.ts +101 -0
- package/src/identity/views.ts +289 -0
- package/src/immutable.ts +25 -0
- package/src/index.ts +7 -0
- package/src/oidc-continuation.ts +59 -0
- package/src/oidc-device-code.ts +401 -0
- package/src/oidc-rp.ts +2217 -0
- package/src/oidc-session-policy.ts +6 -0
- package/src/oidc-surface-server.ts +186 -0
- package/src/profile-form.css +1 -0
- package/src/profile-form.stylex.ts +226 -0
- package/src/profile-form.tsx +314 -0
- package/src/profile.ts +33 -0
- package/src/public-config.ts +294 -0
- package/src/public-profile-form-result.ts +82 -0
- package/src/public-profile-form.tsx +319 -0
- package/src/public-profile-links.ts +100 -0
- package/src/react.tsx +196 -0
- package/src/receipt-verifier.ts +273 -0
- package/src/registry.ts +600 -0
- package/src/urls.ts +234 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
export { SuitePublicProfileForm } from "./public-profile-form.js";
|
|
4
|
+
export type { SuitePublicProfileFormProps, SuitePublicProfileFormResult } from "./public-profile-form.js";
|
|
5
|
+
|
|
6
|
+
import { profileFormClasses } from "./profile-form.stylex.js";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
SUITE_PROFILE_BIO_MAX_LENGTH,
|
|
10
|
+
SUITE_PROFILE_NAME_MAX_LENGTH,
|
|
11
|
+
parseSuiteProfileUpdateRequest,
|
|
12
|
+
type SuiteProfileIssue,
|
|
13
|
+
type SuiteProfileLinkKey,
|
|
14
|
+
type SuiteProfileUpdateRequest,
|
|
15
|
+
type SuiteProfileView,
|
|
16
|
+
} from "./identity/profiles.js";
|
|
17
|
+
import {
|
|
18
|
+
type FormEvent,
|
|
19
|
+
useId,
|
|
20
|
+
useState,
|
|
21
|
+
} from "react";
|
|
22
|
+
|
|
23
|
+
export type SuiteProfileFormResult =
|
|
24
|
+
| Readonly<{ profile: SuiteProfileView; status: "saved" }>
|
|
25
|
+
| Readonly<{ profile: SuiteProfileView; status: "conflict" }>;
|
|
26
|
+
|
|
27
|
+
export type SuiteProfileFormProps = Readonly<{
|
|
28
|
+
className?: string;
|
|
29
|
+
initialProfile: SuiteProfileView;
|
|
30
|
+
onSave: (
|
|
31
|
+
request: SuiteProfileUpdateRequest,
|
|
32
|
+
) => Promise<SuiteProfileFormResult>;
|
|
33
|
+
onSaved?: (profile: SuiteProfileView) => void;
|
|
34
|
+
submitLabel: string;
|
|
35
|
+
}>;
|
|
36
|
+
|
|
37
|
+
type LinkInputs = Record<SuiteProfileLinkKey, string>;
|
|
38
|
+
type FieldErrors = Partial<Record<
|
|
39
|
+
SuiteProfileIssue["field"],
|
|
40
|
+
string
|
|
41
|
+
>>;
|
|
42
|
+
|
|
43
|
+
function withoutFieldError(
|
|
44
|
+
errors: FieldErrors,
|
|
45
|
+
field: SuiteProfileIssue["field"],
|
|
46
|
+
): FieldErrors {
|
|
47
|
+
const next = { ...errors };
|
|
48
|
+
delete next[field];
|
|
49
|
+
return next;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const LINK_FIELDS = [
|
|
53
|
+
["x", "X"],
|
|
54
|
+
["linkedin", "LinkedIn"],
|
|
55
|
+
["bluesky", "Bluesky"],
|
|
56
|
+
["instagram", "Instagram"],
|
|
57
|
+
["telegram", "Telegram"],
|
|
58
|
+
["website", "Personal website"],
|
|
59
|
+
] as const satisfies readonly (readonly [SuiteProfileLinkKey, string])[];
|
|
60
|
+
|
|
61
|
+
function linkInputs(profile: SuiteProfileView): LinkInputs {
|
|
62
|
+
return {
|
|
63
|
+
bluesky: profile.links.bluesky ?? "",
|
|
64
|
+
instagram: profile.links.instagram ?? "",
|
|
65
|
+
linkedin: profile.links.linkedin ?? "",
|
|
66
|
+
telegram: profile.links.telegram ?? "",
|
|
67
|
+
website: profile.links.website ?? "",
|
|
68
|
+
x: profile.links.x ?? "",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function issueMessage(issue: SuiteProfileIssue): string {
|
|
73
|
+
if (issue.field === "name") {
|
|
74
|
+
return issue.reason === "required"
|
|
75
|
+
? "Enter a name."
|
|
76
|
+
: `Use ${SUITE_PROFILE_NAME_MAX_LENGTH} characters or fewer.`;
|
|
77
|
+
}
|
|
78
|
+
if (issue.field === "bio") {
|
|
79
|
+
return `Use ${SUITE_PROFILE_BIO_MAX_LENGTH} characters or fewer.`;
|
|
80
|
+
}
|
|
81
|
+
if (issue.field === "linkedin") return "Enter a LinkedIn profile URL.";
|
|
82
|
+
if (issue.field === "website") return "Enter a complete HTTPS URL.";
|
|
83
|
+
if (
|
|
84
|
+
issue.field === "x"
|
|
85
|
+
|| issue.field === "bluesky"
|
|
86
|
+
|| issue.field === "instagram"
|
|
87
|
+
|| issue.field === "telegram"
|
|
88
|
+
) {
|
|
89
|
+
const label = LINK_FIELDS.find(([key]) => key === issue.field)?.[1]
|
|
90
|
+
?? "profile";
|
|
91
|
+
return `Enter a valid ${label} handle or profile URL.`;
|
|
92
|
+
}
|
|
93
|
+
return "Check the profile and try again.";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function applyProfile(
|
|
97
|
+
profile: SuiteProfileView,
|
|
98
|
+
setters: Readonly<{
|
|
99
|
+
setBio: (value: string) => void;
|
|
100
|
+
setEmail: (value: string) => void;
|
|
101
|
+
setLinks: (value: LinkInputs) => void;
|
|
102
|
+
setName: (value: string) => void;
|
|
103
|
+
setRevision: (value: number) => void;
|
|
104
|
+
}>,
|
|
105
|
+
): void {
|
|
106
|
+
setters.setBio(profile.bio);
|
|
107
|
+
setters.setEmail(profile.email);
|
|
108
|
+
setters.setLinks(linkInputs(profile));
|
|
109
|
+
setters.setName(profile.name);
|
|
110
|
+
setters.setRevision(profile.revision);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function SuiteProfileForm({
|
|
114
|
+
className,
|
|
115
|
+
initialProfile,
|
|
116
|
+
onSave,
|
|
117
|
+
onSaved,
|
|
118
|
+
submitLabel,
|
|
119
|
+
}: SuiteProfileFormProps) {
|
|
120
|
+
const id = useId();
|
|
121
|
+
const [name, setName] = useState(initialProfile.name);
|
|
122
|
+
const [email, setEmail] = useState(initialProfile.email);
|
|
123
|
+
const [bio, setBio] = useState(initialProfile.bio);
|
|
124
|
+
const [links, setLinks] = useState<LinkInputs>(() =>
|
|
125
|
+
linkInputs(initialProfile)
|
|
126
|
+
);
|
|
127
|
+
const [revision, setRevision] = useState(initialProfile.revision);
|
|
128
|
+
const [pending, setPending] = useState(false);
|
|
129
|
+
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
|
130
|
+
const [formError, setFormError] = useState<string | null>(null);
|
|
131
|
+
|
|
132
|
+
async function submit(event: FormEvent<HTMLFormElement>) {
|
|
133
|
+
event.preventDefault();
|
|
134
|
+
if (pending) return;
|
|
135
|
+
setFieldErrors({});
|
|
136
|
+
setFormError(null);
|
|
137
|
+
const parsed = parseSuiteProfileUpdateRequest({
|
|
138
|
+
bio,
|
|
139
|
+
expectedRevision: revision,
|
|
140
|
+
links,
|
|
141
|
+
name,
|
|
142
|
+
});
|
|
143
|
+
if (!parsed.ok) {
|
|
144
|
+
setFieldErrors({
|
|
145
|
+
[parsed.error.field]: issueMessage(parsed.error),
|
|
146
|
+
});
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
setPending(true);
|
|
150
|
+
try {
|
|
151
|
+
const result = await onSave(parsed.value);
|
|
152
|
+
applyProfile(result.profile, {
|
|
153
|
+
setBio,
|
|
154
|
+
setEmail,
|
|
155
|
+
setLinks,
|
|
156
|
+
setName,
|
|
157
|
+
setRevision,
|
|
158
|
+
});
|
|
159
|
+
if (result.status === "conflict") {
|
|
160
|
+
setFormError(
|
|
161
|
+
"The profile changed elsewhere. Review it and save again.",
|
|
162
|
+
);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
onSaved?.(result.profile);
|
|
166
|
+
} catch {
|
|
167
|
+
setFormError("The profile could not be saved. Try again.");
|
|
168
|
+
} finally {
|
|
169
|
+
setPending(false);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const formClassName = className === undefined
|
|
174
|
+
? `suite-profile-form ${profileFormClasses.form}`
|
|
175
|
+
: `suite-profile-form ${profileFormClasses.form} ${className}`;
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<form
|
|
179
|
+
aria-busy={pending}
|
|
180
|
+
className={formClassName}
|
|
181
|
+
onSubmit={(event) => {
|
|
182
|
+
void submit(event);
|
|
183
|
+
}}
|
|
184
|
+
>
|
|
185
|
+
<label className={`suite-profile-field ${profileFormClasses.field}`} htmlFor={`${id}-name`}>
|
|
186
|
+
<span className={profileFormClasses.label}>Name</span>
|
|
187
|
+
<input
|
|
188
|
+
className={profileFormClasses.control}
|
|
189
|
+
aria-describedby={
|
|
190
|
+
fieldErrors.name === undefined ? undefined : `${id}-name-error`
|
|
191
|
+
}
|
|
192
|
+
aria-invalid={fieldErrors.name === undefined ? undefined : "true"}
|
|
193
|
+
autoComplete="name"
|
|
194
|
+
disabled={pending}
|
|
195
|
+
id={`${id}-name`}
|
|
196
|
+
maxLength={SUITE_PROFILE_NAME_MAX_LENGTH}
|
|
197
|
+
onChange={(event) => {
|
|
198
|
+
setName(event.currentTarget.value);
|
|
199
|
+
setFieldErrors(errors => withoutFieldError(errors, "name"));
|
|
200
|
+
}}
|
|
201
|
+
required
|
|
202
|
+
type="text"
|
|
203
|
+
value={name}
|
|
204
|
+
/>
|
|
205
|
+
{fieldErrors.name === undefined
|
|
206
|
+
? null
|
|
207
|
+
: (
|
|
208
|
+
<span
|
|
209
|
+
className={`suite-profile-error ${profileFormClasses.error}`}
|
|
210
|
+
id={`${id}-name-error`}
|
|
211
|
+
role="alert"
|
|
212
|
+
>
|
|
213
|
+
{fieldErrors.name}
|
|
214
|
+
</span>
|
|
215
|
+
)}
|
|
216
|
+
</label>
|
|
217
|
+
|
|
218
|
+
<label className={`suite-profile-field ${profileFormClasses.field}`} htmlFor={`${id}-email`}>
|
|
219
|
+
<span className={profileFormClasses.label}>Email</span>
|
|
220
|
+
<input
|
|
221
|
+
className={profileFormClasses.email}
|
|
222
|
+
aria-readonly="true"
|
|
223
|
+
autoComplete="email"
|
|
224
|
+
id={`${id}-email`}
|
|
225
|
+
readOnly
|
|
226
|
+
type="email"
|
|
227
|
+
value={email}
|
|
228
|
+
/>
|
|
229
|
+
</label>
|
|
230
|
+
|
|
231
|
+
<label className={`suite-profile-field ${profileFormClasses.field}`} htmlFor={`${id}-bio`}>
|
|
232
|
+
<span className={profileFormClasses.label}>Bio</span>
|
|
233
|
+
<textarea
|
|
234
|
+
className={profileFormClasses.textarea}
|
|
235
|
+
aria-describedby={
|
|
236
|
+
fieldErrors.bio === undefined ? undefined : `${id}-bio-error`
|
|
237
|
+
}
|
|
238
|
+
aria-invalid={fieldErrors.bio === undefined ? undefined : "true"}
|
|
239
|
+
disabled={pending}
|
|
240
|
+
id={`${id}-bio`}
|
|
241
|
+
maxLength={SUITE_PROFILE_BIO_MAX_LENGTH}
|
|
242
|
+
onChange={(event) => {
|
|
243
|
+
setBio(event.currentTarget.value);
|
|
244
|
+
setFieldErrors(errors => withoutFieldError(errors, "bio"));
|
|
245
|
+
}}
|
|
246
|
+
placeholder="Introduce yourself"
|
|
247
|
+
rows={5}
|
|
248
|
+
value={bio}
|
|
249
|
+
/>
|
|
250
|
+
{fieldErrors.bio === undefined
|
|
251
|
+
? null
|
|
252
|
+
: (
|
|
253
|
+
<span
|
|
254
|
+
className={`suite-profile-error ${profileFormClasses.error}`}
|
|
255
|
+
id={`${id}-bio-error`}
|
|
256
|
+
role="alert"
|
|
257
|
+
>
|
|
258
|
+
{fieldErrors.bio}
|
|
259
|
+
</span>
|
|
260
|
+
)}
|
|
261
|
+
</label>
|
|
262
|
+
|
|
263
|
+
{LINK_FIELDS.map(([key, label]) => {
|
|
264
|
+
const error = fieldErrors[key];
|
|
265
|
+
return (
|
|
266
|
+
<label
|
|
267
|
+
className={`suite-profile-field ${profileFormClasses.field}`}
|
|
268
|
+
htmlFor={`${id}-${key}`}
|
|
269
|
+
key={key}
|
|
270
|
+
>
|
|
271
|
+
<span className={profileFormClasses.label}>{label}</span>
|
|
272
|
+
<input
|
|
273
|
+
className={profileFormClasses.control}
|
|
274
|
+
aria-describedby={
|
|
275
|
+
error === undefined ? undefined : `${id}-${key}-error`
|
|
276
|
+
}
|
|
277
|
+
aria-invalid={error === undefined ? undefined : "true"}
|
|
278
|
+
autoCapitalize="none"
|
|
279
|
+
autoComplete="url"
|
|
280
|
+
disabled={pending}
|
|
281
|
+
id={`${id}-${key}`}
|
|
282
|
+
inputMode="url"
|
|
283
|
+
maxLength={2_048}
|
|
284
|
+
onChange={(event) => {
|
|
285
|
+
const value = event.currentTarget.value;
|
|
286
|
+
setLinks(current => ({ ...current, [key]: value }));
|
|
287
|
+
setFieldErrors(errors => withoutFieldError(errors, key));
|
|
288
|
+
}}
|
|
289
|
+
spellCheck={false}
|
|
290
|
+
type="text"
|
|
291
|
+
value={links[key]}
|
|
292
|
+
/>
|
|
293
|
+
{error === undefined
|
|
294
|
+
? null
|
|
295
|
+
: (
|
|
296
|
+
<span
|
|
297
|
+
className={`suite-profile-error ${profileFormClasses.error}`}
|
|
298
|
+
id={`${id}-${key}-error`}
|
|
299
|
+
role="alert"
|
|
300
|
+
>
|
|
301
|
+
{error}
|
|
302
|
+
</span>
|
|
303
|
+
)}
|
|
304
|
+
</label>
|
|
305
|
+
);
|
|
306
|
+
})}
|
|
307
|
+
|
|
308
|
+
{formError === null
|
|
309
|
+
? null
|
|
310
|
+
: <p className={`suite-profile-error ${profileFormClasses.error}`} role="alert">{formError}</p>}
|
|
311
|
+
<button className={profileFormClasses.button} disabled={pending} type="submit">{submitLabel}</button>
|
|
312
|
+
</form>
|
|
313
|
+
);
|
|
314
|
+
}
|
package/src/profile.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SUITE_COMMUNITY_APPLICATION_STATUSES,
|
|
3
|
+
SUITE_PROFILE_BIO_MAX_LENGTH,
|
|
4
|
+
SUITE_PROFILE_NAME_MAX_LENGTH,
|
|
5
|
+
SUITE_PROFILE_URL_MAX_LENGTH,
|
|
6
|
+
normalizeSuiteProfileLink,
|
|
7
|
+
parseSuiteCommunityProfileView,
|
|
8
|
+
parseSuiteProfileUpdateRequest,
|
|
9
|
+
parseSuiteProfileView,
|
|
10
|
+
} from "./identity/profiles.js";
|
|
11
|
+
|
|
12
|
+
export * from "./identity/profiles-v2.js";
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
SUITE_COMMUNITY_APPLICATION_STATUSES,
|
|
16
|
+
SUITE_PROFILE_BIO_MAX_LENGTH,
|
|
17
|
+
SUITE_PROFILE_NAME_MAX_LENGTH,
|
|
18
|
+
SUITE_PROFILE_URL_MAX_LENGTH,
|
|
19
|
+
normalizeSuiteProfileLink,
|
|
20
|
+
parseSuiteCommunityProfileView,
|
|
21
|
+
parseSuiteProfileUpdateRequest,
|
|
22
|
+
parseSuiteProfileView,
|
|
23
|
+
};
|
|
24
|
+
export type {
|
|
25
|
+
SuiteCommunityApplicationStatus,
|
|
26
|
+
SuiteCommunityApplicationView,
|
|
27
|
+
SuiteCommunityProfileView,
|
|
28
|
+
SuiteProfileIssue,
|
|
29
|
+
SuiteProfileLinkKey,
|
|
30
|
+
SuiteProfileLinks,
|
|
31
|
+
SuiteProfileUpdateRequest,
|
|
32
|
+
SuiteProfileView,
|
|
33
|
+
} from "./identity/profiles.js";
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { parseConvexDeployment } from "./convex-url.js";
|
|
2
|
+
import { deepFreeze } from "./immutable.js";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
getSuiteAccountsCurrentConsumerEnvironment,
|
|
6
|
+
getSuiteAccountsCurrentConsumer,
|
|
7
|
+
getSuiteAccountsConsumer,
|
|
8
|
+
getSuiteAccountsDeployment,
|
|
9
|
+
isSuiteAccountsCurrentConsumerId,
|
|
10
|
+
type SuiteAccountsAuthConfiguration,
|
|
11
|
+
type SuiteAccountsCurrentConsumerId,
|
|
12
|
+
type SuiteAccountsEnvironment,
|
|
13
|
+
type SuiteAccountsRegisteredConsumerId,
|
|
14
|
+
} from "./registry.js";
|
|
15
|
+
|
|
16
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "[::1]", "localhost"]);
|
|
17
|
+
|
|
18
|
+
export const SUITE_ACCOUNTS_PUBLIC_ENVIRONMENT_KEYS = deepFreeze([
|
|
19
|
+
"NEXT_PUBLIC_ACCOUNTS_CONVEX_URL",
|
|
20
|
+
"NEXT_PUBLIC_ACCOUNTS_CONVEX_SITE_URL",
|
|
21
|
+
"NEXT_PUBLIC_SITE_URL",
|
|
22
|
+
] as const);
|
|
23
|
+
|
|
24
|
+
export type SuiteAccountsPublicEnvironmentKey =
|
|
25
|
+
(typeof SUITE_ACCOUNTS_PUBLIC_ENVIRONMENT_KEYS)[number];
|
|
26
|
+
|
|
27
|
+
export type SuiteAccountsPublicEnvironment = Readonly<{
|
|
28
|
+
NEXT_PUBLIC_VERCEL_SURFACE_ORIGIN?: string | undefined;
|
|
29
|
+
NEXT_PUBLIC_ACCOUNTS_CONVEX_SITE_URL?: string | undefined;
|
|
30
|
+
NEXT_PUBLIC_ACCOUNTS_CONVEX_URL?: string | undefined;
|
|
31
|
+
NEXT_PUBLIC_SITE_URL?: string | undefined;
|
|
32
|
+
}>;
|
|
33
|
+
|
|
34
|
+
type ReadySuiteAccountsPublicConfigBase = Readonly<{
|
|
35
|
+
canonicalProductOrigin: string;
|
|
36
|
+
consumer: SuiteAccountsPublicConsumerId;
|
|
37
|
+
convexSiteUrl: string;
|
|
38
|
+
convexUrl: string;
|
|
39
|
+
environment: SuiteAccountsEnvironment;
|
|
40
|
+
kind: "ready";
|
|
41
|
+
siteUrl: string;
|
|
42
|
+
surfaceOrigin: string;
|
|
43
|
+
}>;
|
|
44
|
+
|
|
45
|
+
export type SuiteAccountsPublicConsumerId =
|
|
46
|
+
| SuiteAccountsRegisteredConsumerId
|
|
47
|
+
| SuiteAccountsCurrentConsumerId;
|
|
48
|
+
|
|
49
|
+
export type ReadySuiteAccountsAuthConfiguration =
|
|
50
|
+
| Readonly<{
|
|
51
|
+
authBasePath: "/api/auth";
|
|
52
|
+
authMode: "authority" | "proxy";
|
|
53
|
+
}>
|
|
54
|
+
| Readonly<{
|
|
55
|
+
authBasePath: "/api/suite-auth";
|
|
56
|
+
authMode: "oidc-rp";
|
|
57
|
+
}>;
|
|
58
|
+
|
|
59
|
+
export type ReadySuiteAccountsPublicConfig =
|
|
60
|
+
ReadySuiteAccountsPublicConfigBase
|
|
61
|
+
& ReadySuiteAccountsAuthConfiguration;
|
|
62
|
+
|
|
63
|
+
export type SuiteAccountsPublicConfig =
|
|
64
|
+
| ReadySuiteAccountsPublicConfig
|
|
65
|
+
| Readonly<{ kind: "invalid"; message: string }>
|
|
66
|
+
| Readonly<{
|
|
67
|
+
kind: "missing";
|
|
68
|
+
missing: readonly SuiteAccountsPublicEnvironmentKey[];
|
|
69
|
+
}>
|
|
70
|
+
| Readonly<{
|
|
71
|
+
canonicalProductOrigin: string;
|
|
72
|
+
environment: "production";
|
|
73
|
+
kind: "unavailable";
|
|
74
|
+
message: string;
|
|
75
|
+
surfaceOrigin: string;
|
|
76
|
+
}>;
|
|
77
|
+
|
|
78
|
+
function parseOrigin(
|
|
79
|
+
value: string,
|
|
80
|
+
field: SuiteAccountsPublicEnvironmentKey | "NEXT_PUBLIC_VERCEL_SURFACE_ORIGIN",
|
|
81
|
+
): URL {
|
|
82
|
+
let url: URL;
|
|
83
|
+
try {
|
|
84
|
+
url = new URL(value);
|
|
85
|
+
} catch {
|
|
86
|
+
throw new Error(`${field} must be an absolute URL.`);
|
|
87
|
+
}
|
|
88
|
+
const loopback = LOOPBACK_HOSTS.has(url.hostname);
|
|
89
|
+
if (url.protocol !== "https:" && !(loopback && url.protocol === "http:")) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`${field} must use HTTPS (HTTP is allowed only on loopback).`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (
|
|
95
|
+
url.username !== ""
|
|
96
|
+
|| url.password !== ""
|
|
97
|
+
|| url.search !== ""
|
|
98
|
+
|| url.hash !== ""
|
|
99
|
+
|| (url.pathname !== "" && url.pathname !== "/")
|
|
100
|
+
) {
|
|
101
|
+
throw new Error(`${field} must be a credential-free origin.`);
|
|
102
|
+
}
|
|
103
|
+
return new URL(url.origin);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function readyRemoteConfig(
|
|
107
|
+
consumer: SuiteAccountsPublicConsumerId,
|
|
108
|
+
siteUrl: string,
|
|
109
|
+
convexUrl: string,
|
|
110
|
+
convexSiteUrl: string,
|
|
111
|
+
): ReadySuiteAccountsPublicConfig | null {
|
|
112
|
+
const registration = getPublicConsumer(consumer);
|
|
113
|
+
const environment = "production" as const;
|
|
114
|
+
const consumerEnvironment = getSuiteAccountsCurrentConsumerEnvironment(
|
|
115
|
+
consumer,
|
|
116
|
+
environment,
|
|
117
|
+
);
|
|
118
|
+
const deployment = getSuiteAccountsDeployment(environment);
|
|
119
|
+
if (
|
|
120
|
+
consumerEnvironment?.siteUrl === siteUrl
|
|
121
|
+
&& deployment.convexUrl === convexUrl
|
|
122
|
+
&& deployment.convexSiteUrl === convexSiteUrl
|
|
123
|
+
) {
|
|
124
|
+
return deepFreeze({
|
|
125
|
+
...publicAuthConfiguration(registration.auth),
|
|
126
|
+
canonicalProductOrigin: siteUrl,
|
|
127
|
+
consumer,
|
|
128
|
+
convexSiteUrl,
|
|
129
|
+
convexUrl,
|
|
130
|
+
environment,
|
|
131
|
+
kind: "ready",
|
|
132
|
+
siteUrl,
|
|
133
|
+
surfaceOrigin: siteUrl,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function publicAuthConfiguration(
|
|
140
|
+
auth: SuiteAccountsAuthConfiguration,
|
|
141
|
+
): ReadySuiteAccountsAuthConfiguration {
|
|
142
|
+
switch (auth.kind) {
|
|
143
|
+
case "authority":
|
|
144
|
+
return { authBasePath: auth.basePath, authMode: auth.kind };
|
|
145
|
+
case "oidc-rp":
|
|
146
|
+
return { authBasePath: auth.basePath, authMode: auth.kind };
|
|
147
|
+
case "proxy":
|
|
148
|
+
return { authBasePath: auth.basePath, authMode: auth.kind };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function getPublicConsumer(consumer: SuiteAccountsPublicConsumerId) {
|
|
153
|
+
return isSuiteAccountsCurrentConsumerId(consumer)
|
|
154
|
+
? getSuiteAccountsCurrentConsumer(consumer)
|
|
155
|
+
: getSuiteAccountsConsumer(consumer);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function parseSuiteAccountsPublicConfig(
|
|
159
|
+
consumer: SuiteAccountsPublicConsumerId,
|
|
160
|
+
environment: SuiteAccountsPublicEnvironment,
|
|
161
|
+
): Exclude<SuiteAccountsPublicConfig, { kind: "invalid" }> {
|
|
162
|
+
const missing = SUITE_ACCOUNTS_PUBLIC_ENVIRONMENT_KEYS.filter((name) => {
|
|
163
|
+
const value = environment[name];
|
|
164
|
+
return typeof value !== "string" || value.trim() === "";
|
|
165
|
+
});
|
|
166
|
+
if (missing.length > 0) return deepFreeze({ kind: "missing", missing });
|
|
167
|
+
|
|
168
|
+
const site = parseOrigin(
|
|
169
|
+
environment.NEXT_PUBLIC_SITE_URL!,
|
|
170
|
+
"NEXT_PUBLIC_SITE_URL",
|
|
171
|
+
);
|
|
172
|
+
const convexSite = parseOrigin(
|
|
173
|
+
environment.NEXT_PUBLIC_ACCOUNTS_CONVEX_SITE_URL!,
|
|
174
|
+
"NEXT_PUBLIC_ACCOUNTS_CONVEX_SITE_URL",
|
|
175
|
+
);
|
|
176
|
+
const deployment = parseConvexDeployment(
|
|
177
|
+
environment.NEXT_PUBLIC_ACCOUNTS_CONVEX_URL,
|
|
178
|
+
);
|
|
179
|
+
if (deployment.kind !== "ready") {
|
|
180
|
+
throw new Error(
|
|
181
|
+
deployment.kind === "invalid"
|
|
182
|
+
? `NEXT_PUBLIC_ACCOUNTS_CONVEX_URL is invalid: ${deployment.message}`
|
|
183
|
+
: "NEXT_PUBLIC_ACCOUNTS_CONVEX_URL is required.",
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
const convex = parseOrigin(
|
|
187
|
+
deployment.url,
|
|
188
|
+
"NEXT_PUBLIC_ACCOUNTS_CONVEX_URL",
|
|
189
|
+
);
|
|
190
|
+
const loopback = [site, convex, convexSite].map(url =>
|
|
191
|
+
LOOPBACK_HOSTS.has(url.hostname)
|
|
192
|
+
);
|
|
193
|
+
if (loopback.every(Boolean)) {
|
|
194
|
+
// This new registration has no local trust surface. Preserve existing
|
|
195
|
+
// consumers' released loopback behavior below.
|
|
196
|
+
if (consumer === "aicharts") {
|
|
197
|
+
throw new Error(
|
|
198
|
+
"AI Charts authentication requires its registered production origin.",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
if (
|
|
202
|
+
deployment.transport !== "local"
|
|
203
|
+
|| site.hostname !== convex.hostname
|
|
204
|
+
|| site.hostname !== convexSite.hostname
|
|
205
|
+
) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
"Local consumer and Accounts endpoints must use the same loopback host.",
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
const registration = getPublicConsumer(consumer);
|
|
211
|
+
return deepFreeze({
|
|
212
|
+
...publicAuthConfiguration(registration.auth),
|
|
213
|
+
canonicalProductOrigin: site.origin,
|
|
214
|
+
consumer,
|
|
215
|
+
convexSiteUrl: convexSite.origin,
|
|
216
|
+
convexUrl: convex.origin,
|
|
217
|
+
environment: "local",
|
|
218
|
+
kind: "ready",
|
|
219
|
+
siteUrl: site.origin,
|
|
220
|
+
surfaceOrigin: site.origin,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
if (loopback.some(Boolean)) {
|
|
224
|
+
throw new Error(
|
|
225
|
+
"Consumer and Accounts endpoints cannot mix local and remote environments.",
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
const previewSurfaceValue = environment.NEXT_PUBLIC_VERCEL_SURFACE_ORIGIN;
|
|
229
|
+
if (previewSurfaceValue !== undefined) {
|
|
230
|
+
const previewSurface = parseOrigin(
|
|
231
|
+
previewSurfaceValue,
|
|
232
|
+
"NEXT_PUBLIC_VERCEL_SURFACE_ORIGIN",
|
|
233
|
+
);
|
|
234
|
+
if (!previewSurface.hostname.endsWith(".vercel.app")) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
"NEXT_PUBLIC_VERCEL_SURFACE_ORIGIN must use a generated .vercel.app origin.",
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
const production = readyRemoteConfig(
|
|
240
|
+
consumer,
|
|
241
|
+
site.origin,
|
|
242
|
+
convex.origin,
|
|
243
|
+
convexSite.origin,
|
|
244
|
+
);
|
|
245
|
+
if (production === null) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`${getPublicConsumer(consumer).displayName} and Accounts endpoints `
|
|
248
|
+
+ "do not match the production deployment.",
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
return deepFreeze({
|
|
252
|
+
canonicalProductOrigin: site.origin,
|
|
253
|
+
environment: "production",
|
|
254
|
+
kind: "unavailable",
|
|
255
|
+
message: "Suite authentication is unavailable on generated Vercel Preview origins.",
|
|
256
|
+
surfaceOrigin: previewSurface.origin,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
const remote = readyRemoteConfig(
|
|
260
|
+
consumer,
|
|
261
|
+
site.origin,
|
|
262
|
+
convex.origin,
|
|
263
|
+
convexSite.origin,
|
|
264
|
+
);
|
|
265
|
+
if (remote !== null) return remote;
|
|
266
|
+
throw new Error(
|
|
267
|
+
`${getPublicConsumer(consumer).displayName} and Accounts endpoints `
|
|
268
|
+
+ "do not match an owned deployment environment.",
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function suiteAccountsPublicConfigFromEnvironment(
|
|
273
|
+
consumer: SuiteAccountsPublicConsumerId,
|
|
274
|
+
environment: SuiteAccountsPublicEnvironment = {
|
|
275
|
+
NEXT_PUBLIC_VERCEL_SURFACE_ORIGIN:
|
|
276
|
+
process.env.NEXT_PUBLIC_VERCEL_SURFACE_ORIGIN,
|
|
277
|
+
NEXT_PUBLIC_ACCOUNTS_CONVEX_SITE_URL:
|
|
278
|
+
process.env.NEXT_PUBLIC_ACCOUNTS_CONVEX_SITE_URL,
|
|
279
|
+
NEXT_PUBLIC_ACCOUNTS_CONVEX_URL:
|
|
280
|
+
process.env.NEXT_PUBLIC_ACCOUNTS_CONVEX_URL,
|
|
281
|
+
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
|
|
282
|
+
},
|
|
283
|
+
): SuiteAccountsPublicConfig {
|
|
284
|
+
try {
|
|
285
|
+
return parseSuiteAccountsPublicConfig(consumer, environment);
|
|
286
|
+
} catch (error) {
|
|
287
|
+
return deepFreeze({
|
|
288
|
+
kind: "invalid",
|
|
289
|
+
message: error instanceof Error
|
|
290
|
+
? error.message
|
|
291
|
+
: "Suite Accounts configuration is invalid.",
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|