forked from casdoor/casdoor
Compare commits
5 Commits
master
...
copilot/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d319db5960 | ||
|
|
de7f78a46e | ||
|
|
aa3c5fedc1 | ||
|
|
657fab0aee | ||
|
|
544be0bd2a |
188
docs/PROFILE_COMPLETION_AFTER_SSO.md
Normal file
188
docs/PROFILE_COMPLETION_AFTER_SSO.md
Normal 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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"}}>
|
||||
|
||||
Reference in New Issue
Block a user