Compare commits

...

5 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
d319db5960 Refine implementation: remove unnecessary state and improve comments
Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com>
2026-02-05 14:44:25 +00:00
copilot-swe-agent[bot]
de7f78a46e Address code review feedback: refactor submitUserEdit and improve error handling
Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com>
2026-02-05 14:42:47 +00:00
copilot-swe-agent[bot]
aa3c5fedc1 Add comprehensive documentation for profile completion after SSO feature
Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com>
2026-02-05 14:41:30 +00:00
copilot-swe-agent[bot]
657fab0aee Extend PromptPage to support all signup item types for profile completion
Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com>
2026-02-05 14:39:30 +00:00
copilot-swe-agent[bot]
544be0bd2a Initial plan 2026-02-05 14:35:32 +00:00
3 changed files with 475 additions and 45 deletions

View File

@@ -0,0 +1,188 @@
# Profile Completion After SSO Login
## Overview
Casdoor now supports requiring users to complete missing profile information after Single Sign-On (SSO) login before being redirected to the application. This is useful when OAuth/SAML providers return partial user information (e.g., only email) and you need additional fields like phone number, display name, etc.
## How It Works
When a user signs in via an OAuth or SAML provider:
1. Casdoor receives user information from the provider (email, name, etc.)
2. If the application has "prompted" signup items configured, the user is redirected to a profile completion page
3. The user must fill in all required prompted fields before being redirected to the application
4. Once completed, the user is redirected with the OAuth authorization code
## Configuration
### Step 1: Configure Signup Items in Application
1. Navigate to **Applications** in the Casdoor admin panel
2. Select the application you want to configure
3. Go to the **Signup items** section
4. For each field you want to prompt after SSO login:
- Check **Visible** - Makes the field available
- Check **Required** - Makes it mandatory (optional)
- Check **Prompted** - Shows it on the profile completion page after SSO
### Example Configuration
To require phone number after SSO login when only email is provided:
| Name | Visible | Required | Prompted |
|-------|---------|----------|----------|
| Phone | ✓ | ✓ | ✓ |
### Supported Signup Items for Prompting
The following signup items can be prompted after SSO login:
- **Display name** - User's display name
- **First name** - User's first name
- **Last name** - User's last name
- **Email** - User's email address (with validation)
- **Phone** - User's phone number (with country code selector and validation)
- **Affiliation** - User's organization/affiliation
- **Country/Region** - User's country or region
- **ID card** - User's ID card number
## User Experience Flow
### Standard OAuth Flow (without profile completion)
```
User → OAuth Provider → Casdoor Login → Application Redirect
```
### OAuth Flow with Profile Completion
```
User → OAuth Provider → Casdoor Login → Profile Completion Page → Application Redirect
```
### Profile Completion Page Features
- Clean, user-friendly interface with form validation
- Required fields are marked and validated before submission
- Phone number includes country code selector
- Email validation ensures proper format
- Custom labels and placeholders can be configured per field
- Regex validation support for custom field rules
## Example Scenarios
### Scenario 1: Collect Phone Number After Email-Only SSO
**Problem**: Google OAuth only returns email, but you need phone numbers for 2FA.
**Solution**:
1. Enable Google OAuth provider
2. Set Phone signup item as: Visible=✓, Required=✓, Prompted=✓
3. Users will be asked to enter their phone number after Google login
### Scenario 2: Complete User Profile After Corporate SAML
**Problem**: Corporate SAML only provides username, need full profile.
**Solution**:
1. Configure SAML provider
2. Set multiple signup items as Prompted:
- Display name: Visible=✓, Required=✓, Prompted=✓
- Email: Visible=✓, Required=✓, Prompted=✓
- Phone: Visible=✓, Required=✓, Prompted=✓
3. Users complete their full profile after SAML login
### Scenario 3: Optional Regional Information
**Problem**: Want to collect user's country/region but don't want to make it mandatory.
**Solution**:
1. Set Country/Region signup item as: Visible=✓, Required=✗, Prompted=✓
2. Users can optionally provide their region, or skip and continue
## Technical Details
### Backend
The backend already supports profile completion through:
- `SignupItem.Prompted` field in application configuration
- `HasPromptPage()` function checks if any items are prompted
- `getAllPromptedSignupItems()` returns list of prompted items
- Session management allows the prompt page to access user context
### Frontend
The frontend implementation includes:
- **PromptPage Component** (`web/src/auth/PromptPage.js`)
- Renders form with all prompted signup items
- Handles form validation before submission
- Updates user profile via API
- Redirects to application after completion
- **Field Validation** (`web/src/Setting.js`)
- `isSignupItemPrompted()` - Checks if item should be shown on prompt page
- `isSignupItemAnswered()` - Validates if required fields are filled
- `isPromptAnswered()` - Checks if all prompted fields are completed
### API Endpoints
- `GET /api/get-account` - Retrieves current user information
- `POST /api/update-user` - Updates user profile with prompted fields
- Existing OAuth/SAML callback endpoints handle redirect to prompt page
## Best Practices
1. **Keep it minimal** - Only prompt for essential information to avoid user friction
2. **Use clear labels** - Configure custom labels that clearly explain what's needed
3. **Combine with email/phone verification** - Can be used with verification codes if needed
4. **Test the flow** - Always test the complete OAuth flow after configuration changes
5. **Consider user experience** - Too many required fields may discourage signups
## Troubleshooting
### Users Not Seeing Prompt Page
**Check:**
1. Signup items have `Prompted` checkbox enabled
2. Application has `EnableSignUp` enabled
3. Provider has `CanSignUp` enabled in provider configuration
4. User doesn't already have the prompted fields filled
### Validation Errors
**Check:**
1. Required fields are properly configured
2. Regex patterns (if configured) are valid
3. Email/phone format matches validation rules
4. Country codes are properly configured for phone validation
### Redirect Issues
**Check:**
1. OAuth redirect URI is properly configured in application
2. State parameter matches between login and prompt page
3. User session is active during prompt page interaction
## Migration from Previous Versions
If you were previously using custom profile completion logic:
1. Remove custom code/redirects
2. Configure signup items with `Prompted` flag as described above
3. Test the built-in prompt page functionality
4. Customize field labels and placeholders as needed
## Future Enhancements
Potential future improvements to this feature:
- Support for custom signup item types
- Multi-step profile completion wizard
- Conditional field prompting based on provider type
- Profile completion progress indicator
- Skip option for optional fields with clear UI
## Support
For issues or questions:
- GitHub Issues: https://github.com/casdoor/casdoor/issues
- Discord: https://discord.gg/5rPsrAzK7S
- Documentation: https://casdoor.org/docs

