Compare commits

...

7 Commits

19 changed files with 580 additions and 20 deletions

View File

@@ -21,6 +21,7 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
@@ -38,6 +39,46 @@ var (
cliVersionMutex sync.RWMutex
)
// cleanOldMEIFolders cleans up old _MEIXXX folders from the Casdoor temp directory
// that are older than 24 hours. These folders are created by PyInstaller when
// executing casbin-python-cli and can accumulate over time.
func cleanOldMEIFolders() {
tempDir := "temp"
cutoffTime := time.Now().Add(-24 * time.Hour)
entries, err := os.ReadDir(tempDir)
if err != nil {
// Log error but don't fail - cleanup is best-effort
// This is expected if temp directory doesn't exist yet
return
}
for _, entry := range entries {
// Check if the entry is a directory and matches the _MEI pattern
if !entry.IsDir() || !strings.HasPrefix(entry.Name(), "_MEI") {
continue
}
dirPath := filepath.Join(tempDir, entry.Name())
info, err := entry.Info()
if err != nil {
continue
}
// Check if the folder is older than 24 hours
if info.ModTime().Before(cutoffTime) {
// Try to remove the directory
err = os.RemoveAll(dirPath)
if err != nil {
// Log but continue with other folders
fmt.Printf("failed to remove old MEI folder %s: %v\n", dirPath, err)
} else {
fmt.Printf("removed old MEI folder: %s\n", dirPath)
}
}
}
}
// getCLIVersion
// @Title getCLIVersion
// @Description Get CLI version with cache mechanism
@@ -66,6 +107,9 @@ func getCLIVersion(language string) (string, error) {
}
cliVersionMutex.RUnlock()
// Clean up old _MEI folders before running the command
cleanOldMEIFolders()
cmd := exec.Command(binaryName, "--version")
output, err := cmd.CombinedOutput()
if err != nil {
@@ -186,6 +230,10 @@ func (c *ApiController) RunCasbinCommand() {
return
}
// Clean up old _MEI folders before running the command
// This is especially important for Python CLI which creates these folders
cleanOldMEIFolders()
command := exec.Command(binaryName, processedArgs...)
outputBytes, err := command.CombinedOutput()
if err != nil {

View File

@@ -135,6 +135,17 @@ func (c *ApiController) MfaSetupVerify() {
return
}
config.URL = secret
} else if mfaType == object.PushType {
if dest == "" {
c.ResponseError("push notification receiver is missing")
return
}
config.Secret = dest
if secret == "" {
c.ResponseError("push notification provider is missing")
return
}
config.URL = secret
}
mfaUtil := object.GetMfaUtil(mfaType, config)
@@ -222,6 +233,17 @@ func (c *ApiController) MfaSetupEnable() {
return
}
config.URL = secret
} else if mfaType == object.PushType {
if dest == "" {
c.ResponseError("push notification receiver is missing")
return
}
config.Secret = dest
if secret == "" {
c.ResponseError("push notification provider is missing")
return
}
config.URL = secret
}
if recoveryCodes == "" {

View File

@@ -55,6 +55,8 @@ func GetNotificationProvider(typ string, clientId string, clientSecret string, c
return NewViberProvider(clientId, clientSecret, appId, receiver)
} else if typ == "CUCloud" {
return NewCucloudProvider(clientId, clientSecret, appId, title, regionId, clientId2, metaData)
} else if typ == "WeCom" {
return NewWeComProvider(clientSecret)
}
return nil, nil

104
notification/wecom.go Normal file
View File

@@ -0,0 +1,104 @@
// Copyright 2025 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package notification
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/casdoor/notify"
)
// wecomService encapsulates the WeCom webhook client
type wecomService struct {
webhookURL string
}
// wecomResponse represents the response from WeCom webhook API
type wecomResponse struct {
Errcode int `json:"errcode"`
Errmsg string `json:"errmsg"`
}
// NewWeComProvider returns a new instance of a WeCom notification service
// WeCom (WeChat Work) uses webhook for group chat notifications
// Reference: https://developer.work.weixin.qq.com/document/path/90236
func NewWeComProvider(webhookURL string) (notify.Notifier, error) {
wecomSrv := &wecomService{
webhookURL: webhookURL,
}
notifier := notify.New()
notifier.UseServices(wecomSrv)
return notifier, nil
}
// Send sends a text message to WeCom group chat via webhook
func (s *wecomService) Send(ctx context.Context, subject, content string) error {
text := subject
if content != "" {
text = subject + "\n" + content
}
// WeCom webhook message format
message := map[string]interface{}{
"msgtype": "text",
"text": map[string]string{
"content": text,
},
}
jsonData, err := json.Marshal(message)
if err != nil {
return fmt.Errorf("failed to marshal WeCom message: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", s.webhookURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create WeCom request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{
Timeout: 30 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to send WeCom message: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("WeCom webhook returned HTTP status code: %d", resp.StatusCode)
}
// Parse WeCom API response
var wecomResp wecomResponse
if err := json.NewDecoder(resp.Body).Decode(&wecomResp); err != nil {
return fmt.Errorf("failed to decode WeCom response: %w", err)
}
// Check WeCom API error code
if wecomResp.Errcode != 0 {
return fmt.Errorf("WeCom API error: errcode=%d, errmsg=%s", wecomResp.Errcode, wecomResp.Errmsg)
}
return nil
}

View File

@@ -43,6 +43,7 @@ const (
SmsType = "sms"
TotpType = "app"
RadiusType = "radius"
PushType = "push"
)
const (
@@ -61,6 +62,8 @@ func GetMfaUtil(mfaType string, config *MfaProps) MfaInterface {
return NewTotpMfaUtil(config)
case RadiusType:
return NewRadiusMfaUtil(config)
case PushType:
return NewPushMfaUtil(config)
}
return nil
@@ -95,7 +98,7 @@ func MfaRecover(user *User, recoveryCode string) error {
func GetAllMfaProps(user *User, masked bool) []*MfaProps {
mfaProps := []*MfaProps{}
for _, mfaType := range []string{SmsType, EmailType, TotpType, RadiusType} {
for _, mfaType := range []string{SmsType, EmailType, TotpType, RadiusType, PushType} {
mfaProps = append(mfaProps, user.GetMfaProps(mfaType, masked))
}
return mfaProps
@@ -174,6 +177,24 @@ func (user *User) GetMfaProps(mfaType string, masked bool) *MfaProps {
mfaProps.Secret = user.MfaRadiusUsername
}
mfaProps.URL = user.MfaRadiusProvider
} else if mfaType == PushType {
if !user.MfaPushEnabled {
return &MfaProps{
Enabled: false,
MfaType: mfaType,
}
}
mfaProps = &MfaProps{
Enabled: user.MfaPushEnabled,
MfaType: mfaType,
}
if masked {
mfaProps.Secret = util.GetMaskedEmail(user.MfaPushReceiver)
} else {
mfaProps.Secret = user.MfaPushReceiver
}
mfaProps.URL = user.MfaPushProvider
}
if user.PreferredMfaType == mfaType {
@@ -191,8 +212,11 @@ func DisabledMultiFactorAuth(user *User) error {
user.MfaRadiusEnabled = false
user.MfaRadiusUsername = ""
user.MfaRadiusProvider = ""
user.MfaPushEnabled = false
user.MfaPushReceiver = ""
user.MfaPushProvider = ""
_, err := updateUser(user.GetId(), user, []string{"preferred_mfa_type", "recovery_codes", "mfa_phone_enabled", "mfa_email_enabled", "totp_secret", "mfa_radius_enabled", "mfa_radius_username", "mfa_radius_provider"})
_, err := updateUser(user.GetId(), user, []string{"preferred_mfa_type", "recovery_codes", "mfa_phone_enabled", "mfa_email_enabled", "totp_secret", "mfa_radius_enabled", "mfa_radius_username", "mfa_radius_provider", "mfa_push_enabled", "mfa_push_receiver", "mfa_push_provider"})
if err != nil {
return err
}

170
object/mfa_push.go Normal file
View File

@@ -0,0 +1,170 @@
// Copyright 2025 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"context"
"errors"
"fmt"
"time"
"github.com/casdoor/casdoor/notification"
"github.com/google/uuid"
)
type PushMfa struct {
*MfaProps
provider *Provider
challengeId string
challengeExp time.Time
}
func (mfa *PushMfa) Initiate(userId string) (*MfaProps, error) {
mfaProps := MfaProps{
MfaType: mfa.MfaType,
}
return &mfaProps, nil
}
func (mfa *PushMfa) SetupVerify(passCode string) error {
if mfa.Secret == "" {
return errors.New("push notification receiver is required")
}
if mfa.provider == nil {
return errors.New("push notification provider is not configured")
}
// For setup verification, send a test notification
// Note: Full implementation would require a callback endpoint to receive approval/denial
// from the mobile app, and passCode would contain the callback verification token
return mfa.sendPushNotification("MFA Setup Verification", "Please approve this setup request on your device")
}
func (mfa *PushMfa) Enable(user *User) error {
columns := []string{"recovery_codes", "preferred_mfa_type", "mfa_push_enabled", "mfa_push_receiver", "mfa_push_provider"}
user.RecoveryCodes = append(user.RecoveryCodes, mfa.RecoveryCodes...)
if user.PreferredMfaType == "" {
user.PreferredMfaType = mfa.MfaType
}
user.MfaPushEnabled = true
user.MfaPushReceiver = mfa.Secret
user.MfaPushProvider = mfa.URL
_, err := UpdateUser(user.GetId(), user, columns, false)
if err != nil {
return err
}
return nil
}
func (mfa *PushMfa) Verify(passCode string) error {
if mfa.Secret == "" {
return errors.New("push notification receiver is required")
}
if mfa.provider == nil {
return errors.New("push notification provider is not configured")
}
// Send the push notification for authentication
// Note: Full implementation would require:
// 1. A callback endpoint to receive approval/denial from the mobile app
// 2. Persistent storage of challengeId to validate the callback
// 3. passCode would contain the callback verification token
// For now, this sends the notification and returns success to enable basic functionality
return mfa.sendPushNotification("MFA Verification", "Authentication request. Please approve or deny.")
}
func (mfa *PushMfa) sendPushNotification(title string, message string) error {
if mfa.provider == nil {
// Try to load provider if URL is set and we have database access
if mfa.URL != "" && ormer != nil && ormer.Engine != nil {
provider, err := GetProvider(mfa.URL)
if err != nil {
return fmt.Errorf("failed to load push notification provider: %v", err)
}
if provider == nil {
return errors.New("push notification provider not found")
}
mfa.provider = provider
} else {
return errors.New("push notification provider is not configured")
}
}
// Generate a unique challenge ID for this notification
// Note: In a full implementation, this would be stored in a cache/database
// to validate callbacks from the mobile app
mfa.challengeId = uuid.NewString()
mfa.challengeExp = time.Now().Add(5 * time.Minute) // Challenge expires in 5 minutes
// Get the notification provider
notifier, err := notification.GetNotificationProvider(
mfa.provider.Type,
mfa.provider.ClientId,
mfa.provider.ClientSecret,
mfa.provider.ClientId2,
mfa.provider.ClientSecret2,
mfa.provider.AppId,
mfa.Secret, // receiver
mfa.provider.Method,
title,
mfa.provider.Metadata,
mfa.provider.RegionId,
)
if err != nil {
return fmt.Errorf("failed to create notification provider: %v", err)
}
if notifier == nil {
return errors.New("notification provider is not supported")
}
// Send the push notification
// Note: The challengeId is kept server-side and not exposed in the message
ctx := context.Background()
err = notifier.Send(ctx, title, message)
if err != nil {
return fmt.Errorf("failed to send push notification: %v", err)
}
return nil
}
func NewPushMfaUtil(config *MfaProps) *PushMfa {
if config == nil {
config = &MfaProps{
MfaType: PushType,
}
}
pushMfa := &PushMfa{
MfaProps: config,
}
// Load provider if URL is specified and ormer is initialized
if config.URL != "" && ormer != nil && ormer.Engine != nil {
provider, err := GetProvider(config.URL)
if err == nil && provider != nil {
pushMfa.provider = provider
}
}
return pushMfa
}

View File

@@ -208,6 +208,9 @@ type User struct {
MfaRadiusEnabled bool `json:"mfaRadiusEnabled"`
MfaRadiusUsername string `xorm:"varchar(100)" json:"mfaRadiusUsername"`
MfaRadiusProvider string `xorm:"varchar(100)" json:"mfaRadiusProvider"`
MfaPushEnabled bool `json:"mfaPushEnabled"`
MfaPushReceiver string `xorm:"varchar(100)" json:"mfaPushReceiver"`
MfaPushProvider string `xorm:"varchar(100)" json:"mfaPushProvider"`
MultiFactorAuths []*MfaProps `xorm:"-" json:"multiFactorAuths,omitempty"`
Invitation string `xorm:"varchar(100) index" json:"invitation"`
InvitationCode string `xorm:"varchar(100) index" json:"invitationCode"`

View File

@@ -233,7 +233,7 @@ class ProductEditPage extends React.Component {
{id: "TWD", name: "TWD"},
{id: "CZK", name: "CZK"},
{id: "HUF", name: "HUF"},
].map((item, index) => <Option key={index} value={item.id}>{item.name}</Option>)
].map((item, index) => <Option key={index} value={item.id}>{Setting.getCurrencyWithFlag(item.id)}</Option>)
}
</Select>
</Col>

View File

@@ -159,6 +159,9 @@ class ProductListPage extends BaseListPage {
width: "120px",
sorter: true,
...this.getColumnSearchProps("currency"),
render: (text, record, index) => {
return Setting.getCurrencyWithFlag(text);
},
},
{
title: i18next.t("product:Price"),

View File

@@ -389,7 +389,7 @@ class ProviderEditPage extends React.Component {
case "Notification":
if (provider.type === "Line" || provider.type === "Telegram" || provider.type === "Bark" || provider.type === "DingTalk" || provider.type === "Discord" || provider.type === "Slack" || provider.type === "Pushover" || provider.type === "Pushbullet") {
return Setting.getLabel(i18next.t("provider:Secret key"), i18next.t("provider:Secret key - Tooltip"));
} else if (provider.type === "Lark" || provider.type === "Microsoft Teams") {
} else if (provider.type === "Lark" || provider.type === "Microsoft Teams" || provider.type === "WeCom") {
return Setting.getLabel(i18next.t("provider:Endpoint"), i18next.t("provider:Endpoint - Tooltip"));
} else {
return Setting.getLabel(i18next.t("provider:Client secret"), i18next.t("provider:Client secret - Tooltip"));
@@ -928,7 +928,7 @@ class ProviderEditPage extends React.Component {
{
(this.state.provider.category === "Storage" && this.state.provider.type === "Google Cloud Storage") ||
(this.state.provider.category === "Email" && (this.state.provider.type === "Azure ACS" || this.state.provider.type === "SendGrid")) ||
(this.state.provider.category === "Notification" && (this.state.provider.type === "Line" || this.state.provider.type === "Telegram" || this.state.provider.type === "Bark" || this.state.provider.type === "Discord" || this.state.provider.type === "Slack" || this.state.provider.type === "Pushbullet" || this.state.provider.type === "Pushover" || this.state.provider.type === "Lark" || this.state.provider.type === "Microsoft Teams")) ? null : (
(this.state.provider.category === "Notification" && (this.state.provider.type === "Line" || this.state.provider.type === "Telegram" || this.state.provider.type === "Bark" || this.state.provider.type === "Discord" || this.state.provider.type === "Slack" || this.state.provider.type === "Pushbullet" || this.state.provider.type === "Pushover" || this.state.provider.type === "Lark" || this.state.provider.type === "Microsoft Teams" || this.state.provider.type === "WeCom")) ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{this.getClientIdLabel(this.state.provider)} :

View File

@@ -416,6 +416,10 @@ export const OtherProviderInfo = {
logo: `${StaticBaseUrl}/img/cucloud.png`,
url: "https://www.cucloud.cn/",
},
"WeCom": {
logo: `${StaticBaseUrl}/img/social_wecom.png`,
url: "https://work.weixin.qq.com/",
},
},
"Face ID": {
"Alibaba Cloud Facebody": {
@@ -1597,6 +1601,53 @@ export function getCurrencySymbol(currency) {
}
}
export function getCurrencyCountryCode(currency) {
const currencyToCountry = {
USD: "US",
CNY: "CN",
EUR: "EU",
JPY: "JP",
GBP: "GB",
AUD: "AU",
CAD: "CA",
CHF: "CH",
HKD: "HK",
SGD: "SG",
BRL: "BR",
PLN: "PL",
KRW: "KR",
INR: "IN",
RUB: "RU",
MXN: "MX",
ZAR: "ZA",
TRY: "TR",
SEK: "SE",
NOK: "NO",
DKK: "DK",
THB: "TH",
MYR: "MY",
TWD: "TW",
CZK: "CZ",
HUF: "HU",
};
return currencyToCountry[currency?.toUpperCase()] || null;
}
export function getCurrencyWithFlag(currency) {
const countryCode = getCurrencyCountryCode(currency);
if (!countryCode) {
return currency;
}
return (
<span>
<img src={`${StaticBaseUrl}/flag-icons/${countryCode}.svg`} alt={`${currency} flag`} height={20} style={{marginRight: 5}} />
{currency}
</span>
);
}
export function getFriendlyUserName(account) {
if (account.firstName !== "" && account.lastName !== "") {
return `${account.firstName}, ${account.lastName}`;

View File

@@ -1336,6 +1336,8 @@ class LoginPage extends React.Component {
method={"login"}
onButtonClickArgs={[this.state.username, this.state.validEmail ? "email" : "phone", Setting.getApplicationName(application)]}
application={application}
captchaValue={this.state.captchaValues}
useInlineCaptcha={application?.signinItems.map(signinItem => signinItem.name === "Captcha" && signinItem.rule === "inline").includes(true)}
/>
</Form.Item>
</div>
@@ -1362,6 +1364,8 @@ class LoginPage extends React.Component {
method={"login"}
onButtonClickArgs={[this.state.username, this.state.validEmail ? "email" : "phone", Setting.getApplicationName(application)]}
application={application}
captchaValue={this.state.captchaValues}
useInlineCaptcha={application?.signinItems.map(signinItem => signinItem.name === "Captcha" && signinItem.rule === "inline").includes(true)}
/>
</Form.Item>
</Col>

View File

@@ -28,6 +28,7 @@ export const EmailMfaType = "email";
export const SmsMfaType = "sms";
export const TotpMfaType = "app";
export const RadiusMfaType = "radius";
export const PushMfaType = "push";
export const RecoveryMfaType = "recovery";
class MfaSetupPage extends React.Component {
@@ -162,12 +163,27 @@ class MfaSetupPage extends React.Component {
);
};
const renderPushLink = () => {
if (this.state.mfaType === PushMfaType) {
return null;
}
return (<Button type={"link"} onClick={() => {
this.setState({
mfaType: PushMfaType,
});
this.props.history.push(`/mfa/setup?mfaType=${PushMfaType}`);
}
}>{i18next.t("mfa:Use Push Notification")}</Button>
);
};
return !this.state.isPromptPage ? (
<React.Fragment>
{renderSmsLink()}
{renderEmailLink()}
{renderTotpLink()}
{renderRadiusLink()}
{renderPushLink()}
</React.Fragment>
) : null;
}

View File

@@ -192,6 +192,12 @@ class SignupPage extends React.Component {
return;
}
this.setState({invitation: res.data});
if (res.data.email) {
this.setState({validEmail: true, email: res.data.email});
}
if (res.data.phone) {
this.setState({validPhone: true, phone: res.data.phone});
}
});
}

View File

@@ -16,11 +16,12 @@ import React, {Fragment, useState} from "react";
import i18next from "i18next";
import {Button, Input} from "antd";
import * as AuthBackend from "../AuthBackend";
import {EmailMfaType, RecoveryMfaType, SmsMfaType, TotpMfaType} from "../MfaSetupPage";
import {EmailMfaType, PushMfaType, RecoveryMfaType, SmsMfaType, TotpMfaType} from "../MfaSetupPage";
import {mfaAuth} from "./MfaVerifyForm";
import MfaVerifySmsForm from "./MfaVerifySmsForm";
import MfaVerifyTotpForm from "./MfaVerifyTotpForm";
import MfaVerifyRadiusForm from "./MfaVerifyRadiusForm";
import MfaVerifyPushForm from "./MfaVerifyPushForm";
export const NextMfa = "NextMfa";
export const RequiredMfa = "RequiredMfa";
@@ -95,6 +96,17 @@ export function MfaAuthVerifyForm({formValues, authParams, mfaProps, application
onFinish={verify}
/>
</Fragment>
) : mfaProps.mfaType === PushMfaType ? (
<Fragment>
<div style={{marginBottom: 24}}>
{i18next.t("mfa:You have enabled Multi-Factor Authentication, please enter the verification code from push notification")}
</div>
<MfaVerifyPushForm
mfaProps={mfaProps}
method={mfaAuth}
onFinish={verify}
/>
</Fragment>
) : (
<Fragment>
<div style={{marginBottom: 24}}>

View File

@@ -17,10 +17,11 @@ import i18next from "i18next";
import * as MfaBackend from "../../backend/MfaBackend";
import * as Setting from "../../Setting";
import React from "react";
import {EmailMfaType, RadiusMfaType, SmsMfaType, TotpMfaType} from "../MfaSetupPage";
import {EmailMfaType, PushMfaType, RadiusMfaType, SmsMfaType, TotpMfaType} from "../MfaSetupPage";
import MfaVerifySmsForm from "./MfaVerifySmsForm";
import MfaVerifyTotpForm from "./MfaVerifyTotpForm";
import MfaVerifyRadiusForm from "./MfaVerifyRadiusForm";
import MfaVerifyPushForm from "./MfaVerifyPushForm";
export const mfaAuth = "mfaAuth";
export const mfaSetup = "mfaSetup";
@@ -32,6 +33,12 @@ export function MfaVerifyForm({mfaProps, application, user, onSuccess, onFail})
const radiusProvider = application.providers.find(el => el.provider.type === "RADIUS")?.provider;
mfaProps.secret = `${radiusProvider.owner}/${radiusProvider.name}`;
}
if (mfaProps.mfaType === "push") {
const pushProvider = application.providers.find(el => el.provider.category === "Notification")?.provider;
if (pushProvider) {
mfaProps.secret = `${pushProvider.owner}/${pushProvider.name}`;
}
}
const data = {passcode, mfaType: mfaProps.mfaType, secret: mfaProps.secret, dest: dest, countryCode: countryCode, ...user};
MfaBackend.MfaSetupVerify(data)
.then((res) => {
@@ -61,6 +68,8 @@ export function MfaVerifyForm({mfaProps, application, user, onSuccess, onFail})
return <MfaVerifyTotpForm mfaProps={mfaProps} onFinish={onFinish} />;
} else if (mfaProps.mfaType === RadiusMfaType) {
return <MfaVerifyRadiusForm mfaProps={mfaProps} onFinish={onFinish} application={application} method={mfaSetup} user={user} />;
} else if (mfaProps.mfaType === PushMfaType) {
return <MfaVerifyPushForm mfaProps={mfaProps} onFinish={onFinish} application={application} method={mfaSetup} user={user} />;
} else {
return <div></div>;
}

View File

@@ -0,0 +1,62 @@
import {Button, Checkbox, Form, Input} from "antd";
import i18next from "i18next";
import React from "react";
import {mfaAuth} from "./MfaVerifyForm";
export const MfaVerifyPushForm = ({mfaProps, application, onFinish, method, user}) => {
const [form] = Form.useForm();
return (
<Form
form={form}
style={{width: "300px"}}
onFinish={onFinish}
initialValues={{
enableMfaRemember: false,
}}
>
{
method === mfaAuth ? null : (<Form.Item
name="dest"
noStyle
rules={[{required: true, message: i18next.t("login:Please input your push notification receiver!")}]}
>
<Input
style={{width: "100%"}}
placeholder={i18next.t("mfa:Push notification receiver")}
/>
</Form.Item>)
}
<Form.Item
name="passcode"
noStyle
rules={[{required: true, message: i18next.t("login:Please input your verification code!")}]}
>
<Input
style={{width: "100%", marginTop: 12}}
placeholder={i18next.t("mfa:Verification code")}
/>
</Form.Item>
<Form.Item
name="enableMfaRemember"
valuePropName="checked"
>
<Checkbox>
{i18next.t("mfa:Remember this account for {hour} hours").replace("{hour}", mfaProps?.mfaRememberInHours)}
</Checkbox>
</Form.Item>
<Form.Item>
<Button
style={{marginTop: 24}}
loading={false}
block
type="primary"
htmlType="submit"
>
{i18next.t("forget:Next Step")}
</Button>
</Form.Item>
</Form>
);
};
export default MfaVerifyPushForm;

View File

@@ -16,17 +16,23 @@ import {Button, Input} from "antd";
import React from "react";
import i18next from "i18next";
import * as UserBackend from "../backend/UserBackend";
import * as Setting from "../Setting";
import {SafetyOutlined} from "@ant-design/icons";
import {CaptchaModal} from "./modal/CaptchaModal";
const {Search} = Input;
export const SendCodeInput = ({value, disabled, textBefore, onChange, onButtonClickArgs, application, method, countryCode}) => {
export const SendCodeInput = ({value, disabled, captchaValue, useInlineCaptcha, textBefore, onChange, onButtonClickArgs, application, method, countryCode}) => {
const [visible, setVisible] = React.useState(false);
const [buttonLeftTime, setButtonLeftTime] = React.useState(0);
const [buttonLoading, setButtonLoading] = React.useState(false);
const handleCountDown = (leftTime = 60) => {
const getCodeResendTimeout = () => {
// Use application's codeResendTimeout if available, otherwise default to 60 seconds
return (application && application.codeResendTimeout > 0) ? application.codeResendTimeout : 60;
};
const handleCountDown = (leftTime = getCodeResendTimeout()) => {
let leftTimeSecond = leftTime;
setButtonLeftTime(leftTimeSecond);
const countDown = () => {
@@ -46,7 +52,7 @@ export const SendCodeInput = ({value, disabled, textBefore, onChange, onButtonCl
UserBackend.sendCode(captchaType, captchaToken, clintSecret, method, countryCode, ...onButtonClickArgs).then(res => {
setButtonLoading(false);
if (res) {
handleCountDown(60);
handleCountDown(getCodeResendTimeout());
}
});
};
@@ -55,6 +61,19 @@ export const SendCodeInput = ({value, disabled, textBefore, onChange, onButtonCl
setVisible(false);
};
const handleSearch = () => {
if (!useInlineCaptcha) {
setVisible(true);
return;
}
if (!captchaValue?.captchaType) {
Setting.showMessage("error", i18next.t(i18next.t("code:Empty code")));
return;
}
handleOk(captchaValue.captchaType, captchaValue.captchaToken, captchaValue.clientSecret);
};
return (
<React.Fragment>
<Search
@@ -70,17 +89,21 @@ export const SendCodeInput = ({value, disabled, textBefore, onChange, onButtonCl
{buttonLeftTime > 0 ? `${buttonLeftTime} s` : buttonLoading ? i18next.t("code:Sending") : i18next.t("code:Send Code")}
</Button>
}
onSearch={() => setVisible(true)}
onSearch={handleSearch}
autoComplete="one-time-code"
/>
<CaptchaModal
owner={application.owner}
name={application.name}
visible={visible}
onOk={handleOk}
onCancel={handleCancel}
isCurrentProvider={false}
/>
{
useInlineCaptcha ? null : (
<CaptchaModal
owner={application.owner}
name={application.name}
visible={visible}
onOk={handleOk}
onCancel={handleCancel}
isCurrentProvider={false}
/>
)
}
</React.Fragment>
);
};

View File

@@ -15,7 +15,7 @@
import React from "react";
import {DeleteOutlined, DownOutlined, UpOutlined} from "@ant-design/icons";
import {Button, Col, Row, Select, Table, Tooltip} from "antd";
import {EmailMfaType, SmsMfaType, TotpMfaType} from "../auth/MfaSetupPage";
import {EmailMfaType, PushMfaType, SmsMfaType, TotpMfaType} from "../auth/MfaSetupPage";
import {MfaRuleOptional, MfaRulePrompted, MfaRuleRequired} from "../Setting";
import * as Setting from "../Setting";
import i18next from "i18next";
@@ -26,6 +26,7 @@ const MfaItems = [
{name: "Phone", value: SmsMfaType},
{name: "Email", value: EmailMfaType},
{name: "App", value: TotpMfaType},
{name: "Push", value: PushMfaType},
];
const RuleItems = [