@salesforce/webapp-template-feature-react-authentication-experimental 1.6.2 → 1.7.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/dist/CHANGELOG.md +8 -0
- package/dist/force-app/main/default/classes/WebAppAuthUtils.cls +56 -0
- package/dist/force-app/main/default/classes/WebAppAuthUtils.cls-meta.xml +5 -0
- package/dist/force-app/main/default/classes/WebAppRegistration.cls +161 -0
- package/dist/force-app/main/default/classes/WebAppRegistration.cls-meta.xml +5 -0
- package/dist/force-app/main/default/webapplications/feature-react-authentication/src/pages/Register.tsx +28 -9
- package/dist/force-app/main/default/webapplications/feature-react-authentication/src/utils/helpers.ts +18 -3
- package/dist/package.json +1 -1
- package/package.json +3 -3
package/dist/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,14 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# [1.7.0](https://github.com/salesforce-experience-platform-emu/webapps/compare/v1.6.2...v1.7.0) (2026-02-04)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @salesforce/webapp-template-base-sfdx-project-experimental
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
6
14
|
## [1.6.2](https://github.com/salesforce-experience-platform-emu/webapps/compare/v1.6.1...v1.6.2) (2026-02-04)
|
|
7
15
|
|
|
8
16
|
**Note:** Version bump only for package @salesforce/webapp-template-base-sfdx-project-experimental
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility class for Web Application authentication REST endpoints.
|
|
3
|
+
*/
|
|
4
|
+
public without sharing class WebAppAuthUtils {
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Exception for authentication errors with HTTP status code support.
|
|
8
|
+
*/
|
|
9
|
+
public class AuthException extends Exception {
|
|
10
|
+
public Integer statusCode { get; private set; } // HTTP status code (e.g. 400, 500)
|
|
11
|
+
public List<String> messages { get; private set; } // List of error messages
|
|
12
|
+
|
|
13
|
+
public AuthException(Integer statusCode, String message) {
|
|
14
|
+
this.statusCode = statusCode;
|
|
15
|
+
this.messages = new List<String>{ message };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
public AuthException(Integer statusCode, List<String> messages) {
|
|
19
|
+
this.statusCode = statusCode;
|
|
20
|
+
this.messages = new List<String>(messages);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Validates and sanitizes redirect URL to prevent open redirect vulnerabilities.
|
|
26
|
+
* @param url The URL to validate.
|
|
27
|
+
* @param defaultUrl Fallback URL if validation fails.
|
|
28
|
+
* @return Sanitized URL or defaultUrl if invalid.
|
|
29
|
+
*/
|
|
30
|
+
public static String getSanitizedStartUrl(String url, String defaultUrl) {
|
|
31
|
+
if (String.isBlank(url) || url.equals('/')) { return defaultUrl; }
|
|
32
|
+
|
|
33
|
+
String decoded; // Decode URL to catch encoded bypasses (%2f%2f -> //)
|
|
34
|
+
try {
|
|
35
|
+
decoded = EncodingUtil.urlDecode(url, 'UTF-8');
|
|
36
|
+
} catch (Exception e) {
|
|
37
|
+
return defaultUrl;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Must start with / but not // (protocol-relative)
|
|
41
|
+
if (!decoded.startsWith('/') || decoded.startsWith('//')) { return defaultUrl; }
|
|
42
|
+
// Reject backslashes (some browsers treat \ as /)
|
|
43
|
+
if (decoded.contains('\\')) { return defaultUrl; }
|
|
44
|
+
// Reject @ which can indicate user info in URL (//user@evil.com)
|
|
45
|
+
if (decoded.contains('@')) { return defaultUrl; }
|
|
46
|
+
// Reject : which could indicate protocol or port manipulation
|
|
47
|
+
if (decoded.contains(':')) { return defaultUrl; }
|
|
48
|
+
// Reject control characters (ASCII < 32) and DEL (127)
|
|
49
|
+
for (Integer i = 0; i < decoded.length(); i++) {
|
|
50
|
+
Integer charCode = decoded.charAt(i);
|
|
51
|
+
if (charCode < 32 || charCode == 127) { return defaultUrl; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return defaultUrl + url;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST endpoint for Web Applications for user self-registration.
|
|
3
|
+
*
|
|
4
|
+
* Endpoint: POST /services/apexrest/auth/register/
|
|
5
|
+
*
|
|
6
|
+
* Creates a new external user account, validates credentials against org password policy,
|
|
7
|
+
* and returns a login URL for automatic session creation.
|
|
8
|
+
*
|
|
9
|
+
* Security: Uses 'without sharing' to allow guest users to check for duplicate usernames.
|
|
10
|
+
*/
|
|
11
|
+
@RestResource(urlMapping='/auth/register/')
|
|
12
|
+
global without sharing class WebAppRegistration {
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Registers a new external user and logs them in.
|
|
16
|
+
* @param request The registration request containing user details.
|
|
17
|
+
* @return SuccessResponse or ErrorResponse.
|
|
18
|
+
*/
|
|
19
|
+
@HttpPost
|
|
20
|
+
global static RegistrationResponse registerUser(RegistrationRequest request) {
|
|
21
|
+
Savepoint sp = Database.setSavepoint();
|
|
22
|
+
try {
|
|
23
|
+
request.validate();
|
|
24
|
+
|
|
25
|
+
User u = new User(
|
|
26
|
+
Username = request.email,
|
|
27
|
+
Email = request.email,
|
|
28
|
+
FirstName = request.firstName,
|
|
29
|
+
LastName = request.lastName,
|
|
30
|
+
// Generate unique nickname: first initial + up to 20 chars of last name + 4 random digits
|
|
31
|
+
CommunityNickname = request.firstName.left(1) + request.lastName.left(20) +
|
|
32
|
+
String.valueOf(Crypto.getRandomInteger()).left(4)
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
validatePassword(u, request.password);
|
|
36
|
+
createUser(u, request.password);
|
|
37
|
+
|
|
38
|
+
String startUrl = WebAppAuthUtils.getSanitizedStartUrl(request.startUrl, Site.getPathPrefix());
|
|
39
|
+
PageReference pageRef = Site.login(request.email, request.password, startUrl);
|
|
40
|
+
|
|
41
|
+
return new SuccessRegistrationResponse(pageRef?.getUrl());
|
|
42
|
+
} catch (WebAppAuthUtils.AuthException ex) {
|
|
43
|
+
Database.rollback(sp);
|
|
44
|
+
RestContext.response.statusCode = ex.statusCode;
|
|
45
|
+
return new ErrorRegistrationResponse(ex.messages);
|
|
46
|
+
} catch (Exception ex) {
|
|
47
|
+
Database.rollback(sp);
|
|
48
|
+
RestContext.response.statusCode = 500;
|
|
49
|
+
return new ErrorRegistrationResponse(ex.getMessage());
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Base response class for registration.
|
|
55
|
+
*/
|
|
56
|
+
global abstract class RegistrationResponse {
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Success response with login URL.
|
|
61
|
+
*/
|
|
62
|
+
global class SuccessRegistrationResponse extends RegistrationResponse {
|
|
63
|
+
global String loginUrl;
|
|
64
|
+
|
|
65
|
+
global SuccessRegistrationResponse(String loginUrl) {
|
|
66
|
+
this.loginUrl = loginUrl;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Error response with error messages.
|
|
72
|
+
*/
|
|
73
|
+
global class ErrorRegistrationResponse extends RegistrationResponse {
|
|
74
|
+
global List<String> errors;
|
|
75
|
+
|
|
76
|
+
global ErrorRegistrationResponse(List<String> errors) {
|
|
77
|
+
this.errors = new List<String>(errors);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
global ErrorRegistrationResponse(String error) {
|
|
81
|
+
this.errors = new List<String>{ error };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Request payload for user registration.
|
|
87
|
+
*/
|
|
88
|
+
global class RegistrationRequest {
|
|
89
|
+
global String email;
|
|
90
|
+
global String firstName;
|
|
91
|
+
global String lastName;
|
|
92
|
+
global String password;
|
|
93
|
+
global String startUrl;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Trims fields and validates required fields and business rules.
|
|
97
|
+
* @throws WebAppAuthUtils.AuthException if validation fails.
|
|
98
|
+
*/
|
|
99
|
+
public void validate() {
|
|
100
|
+
email = email?.trim()?.toLowerCase();
|
|
101
|
+
firstName = firstName?.trim();
|
|
102
|
+
lastName = lastName?.trim();
|
|
103
|
+
startUrl = startUrl?.trim();
|
|
104
|
+
|
|
105
|
+
List<String> errors = new List<String>();
|
|
106
|
+
if (String.isBlank(firstName)) { errors.add('First name is required.'); }
|
|
107
|
+
if (String.isBlank(lastName)) { errors.add('Last name is required.'); }
|
|
108
|
+
if (String.isBlank(password)) { errors.add('Password is required.'); }
|
|
109
|
+
if (!Site.isValidUserName(email)) { errors.add('Email is invalid.'); }
|
|
110
|
+
else if (!isUserUnique(email)) {
|
|
111
|
+
errors.add('A user with this email already exists.');
|
|
112
|
+
}
|
|
113
|
+
if (!errors.isEmpty()) {
|
|
114
|
+
throw new WebAppAuthUtils.AuthException(400, errors);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Validates password against org password policy.
|
|
121
|
+
* @param user The user record.
|
|
122
|
+
* @param password The password to validate.
|
|
123
|
+
* @throws WebAppAuthUtils.AuthException if password does not meet requirements.
|
|
124
|
+
*/
|
|
125
|
+
private static void validatePassword(User user, String password) {
|
|
126
|
+
try {
|
|
127
|
+
Site.validatePassword(user, password, password);
|
|
128
|
+
} catch (System.SecurityException ex) {
|
|
129
|
+
throw new WebAppAuthUtils.AuthException(400, ex.getMessage());
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Creates the external user account.
|
|
135
|
+
* @param u The user record.
|
|
136
|
+
* @param password The password for the new user.
|
|
137
|
+
* @return The new user's ID.
|
|
138
|
+
* @throws WebAppAuthUtils.AuthException if user creation fails.
|
|
139
|
+
*/
|
|
140
|
+
private static String createUser(User u, String password) {
|
|
141
|
+
String userId;
|
|
142
|
+
try {
|
|
143
|
+
userId = Site.createExternalUser(u, null, password);
|
|
144
|
+
} catch (Site.ExternalUserCreateException ex) {
|
|
145
|
+
throw new WebAppAuthUtils.AuthException(500, ex.getDisplayMessages());
|
|
146
|
+
}
|
|
147
|
+
if (userId == null) {
|
|
148
|
+
throw new WebAppAuthUtils.AuthException(500, 'Could not register new user.');
|
|
149
|
+
}
|
|
150
|
+
return userId;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Checks if a username is unique (not already taken).
|
|
155
|
+
* @param username The username to check.
|
|
156
|
+
* @return {@code true} if unique, {@code false} if already exists.
|
|
157
|
+
*/
|
|
158
|
+
private static Boolean isUserUnique(String username) {
|
|
159
|
+
return [SELECT Id FROM User WHERE Username = :username LIMIT 1].isEmpty();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -14,6 +14,10 @@ import {
|
|
|
14
14
|
getErrorMessage,
|
|
15
15
|
} from "../utils/helpers";
|
|
16
16
|
|
|
17
|
+
interface RegistrationResponse {
|
|
18
|
+
loginUrl: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
17
21
|
const registerSchema = z
|
|
18
22
|
.object({
|
|
19
23
|
firstName: z.string().trim().min(1, "First name is required"),
|
|
@@ -21,6 +25,7 @@ const registerSchema = z
|
|
|
21
25
|
email: emailSchema,
|
|
22
26
|
password: passwordSchema,
|
|
23
27
|
confirmPassword: z.string().min(1, "Please confirm your password"),
|
|
28
|
+
startUrl: z.string(),
|
|
24
29
|
})
|
|
25
30
|
.refine((data) => data.password === data.confirmPassword, {
|
|
26
31
|
message: "Passwords do not match",
|
|
@@ -33,21 +38,35 @@ export default function Register() {
|
|
|
33
38
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
34
39
|
|
|
35
40
|
const form = useAppForm({
|
|
36
|
-
defaultValues: {
|
|
41
|
+
defaultValues: {
|
|
42
|
+
firstName: "",
|
|
43
|
+
lastName: "",
|
|
44
|
+
email: "",
|
|
45
|
+
password: "",
|
|
46
|
+
confirmPassword: "",
|
|
47
|
+
startUrl: getStartUrl(searchParams) || "",
|
|
48
|
+
},
|
|
37
49
|
validators: { onChange: registerSchema, onSubmit: registerSchema },
|
|
38
|
-
onSubmit: async ({ value }) => {
|
|
50
|
+
onSubmit: async ({ value: formFieldValues }) => {
|
|
39
51
|
setSubmitError(null);
|
|
40
52
|
try {
|
|
41
53
|
// [Dev Note] Calls the custom Apex REST endpoint for user registration.
|
|
42
54
|
// Ensure your Apex logic handles duplicate checks and user creation (e.g., Site.createExternalUser).
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
email: value.email.trim().toLowerCase(),
|
|
47
|
-
password: value.password,
|
|
55
|
+
const { confirmPassword, ...request } = formFieldValues;
|
|
56
|
+
const response = await baseClient.post("/sfdcapi/services/apexrest/auth/register/", {
|
|
57
|
+
request,
|
|
48
58
|
});
|
|
49
|
-
await handleApiResponse(
|
|
50
|
-
|
|
59
|
+
const result = await handleApiResponse<RegistrationResponse>(
|
|
60
|
+
response,
|
|
61
|
+
"Registration failed",
|
|
62
|
+
);
|
|
63
|
+
if (result?.loginUrl) {
|
|
64
|
+
// Hard navigate to the URL which logs the new user in
|
|
65
|
+
window.location.replace(result.loginUrl);
|
|
66
|
+
} else {
|
|
67
|
+
// In case loginUrl is null, redirect to the login page
|
|
68
|
+
navigate(ROUTES.LOGIN.PATH, { replace: true });
|
|
69
|
+
}
|
|
51
70
|
} catch (err) {
|
|
52
71
|
setSubmitError(getErrorMessage(err, "Registration failed"));
|
|
53
72
|
}
|
|
@@ -155,7 +155,22 @@ function parseApiResponseError(
|
|
|
155
155
|
data: any,
|
|
156
156
|
fallbackError: string = "An unknown error occurred",
|
|
157
157
|
): string {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
158
|
+
if (data?.message) {
|
|
159
|
+
return data.message;
|
|
160
|
+
}
|
|
161
|
+
if (data?.error) {
|
|
162
|
+
return data.error;
|
|
163
|
+
}
|
|
164
|
+
if (data?.errors && Array.isArray(data.errors) && data.errors.length > 0) {
|
|
165
|
+
return data.errors.join(" ") || fallbackError;
|
|
166
|
+
}
|
|
167
|
+
if (Array.isArray(data) && data.length > 0) {
|
|
168
|
+
return (
|
|
169
|
+
data
|
|
170
|
+
.map((e) => e?.message)
|
|
171
|
+
.filter(Boolean)
|
|
172
|
+
.join(" ") || fallbackError
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return fallbackError;
|
|
161
176
|
}
|
package/dist/package.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salesforce/webapp-template-feature-react-authentication-experimental",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Authentication feature for web applications",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"author": "",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"watch": "npx tsx ../../cli/src/index.ts watch-patches packages/template/feature/feature-react-authentication packages/template/base-app/base-react-app packages/template/feature/feature-react-authentication/dist"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
|
-
"@salesforce/webapp-experimental": "^1.
|
|
22
|
+
"@salesforce/webapp-experimental": "^1.7.0",
|
|
23
23
|
"@tanstack/react-form": "^1.27.7",
|
|
24
24
|
"@types/react": "^19.2.7",
|
|
25
25
|
"@types/react-dom": "^19.2.3",
|
|
@@ -27,5 +27,5 @@
|
|
|
27
27
|
"react-router": "^7.10.1",
|
|
28
28
|
"vite": "^7.3.1"
|
|
29
29
|
},
|
|
30
|
-
"gitHead": "
|
|
30
|
+
"gitHead": "a54d5cb1ebbbd4671fbbe097750e8b53f172b796"
|
|
31
31
|
}
|