View File

@@ -862,12 +862,29 @@ function isSignupItemAnswered(user, signupItem) {
return false;
}
if (signupItem.name !== "Country/Region") {
// Map signup item names to user field names
// All fields mapped here are string types in the current implementation
const fieldMapping = {
"Display name": "displayName",
"First name": "firstName",
"Last name": "lastName",
"Email": "email",
"Phone": "phone",
"Affiliation": "affiliation",
"Country/Region": "region",
"ID card": "idCard",
};
const fieldName = fieldMapping[signupItem.name];
if (!fieldName) {
// Unknown signup item, consider it answered
return true;
}
const value = user["region"];
return value !== undefined && value !== "";
const value = user[fieldName];
// Check for empty string, null, or undefined
// All current fields are string types, so this check is appropriate
return value !== undefined && value !== null && value !== "";
}
export function isPromptAnswered(user, application) {

View File

@@ -13,7 +13,7 @@
// limitations under the License.
import React from "react";
import {Button, Card, Col, Result, Row} from "antd";
import {Button, Card, Col, Input, Result, Row, Form} from "antd";
import * as ApplicationBackend from "../backend/ApplicationBackend";
import * as UserBackend from "../backend/UserBackend";
import * as Setting from "../Setting";
@@ -23,6 +23,7 @@ import OAuthWidget from "../common/OAuthWidget";
import RegionSelect from "../common/select/RegionSelect";
import {withRouter} from "react-router-dom";
import * as AuthBackend from "./AuthBackend";
import {CountryCodeSelect} from "../common/select/CountryCodeSelect";
class PromptPage extends React.Component {
constructor(props) {
@@ -37,6 +38,7 @@ class PromptPage extends React.Component {
current: 0,
finished: false,
};
this.form = React.createRef();
}
UNSAFE_componentWillMount() {
@@ -124,31 +126,251 @@ class PromptPage extends React.Component {
});
}
renderAffiliation(application) {
if (!Setting.isAffiliationPrompted(application)) {
return null;
}
if (application === null || this.state.user === null) {
return null;
}
return (
<AffiliationSelect labelSpan={6} application={application} user={this.state.user} onUpdateUserField={(key, value) => {return this.updateUserField(key, value);}} />
);
}
unlinked() {
this.getUser();
}
renderSignupItem(signupItem) {
const required = signupItem.required;
const user = this.state.user;
if (signupItem.name === "Display name") {
const displayNameRules = [
{
required: required,
message: i18next.t("signup:Please input your display name!"),
whitespace: true,
},
];
if (signupItem.regex) {
displayNameRules.push({
pattern: new RegExp(signupItem.regex),
message: i18next.t("signup:The input doesn't match the signup item regex!"),
});
}
return (
<Form.Item
key={signupItem.name}
name="name"
label={signupItem.label ? signupItem.label : i18next.t("general:Display name")}
rules={displayNameRules}
initialValue={user?.displayName || ""}
>
<Input placeholder={signupItem.placeholder} onChange={e => this.updateUserFieldWithoutSubmit("displayName", e.target.value)} />
</Form.Item>
);
} else if (signupItem.name === "First name") {
const firstNameRules = [
{
required: required,
message: i18next.t("signup:Please input your first name!"),
whitespace: true,
},
];
if (signupItem.regex) {
firstNameRules.push({
pattern: new RegExp(signupItem.regex),
message: i18next.t("signup:The input doesn't match the signup item regex!"),
});
}
return (
<Form.Item
key={signupItem.name}
name="firstName"
label={signupItem.label ? signupItem.label : i18next.t("general:First name")}
rules={firstNameRules}
initialValue={user?.firstName || ""}
>
<Input placeholder={signupItem.placeholder} onChange={e => this.updateUserFieldWithoutSubmit("firstName", e.target.value)} />
</Form.Item>
);
} else if (signupItem.name === "Last name") {
const lastNameRules = [
{
required: required,
message: i18next.t("signup:Please input your last name!"),
whitespace: true,
},
];
if (signupItem.regex) {
lastNameRules.push({
pattern: new RegExp(signupItem.regex),
message: i18next.t("signup:The input doesn't match the signup item regex!"),
});
}
return (
<Form.Item
key={signupItem.name}
name="lastName"
label={signupItem.label ? signupItem.label : i18next.t("general:Last name")}
rules={lastNameRules}
initialValue={user?.lastName || ""}
>
<Input placeholder={signupItem.placeholder} onChange={e => this.updateUserFieldWithoutSubmit("lastName", e.target.value)} />
</Form.Item>
);
} else if (signupItem.name === "Email") {
const emailRules = [
{
required: required,
message: i18next.t("signup:Please input your Email!"),
},
{
validator: (_, value) => {
if (value !== "" && !Setting.isValidEmail(value)) {
return Promise.reject(i18next.t("signup:The input is not valid Email!"));
}
if (signupItem.regex) {
const reg = new RegExp(signupItem.regex);
if (!reg.test(value)) {
return Promise.reject(i18next.t("signup:The input Email doesn't match the signup item regex!"));
}
}
return Promise.resolve();
},
},
];
return (
<Form.Item
key={signupItem.name}
name="email"
label={signupItem.label ? signupItem.label : i18next.t("general:Email")}
rules={emailRules}
initialValue={user?.email || ""}
>
<Input placeholder={signupItem.placeholder} onChange={e => this.updateUserFieldWithoutSubmit("email", e.target.value)} />
</Form.Item>
);
} else if (signupItem.name === "Phone") {
return (
<Form.Item key={signupItem.name} label={signupItem.label ? signupItem.label : i18next.t("general:Phone")} required={required}>
<Input.Group compact>
<Form.Item
name="countryCode"
noStyle
rules={[
{
required: required,
message: i18next.t("signup:Please select your country code!"),
},
]}
initialValue={user?.countryCode || ""}
>
<CountryCodeSelect
style={{width: "35%"}}
countryCodes={this.getApplicationObj().organizationObj.countryCodes}
onChange={value => this.updateUserFieldWithoutSubmit("countryCode", value)}
/>
</Form.Item>
<Form.Item
name="phone"
dependencies={["countryCode"]}
noStyle
rules={[
{
required: required,
message: i18next.t("signup:Please input your phone number!"),
},
({getFieldValue}) => ({
validator: (_, value) => {
if (!required && !value) {
return Promise.resolve();
}
if (value && !Setting.isValidPhone(value, getFieldValue("countryCode"))) {
return Promise.reject(i18next.t("signup:The input is not valid Phone!"));
}
return Promise.resolve();
},
}),
]}
initialValue={user?.phone || ""}
>
<Input
placeholder={signupItem.placeholder}
style={{width: "65%"}}
onChange={e => this.updateUserFieldWithoutSubmit("phone", e.target.value)}
/>
</Form.Item>
</Input.Group>
</Form.Item>
);
} else if (signupItem.name === "Affiliation") {
const affiliationRules = [
{
required: required,
message: i18next.t("signup:Please input your affiliation!"),
whitespace: true,
},
];
if (signupItem.regex) {
affiliationRules.push({
pattern: new RegExp(signupItem.regex),
message: i18next.t("signup:The input doesn't match the signup item regex!"),
});
}
return (
<Form.Item
key={signupItem.name}
name="affiliation"
label={signupItem.label ? signupItem.label : i18next.t("user:Affiliation")}
rules={affiliationRules}
initialValue={user?.affiliation || ""}
>
<Input placeholder={signupItem.placeholder} onChange={e => this.updateUserFieldWithoutSubmit("affiliation", e.target.value)} />
</Form.Item>
);
} else if (signupItem.name === "Country/Region") {
return (
<Form.Item
key={signupItem.name}
name="region"
label={signupItem.label ? signupItem.label : i18next.t("user:Country/Region")}
rules={[
{
required: required,
message: i18next.t("signup:Please select your country/region!"),
},
]}
initialValue={user?.region || ""}
>
<RegionSelect onChange={(value) => {
this.updateUserFieldWithoutSubmit("region", value);
}} />
</Form.Item>
);
} else if (signupItem.name === "ID card") {
return (
<Form.Item
key={signupItem.name}
name="idCard"
label={signupItem.label ? signupItem.label : i18next.t("user:ID card")}
rules={[
{
required: required,
message: i18next.t("signup:Please input your ID card number!"),
whitespace: true,
},
]}
initialValue={user?.idCard || ""}
>
<Input placeholder={signupItem.placeholder} onChange={e => this.updateUserFieldWithoutSubmit("idCard", e.target.value)} />
</Form.Item>
);
}
return null;
}
renderContent(application) {
const promptedSignupItems = application?.signupItems?.filter(signupItem => Setting.isSignupItemPrompted(signupItem)) || [];
return (
<div style={{width: "500px"}}>
{
this.renderAffiliation(application)
}
<div>
<Form ref={this.form}>
{
(application === null || this.state.user === null) ? null : (
application?.providers.filter(providerItem => Setting.isProviderPrompted(providerItem)).map((providerItem, index) => <OAuthWidget key={providerItem.name} labelSpan={6} user={this.state.user} application={application} providerItem={providerItem} account={this.props.account} onUnlinked={() => {return this.unlinked();}} />)
@@ -156,30 +378,10 @@ class PromptPage extends React.Component {
}
{
(application === null || this.state.user === null) ? null : (
application?.signupItems?.filter(signupItem => Setting.isSignupItemPrompted(signupItem)).map((signupItem, index) => {
if (signupItem.name !== "Country/Region") {
return null;
}
return (
<Row key={signupItem.name} style={{marginTop: "20px", justifyContent: "space-between"}} >
<Col style={{marginTop: "5px"}} >
<span style={{marginLeft: "5px"}}>
{
i18next.t("user:Country/Region")
}:
</span>
</Col>
<Col >
<RegionSelect defaultValue={this.state.user.region} onChange={(value) => {
this.updateUserFieldWithoutSubmit("region", value);
}} />
</Col>
</Row>
);
})
promptedSignupItems.map((signupItem, index) => this.renderSignupItem(signupItem))
)
}
</div>
</Form>
</div>
);
}
@@ -226,7 +428,7 @@ class PromptPage extends React.Component {
});
}
submitUserEdit(isFinal) {
performUserUpdate(isFinal) {
const user = Setting.deepCopy(this.state.user);
UserBackend.updateUser(this.state.user.owner, this.state.user.name, user)
.then((res) => {
@@ -248,6 +450,29 @@ class PromptPage extends React.Component {
});
}
submitUserEdit(isFinal) {
if (isFinal && this.form.current) {
// Validate all form fields before submission
this.form.current.validateFields()
.then(values => {
this.performUserUpdate(isFinal);
})
.catch(errorInfo => {
// Extract field-specific error messages for better user feedback
const errors = errorInfo.errorFields || [];
if (errors.length > 0) {
const firstError = errors[0];
const errorMsg = firstError.errors[0] || i18next.t("signup:Please fill in all required fields!");
Setting.showMessage("error", errorMsg);
} else {
Setting.showMessage("error", i18next.t("signup:Please fill in all required fields!"));
}
});
} else {
this.performUserUpdate(isFinal);
}
}
renderPromptProvider(application) {
return (
<div style={{display: "flex", alignItems: "center", flexDirection: "column"}}>