@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,82 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parseSuiteProfileEditorV2,
|
|
3
|
+
parseSuiteProfileUpdateV2,
|
|
4
|
+
type SuiteProfileEditorV2,
|
|
5
|
+
type SuiteProfileUpdateV2,
|
|
6
|
+
} from "./identity/profiles-v2.js";
|
|
7
|
+
|
|
8
|
+
export type PublicProfileFormResult =
|
|
9
|
+
| Readonly<{ status: "saved"; profile: SuiteProfileEditorV2 }>
|
|
10
|
+
| Readonly<{ status: "conflict"; profile: SuiteProfileEditorV2 }>
|
|
11
|
+
| Readonly<{ status: "unauthorized" }>
|
|
12
|
+
| Readonly<{ status: "username_required" }>
|
|
13
|
+
| Readonly<{ status: "invalid_avatar" }>;
|
|
14
|
+
|
|
15
|
+
const LINK_KEYS = [
|
|
16
|
+
"bluesky", "github", "instagram", "linkedin", "telegram", "website", "x",
|
|
17
|
+
] as const;
|
|
18
|
+
|
|
19
|
+
function matchesRequest(profile: SuiteProfileEditorV2, request: SuiteProfileUpdateV2): boolean {
|
|
20
|
+
return profile.name === request.name && profile.bio === request.bio
|
|
21
|
+
&& profile.avatarRef === request.avatarRef
|
|
22
|
+
&& profile.publication === (request.publication === "publish" ? "published" : "private")
|
|
23
|
+
&& LINK_KEYS.every((key) => profile.links[key] === request.links[key]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Own the small envelope without invoking property accessors. */
|
|
27
|
+
function snapshot(value: unknown): Record<string, unknown> | null {
|
|
28
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
|
|
29
|
+
const prototype: unknown = Object.getPrototypeOf(value);
|
|
30
|
+
if (prototype !== null && prototype !== Object.prototype) return null;
|
|
31
|
+
const keys = Reflect.ownKeys(value);
|
|
32
|
+
if (keys.length < 1 || keys.length > 2 || !keys.includes("status")) return null;
|
|
33
|
+
const owned = Object.create(null) as Record<string, unknown>;
|
|
34
|
+
for (const key of keys) {
|
|
35
|
+
if (key !== "status" && key !== "profile") return null;
|
|
36
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
37
|
+
if (descriptor === undefined || !descriptor.enumerable
|
|
38
|
+
|| !Object.hasOwn(descriptor, "value")) return null;
|
|
39
|
+
const field: unknown = descriptor.value;
|
|
40
|
+
owned[key] = field;
|
|
41
|
+
}
|
|
42
|
+
return owned;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Validate a save response against its request; this does not authenticate it. */
|
|
46
|
+
export function parsePublicProfileFormResult(
|
|
47
|
+
value: unknown,
|
|
48
|
+
currentProfile: SuiteProfileEditorV2,
|
|
49
|
+
request: SuiteProfileUpdateV2,
|
|
50
|
+
): PublicProfileFormResult | null {
|
|
51
|
+
try {
|
|
52
|
+
// Snapshot both expectations before reflecting over a foreign response.
|
|
53
|
+
const current = parseSuiteProfileEditorV2(currentProfile);
|
|
54
|
+
const update = parseSuiteProfileUpdateV2(request);
|
|
55
|
+
if (!current.ok || !update.ok || update.value.expectedRevision !== current.value.revision) return null;
|
|
56
|
+
const envelope = snapshot(value);
|
|
57
|
+
if (envelope === null) return null;
|
|
58
|
+
const status = envelope["status"];
|
|
59
|
+
if (status === "unauthorized" || status === "username_required" || status === "invalid_avatar") {
|
|
60
|
+
return Object.hasOwn(envelope, "profile") ? null : Object.freeze({ status });
|
|
61
|
+
}
|
|
62
|
+
if (status !== "saved" && status !== "conflict") return null;
|
|
63
|
+
if (!Object.hasOwn(envelope, "profile")) return null;
|
|
64
|
+
const parsed = parseSuiteProfileEditorV2(envelope["profile"]);
|
|
65
|
+
if (!parsed.ok || parsed.value.accountId !== current.value.accountId) return null;
|
|
66
|
+
const profile = parsed.value;
|
|
67
|
+
const expected = update.value;
|
|
68
|
+
if (status === "conflict") {
|
|
69
|
+
return profile.revision > expected.expectedRevision
|
|
70
|
+
? Object.freeze({ status, profile })
|
|
71
|
+
: null;
|
|
72
|
+
}
|
|
73
|
+
const sameRevision = profile.revision === expected.expectedRevision
|
|
74
|
+
&& matchesRequest(current.value, expected);
|
|
75
|
+
const nextRevision = expected.expectedRevision < Number.MAX_SAFE_INTEGER
|
|
76
|
+
&& profile.revision === expected.expectedRevision + 1;
|
|
77
|
+
if ((!sameRevision && !nextRevision) || !matchesRequest(profile, expected)) return null;
|
|
78
|
+
return Object.freeze({ status, profile });
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useId, useRef, useState, type FormEvent } from "react";
|
|
4
|
+
import {
|
|
5
|
+
SUITE_PROFILE_BIO_MAX_LENGTH, SUITE_PROFILE_NAME_MAX_LENGTH,
|
|
6
|
+
SUITE_PROFILE_URL_MAX_LENGTH,
|
|
7
|
+
} from "./identity/profiles.js";
|
|
8
|
+
import {
|
|
9
|
+
parseSuiteProfileEditorV2, parseSuiteProfileUpdateV2,
|
|
10
|
+
type SuiteProfileEditorV2, type SuiteProfileLinkKeyV2,
|
|
11
|
+
type SuiteProfileUpdateV2, type SuiteProfileV2Issue,
|
|
12
|
+
} from "./identity/profiles-v2.js";
|
|
13
|
+
import { profileFormClasses as classes } from "./profile-form.stylex.js";
|
|
14
|
+
import { parsePublicProfileFormResult } from "./public-profile-form-result.js";
|
|
15
|
+
import {
|
|
16
|
+
expandSuiteProfileLinkInput,
|
|
17
|
+
SUITE_PROFILE_LINK_AFFORDANCES,
|
|
18
|
+
type SuiteProfileLinkGlyph,
|
|
19
|
+
} from "./public-profile-links.js";
|
|
20
|
+
|
|
21
|
+
export type { PublicProfileFormResult as SuitePublicProfileFormResult } from "./public-profile-form-result.js";
|
|
22
|
+
|
|
23
|
+
export type SuitePublicProfileFormProps = Readonly<{
|
|
24
|
+
className?: string;
|
|
25
|
+
/** Initial snapshot. Remount explicitly to replace a same-account draft. */
|
|
26
|
+
initialProfile: SuiteProfileEditorV2;
|
|
27
|
+
/** Captured on mount. The caller owns authenticated transport and session unmount. */
|
|
28
|
+
onSave: (request: SuiteProfileUpdateV2) => Promise<unknown>;
|
|
29
|
+
onSaved?: (profile: SuiteProfileEditorV2) => void;
|
|
30
|
+
}>;
|
|
31
|
+
|
|
32
|
+
type Visibility = SuiteProfileUpdateV2["publication"] | null;
|
|
33
|
+
type LinkInputs = Record<SuiteProfileLinkKeyV2, string>;
|
|
34
|
+
type FieldErrors = Partial<Record<SuiteProfileV2Issue["field"], string>>;
|
|
35
|
+
|
|
36
|
+
const LINK_FIELDS = [
|
|
37
|
+
["x", "X"], ["github", "GitHub"], ["linkedin", "LinkedIn"],
|
|
38
|
+
["bluesky", "Bluesky"], ["instagram", "Instagram"],
|
|
39
|
+
["telegram", "Telegram"], ["website", "Website"],
|
|
40
|
+
] as const satisfies readonly (readonly [SuiteProfileLinkKeyV2, string])[];
|
|
41
|
+
|
|
42
|
+
function expandedLinks(links: LinkInputs): LinkInputs {
|
|
43
|
+
const expanded = { ...links };
|
|
44
|
+
for (const key of Object.keys(expanded) as SuiteProfileLinkKeyV2[]) {
|
|
45
|
+
expanded[key] = expandSuiteProfileLinkInput(key, links[key]);
|
|
46
|
+
}
|
|
47
|
+
return expanded;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function LinkMark({ glyph }: { readonly glyph: SuiteProfileLinkGlyph }) {
|
|
51
|
+
return (
|
|
52
|
+
<svg
|
|
53
|
+
aria-hidden="true"
|
|
54
|
+
className={classes.mark}
|
|
55
|
+
fill="none"
|
|
56
|
+
stroke="currentColor"
|
|
57
|
+
strokeLinecap="round"
|
|
58
|
+
strokeLinejoin="round"
|
|
59
|
+
strokeWidth={1.5}
|
|
60
|
+
viewBox="0 0 24 24"
|
|
61
|
+
>
|
|
62
|
+
{glyph.map(([tag, attrs], index) => tag === "circle"
|
|
63
|
+
? <circle key={index} {...attrs} />
|
|
64
|
+
: <path key={index} {...attrs} />)}
|
|
65
|
+
</svg>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function linkInputs(profile: SuiteProfileEditorV2): LinkInputs {
|
|
70
|
+
return {
|
|
71
|
+
x: profile.links.x ?? "", github: profile.links.github ?? "",
|
|
72
|
+
linkedin: profile.links.linkedin ?? "", website: profile.links.website ?? "",
|
|
73
|
+
bluesky: profile.links.bluesky ?? "", instagram: profile.links.instagram ?? "",
|
|
74
|
+
telegram: profile.links.telegram ?? "",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function issueMessage(issue: SuiteProfileV2Issue): string {
|
|
79
|
+
if (issue.field === "name") return issue.reason === "required"
|
|
80
|
+
? "Enter a public-facing name."
|
|
81
|
+
: `Use ${SUITE_PROFILE_NAME_MAX_LENGTH} characters or fewer.`;
|
|
82
|
+
if (issue.field === "bio") return `Use ${SUITE_PROFILE_BIO_MAX_LENGTH} characters or fewer.`;
|
|
83
|
+
if (issue.field === "publication") return "Choose whether to keep this profile private or publish it.";
|
|
84
|
+
if (issue.field === "github") return "Enter a GitHub profile URL, such as https://github.com/username.";
|
|
85
|
+
if (issue.field === "linkedin") return "Enter a LinkedIn profile URL.";
|
|
86
|
+
if (issue.field === "website") return "Enter a complete HTTPS URL.";
|
|
87
|
+
const label = LINK_FIELDS.find(([key]) => key === issue.field)?.[1];
|
|
88
|
+
return label === undefined ? "Check the profile and try again."
|
|
89
|
+
: `Enter a valid ${label} handle or profile URL.`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Optional editor only. The authority authenticates saves and applies consent. */
|
|
93
|
+
export function SuitePublicProfileForm(props: SuitePublicProfileFormProps) {
|
|
94
|
+
const parsed = parseSuiteProfileEditorV2(props.initialProfile);
|
|
95
|
+
if (!parsed.ok) {
|
|
96
|
+
return <p className={`suite-profile-error ${classes.error}`} role="alert">The profile is unavailable. Reload the page to try again.</p>;
|
|
97
|
+
}
|
|
98
|
+
return <PublicProfileEditor {...props} initialProfile={parsed.value} key={parsed.value.accountId} />;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function PublicProfileEditor({ className, initialProfile, onSave, onSaved }: SuitePublicProfileFormProps) {
|
|
102
|
+
const id = useId();
|
|
103
|
+
const [transport] = useState(() => ({ onSave, onSaved }));
|
|
104
|
+
const [profile, setProfile] = useState(initialProfile);
|
|
105
|
+
const [name, setName] = useState(initialProfile.name);
|
|
106
|
+
const [bio, setBio] = useState(initialProfile.bio);
|
|
107
|
+
const [links, setLinks] = useState(() => linkInputs(initialProfile));
|
|
108
|
+
const [linksOpen, setLinksOpen] = useState(() =>
|
|
109
|
+
Object.values(linkInputs(initialProfile)).some(value => value !== ""));
|
|
110
|
+
const [visibility, setVisibility] = useState<Visibility>(initialProfile.publication === "published" ? "publish" : "private");
|
|
111
|
+
const [pending, setPending] = useState(false);
|
|
112
|
+
const [ready, setReady] = useState(false);
|
|
113
|
+
const [conflict, setConflict] = useState<SuiteProfileEditorV2 | null>(null);
|
|
114
|
+
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
|
115
|
+
const [formError, setFormError] = useState<string | null>(null);
|
|
116
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
117
|
+
const [unconfirmed, setUnconfirmed] = useState(false);
|
|
118
|
+
const inFlight = useRef<symbol | null>(null);
|
|
119
|
+
const liveForm = useRef<HTMLFormElement | null>(null);
|
|
120
|
+
const abandoned = useRef(false);
|
|
121
|
+
const nameControl = useRef<HTMLInputElement>(null);
|
|
122
|
+
const focusLoadedProfile = useRef(false);
|
|
123
|
+
const pendingFocus = useRef<SuiteProfileV2Issue["field"] | null>(null);
|
|
124
|
+
// A DOM ref detaches during commit, before a retired transport can resolve
|
|
125
|
+
// in the gap before passive-effect cleanup. Reconnection never resends it.
|
|
126
|
+
const attachForm = useCallback((node: HTMLFormElement | null) => {
|
|
127
|
+
liveForm.current = node;
|
|
128
|
+
if (node === null) {
|
|
129
|
+
if (inFlight.current !== null) abandoned.current = true;
|
|
130
|
+
inFlight.current = null;
|
|
131
|
+
} else if (abandoned.current) {
|
|
132
|
+
abandoned.current = false;
|
|
133
|
+
setPending(false);
|
|
134
|
+
setUnconfirmed(true);
|
|
135
|
+
setFormError("The save could not be confirmed. Your draft is preserved. Try saving again, or reload the profile to check its saved state.");
|
|
136
|
+
}
|
|
137
|
+
}, []);
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
setReady(true);
|
|
140
|
+
}, []);
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (focusLoadedProfile.current && conflict === null) {
|
|
143
|
+
focusLoadedProfile.current = false;
|
|
144
|
+
nameControl.current?.focus();
|
|
145
|
+
}
|
|
146
|
+
}, [conflict]);
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
const field = pendingFocus.current;
|
|
149
|
+
if (field === null) return;
|
|
150
|
+
pendingFocus.current = null;
|
|
151
|
+
liveForm.current?.querySelector<HTMLElement>(`[id="${id}-${field}"]`)?.focus();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
function clearField(field: SuiteProfileV2Issue["field"]) {
|
|
155
|
+
setFieldErrors(current => { const next = { ...current }; delete next[field]; return next; });
|
|
156
|
+
setNotice(null);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function applyProfile(next: SuiteProfileEditorV2, nextVisibility: Visibility) {
|
|
160
|
+
setProfile(next);
|
|
161
|
+
setName(next.name);
|
|
162
|
+
setBio(next.bio);
|
|
163
|
+
setLinks(linkInputs(next));
|
|
164
|
+
setVisibility(nextVisibility);
|
|
165
|
+
setUnconfirmed(false);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function submit(event: FormEvent<HTMLFormElement>) {
|
|
169
|
+
event.preventDefault();
|
|
170
|
+
if (!ready || liveForm.current !== event.currentTarget || inFlight.current !== null || conflict !== null) return;
|
|
171
|
+
setNotice(null);
|
|
172
|
+
setFormError(null);
|
|
173
|
+
setFieldErrors({});
|
|
174
|
+
const parsed = parseSuiteProfileUpdateV2({
|
|
175
|
+
schemaVersion: 2, expectedRevision: profile.revision,
|
|
176
|
+
name, bio, links: expandedLinks(links),
|
|
177
|
+
avatarRef: profile.avatarRef, publication: visibility,
|
|
178
|
+
});
|
|
179
|
+
if (!parsed.ok) {
|
|
180
|
+
setFieldErrors({ [parsed.error.field]: issueMessage(parsed.error) });
|
|
181
|
+
if (parsed.error.field in SUITE_PROFILE_LINK_AFFORDANCES) setLinksOpen(true);
|
|
182
|
+
pendingFocus.current = parsed.error.field;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (parsed.value.publication === "publish" && profile.username === null) {
|
|
186
|
+
setFieldErrors({ publication: "Choose a username in account settings before publishing." });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const dispatch = Symbol();
|
|
190
|
+
inFlight.current = dispatch;
|
|
191
|
+
setPending(true);
|
|
192
|
+
let saved: SuiteProfileEditorV2 | null = null;
|
|
193
|
+
try {
|
|
194
|
+
const raw = await transport.onSave(parsed.value);
|
|
195
|
+
if (liveForm.current === null || inFlight.current !== dispatch) return;
|
|
196
|
+
const result = parsePublicProfileFormResult(raw, profile, parsed.value);
|
|
197
|
+
if (result === null) throw new Error("Unconfirmed profile result.");
|
|
198
|
+
if (result.status === "conflict") {
|
|
199
|
+
setConflict(result.profile);
|
|
200
|
+
setVisibility(null);
|
|
201
|
+
setUnconfirmed(false);
|
|
202
|
+
setFormError("This profile changed elsewhere. Your draft is still below. Load the latest profile to replace it, then review visibility before saving.");
|
|
203
|
+
} else if (result.status === "saved") {
|
|
204
|
+
applyProfile(result.profile, result.profile.publication === "published" ? "publish" : "private");
|
|
205
|
+
setNotice(result.profile.publication === "published" ? "Public profile saved." : "Private profile saved.");
|
|
206
|
+
saved = result.profile;
|
|
207
|
+
} else if (result.status === "unauthorized") {
|
|
208
|
+
setFormError("Your account session is unavailable. Sign in again before saving.");
|
|
209
|
+
} else if (result.status === "username_required") {
|
|
210
|
+
setFormError("Choose a username in account settings, then reload this profile before publishing.");
|
|
211
|
+
} else {
|
|
212
|
+
setFormError("Your profile image is unavailable. Reload the profile before saving.");
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
if (liveForm.current === null || inFlight.current !== dispatch) return;
|
|
216
|
+
setUnconfirmed(true);
|
|
217
|
+
setFormError("The save could not be confirmed. Your draft is preserved. Try saving again, or reload the profile to check its saved state.");
|
|
218
|
+
} finally {
|
|
219
|
+
if (liveForm.current !== null && inFlight.current === dispatch) {
|
|
220
|
+
inFlight.current = null;
|
|
221
|
+
setPending(false);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (saved !== null && liveForm.current !== null) {
|
|
225
|
+
try { transport.onSaved?.(saved); }
|
|
226
|
+
catch {
|
|
227
|
+
if (liveForm.current !== null) setFormError("The profile was saved, but the page could not refresh. Reload the page.");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const disabled = !ready || pending || conflict !== null;
|
|
233
|
+
const currentProfile = conflict ?? profile;
|
|
234
|
+
const action = pending ? "Saving profile" : visibility === "publish"
|
|
235
|
+
? profile.publication === "published" ? "Save public profile" : "Publish profile"
|
|
236
|
+
: visibility === "private" && profile.publication === "published"
|
|
237
|
+
? "Make profile private" : visibility === "private" ? "Save private profile" : "Save profile";
|
|
238
|
+
const formClass = `suite-profile-form suite-public-profile-form ${classes.form}${className === undefined ? "" : ` ${className}`}`;
|
|
239
|
+
const describedBy = (field: SuiteProfileV2Issue["field"]) => fieldErrors[field] === undefined ? undefined : `${id}-${field}-error`;
|
|
240
|
+
const fieldError = (field: SuiteProfileV2Issue["field"]) => fieldErrors[field] === undefined ? null
|
|
241
|
+
: <span className={`suite-profile-error ${classes.error}`} id={`${id}-${field}-error`} role="alert">{fieldErrors[field]}</span>;
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
<form aria-busy={!ready || pending} className={formClass} method="post" onSubmit={event => { void submit(event); }} ref={attachForm}>
|
|
245
|
+
<noscript><p className={classes.note}>Enable JavaScript to edit this profile. No changes can be saved from this form without it.</p></noscript>
|
|
246
|
+
{formError === null ? null : <p className={`suite-profile-error ${classes.error}`} role="alert">{formError}</p>}
|
|
247
|
+
{conflict === null ? null : <button className={classes.button} onClick={() => {
|
|
248
|
+
applyProfile(conflict, null);
|
|
249
|
+
setConflict(null);
|
|
250
|
+
setFieldErrors({});
|
|
251
|
+
setFormError(null);
|
|
252
|
+
setNotice("Latest profile loaded. Choose visibility before saving.");
|
|
253
|
+
focusLoadedProfile.current = true;
|
|
254
|
+
}} type="button">Load latest profile</button>}
|
|
255
|
+
<div className={`suite-profile-field ${classes.field}`}>
|
|
256
|
+
<label className={classes.label} htmlFor={`${id}-name`}>Public-facing name</label>
|
|
257
|
+
<input aria-describedby={describedBy("name")} aria-invalid={fieldErrors.name === undefined ? undefined : true}
|
|
258
|
+
autoComplete="off" className={classes.control} disabled={disabled} id={`${id}-name`}
|
|
259
|
+
maxLength={SUITE_PROFILE_NAME_MAX_LENGTH} onChange={event => { setName(event.currentTarget.value); clearField("name"); }}
|
|
260
|
+
placeholder="Ada Lovelace" ref={nameControl} required type="text" value={name} />
|
|
261
|
+
{fieldError("name")}
|
|
262
|
+
</div>
|
|
263
|
+
<div className={`suite-profile-field ${classes.field}`}>
|
|
264
|
+
<label className={classes.label} htmlFor={`${id}-bio`}>Bio</label>
|
|
265
|
+
<textarea aria-describedby={describedBy("bio")} aria-invalid={fieldErrors.bio === undefined ? undefined : true}
|
|
266
|
+
className={classes.textarea} disabled={disabled} id={`${id}-bio`} maxLength={SUITE_PROFILE_BIO_MAX_LENGTH}
|
|
267
|
+
onChange={event => { setBio(event.currentTarget.value); clearField("bio"); }}
|
|
268
|
+
placeholder="A short introduction." rows={3} value={bio} />
|
|
269
|
+
{fieldError("bio")}
|
|
270
|
+
</div>
|
|
271
|
+
<details className={`suite-profile-links ${classes.links}`} onToggle={event => { setLinksOpen(event.currentTarget.open); }} open={linksOpen}>
|
|
272
|
+
<summary className={classes.linksSummary}>Links <span className={classes.linksHint}>optional; a handle is enough</span></summary>
|
|
273
|
+
<div className={classes.linksBody}>
|
|
274
|
+
{LINK_FIELDS.map(([key, label]) => {
|
|
275
|
+
const affordance = SUITE_PROFILE_LINK_AFFORDANCES[key];
|
|
276
|
+
return (
|
|
277
|
+
<div className={`suite-profile-field ${classes.field}`} key={key}>
|
|
278
|
+
<label className={classes.label} htmlFor={`${id}-${key}`}>{label}</label>
|
|
279
|
+
<div className={classes.affix}>
|
|
280
|
+
<span aria-hidden="true" className={classes.affixLead}>
|
|
281
|
+
<LinkMark glyph={affordance.glyph} />
|
|
282
|
+
<span>{affordance.prefix}</span>
|
|
283
|
+
</span>
|
|
284
|
+
<input aria-describedby={describedBy(key)} aria-invalid={fieldErrors[key] === undefined ? undefined : true}
|
|
285
|
+
autoCapitalize="none" autoComplete="off" className={classes.affixInput} disabled={disabled} id={`${id}-${key}`}
|
|
286
|
+
inputMode="url" maxLength={SUITE_PROFILE_URL_MAX_LENGTH} onChange={event => {
|
|
287
|
+
const value = event.currentTarget.value;
|
|
288
|
+
setLinks(current => ({ ...current, [key]: value })); clearField(key);
|
|
289
|
+
}} placeholder={affordance.placeholder} spellCheck={false} type="text" value={links[key]} />
|
|
290
|
+
</div>
|
|
291
|
+
{fieldError(key)}
|
|
292
|
+
</div>
|
|
293
|
+
);
|
|
294
|
+
})}
|
|
295
|
+
</div>
|
|
296
|
+
</details>
|
|
297
|
+
<fieldset aria-describedby={`${id}-visibility-note${fieldErrors.publication === undefined ? "" : ` ${id}-publication-error`}`}
|
|
298
|
+
aria-invalid={fieldErrors.publication === undefined ? undefined : true}
|
|
299
|
+
className={`suite-profile-visibility ${classes.visibility}`} disabled={disabled} id={`${id}-publication`} tabIndex={-1}>
|
|
300
|
+
<legend className={classes.label}>Profile visibility</legend>
|
|
301
|
+
<p className={classes.note} id={`${id}-visibility-note`}>{unconfirmed ? "Publication status is unconfirmed." : currentProfile.publication === "published"
|
|
302
|
+
? `The saved profile for @${currentProfile.username} is public.` : "The saved profile is private."} Your choice takes effect when you save.</p>
|
|
303
|
+
<label className={classes.choice}>
|
|
304
|
+
<input checked={visibility === "private"} className={classes.radio} name="publication" onChange={() => { setVisibility("private"); clearField("publication"); }} required type="radio" value="private" />
|
|
305
|
+
<span>Keep private</span>
|
|
306
|
+
</label>
|
|
307
|
+
<label className={classes.choice}>
|
|
308
|
+
<input aria-describedby={`${id}-publication-note`} checked={visibility === "publish"} className={classes.radio}
|
|
309
|
+
disabled={profile.username === null} name="publication" onChange={() => { setVisibility("publish"); clearField("publication"); }} required type="radio" value="publish" />
|
|
310
|
+
<span>Publish profile</span>
|
|
311
|
+
</label>
|
|
312
|
+
<p className={classes.note} id={`${id}-publication-note`}>{profile.username === null ? "Choose a username in account settings before publishing. You can still save privately." : "Anyone can read your published name, bio, social links, and profile image. Your sign-in email is not included."}</p>
|
|
313
|
+
{fieldError("publication")}
|
|
314
|
+
</fieldset>
|
|
315
|
+
<button className={classes.button} disabled={disabled} type="submit">{action}</button>
|
|
316
|
+
<p aria-live="polite" className={classes.note} role="status">{notice}</p>
|
|
317
|
+
</form>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { SuiteProfileLinkKeyV2 } from "./identity/profiles-v2.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Brand glyphs adapted from @hugeicons/core-free-icons (MIT, hugeicons.com).
|
|
5
|
+
* The geometry is vendored so this opt-in form surface stays free of a
|
|
6
|
+
* runtime icon dependency; stroke presentation is applied by the renderer.
|
|
7
|
+
*/
|
|
8
|
+
export type SuiteProfileLinkGlyph = ReadonlyArray<
|
|
9
|
+
| readonly ["path", Readonly<{ d: string }>]
|
|
10
|
+
| readonly ["circle", Readonly<{ cx: number; cy: number; r: number }>]
|
|
11
|
+
>;
|
|
12
|
+
|
|
13
|
+
export type SuiteProfileLinkAffordance = Readonly<{
|
|
14
|
+
glyph: SuiteProfileLinkGlyph;
|
|
15
|
+
placeholder: string;
|
|
16
|
+
prefix: string;
|
|
17
|
+
}>;
|
|
18
|
+
|
|
19
|
+
const X_GLYPH: SuiteProfileLinkGlyph = [
|
|
20
|
+
["path", { d: "M3 21L10.5484 13.4516M21 3L13.4516 10.5484M13.4516 10.5484L8 3H3L10.5484 13.4516M13.4516 10.5484L21 21H16L10.5484 13.4516" }],
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const GITHUB_GLYPH: SuiteProfileLinkGlyph = [
|
|
24
|
+
["path", { d: "M10 20.5675C6.57143 21.7248 3.71429 20.5675 2 17" }],
|
|
25
|
+
["path", { d: "M10 22V18.7579C10 18.1596 10.1839 17.6396 10.4804 17.1699C10.6838 16.8476 10.5445 16.3904 10.1771 16.2894C7.13394 15.4528 5 14.1077 5 9.64606C5 8.48611 5.38005 7.39556 6.04811 6.4464C6.21437 6.21018 6.29749 6.09208 6.31748 5.9851C6.33746 5.87813 6.30272 5.73852 6.23322 5.45932C5.95038 4.32292 5.96871 3.11619 6.39322 2.02823C6.39322 2.02823 7.27042 1.74242 9.26698 2.98969C9.72282 3.27447 9.95075 3.41686 10.1515 3.44871C10.3522 3.48056 10.6206 3.41384 11.1573 3.28041C11.8913 3.09795 12.6476 3 13.5 3C14.3524 3 15.1087 3.09795 15.8427 3.28041C16.3794 3.41384 16.6478 3.48056 16.8485 3.44871C17.0493 3.41686 17.2772 3.27447 17.733 2.98969C19.7296 1.74242 20.6068 2.02823 20.6068 2.02823C21.0313 3.11619 21.0496 4.32292 20.7668 5.45932C20.6973 5.73852 20.6625 5.87813 20.6825 5.9851C20.7025 6.09207 20.7856 6.21019 20.9519 6.4464C21.6199 7.39556 22 8.48611 22 9.64606C22 14.1077 19.8661 15.4528 16.8229 16.2894C16.4555 16.3904 16.3162 16.8476 16.5196 17.1699C16.8161 17.6396 17 18.1596 17 18.7579V22" }],
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const LINKEDIN_GLYPH: SuiteProfileLinkGlyph = [
|
|
29
|
+
["path", { d: "M7 10V17" }],
|
|
30
|
+
["path", { d: "M11 13V17M11 13C11 11.3431 12.3431 10 14 10C15.6569 10 17 11.3431 17 13V17M11 13V10" }],
|
|
31
|
+
["path", { d: "M7.125 6.75H7M7.25 6.75C7.25 6.88807 7.13807 7 7 7C6.86193 7 6.75 6.88807 6.75 6.75C6.75 6.61193 6.86193 6.5 7 6.5C7.13807 6.5 7.25 6.61193 7.25 6.75Z" }],
|
|
32
|
+
["path", { d: "M3 12C3 7.75736 3 5.63604 4.31802 4.31802C5.63604 3 7.75736 3 12 3C16.2426 3 18.364 3 19.682 4.31802C21 5.63604 21 7.75736 21 12C21 16.2426 21 18.364 19.682 19.682C18.364 21 16.2426 21 12 21C7.75736 21 5.63604 21 4.31802 19.682C3 18.364 3 16.2426 3 12Z" }],
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const WEBSITE_GLYPH: SuiteProfileLinkGlyph = [
|
|
36
|
+
["circle", { cx: 12, cy: 12, r: 10 }],
|
|
37
|
+
["path", { d: "M8 12C8 18 12 22 12 22C12 22 16 18 16 12C16 6 12 2 12 2C12 2 8 6 8 12Z" }],
|
|
38
|
+
["path", { d: "M21 15H3" }],
|
|
39
|
+
["path", { d: "M21 9H3" }],
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const BLUESKY_GLYPH: SuiteProfileLinkGlyph = [
|
|
43
|
+
["path", { d: "M12 11.4963C11.8936 11.2963 7.45492 3 3.50417 3C1.33647 3 2.00456 8 2.50443 10.5C2.70653 11.5108 3.50417 14.5 8.003 14C8.003 14 4.00404 14.5 4.00404 17C4.00404 18.5 6.50339 21 8.50287 21C10.4606 21 11.9391 16.6859 12 16.5058C12.0609 16.6859 13.5394 21 15.4971 21C17.4966 21 19.996 18.5 19.996 17C19.996 14.5 15.997 14 15.997 14C20.4958 14.5 21.2935 11.5108 21.4956 10.5C21.9954 8 22.6635 3 20.4958 3C16.5451 3 12.1064 11.2963 12 11.4963Z" }],
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const INSTAGRAM_GLYPH: SuiteProfileLinkGlyph = [
|
|
47
|
+
["path", { d: "M3 12C3 7.75736 3 5.63604 4.31802 4.31802C5.63604 3 7.75736 3 12 3C16.2426 3 18.364 3 19.682 4.31802C21 5.63604 21 7.75736 21 12C21 16.2426 21 18.364 19.682 19.682C18.364 21 16.2426 21 12 21C7.75736 21 5.63604 21 4.31802 19.682C3 18.364 3 16.2426 3 12Z" }],
|
|
48
|
+
["path", { d: "M16 12C16 14.2091 14.2091 16 12 16C9.79086 16 8 14.2091 8 12C8 9.79086 9.79086 8 12 8C14.2091 8 16 9.79086 16 12Z" }],
|
|
49
|
+
["path", { d: "M17.3748 6.75024H17.2498M17.4998 6.75024C17.4998 6.88832 17.3878 7.00024 17.2498 7.00024C17.1117 7.00024 16.9998 6.88832 16.9998 6.75024C16.9998 6.61217 17.1117 6.50024 17.2498 6.50024C17.3878 6.50024 17.4998 6.61217 17.4998 6.75024Z" }],
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const TELEGRAM_GLYPH: SuiteProfileLinkGlyph = [
|
|
53
|
+
["path", { d: "M11.9854 15.4083L15.2268 19.0936C16.4277 20.4589 17.0282 21.1416 17.6567 20.9754C18.2852 20.8092 18.5008 19.9108 18.9318 18.1138L21.3229 8.1459C21.9868 5.37832 22.3187 3.99454 21.5808 3.312C20.843 2.62947 19.564 3.13725 17.0061 4.15282L5.13876 8.86449C3.09293 9.67674 2.07001 10.0829 2.00507 10.7808C1.99842 10.8522 1.99831 10.9241 2.00474 10.9955C2.06754 11.6937 3.08921 12.1033 5.13255 12.9223C6.05838 13.2934 6.5213 13.479 6.8532 13.8344C6.89052 13.8743 6.9264 13.9157 6.96078 13.9584C7.26658 14.3384 7.39709 14.8371 7.65808 15.8344L8.14653 17.701C8.4005 18.6715 8.52749 19.1568 8.86008 19.223C9.19267 19.2891 9.48225 18.8867 10.0614 18.0819L11.9854 15.4083ZM11.9854 15.4083L11.6676 15.0771C11.3059 14.7001 11.1251 14.5117 11.1251 14.2775C11.1251 14.0433 11.3059 13.8548 11.6676 13.4778L15.2406 9.75409" }],
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
export const SUITE_PROFILE_LINK_AFFORDANCES = {
|
|
57
|
+
bluesky: { glyph: BLUESKY_GLYPH, placeholder: "name.bsky.social", prefix: "bsky.app/profile/" },
|
|
58
|
+
github: { glyph: GITHUB_GLYPH, placeholder: "username", prefix: "github.com/" },
|
|
59
|
+
instagram: { glyph: INSTAGRAM_GLYPH, placeholder: "handle", prefix: "instagram.com/" },
|
|
60
|
+
linkedin: { glyph: LINKEDIN_GLYPH, placeholder: "handle", prefix: "linkedin.com/in/" },
|
|
61
|
+
telegram: { glyph: TELEGRAM_GLYPH, placeholder: "handle", prefix: "t.me/" },
|
|
62
|
+
website: { glyph: WEBSITE_GLYPH, placeholder: "example.com", prefix: "https://" },
|
|
63
|
+
x: { glyph: X_GLYPH, placeholder: "handle", prefix: "x.com/" },
|
|
64
|
+
} satisfies Readonly<Record<SuiteProfileLinkKeyV2, SuiteProfileLinkAffordance>>;
|
|
65
|
+
|
|
66
|
+
const SCHEME = /^https?:\/\//iu;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Expands a bare handle or schemeless URL into the canonical HTTPS form the
|
|
70
|
+
* parser accepts, so each link field can be filled with just a handle. The
|
|
71
|
+
* authoritative normalizer still validates the expanded value on save.
|
|
72
|
+
*/
|
|
73
|
+
export function expandSuiteProfileLinkInput(
|
|
74
|
+
key: SuiteProfileLinkKeyV2,
|
|
75
|
+
raw: string,
|
|
76
|
+
): string {
|
|
77
|
+
const trimmed = raw.trim();
|
|
78
|
+
if (trimmed === "" || SCHEME.test(trimmed)) return trimmed;
|
|
79
|
+
const withoutAt = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
|
|
80
|
+
if (key === "website") return `https://${trimmed}`;
|
|
81
|
+
if (withoutAt.includes("/")) return `https://${withoutAt}`;
|
|
82
|
+
switch (key) {
|
|
83
|
+
case "bluesky": {
|
|
84
|
+
const handle = withoutAt.includes(".")
|
|
85
|
+
? withoutAt
|
|
86
|
+
: `${withoutAt}.bsky.social`;
|
|
87
|
+
return `https://bsky.app/profile/${handle}`;
|
|
88
|
+
}
|
|
89
|
+
case "github":
|
|
90
|
+
return `https://github.com/${withoutAt}`;
|
|
91
|
+
case "instagram":
|
|
92
|
+
return `https://www.instagram.com/${withoutAt}`;
|
|
93
|
+
case "linkedin":
|
|
94
|
+
return `https://www.linkedin.com/in/${withoutAt}`;
|
|
95
|
+
case "telegram":
|
|
96
|
+
return `https://t.me/${withoutAt}`;
|
|
97
|
+
case "x":
|
|
98
|
+
return `https://x.com/${withoutAt}`;
|
|
99
|
+
}
|
|
100
|
+
}
|